[TMT-195] feat: 매장 검색 커서 전환 — 정렬 키 확정 - #72
Conversation
| ORDER BY c.distanceMeters, c.placeId | ||
| LIMIT :limitPlusOne | ||
| """, | ||
| nativeQuery = true, |
There was a problem hiding this comment.
후속 — TMT-295(#70) 머지 후 이 native 쿼리에 통합 테스트를 추가할 예정입니다.
지금은 DB 통합 테스트 환경이 없어 MockMvc + Fake 포트로만 검증했습니다. 실제 Postgres에서 확인해야 하는 것:
round(similarity(name, :q) × 1000)정렬이 의도대로 나오는지- 키셋 커서가 경계에서 중복·누락 없이 이어지는지
EXPLAIN— 3-wayILIKE술어가 어떤 경로를 타는지 (place_name_trgm은 GIN이라 KNN 정렬은 못 쓴다)
wnsvy607
left a comment
There was a problem hiding this comment.
키셋 전환의 핵심이 전부 정확합니다 — 거리 ASC >·유사도 DESC < 방향 일치, 전체 키 튜플 커서 + placeId tie-breaker, 조건 해시에 query·태그·좌표·nearbyOnly 전부 포함(거리 정렬 키셋의 난점을 좌표 해시 → INVALID_CURSOR로 끊는 처리 좋습니다), float 커서를 similarity×1000 정수로 바꾼 결정도 Nearby의 반올림 미터 선례와 정합. approve합니다.
[want] mock/CurationPresets.kt — PlaceMockController 삭제로 사용처가 0이 됐는데 파일이 남았고, 본문의 "CurationPresets(mock 쪽)…다른 mock이 쓴다"는 서술이 실제와 다릅니다(브랜치 전체 grep 결과 선언 1건뿐, application 쪽 동명 객체는 별개로 계속 쓰임). mock 격리 규칙상 이 PR에서 같이 지우는 게 맞습니다.
[q] PlaceSearchRepository.kt:84 — 유사도 정렬이 가게명만 봅니다. 주소·카테고리로만 매칭된 행은 점수 ≈ 0으로 전부 뒤로 밀려 사실상 id DESC가 되는데, F §2-1 해석상 의도인가요? 의도라면 [TBD] 확정 문안에 한 줄이면 됩니다.
[q] 좌표가 조건 해시에 들어가므로 FE가 무한 스크롤 중 GPS 갱신 좌표를 보내면 즉시 400입니다. 설계는 규약 §5-3에 맞아요 — 명세 반영 시 "페이지네이션 동안 첫 요청 좌표를 고정해서 보낼 것"을 FE 행동 지침으로 명시 부탁드립니다.
[q] 사소 2건: ILIKE '%'||:query||'%'에 %/_ 이스케이프가 없는 건 main의 E9 술어와 같은 기존 정책이라 신규 결함은 아닌데 수용 중인 정책인지 확인차, 그리고 PlaceSearchAdapter.kt:17 주석의 "빈 목록이면 '' 단일 원소"는 부정확합니다(PG 9.1+에서 string_to_array('', ',')는 빈 배열, 동작은 동일) — 주석만 정정하면 됩니다.
|
|
Related Issue
[매장등록] GET /v1/places/search커서 전환 · 정렬 키 확정PlaceCard· B §2-2 · F §2-1 (§2-1의[TBD]를 채우는 작업)Why
정렬 키를 티켓 제안(
similarity DESC, id DESC+ 커서에(similarity, id))에서 한 군데 바꿨다. 방향은 맞지만 커서에 담는 값을 float으로 두지 않았다.similarity()는real(float4)이다. 커서는 문자열 왕복이라real → 문자열 → real정확도를 매번 보장해야 하고, JDBC/KotlinFloat/Postgres 파싱 세 구간의 반올림에 정합성이 걸린다. 실패하면 조용히 경계 한 줄이 사라진다round(similarity × 1000)을 정수로 만들어 커서에 담는다. 이미 이 레포는 거리 정렬 키를 반올림 정수 미터로 쓰고 있고(NearbyQueryRepository, 규약 §8-3), 같은 방식이다. 정렬 순서는 사실상 동일하고(0.001 미만 차이는 tie로 합쳐질 뿐, tie-breaker가 받는다) 커서 왕복은 정수라 무손실이다distance ASC, placeId ASC, 없으면 검색어 적합도 순"이라고 못박고 있고 mock도 그렇게 동작했다. F §2-1의 유사도 정렬은 좌표가 없는 경우다. 두 명세를 다 만족시키려고 정렬 모드를 좌표 유무로 가르고, 좌표를 커서 조건 해시에 넣어 축이 바뀌면 이전 커서가INVALID_CURSOR가 되게 했다query없이curationTagId만 온 경우(근처보기 칩) 유사도 점수가 없다.COALESCE(similarity(...), 0)으로 전 행이 0점이 되어 사실상placeId DESC한 축이 된다 — 마지막 키가 유일하므로 경계는 여전히 안전하다place_name_trgm은GIN (name gin_trgm_ops)이다. GIN은ORDER BY similarity인덱스 정렬을 지원하지 않는다(KNN 정렬은 GiST 전용). 즉 이 인덱스는 후보를 좁히는 데만 쓰이고 정렬은 필터된 집합에서 일어난다. 인덱스를 GiST로 바꾸면 정렬까지 태울 수 있지만 마이그레이션도 인덱스 변경도 이 PR에 넣지 않았다 — 술어를ILIKE(E9의 가게명·주소·카테고리 통합 검색, 근처 핀과 같은 술어)로 유지하는 한 KNN 정렬을 쓸 수 없고, 검색 술어 자체를%연산자로 바꾸는 것은 검색 품질 결정이라 이 티켓 범위를 넘는다V1~V4무수정).What
GET /v1/places/search가 mock 인메모리에서 실제 DB 조회로 바뀌었다. 근처보기 검색·칩과 리뷰 작성 1단계가 같은 엔드포인트를 계속 공유하고, 응답은PlaceCard그대로다 — 썸네일(P7)·평균 별점(P9)·거리·하트까지 채운다.커서 페이징이 실제 키셋으로 동작한다. 같은 유사도 점수·같은 거리가 페이지 경계에 걸려도
placeIdtie-breaker가 중복·누락을 막고, 검색 조건이나 좌표가 바뀐 커서는INVALID_CURSOR(400)로 끊긴다.query·curationTagId가 둘 다 없으면 400,nearbyOnly=true인데 좌표가 없어도 400, 결과 0건은items: []다.How
컨트롤러(
tmt-input-http) → 유스케이스(tmt-application) → native 쿼리(postgres) 3단으로,NearbyController(TMT-228)와 같은 모양이다.CursorCodec/CursorCondition/CursorSpec(TMT-178)을 그대로 쓴다. 스펙은(sortValue, placeId)2키이고sortValue는 거리 미터 또는 유사도×1000 — 어느 모드든 정수다. 조건 해시에query(공백 정규화 후)·curationTagId·latitude·longitude·nearbyOnly를 넣었다(a, b) > / < (:a, :b))이고 CTE 뒤에서 적용한다 — 계산 컬럼(거리·점수)에 별칭으로 비교하려면NearbyQueryRepository와 같은 CTE 구조가 필요하다ILIKE, 카테고리 라벨은 서버 상수라 서비스가 id로 풀어 CSV로 넘긴다. 큐레이션 칩은CurationPresets로categoryId/regionPrefix프리셋이 되고, 알 수 없는 칩은 mock·NearbyService와 같게 쿼리 없이 빈 결과다DISTINCT ON (place_id)+ 리뷰 최신순). 카드마다 한 번씩 돌면 N+1이 된다nearbyOnly=true의 반경은 근처 피드와 같은 서버 고정 1km(E1)이고 클라이언트가 못 바꾼다Notes for Reviewer
집중해서 볼 곳
PlaceSearchRepository의 두 native 쿼리. 특히 relevance 쪽CAST(round(COALESCE(similarity(p.name, CAST(:query AS text)), 0) * 1000) AS int)와 키셋 행 비교 방향검증한 것
./gradlew ktlintFormat→./gradlew build통과 (전체)items: []/ 커서 왕복(같은 점수 700이 경계에 걸린 케이스) 중복·누락 없음 / 조건 바뀐 커서·좌표가 붙은 커서·깨진 커서 모두INVALID_CURSOR/limit기본 20·51→50못 한 것 (CI가 보여주지 못하는 범위)
similarity()정렬을 실제 Postgres에서 검증하지 못했다. 이 레포에 아직 DB 통합 테스트 환경이 없어(TMT-295 / PR [TMT-295] test: persistence 통합 테스트 환경 — Testcontainers PostGIS #70 머지 대기) 기존 방식대로 MockMvcstandaloneSetup+ Fake 포트로만 테스트했다. 즉 native SQL 자체(문법·similarity사용·CTE 키셋·DISTINCT ON)는 테스트가 통과해도 실행이 검증된 것이 아니다. 로컬 기동 + 실데이터 확인이 필요하고, TMT-295가 들어오면 정렬·경계 케이스를 통합 테스트로 옮기는 게 맞다ILIKE '%...%'3-way OR가 어떤 플랜을 타는지는 실측이 필요하다 — 느리면 TMT-208(공간 쿼리 개선) 계열에서 같이 보는 게 나을 것 같다범위 밖으로 남긴 것
place_name_trgm의 GIN→GiST 변경(위 Why 참고). 인덱스 변경도 마이그레이션도 없다PlaceCardAssembler·MockCursor·MockGeo·CurationPresets(mock 쪽)·MockMediaUrls는 남겼다.PlaceCardAssembler는UserMockController가 아직 쓰고, 나머지도 다른 mock이 쓴다 — 다만 이 PR로PlaceCardAssembler의 사용처가 하나 줄었으니 좋아요 탭 실구현이 들어오면 같이 정리될 수 있다PlaceCardResponse·CursorPage는 읽기만 했고 시그니처를 바꾸지 않았다.NearbyController·NearbyQueryAdapter도 참고만 했다계약이 어떻게 달라지나 (Confluence는 안 건드렸다 — 릴리즈 태그 시점 일괄 반영)
place_접두 모두 mock과 동일하다. Breaking 없음nearbyOnly=false에 좌표만 준 경우 mock도 거리순이었고 그대로다[TBD]에 넣을 확정 내용: 정렬 키는(sortValue, placeId)2키. 좌표가 있으면distanceMeters ASC, placeId ASC, 없으면round(similarity(name, :q) × 1000) DESC, placeId DESC. 커서에 담는 앞자리는 항상 정수이고, 좌표·검색 조건이 바뀌면 조건 해시가 달라져INVALID_CURSOR다로컬 실행 사전 조건: 특별한 것 없음.
pg_trgm은V1__init.sql이 이미 만든다Prompt Log
티켓 제안을 그대로 구현하려다 두 군데서 멈췄다.
먼저
place_name_trgm을V1__init.sql에서 확인했더니 GIN이었다. GIN은ORDER BY similarity인덱스 정렬을 못 태운다는 걸 근거로 "인덱스를 GiST로 바꾸는 마이그레이션이 필요한가"를 검토했는데, 지금 검색 술어가%유사도 연산자가 아니라 가게명·주소·카테고리 3-wayILIKE(E9)라 어차피 KNN 정렬 경로가 아니었다. 술어를 바꾸는 건 검색 품질 결정이라 접고, 인덱스는 그대로 뒀다.다음이 float이었다. 제안대로 커서에
similarity를 담으면 문자열 왕복 정확도에 경계 정합성이 걸린다. 레포에 이미 거리 정렬 키를 반올림 정수로 쓰는 선례가 있어 같은 방식(×1000 정수화)으로 맞췄다 — 정렬 결과는 사실상 같고 커서만 안전해진다.마지막으로 "유사도가 없는 경우"를 두 갈래로 나눠 봤다. 칩만 온 경우는 점수 0으로 접어
placeId DESC한 축이 되게 했고, 좌표가 온 경우는 B §2-2가 거리순을 명시하고 있어 별도 정렬 모드로 뒀다. 처음에는 정렬 축을 하나로 통일하려 했는데, 그러면 두 명세 중 하나를 어기게 되어 모드 분기 + 조건 해시로 방향을 바꿨다.