diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md b/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md
new file mode 100644
index 0000000..f2a6a31
--- /dev/null
+++ b/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md
@@ -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 경로 모두 질의 이후 한 지점에서 채우므로 경로별 분기가 없다.
diff --git a/src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java
new file mode 100644
index 0000000..aec0f13
--- /dev/null
+++ b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java
@@ -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이다.
+ *
+ *
"가장 최근"의 기준은 컬렉션 내부 정렬(데이터모델 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. 어느 Collection에도 담기지 않은
+ * Record는 키가 없다 — 호출부가 {@code null}로 채운다
+ */
+ public Map findLatestCollectionIds(List recordIds) {
+ if (recordIds.isEmpty()) {
+ return Map.of();
+ }
+ Map 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;
+ }
+}
diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java b/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java
index 29f85d9..def64d8 100644
--- a/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java
+++ b/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java
@@ -4,6 +4,19 @@
/**
* 지도 마커(API 명세 4.2). JPQL 생성자 표현식으로 직접 조회한다.
+ *
+ * {@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);
+ }
}
diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java b/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java
index f4a023c..52890ee 100644
--- a/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java
+++ b/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java
@@ -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;
@@ -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;
@@ -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;
}
@@ -167,11 +171,26 @@ public MapResponse map(Long memberId, BigDecimal swLat, BigDecimal swLng, BigDec
List found = allPresent
? recordRepository.findMarkersWithinBounds(memberId, swLat, swLng, neLat, neLng, likeKeyword)
: recordRepository.findMarkers(memberId, likeKeyword);
- List items = sortByName(found);
+ List 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 withLatestCollectionIds(List markers) {
+ if (markers.isEmpty()) {
+ return markers;
+ }
+ Map 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에서는
diff --git a/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java
index 5931fd1..1a1e496 100644
--- a/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java
+++ b/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java
@@ -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;
@@ -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();
@@ -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();
@@ -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();
}
}