Skip to content

[TMT-195] feat: 매장 검색 커서 전환 — 정렬 키 확정 - #72

Merged
mingdodev merged 3 commits into
mainfrom
feat/TMT-195-place-search-cursor
Sep 1, 2026
Merged

[TMT-195] feat: 매장 검색 커서 전환 — 정렬 키 확정#72
mingdodev merged 3 commits into
mainfrom
feat/TMT-195-place-search-cursor

Conversation

@mingdodev

Copy link
Copy Markdown
Member

Related Issue

  • TMT-195 — [매장등록] GET /v1/places/search 커서 전환 · 정렬 키 확정
  • 명세: B §1-2 PlaceCard · B §2-2 · F §2-1 (§2-1의 [TBD]를 채우는 작업)

Why

정렬 키를 티켓 제안(similarity DESC, id DESC + 커서에 (similarity, id))에서 한 군데 바꿨다. 방향은 맞지만 커서에 담는 값을 float으로 두지 않았다.

  • similarity()real(float4)이다. 커서는 문자열 왕복이라 real → 문자열 → real 정확도를 매번 보장해야 하고, JDBC/Kotlin Float/Postgres 파싱 세 구간의 반올림에 정합성이 걸린다. 실패하면 조용히 경계 한 줄이 사라진다
  • 대신 round(similarity × 1000)을 정수로 만들어 커서에 담는다. 이미 이 레포는 거리 정렬 키를 반올림 정수 미터로 쓰고 있고(NearbyQueryRepository, 규약 §8-3), 같은 방식이다. 정렬 순서는 사실상 동일하고(0.001 미만 차이는 tie로 합쳐질 뿐, tie-breaker가 받는다) 커서 왕복은 정수라 무손실이다
  • 정렬 축이 하나가 아니다. B §2-2가 "좌표가 있으면 distance ASC, placeId ASC, 없으면 검색어 적합도 순"이라고 못박고 있고 mock도 그렇게 동작했다. F §2-1의 유사도 정렬은 좌표가 없는 경우다. 두 명세를 다 만족시키려고 정렬 모드를 좌표 유무로 가르고, 좌표를 커서 조건 해시에 넣어 축이 바뀌면 이전 커서가 INVALID_CURSOR가 되게 했다
  • query 없이 curationTagId만 온 경우(근처보기 칩) 유사도 점수가 없다. COALESCE(similarity(...), 0)으로 전 행이 0점이 되어 사실상 placeId DESC 한 축이 된다 — 마지막 키가 유일하므로 경계는 여전히 안전하다
  • place_name_trgmGIN (name gin_trgm_ops)이다. GIN은 ORDER BY similarity 인덱스 정렬을 지원하지 않는다(KNN 정렬은 GiST 전용). 즉 이 인덱스는 후보를 좁히는 데만 쓰이고 정렬은 필터된 집합에서 일어난다. 인덱스를 GiST로 바꾸면 정렬까지 태울 수 있지만 마이그레이션도 인덱스 변경도 이 PR에 넣지 않았다 — 술어를 ILIKE(E9의 가게명·주소·카테고리 통합 검색, 근처 핀과 같은 술어)로 유지하는 한 KNN 정렬을 쓸 수 없고, 검색 술어 자체를 % 연산자로 바꾸는 것은 검색 품질 결정이라 이 티켓 범위를 넘는다
  • production 의존성 추가 없음. 마이그레이션 추가 없음(V1~V4 무수정).

What

GET /v1/places/search가 mock 인메모리에서 실제 DB 조회로 바뀌었다. 근처보기 검색·칩과 리뷰 작성 1단계가 같은 엔드포인트를 계속 공유하고, 응답은 PlaceCard 그대로다 — 썸네일(P7)·평균 별점(P9)·거리·하트까지 채운다.

커서 페이징이 실제 키셋으로 동작한다. 같은 유사도 점수·같은 거리가 페이지 경계에 걸려도 placeId tie-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를 넣었다
  • 쿼리: 정렬 방향이 반대(거리 오름 / 점수 내림)라 native 쿼리를 둘로 나눴다. 키셋은 둘 다 행 비교((a, b) > / < (:a, :b))이고 CTE 뒤에서 적용한다 — 계산 컬럼(거리·점수)에 별칭으로 비교하려면 NearbyQueryRepository와 같은 CTE 구조가 필요하다
  • 술어는 근처 핀(E9)과 같게 맞췄다: 가게명·도로명주소 ILIKE, 카테고리 라벨은 서버 상수라 서비스가 id로 풀어 CSV로 넘긴다. 큐레이션 칩은 CurationPresetscategoryId/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)와 키셋 행 비교 방향
  • 좌표 유무로 정렬 축이 갈리는 설계가 B §2-2 해석으로 맞는지. F §2-1만 보면 유사도 단일 축이지만, B가 명시적으로 거리순을 적고 있고 mock도 그랬다

검증한 것

  • ./gradlew ktlintFormat./gradlew build 통과 (전체)
  • 컨트롤러: 필수 파라미터 400 / 0건 items: [] / 커서 왕복(같은 점수 700이 경계에 걸린 케이스) 중복·누락 없음 / 조건 바뀐 커서·좌표가 붙은 커서·깨진 커서 모두 INVALID_CURSOR / limit 기본 20·51→50
  • 서비스: 정렬 모드 선택, 반경 적용, 칩 프리셋, 평균 별점 소수 첫째 자리·리뷰 0건 null, 커서 키 왕복

못 한 것 (CI가 보여주지 못하는 범위)

  • similarity() 정렬을 실제 Postgres에서 검증하지 못했다. 이 레포에 아직 DB 통합 테스트 환경이 없어(TMT-295 / PR [TMT-295] test: persistence 통합 테스트 환경 — Testcontainers PostGIS #70 머지 대기) 기존 방식대로 MockMvc standaloneSetup + Fake 포트로만 테스트했다. 즉 native SQL 자체(문법·similarity 사용·CTE 키셋·DISTINCT ON)는 테스트가 통과해도 실행이 검증된 것이 아니다. 로컬 기동 + 실데이터 확인이 필요하고, TMT-295가 들어오면 정렬·경계 케이스를 통합 테스트로 옮기는 게 맞다
  • EXPLAIN도 못 봤다. 141k 규모에서 ILIKE '%...%' 3-way OR가 어떤 플랜을 타는지는 실측이 필요하다 — 느리면 TMT-208(공간 쿼리 개선) 계열에서 같이 보는 게 나을 것 같다

범위 밖으로 남긴 것

  • place_name_trgm의 GIN→GiST 변경(위 Why 참고). 인덱스 변경도 마이그레이션도 없다
  • PlaceCardAssembler·MockCursor·MockGeo·CurationPresets(mock 쪽)·MockMediaUrls남겼다. PlaceCardAssemblerUserMockController가 아직 쓰고, 나머지도 다른 mock이 쓴다 — 다만 이 PR로 PlaceCardAssembler의 사용처가 하나 줄었으니 좋아요 탭 실구현이 들어오면 같이 정리될 수 있다
  • PlaceCardResponse·CursorPage는 읽기만 했고 시그니처를 바꾸지 않았다. NearbyController·NearbyQueryAdapter도 참고만 했다

계약이 어떻게 달라지나 (Confluence는 안 건드렸다 — 릴리즈 태그 시점 일괄 반영)

  • 필드명·타입·place_ 접두 모두 mock과 동일하다. Breaking 없음
  • 동작 차이 두 가지: (1) 좌표가 없을 때 mock은 시드 등록 순서였는데 이제 유사도순이다 (2) nearbyOnly=false에 좌표만 준 경우 mock도 거리순이었고 그대로다
  • 명세 §2-1의 [TBD]에 넣을 확정 내용: 정렬 키는 (sortValue, placeId) 2키. 좌표가 있으면 distanceMeters ASC, placeId ASC, 없으면 round(similarity(name, :q) × 1000) DESC, placeId DESC. 커서에 담는 앞자리는 항상 정수이고, 좌표·검색 조건이 바뀌면 조건 해시가 달라져 INVALID_CURSOR

로컬 실행 사전 조건: 특별한 것 없음. pg_trgmV1__init.sql이 이미 만든다

Prompt Log

티켓 제안을 그대로 구현하려다 두 군데서 멈췄다.

먼저 place_name_trgmV1__init.sql에서 확인했더니 GIN이었다. GIN은 ORDER BY similarity 인덱스 정렬을 못 태운다는 걸 근거로 "인덱스를 GiST로 바꾸는 마이그레이션이 필요한가"를 검토했는데, 지금 검색 술어가 % 유사도 연산자가 아니라 가게명·주소·카테고리 3-way ILIKE(E9)라 어차피 KNN 정렬 경로가 아니었다. 술어를 바꾸는 건 검색 품질 결정이라 접고, 인덱스는 그대로 뒀다.

다음이 float이었다. 제안대로 커서에 similarity를 담으면 문자열 왕복 정확도에 경계 정합성이 걸린다. 레포에 이미 거리 정렬 키를 반올림 정수로 쓰는 선례가 있어 같은 방식(×1000 정수화)으로 맞췄다 — 정렬 결과는 사실상 같고 커서만 안전해진다.

마지막으로 "유사도가 없는 경우"를 두 갈래로 나눠 봤다. 칩만 온 경우는 점수 0으로 접어 placeId DESC 한 축이 되게 했고, 좌표가 온 경우는 B §2-2가 거리순을 명시하고 있어 별도 정렬 모드로 뒀다. 처음에는 정렬 축을 하나로 통일하려 했는데, 그러면 두 명세 중 하나를 어기게 되어 모드 분기 + 조건 해시로 방향을 바꿨다.

ORDER BY c.distanceMeters, c.placeId
LIMIT :limitPlusOne
""",
nativeQuery = true,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

후속 — TMT-295(#70) 머지 후 이 native 쿼리에 통합 테스트를 추가할 예정입니다.

지금은 DB 통합 테스트 환경이 없어 MockMvc + Fake 포트로만 검증했습니다. 실제 Postgres에서 확인해야 하는 것:

  • round(similarity(name, :q) × 1000) 정렬이 의도대로 나오는지
  • 키셋 커서가 경계에서 중복·누락 없이 이어지는지
  • EXPLAIN — 3-way ILIKE 술어가 어떤 경로를 타는지 (place_name_trgm은 GIN이라 KNN 정렬은 못 쓴다)

@wnsvy607 wnsvy607 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

키셋 전환의 핵심이 전부 정확합니다 — 거리 ASC >·유사도 DESC < 방향 일치, 전체 키 튜플 커서 + placeId tie-breaker, 조건 해시에 query·태그·좌표·nearbyOnly 전부 포함(거리 정렬 키셋의 난점을 좌표 해시 → INVALID_CURSOR로 끊는 처리 좋습니다), float 커서를 similarity×1000 정수로 바꾼 결정도 Nearby의 반올림 미터 선례와 정합. approve합니다.

[want] mock/CurationPresets.ktPlaceMockController 삭제로 사용처가 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('', ',')는 빈 배열, 동작은 동일) — 주석만 정정하면 됩니다.

@mingdodev

Copy link
Copy Markdown
Member Author

[want] 반영했습니다 — mock CurationPresets 삭제. 본문의 "다른 mock이 쓴다"가 틀렸습니다. 브랜치 전체 grep에서 선언 1건뿐이고, 나머지 3건은 application 쪽 동명 객체(NearbyService·FoodCategories·PlaceSearchService가 쓰는 것)라 별개였습니다.

[q] 유사도가 가게명만 보는 것 — 실측했습니다

마침 TMT-295(#70)로 컨테이너 환경이 생겨서 로컬 PostGIS에 직접 재봤습니다.

datcollate/datctype = en_US.utf8   ← C 로케일이 아니라 한국어 트라이그램 정상 동작
similarity('한판승부', '한판승부') = 1.0
similarity('한판승부', '한판승')   = 0.5
similarity('한판승부', '한판')     = 0.33
similarity('한판승부', '한')       = 0.17
similarity('델리스피자', '피자')   = 0.125   ← 접미
similarity('김밥천국', '천국')     = 0.14    ← 접미
similarity('BBQ치킨', 'bbq')       = 0.43    ← 영문 대소문자는 무시됨

접두는 잘 잡고 중간·접미는 바닥입니다. 즉 지적하신 "주소·카테고리 매칭 행이 뒤로 밀린다"보다 범위가 넓습니다 — 가게명 중간에 걸린 행도 0.12~0.14라 사실상 같이 밀립니다. 피자로 검색하면 피자나라는 앞에, 델리스피자는 뒤로 갑니다.

의도인지 아닌지를 제가 단정하기 어려워 선택지를 정리해서 명세 §2-1 확정 문안에 올리겠습니다.

  1. 현행 유지 — 접두 우선. 단순하고 예측 가능. "왜 델리스피자가 뒤에 있냐"는 질문이 남음
  2. greatest(similarity(name), similarity(road_address) * w) — 주소 매칭에 실제 점수를 줌. 무작위가 되진 않고 축이 하나 늘 뿐인데, 가중치 w가 임의값이 됨
  3. 티어 정렬CASE로 이름 접두 > 이름 부분 > 주소 > 카테고리 순위를 매기고 그 안에서 유사도. 설명 가능하고 결정적

지금 PR을 막을 사안은 아니라고 봅니다 — 어느 쪽이든 후속이고, 지금 형태를 명세에 적어두면 나중에 바꿀 때 근거가 남습니다.

[q] 좌표 조건 해시 → FE 지침

동의합니다. 명세 §2-1에 "페이지네이션 동안 첫 요청의 좌표를 고정해서 보낼 것"을 FE 행동 지침으로 넣겠습니다. 무한 스크롤 중 GPS가 갱신되면 즉시 400인데, 이건 설계대로지만 모르면 반드시 한 번 밟습니다.

[q] 사소 2건

  • LIKE 이스케이프 — 기존 E9 술어와 같은 정책 맞습니다. 다만 수용하고 있다기보다 아무도 안 정한 상태에 가깝습니다. #79에도 같은 코멘트를 남겼는데, 검색어 정제 유틸을 하나 두고 한 번에 정리하는 게 어떨까요
  • string_to_array('', ',') 주석 — 정정하겠습니다

@mingdodev
mingdodev merged commit bc5fc43 into main Sep 1, 2026
2 checks passed
@mingdodev
mingdodev deleted the feat/TMT-195-place-search-cursor branch September 1, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants