Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 지도 마커 응답에 가장 최근에 담긴 컬렉션 id를 더한다

- **날짜**: 2026-08-04
- **추적**: S15P11A705-308
- **관련**: [docs#48](https://github.com/Team-PinLog/docs/pull/48) (08 §4.2 계약 선행 개정)

프론트가 지도 마커 색상을 레코드가 담긴 컬렉션 기준으로 구분하기로 해서, `GET /v1/records/map` 응답 `items`의 각 마커에 `latestCollectionId`를 실었다. 값은 그 Record가 담긴 활성 연결(`collection_record`) 중 **담은 시각 최신**(`created_at DESC`, 동시각이면 `id DESC`) 기준 Collection id — 컬렉션 내부 정렬(데이터모델 2.7)과 같은 기준이라 "컬렉션에서 보이는 최신"과 "마커 색"이 어긋나지 않는다. 어느 컬렉션에도 담기지 않은 Record는 `null`이고, 컬렉션에서 뺀(소프트 삭제) 연결은 판단에서 제외된다.

구현 판단 둘:

1. **마커 쿼리에 조인하지 않고 별도 배치 질의로 채웠다.** "record별 최신 1건"은 `DISTINCT ON`이 필요해 JPQL로는 안 되고, 기존 마커 JPQL을 native로 갈아엎는 것보다 `RecordLatestCollectionRepository`(NamedParameterJdbcTemplate, `ContextKeywordRepository`·`CollectionFirstPageRepository`와 같은 패턴) 한 번을 더 부르는 쪽이 변경 반경이 작다. 마커마다 반복 조회하면 N+1이라 record id 목록을 IN으로 묶어 한 번에 가져온다.
2. **native라 소프트 삭제 제외를 쿼리에 직접 적었다.** `@SQLRestriction`은 native 쿼리에 적용되지 않으므로 `deleted_at IS NULL`을 빼먹으면 뺀 연결이 최신 판단에 되살아난다 — 제거 링크 제외를 테스트로 고정했다.

`MapMarkerResponse`는 6번째 컴포넌트로 `latestCollectionId`를 얻었고, 마커 JPQL은 5개 인자 보조 생성자를 그대로 쓴다. bbox·keyword 경로 모두 질의 이후 한 지점에서 채우므로 경로별 분기가 없다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.pinlog.pinlogback.domain.collection.repository;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

/**
* Record별 "가장 최근에 담긴" Collection id를 한 번의 질의로 모은다(API 명세 4.2,
* S15P11A705-308). 지도 마커 색상 구분용이라 마커마다 반복 조회하면 그대로 N+1이다.
*
* <p>"가장 최근"의 기준은 컬렉션 내부 정렬(데이터모델 2.7)과 같은 담은 시각
* ({@code collection_record.created_at DESC}, 동시각이면 {@code id DESC})이다 — Collection의
* 생성·수정 시각이 아니다. {@code DISTINCT ON}은 JPQL에 없어 native가 필요하고, native는
* {@code @SQLRestriction} 밖이므로 소프트 삭제 제외({@code deleted_at IS NULL})를 직접 적는다.
*/
@Repository
public class RecordLatestCollectionRepository {

private static final String LATEST_COLLECTION_IDS_SQL = """
SELECT DISTINCT ON (cr.record_id) cr.record_id, cr.collection_id
FROM core.collection_record cr
WHERE cr.record_id IN (:recordIds)
AND cr.deleted_at IS NULL
ORDER BY cr.record_id, cr.created_at DESC, cr.id DESC
""";

private final NamedParameterJdbcTemplate jdbc;

public RecordLatestCollectionRepository(NamedParameterJdbcTemplate jdbc) {
this.jdbc = jdbc;
}

/**
* @return Record id → 가장 최근에 담긴 Collection id. <b>어느 Collection에도 담기지 않은
* Record는 키가 없다</b> — 호출부가 {@code null}로 채운다
*/
public Map<Long, Long> findLatestCollectionIds(List<Long> recordIds) {
if (recordIds.isEmpty()) {
return Map.of();
}
Map<Long, Long> byRecord = new LinkedHashMap<>();
jdbc.query(LATEST_COLLECTION_IDS_SQL, Map.of("recordIds", recordIds), rows -> {
byRecord.put(rows.getLong("record_id"), rows.getLong("collection_id"));
});
return byRecord;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

/**
* 지도 마커(API 명세 4.2). JPQL 생성자 표현식으로 직접 조회한다.
*
* <p>{@code latestCollectionId}는 이 Record가 가장 최근에 담긴 Collection의 id이며, 어느
* Collection에도 담기지 않았으면 {@code null}이다(프론트 마커 색상 구분용, S15P11A705-308).
* 마커 조회와 별개의 배치 질의로 채우므로 JPQL은 5개 인자 생성자를 쓴다.
*/
public record MapMarkerResponse(Long recordId, Long placeId, String name, BigDecimal lat, BigDecimal lng) {
public record MapMarkerResponse(Long recordId, Long placeId, String name, BigDecimal lat, BigDecimal lng,
Long latestCollectionId) {

public MapMarkerResponse(Long recordId, Long placeId, String name, BigDecimal lat, BigDecimal lng) {
this(recordId, placeId, name, lat, lng, null);
}

public MapMarkerResponse withLatestCollectionId(Long latestCollectionId) {
return new MapMarkerResponse(recordId, placeId, name, lat, lng, latestCollectionId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
Expand All @@ -16,6 +17,7 @@
import com.pinlog.pinlogback.domain.ai.repository.AiDerivedDataRepository;
import com.pinlog.pinlogback.domain.ai.repository.ContextAiStateRepository;
import com.pinlog.pinlogback.domain.ai.repository.ContextKeywordRepository;
import com.pinlog.pinlogback.domain.collection.repository.RecordLatestCollectionRepository;
import com.pinlog.pinlogback.domain.place.entity.Place;
import com.pinlog.pinlogback.domain.place.repository.PlaceRepository;
import com.pinlog.pinlogback.domain.record.dto.ContextMutationResponse;
Expand Down Expand Up @@ -50,18 +52,20 @@ public class RecordService {
private final ContextAiStateRepository contextAiStateRepository;
private final AiDerivedDataRepository aiDerivedDataRepository;
private final ContextKeywordRepository contextKeywordRepository;
private final RecordLatestCollectionRepository recordLatestCollectionRepository;
private final ApplicationEventPublisher events;

public RecordService(PlaceRepository placeRepository, RecordRepository recordRepository,
ContextRepository contextRepository, ContextAiStateRepository contextAiStateRepository,
AiDerivedDataRepository aiDerivedDataRepository, ContextKeywordRepository contextKeywordRepository,
ApplicationEventPublisher events) {
RecordLatestCollectionRepository recordLatestCollectionRepository, ApplicationEventPublisher events) {
this.placeRepository = placeRepository;
this.recordRepository = recordRepository;
this.contextRepository = contextRepository;
this.contextAiStateRepository = contextAiStateRepository;
this.aiDerivedDataRepository = aiDerivedDataRepository;
this.contextKeywordRepository = contextKeywordRepository;
this.recordLatestCollectionRepository = recordLatestCollectionRepository;
this.events = events;
}

Expand Down Expand Up @@ -167,11 +171,26 @@ public MapResponse map(Long memberId, BigDecimal swLat, BigDecimal swLng, BigDec
List<MapMarkerResponse> found = allPresent
? recordRepository.findMarkersWithinBounds(memberId, swLat, swLng, neLat, neLng, likeKeyword)
: recordRepository.findMarkers(memberId, likeKeyword);
List<MapMarkerResponse> items = sortByName(found);
List<MapMarkerResponse> items = sortByName(withLatestCollectionIds(found));
return new MapResponse(
BoundsResponse.enclosing(items, MapMarkerResponse::lat, MapMarkerResponse::lng), items);
}

/**
* 마커마다 가장 최근에 담긴 Collection id를 붙인다(API 명세 4.2 — 프론트 마커 색상 구분용).
* 담기지 않은 Record는 {@code null}로 남는다.
*/
private List<MapMarkerResponse> withLatestCollectionIds(List<MapMarkerResponse> markers) {
if (markers.isEmpty()) {
return markers;
}
Map<Long, Long> latestByRecord = recordLatestCollectionRepository.findLatestCollectionIds(
markers.stream().map(MapMarkerResponse::recordId).toList());
return markers.stream()
.map(marker -> marker.withLatestCollectionId(latestByRecord.get(marker.recordId())))
.toList();
}

/**
* 이름 오름차순, 동명이면 recordId 오름차순(API 명세 4.2). DB {@code ORDER BY}가 아니라 여기서
* 정렬하는 이유는 collation 의존성 때문이다 — 한글에 동순위 가중치를 주는 collation에서는
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;

import com.pinlog.pinlogback.domain.collection.entity.Collection;
import com.pinlog.pinlogback.domain.collection.entity.CollectionRecord;
import com.pinlog.pinlogback.domain.collection.repository.CollectionRecordRepository;
import com.pinlog.pinlogback.domain.collection.repository.CollectionRepository;
import com.pinlog.pinlogback.domain.member.entity.Member;
import com.pinlog.pinlogback.domain.member.repository.MemberRepository;
import com.pinlog.pinlogback.domain.place.entity.Place;
Expand All @@ -38,6 +42,12 @@ class RecordMapApiTests extends IntegrationContainerSupport {
@Autowired
private RecordRepository recordRepository;

@Autowired
private CollectionRepository collectionRepository;

@Autowired
private CollectionRecordRepository collectionRecordRepository;

@Test
void mapWithoutBboxReturnsAllMyMarkersWithEnclosingBounds() throws Exception {
long memberId = newMemberId();
Expand Down Expand Up @@ -227,6 +237,60 @@ void itemsAreSortedByNameAscending() throws Exception {
.andExpect(jsonPath("$.data.items[2].name").value("코엑스"));
}

@Test
void markerCarriesLatestCollectionId() throws Exception {
long memberId = newMemberId();
long recordId = saveMarker(memberId, "map-col-latest-1", "코엑스", "37.5118242", "127.0591586");
long earlier = newCollectionId(memberId, "먼저 담음");
long later = newCollectionId(memberId, "나중 담음");
collectionRecordRepository.save(CollectionRecord.create(earlier, recordId));
collectionRecordRepository.save(CollectionRecord.create(later, recordId));

mockMvc.perform(get("/v1/records/map").with(loginAs(memberId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].latestCollectionId").value(later));
}

@Test
void markerWithoutCollectionHasNullLatestCollectionId() throws Exception {
long memberId = newMemberId();
saveMarker(memberId, "map-col-none-1", "코엑스", "37.5118242", "127.0591586");

mockMvc.perform(get("/v1/records/map").with(loginAs(memberId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0]").exists())
.andExpect(jsonPath("$.data.items[0].latestCollectionId").value(Matchers.nullValue()));
}

@Test
void removedCollectionLinkIsExcludedFromLatest() throws Exception {
long memberId = newMemberId();
long recordId = saveMarker(memberId, "map-col-removed-1", "코엑스", "37.5118242", "127.0591586");
long kept = newCollectionId(memberId, "남는 쪽");
long removed = newCollectionId(memberId, "빠지는 쪽");
collectionRecordRepository.save(CollectionRecord.create(kept, recordId));
CollectionRecord removedLink = collectionRecordRepository.save(CollectionRecord.create(removed, recordId));
collectionRecordRepository.delete(removedLink);

mockMvc.perform(get("/v1/records/map").with(loginAs(memberId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].latestCollectionId").value(kept));
}

@Test
void bboxPathAlsoCarriesLatestCollectionId() throws Exception {
long memberId = newMemberId();
long recordId = saveMarker(memberId, "map-col-bbox-1", "코엑스", "37.5118242", "127.0591586");
long collectionId = newCollectionId(memberId, "유일");
collectionRecordRepository.save(CollectionRecord.create(collectionId, recordId));

mockMvc.perform(get("/v1/records/map").with(loginAs(memberId))
.param("swLat", "37.4").param("swLng", "126.9")
.param("neLat", "37.6").param("neLng", "127.1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].latestCollectionId").value(collectionId));
}

@Test
void partialBboxIs400() throws Exception {
long memberId = newMemberId();
Expand All @@ -241,13 +305,17 @@ private long newMemberId() {
return memberRepository.save(Member.create()).getId();
}

private void saveMarker(long memberId, String kakaoPlaceId, String name, String lat, String lng) {
saveMarker(memberId, kakaoPlaceId, name, "주소", lat, lng);
private long newCollectionId(long memberId, String title) {
return collectionRepository.save(Collection.create(memberId, title)).getId();
}

private long saveMarker(long memberId, String kakaoPlaceId, String name, String lat, String lng) {
return saveMarker(memberId, kakaoPlaceId, name, "주소", lat, lng);
}

private void saveMarker(long memberId, String kakaoPlaceId, String name, String address, String lat, String lng) {
private long saveMarker(long memberId, String kakaoPlaceId, String name, String address, String lat, String lng) {
Place place = placeRepository.save(Place.create(
kakaoPlaceId, name, address, null, null, null, new BigDecimal(lat), new BigDecimal(lng)));
recordRepository.save(Record.create(memberId, place.getId()));
return recordRepository.save(Record.create(memberId, place.getId())).getId();
}
}
Loading