[TMT-230] feat: 홈 실구현 — 인사·내 그룹·추천 캐러셀 · 홈 피드 커서 · 큐레이션 칩 - #74
Conversation
| WHERE gm.user_id = :userId AND gm.status = 'ACTIVE' | ||
| ORDER BY gm.joined_at, g.id | ||
| """, | ||
| nativeQuery = true, |
There was a problem hiding this comment.
후속 — TMT-295(#70) 머지 후 통합 테스트를 추가할 예정입니다.
가입 그룹 제외와 피드 중복 제거가 SQL 술어라 지금의 MockMvc + Fake 테스트가 지나가지 못합니다. 로컬 Postgres에 직접 붙여 EXPLAIN과 동작을 확인했지만 CI에는 남지 않아 회귀를 못 잡습니다.
TMT-295 위로 옮길 것: 가입 그룹이 추천에서 빠지는 것 · 두 그룹에 공유된 같은 리뷰가 피드에 1행으로만 나오는 것 · 거리/최신 두 정렬 경로의 커서 왕복 · 공간 쿼리 EXPLAIN
wnsvy607
left a comment
There was a problem hiding this comment.
어려운 지점이 전부 정합합니다 — G17 추천순이 탐색 RECOMMENDED와 같은 기준, 거리 ASC >·최신 DESC < 방향 정확, 좌표 유무·값이 조건 해시에 들어가 좌표 탈부착 시 INVALID_CURSOR(테스트 확인), G19 중복 제거를 EXISTS로 풀어 키셋 페이지 크기 보장, 카드 조립은 ReviewCardComposer 재사용이라 N+1 없음, mock 제거 잔재 0건. 홈 피드가 가입 그룹 공유 리뷰만 내리므로 비마스킹 toResponse()도 맞습니다. approve합니다.
[want] HomeService.kt:43-46 — 반쪽 좌표(latitude만 옴)가 조용히 최신순으로 떨어지고 이때 좌표 범위 검증도 건너뜁니다. 명세의 "좌표 선택"은 둘 다 있거나 둘 다 없거나이지 반쪽 허용이 아니고, Nearby는 좌표 불완전 시 400입니다. 반쪽은 VALIDATION_FAILED로 끊어 클라이언트 버그를 숨기지 않는 게 맞다고 봅니다. (커서 해시엔 반쪽 값도 들어가 정합성은 안 깨짐 — 동작 버그가 아니라 은폐 문제.)
[want] HomeQueryRepository.kt:67-76의 커버 이미지 LATERAL이 GroupExploreRepository.kt:46-56과 자구 단위 중복입니다. G16 커버 규칙이 두 곳에 박혀 한쪽만 고치면 홈 캐러셀과 그룹 탐색의 커버가 갈려요. "그룹 실구현 병합 시 합친다"는 방향에 동의하고 — 잊히지 않게 티켓(또는 TMT-220~223 본문)에 명시해 두는 것까지가 이 PR 몫입니다.
[q] 같은 count(*)를 여기선 Int, GroupExploreRepository.kt:111은 Long으로 받습니다. 동작은 되지만 한쪽으로 통일하면 좋겠어요. 그리고 GroupAssembler.RECOMMENDED_ORDER는 이 PR로 사용처가 확정 0이 됐습니다(유일 사용처가 삭제되는 HomeMockController.kt:64 — grep 확인). 본문의 "안 쓰이게 됐을 수 있다"보다 강한 사실이라, 그룹 mock 정리를 기다리지 않고 지워도 안전합니다.
[q] 피드 좌표 필수→선택은 FE가 보는 Additive 계약 변경인데 변경 이력 미기재입니다. "릴리즈 태그 시점 일괄 반영 합의"가 실재하면 문제없는데, 그 합의는 CLAUDE.md의 "이력이 구현보다 먼저"와 어긋나니 합의가 맞다면 CLAUDE.md 쪽을 고치는 게 규칙("규칙이 틀렸으면 문서부터")에 맞겠습니다 — 팀 확인 부탁해요.
|
if ((latitude == null) != (longitude == null)) {
throw TmtException(ErrorCode.VALIDATION_FAILED, "latitude·longitude는 함께 보내야 합니다.")
}말씀대로 동작 버그가 아니라 은폐 문제라는 게 정확합니다 — 커서 해시엔 반쪽 값도 들어가서 정합성은 안 깨지는데, 클라이언트가
|
Related Issue
TMT-230 · 명세 v2 A. 홈 · 큐레이션은 B. 근처 탐색 §2-4
Why
피드 좌표를 필수에서 선택으로 되돌렸다. mock은 좌표가 없으면 400이었는데, A 명세 §3은 좌표를 선택(
필수 N)으로 두고 "좌표가 없으면createdAt DESC, reviewId DESC로 대체한다" 고 못박고 있다. 티켓과 명세가 갈리는 지점이라 명세를 따랐다. 그래서 정렬 경로가 둘이고, 커서 스펙도(distance, reviewId)와(createdAt, reviewId)둘이다. 좌표 유무 자체가CursorCondition에 들어가 있어 좌표를 붙이거나 떼면 이전 커서는INVALID_CURSOR가 된다 — 정렬이 통째로 갈리는데 커서에는 그 조건이 안 담기기 때문이다 (규약 §5-3).추천 그룹 제외를 SQL에 뒀다. 후보를 전부 읽어 애플리케이션에서 거르면 "상위 5개"를 보장하려고 몇 개를 읽어야 할지 알 수 없다.
NOT EXISTS로 미리 빼면LIMIT 5가 그대로 답이 된다.공유 리뷰 중복 제거를
DISTINCT가 아니라EXISTS로 했다. 조인으로 펼친 뒤DISTINCT로 접으면 커서 키셋이 접기 전 행 수를 세서 페이지 크기가 어긋난다.EXISTS는 애초에 행을 늘리지 않는다.큐레이션 칩에 테이블을 만들지 않았다. 정본은 이미
tmt-application의CurationPresets다 — TMT-228이 지도 핀의 칩 조건(categoryId·regionPrefix)을 여기로 옮겨 뒀고, mock 컨트롤러에는 화면 문구만 상수로 남아 있었다. 그label을 같은 자리로 합쳐 한 곳이 조건과 문구를 같이 들고 있게 했다. 칩은 검색 조건 프리셋이라 조건이 바뀌면 어차피 배포가 나가야 하고, 시드 테이블로 두면 조건은 코드에 문구는 DB에 갈라진다. 마이그레이션·스키마 변경 없음.What
홈 3개 엔드포인트가 인메모리 mock이 아니라 실 DB로 답한다.
GET /v1/home— 인사말, 내 그룹(가입 오래된 순), 추천 그룹 캐러셀. 가입한 그룹이 0개인 신규 사용자도 정상 응답하고 추천 캐러셀은 그대로 채워진다. 가입자에게도 추천을 내리되 이미 가입한 그룹은 서버가 뺀다 (A §5-3, TMT-242가 mock에 넣어둔 규칙). 추천순은 탐색과 같은 G17 기준 — 내 저장 매장과 겹치는 수 → 가입자 수 →groupIdGET /v1/home/feed— 가입한 그룹들에 공유된 리뷰를 하나로 합쳐 내린다 (G19). 같은 리뷰가 여러 그룹에 공유돼 있어도 한 번만 나간다. 좌표를 주면 거리순, 없으면 최신순이고 어느 쪽이든reviewId가 유일한 tie-breaker다GET /v1/curation-tags— 칩 목록이 지도 핀 필터가 이미 쓰고 있던 프리셋과 같은 상수에서 나온다. 목록에 있는 칩과 실제로 결과를 내는 칩이 갈릴 수 없다HomeMockController·CurationTagMockController와 두 테스트를 지웠다.How
컨트롤러(
tmt-input-http) → 유스케이스(tmt-application) → 쿼리(tmt-output-persistence/postgres)로 TMT-228·229와 같은 층을 탄다. 커서 문자열의 인코딩·해석은 어댑터에 남기고 유스케이스는 정렬 키(HomeFeedKey)만 주고받는다.그룹 쪽 파일은 새로 만들지도 고치지도 않았다. TMT-220~223이 동시에 진행 중이라, 홈이 필요로 하는 그룹 읽기는
HomeQueryPort/HomeQueryAdapter/HomeQueryRepository라는 홈 소유 파일 안에 담았다. 엔티티(GroupEntity·GroupMembershipEntity·GroupPlaceEntity)는 읽기만 한다. 그룹 목록 실구현이 들어오면 추천 쿼리를 그쪽 포트로 합칠 수 있다.성능 때문에 단순한 방식을 피한 곳이 둘이다.
LEFT JOIN LATERAL ... LIMIT 1로 그룹당 1행만 뽑는다group_place와 내save의 교집합 크기다.JOIN LATERAL로 뽑아 같은 쿼리 안에서 정렬 1차 키로 바로 쓴다카드 조립은 기존
ReviewCardComposer를 그대로 태워 사진·태그·AI 요약을 배치 조회한다 — 근처 피드·가게 상세와 같은 카드다.기존 동작을 바꾼 부분: 피드의 좌표 필수 → 선택 (위 Why). 그 외 응답 형태·필드명·
place_/group_/rv_/user_/sp_접두는 mock과 같다.PublicIds에group()을 더했다 — 추가일 뿐 기존 시그니처는 그대로다.Notes for Reviewer
집중해서 볼 곳 —
HomeQueryRepository의 native 쿼리 4개다. 이 레포에 아직 DB 통합 테스트 환경이 없어(TMT-295) 자동 테스트가 SQL을 안 지나간다. 대신 로컬 Postgres(빈 볼륨 기동분)에 직접 붙여 확인했다.EXPLAIN이 통과하고 의도한 정렬 키(distance, review_id/created_at DESC, review_id DESC/matched DESC, member_count DESC, id DESC/joined_at, group_id)로 계획된다명세에 없어서 내가 정한 값
myGroups정렬의 tie-breaker를group_id로 정했다. 명세는 "가입 순서(오래된 순)"까지만 말한다.joined_at이 같은 두 그룹의 순서가 요청마다 흔들리지 않게 붙였다LIMIT은 5로 명세 §2 그대로다 (HomeService.RECOMMENDED_COUNT)limit은 공통PageLimit— 기본 20, 상한 50 (규약 §5-2)nickname을 못 찾으면USER_NOT_FOUND(404)다. 명세의 오류 표에는 401만 있다. 헤더는 왔는데 그 id의 사용자가 없는 상태라 401은 아니라고 봤다확신이 덜한 곳
share_review_ix·membership_user_ix를 타지만 최신순 경로는review를 훑는다. 인덱스는 스키마 변경이라 이 PR 범위 밖으로 뒀다 — 실데이터로 느려지면 별도 티켓이 맞다고 본다groups전체를 훑고 그룹당 LATERAL 2개를 돈다. 그룹 수가 UT2 규모(수십)라 지금은 문제가 아니지만, 그룹 목록 실구현과 합칠 때 같이 볼 만하다범위 밖으로 남긴 것
MockCursor·MockMediaUrls·ReviewCardAssembler·PlaceCardAssembler·GroupAssembler·mockCurationPresets등). TMT-226·195가 동시에 도는 중이라 그대로 뒀다. 다만 mockCurationPresets(tmt-input-http쪽)는 이제PlaceMockController만 쓰고,GroupAssembler.RECOMMENDED_ORDER는 홈이 빠지면서 안 쓰이게 됐을 수 있다 — 그룹 mock이 걷힐 때 같이 보면 될 것 같다ReviewCardResponse·GroupCardResponse)는 읽기만 했고 시그니처를 건드리지 않았다docs/DB-SCHEMA.md도 그대로다계약 변경 이력 — mock→실구현 전환 파생이라 Confluence는 손대지 않았다 (릴리즈 태그 시점에 한 줄로 묶기로 한 합의). FE 기준으로 계약이 달라지는 건
GET /v1/home/feed의 좌표가 필수에서 선택으로 바뀐 것 하나이고, 이건 400을 풀어주는 방향이라 기존 FE 코드는 안 깨진다 (Additive). 나머지는 형태·필드명·ID 표기 모두 동일하다.로컬 실행 사전 조건 — 없다. 마이그레이션이 그대로라 기존 볼륨에서 바로 뜬다.
검증한 것 —
./gradlew ktlintFormat후./gradlew build전체 통과 (신규 테스트 포함). 위에 적은 대로 native 쿼리 4개는 로컬 Postgres에서EXPLAIN과 실데이터 왕복으로 따로 확인했다.Prompt Log
mock 2개를 옮기는 일이라 처음엔 mock의 동작을 그대로 SQL로 번역하는 방향으로 잡았다. 그러다 피드의 좌표 처리에서 mock과 A 명세가 갈리는 걸 발견했다 — mock은 "거리순 정렬이 좌표 유무로 갈리면 커서가 무효가 되니 좌표를 필수로 둔다"는 주석과 함께 400을 던지는데, 명세 §3은 좌표를 선택으로 두고 좌표 없을 때의 대체 정렬까지 지정해 뒀다. 명세를 정본으로 보고 두 정렬 경로를 다 구현하는 쪽으로 정정했다. mock이 피하려던 문제는 좌표 유무를
CursorCondition에 넣어 해결했고, 그래서 커서 무효화 테스트에 "좌표를 떼고 이어붙이는 것도 막힌다"는 케이스를 같이 넣었다.큐레이션 칩은 시드 마이그레이션이 필요한지부터 확인했는데, TMT-228이 이미 칩 조건을
tmt-application으로 옮겨놨고 mock에는 문구만 남아 있었다. 새 테이블을 만드는 대신 문구를 그 상수로 합치는 쪽을 골랐다 — 조건과 문구가 갈라지는 게 더 위험해 보였다.추천 그룹 조회를 어디에 둘지도 한 번 접었다. 그룹 포트에 붙이는 게 자연스러운데 TMT-220~223이 동시에 도는 중이라 충돌이 확실해서, 홈 소유 포트에 담고 나중에 합치기로 했다.
DB 통합 테스트가 없어 SQL이 테스트를 안 지나가는 게 걸려서, Testcontainers를 붙이는 대신(TMT-295와 겹친다) 로컬 Postgres에 직접 붙여
EXPLAIN과 실데이터 왕복으로 두 승인 기준을 확인하고 그 결과를 위에 적었다.