Skip to content

[TMT-230] feat: 홈 실구현 — 인사·내 그룹·추천 캐러셀 · 홈 피드 커서 · 큐레이션 칩 - #74

Open
mingdodev wants to merge 2 commits into
mainfrom
feat/TMT-230-home-feed
Open

[TMT-230] feat: 홈 실구현 — 인사·내 그룹·추천 캐러셀 · 홈 피드 커서 · 큐레이션 칩#74
mingdodev wants to merge 2 commits into
mainfrom
feat/TMT-230-home-feed

Conversation

@mingdodev

Copy link
Copy Markdown
Member

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-applicationCurationPresets다 — TMT-228이 지도 핀의 칩 조건(categoryId·regionPrefix)을 여기로 옮겨 뒀고, mock 컨트롤러에는 화면 문구만 상수로 남아 있었다. 그 label을 같은 자리로 합쳐 한 곳이 조건과 문구를 같이 들고 있게 했다. 칩은 검색 조건 프리셋이라 조건이 바뀌면 어차피 배포가 나가야 하고, 시드 테이블로 두면 조건은 코드에 문구는 DB에 갈라진다. 마이그레이션·스키마 변경 없음.

What

홈 3개 엔드포인트가 인메모리 mock이 아니라 실 DB로 답한다.

  • GET /v1/home — 인사말, 내 그룹(가입 오래된 순), 추천 그룹 캐러셀. 가입한 그룹이 0개인 신규 사용자도 정상 응답하고 추천 캐러셀은 그대로 채워진다. 가입자에게도 추천을 내리되 이미 가입한 그룹은 서버가 뺀다 (A §5-3, TMT-242가 mock에 넣어둔 규칙). 추천순은 탐색과 같은 G17 기준 — 내 저장 매장과 겹치는 수 → 가입자 수 → groupId
  • GET /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)는 읽기만 한다. 그룹 목록 실구현이 들어오면 추천 쿼리를 그쪽 포트로 합칠 수 있다.

성능 때문에 단순한 방식을 피한 곳이 둘이다.

  • 커버 이미지 — 그룹 카드의 커버는 최신 공유 리뷰의 첫 사진이다 (G16). 그룹마다 따로 조회하면 N+1이라 LEFT JOIN LATERAL ... LIMIT 1로 그룹당 1행만 뽑는다
  • 일치 매장 수group_place와 내 save의 교집합 크기다. JOIN LATERAL로 뽑아 같은 쿼리 안에서 정렬 1차 키로 바로 쓴다

카드 조립은 기존 ReviewCardComposer를 그대로 태워 사진·태그·AI 요약을 배치 조회한다 — 근처 피드·가게 상세와 같은 카드다.

기존 동작을 바꾼 부분: 피드의 좌표 필수 → 선택 (위 Why). 그 외 응답 형태·필드명·place_/group_/rv_/user_/sp_ 접두는 mock과 같다. PublicIdsgroup()을 더했다 — 추가일 뿐 기존 시그니처는 그대로다.

Notes for Reviewer

집중해서 볼 곳HomeQueryRepository의 native 쿼리 4개다. 이 레포에 아직 DB 통합 테스트 환경이 없어(TMT-295) 자동 테스트가 SQL을 안 지나간다. 대신 로컬 Postgres(빈 볼륨 기동분)에 직접 붙여 확인했다.

  • 4개 모두 EXPLAIN이 통과하고 의도한 정렬 키(distance, review_id / created_at DESC, review_id DESC / matched DESC, member_count DESC, id DESC / joined_at, group_id)로 계획된다
  • 데이터를 심어 트랜잭션 안에서 두 가지를 확인하고 롤백했다: 가입한 그룹 2개가 추천에서 빠지고 미가입 그룹만 남는 것, 두 그룹에 공유된 같은 리뷰가 피드에 1행으로만 나오는 것
  • 자동 테스트는 어댑터 계약(응답 형태·ID 표기·커서 왕복·인증)과 유스케이스 분기까지다. SQL 술어 자체는 CI가 보여주지 못한다 — TMT-295가 들어오면 위 두 시나리오를 통합 테스트로 옮기는 게 맞다

명세에 없어서 내가 정한 값

  • 홈 피드에 거리 상한이 없다. 근처 탐색은 반경 1km 고정(E1)인데 홈 피드는 명세에 반경 언급이 없고 mock도 무제한이었다. 그대로 뒀다 — 가입 그룹의 리뷰는 멀어도 봐야 한다고 봤는데, 상한이 필요하면 알려주면 좋겠다
  • 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 규모(수십)라 지금은 문제가 아니지만, 그룹 목록 실구현과 합칠 때 같이 볼 만하다

범위 밖으로 남긴 것

  • 공유 mock 헬퍼를 하나도 지우지 않았다 (MockCursor·MockMediaUrls·ReviewCardAssembler·PlaceCardAssembler·GroupAssembler·mock CurationPresets 등). TMT-226·195가 동시에 도는 중이라 그대로 뒀다. 다만 mock CurationPresets(tmt-input-http 쪽)는 이제 PlaceMockController만 쓰고, GroupAssembler.RECOMMENDED_ORDER는 홈이 빠지면서 안 쓰이게 됐을 수 있다 — 그룹 mock이 걷힐 때 같이 보면 될 것 같다
  • 공통 카드 DTO(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과 실데이터 왕복으로 두 승인 기준을 확인하고 그 결과를 위에 적었다.

WHERE gm.user_id = :userId AND gm.status = 'ACTIVE'
ORDER BY gm.joined_at, g.id
""",
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) 머지 후 통합 테스트를 추가할 예정입니다.

가입 그룹 제외와 피드 중복 제거가 SQL 술어라 지금의 MockMvc + Fake 테스트가 지나가지 못합니다. 로컬 Postgres에 직접 붙여 EXPLAIN과 동작을 확인했지만 CI에는 남지 않아 회귀를 못 잡습니다.

TMT-295 위로 옮길 것: 가입 그룹이 추천에서 빠지는 것 · 두 그룹에 공유된 같은 리뷰가 피드에 1행으로만 나오는 것 · 거리/최신 두 정렬 경로의 커서 왕복 · 공간 쿼리 EXPLAIN

@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.

어려운 지점이 전부 정합합니다 — 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:111Long으로 받습니다. 동작은 되지만 한쪽으로 통일하면 좋겠어요. 그리고 GroupAssembler.RECOMMENDED_ORDER는 이 PR로 사용처가 확정 0이 됐습니다(유일 사용처가 삭제되는 HomeMockController.kt:64 — grep 확인). 본문의 "안 쓰이게 됐을 수 있다"보다 강한 사실이라, 그룹 mock 정리를 기다리지 않고 지워도 안전합니다.

[q] 피드 좌표 필수→선택은 FE가 보는 Additive 계약 변경인데 변경 이력 미기재입니다. "릴리즈 태그 시점 일괄 반영 합의"가 실재하면 문제없는데, 그 합의는 CLAUDE.md의 "이력이 구현보다 먼저"와 어긋나니 합의가 맞다면 CLAUDE.md 쪽을 고치는 게 규칙("규칙이 틀렸으면 문서부터")에 맞겠습니다 — 팀 확인 부탁해요.

@mingdodev

Copy link
Copy Markdown
Member Author

[want] 반쪽 좌표 반영했습니다.

if ((latitude == null) != (longitude == null)) {
    throw TmtException(ErrorCode.VALIDATION_FAILED, "latitude·longitude는 함께 보내야 합니다.")
}

말씀대로 동작 버그가 아니라 은폐 문제라는 게 정확합니다 — 커서 해시엔 반쪽 값도 들어가서 정합성은 안 깨지는데, 클라이언트가 longitude를 빠뜨린 걸 모른 채 최신순 목록을 보게 됩니다. 근처 탐색은 같은 상황에서 400이고요. 테스트도 추가했습니다.

[want] 커버 LATERAL 중복 — 어떻게 합칠지 정해야 할 것 같습니다

같은 G16 규칙이 HomeQueryRepositoryGroupExploreRepository에 자구 그대로 있는 것 맞습니다. 그런데 합치려니 걸리는 게 있어서 의견을 듣고 싶습니다.

두 리포지토리가 서로 다른 도메인 소유입니다. 홈이 그룹 리포지토리를 직접 참조하면 홈→그룹 방향 결합이 생기고, 반대도 마찬가지고요. 헥사고날 경계상 어느 쪽도 상대 도메인의 포트를 끌어다 쓰지 않는 게 지금 레포의 방식입니다(#74에서 HomeQueryPort를 따로 둔 것도 그래서였습니다).

떠오르는 선택지는 셋입니다.

  1. SQL 조각을 상수로 공유persistence 안에 GroupCoverSql 같은 걸 두고 두 네이티브 쿼리가 같은 문자열을 삽입. 도메인 결합 없이 자구 중복만 제거. 대신 문자열 조립이라 컴파일 타임 검증이 없음
  2. DB 뷰로group_cover_v를 마이그레이션으로 만들고 양쪽이 조인. 규칙이 한 곳에 남고 실행 계획도 안정적. 대신 스키마 오브젝트가 늘고 마이그레이션이 필요
  3. 커버 조회를 포트 하나로 빼기GroupCoverPort.coversOf(groupIds)를 두고 양쪽이 카드 조립 단계에서 호출. 경계가 가장 깨끗한데 쿼리가 한 번 더 나가고, 지금 LATERAL이 한 방에 끝내던 걸 쪼개게 됨

저는 **2번(뷰)**이 나아 보입니다 — G16이 "공유 리뷰의 최신 사진 1장"이라는 읽기 규칙이라 뷰의 의미와 맞고, 두 도메인 중 누구의 소유도 아니게 됩니다. 다만 그룹 실구현(#79~#81)이 다 들어온 뒤에 하는 게 맞을 것 같아 TMT-220 본문에 후속으로 명시하려 합니다.

어느 쪽이 좋을지, 아니면 다른 방법이 있을지 의견 주시면 그대로 티켓에 적겠습니다.

[q] 두 건

count(*) 타입Long으로 통일하겠습니다(GroupExploreRepository 쪽에 맞춤). 그룹 실구현 머지 후 한 번에 정리하는 게 나을 것 같습니다.

RECOMMENDED_ORDER — 확인해보니 이 브랜치에서는 아직 살아 있습니다. GroupMockController.kt:83SORT_RECOMMENDED -> cards.sortedWith(GroupAssembler.RECOMMENDED_ORDER)가 씁니다. #79가 mock listGroups를 지우면 그때 사용처 0이 되고요. 지금 지우면 이 PR이 깨져서 #79 쪽에서 같이 걷어내는 게 맞겠습니다.

피드 좌표 필수→선택 계약 이력 — 짚어주신 게 맞습니다. "릴리즈 태그 시점 일괄" 합의는 실재하고 변경 이력 문서 코멘트에 남겨뒀는데, CLAUDE.md의 "이력이 구현보다 먼저"와 어긋나는 걸 제가 안 고쳤습니다. 규칙이 틀렸으면 문서부터 고치는 게 이 레포 원칙이니 CLAUDE.md 쪽을 고치겠습니다 — 팀에도 한 번 더 확인하겠습니다.

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