Friend 도메인 API 구현 - #22
Conversation
cfcromn
left a comment
There was a problem hiding this comment.
총평
Friend 도메인 6개 API를 패키지 구조(domain/friend/{presentation,application,domain}), {Action}{Domain}Service + execute() 컨벤션, MockK/@MockkBean/Testcontainers 테스트 규칙까지 AGENTS.md에 정의된 대로 정확히 지켜서 구현하셨습니다. 특히 좋았던 부분:
- 역방향 동시 요청 레이스를 애플리케이션 검증만으로 끝내지 않고
LEAST/GREATESTpartial unique index로 DB 레벨까지 막은 설계. 기존 제약/컬럼을 안 건드리면서 해결한 점이 특히 좋습니다. - JOIN FETCH로 N+1 차단 —
findAllBetween은 id만 접근하니 fetch join 없이 프록시로 두고, 응답 매핑이 필요한 세 쿼리에만 fetch join을 건 판단이 정확합니다. - LIKE 와일드카드 이스케이프를 놓치지 않고
ESCAPE '!'까지 붙인 점, 그리고 그걸 실제 Postgres IT로 검증한 점. - 자동 ACCEPT를 임의로 도입하지 않고 명세에 없으니 409로 보수적 처리 + PR 본문에 근거를 남긴 판단.
- 셀프 리뷰의 흔적이 코드 주석에 잘 남아 있어서 리뷰하기 편했습니다.
다만 머지 전에 반드시 잡아야 할 이슈가 하나 있어 Request changes로 남깁니다.
반드시 수정 (1)
거절 후 재요청이 영구히 불가능합니다. uq_friend_requester_receiver는 status 조건이 없는 무조건 unique라 (A, B) row는 상태 무관 하나만 존재할 수 있는데, SendFriendRequestService는 REJECTED row가 있어도 새 row를 insert하려 합니다. 결과적으로 제약 위반 → catch에서 REVERSE_FRIEND_REQUEST_EXISTS(409, "상대가 이미 요청을 보냈습니다")로 변환되어, 사용자는 잘못된 메시지와 함께 그 상대에게 다시는 요청을 보낼 수 없게 됩니다. 검색 API가 REJECTED를 NONE으로 내려주기 때문에 클라이언트는 "친구 추가" 버튼을 계속 노출하고, 누를 때마다 409를 받습니다.
allows a new request after a prior rejection 단위 테스트가 saveAndFlush를 목킹하는 바람에 이 경로가 가려졌습니다. 수정 후 FriendRepositoryIntegrationTest에 실 DB 케이스를 추가해주세요.
함께 검토 (권장)
- 요청 수락 시점에 차단 재검증이 없어 차단 우회 경로가 있습니다 (요청 목록도 차단 필터링 없음)
DataIntegrityViolationException을 무조건 F004로 변환 → 다른 제약 위반이 잘못된 코드로 위장됩니다BLOCKED_MEMBER(403)가 차단 사실을 노출 — 검색은 조용히 숨기는데 정책이 어긋납니다- 페이지네이션 정렬 키가 유일하지 않아 페이지 간 누락/중복 가능 (
, f.id DESC추가)
나중에 (nit)
idx_friend_receiver가idx_friend_receiver_status와 중복 →DROP INDEX- 닉네임 검색이 선행 와일드카드 LIKE라 풀스캔 (나중에
pg_trgm) requireNotNull(friend.acceptedAt)하나가 페이지 전체를 500으로 만듦 → CHECK 제약 또는 fallbackescapeLike가 서비스에 노출 → repository 기본 구현으로 캡슐화PostgisContainer+ 컨테이너 설정이 두 IT 파일에 중복 → 추상 베이스 클래스로 추출
각 항목은 해당 라인 코멘트에 이유와 수정 예시를 적어뒀습니다. 반박하실 부분 있으면 편하게 말씀해주세요 — 특히 차단 정책과 재요청 시 createdAt 처리 방식은 제품 판단이 필요한 영역이라 하민님 결정을 따르겠습니다.
- fix reject-then-resend being permanently blocked by making uq_friend_requester_receiver a partial index that excludes REJECTED rows - log constraint violations instead of silently folding them into one error code - report block-by-target as member-not-found instead of leaking it via BLOCKED_MEMBER - re-verify block state when accepting a request, not just when sending one - move friend-list block filtering into the repository query so pagination metadata stays accurate instead of drifting from a post-fetch filter - add a tie-breaker to every ORDER BY so paginated results are stable - drop idx_friend_receiver, now redundant with idx_friend_receiver_status - add ck_friend_accepted_at so the ACCEPTED -> accepted_at invariant is enforced by the database, not just by convention - encapsulate LIKE-wildcard escaping in MemberRepository instead of the service - enable -Xjvm-default=all so the above Kotlin interface default method is actually dispatched as a JVM default method by the Spring Data proxy
- update unit tests for the direction-aware block checks, the simplified findFriendships signature, and the MemberRepository escaping change - add integration coverage for: reject-then-resend, the accepted_at CHECK constraint, and the block filter now applied in findFriendships itself - extract PostgresIntegrationTest as a shared base for FriendRepositoryIntegrationTest and MemberRepositorySearchIntegrationTest so both reuse one Testcontainers Postgres instance and Spring context instead of starting their own
develop merged PR #20's V4__support_pending_media_uploads.sql after this branch's V4__add_friend_request_indexes_and_pending_pair_constraint.sql was already written. Renumbering to V5 to avoid two migrations claiming version 4, which Flyway rejects (this is what broke CI on the merge commit).
개요
Friend 도메인의 친구 요청, 목록, 검색, 수락·거절, 삭제 API를 구현했습니다.
주요 변경 사항
GET /api/v1/friends)GET /api/v1/friends/search)POST /api/v1/friends/requests)GET /api/v1/friends/requests)PATCH /api/v1/friends/requests/{requestId})DELETE /api/v1/friends/{memberId})FriendRequestAction(ACCEPT/REJECT) enum 신규 추가, 기존FriendStatus/FriendRequestTypeenum 재사용ErrorCode(F001~F010) 추가MemberRepository/BlockRepository에 Friend 기능에 필요한 최소 조회 메서드 추가@Tag,@Operation, 주요 응답 코드)V4마이그레이션 추가주요 설계 결정
FriendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId를 그대로 재사용해 양방향 조회.requester/receiver어느 쪽이 로그인 사용자인지에 따라 응답의 "상대방"을 결정.JOIN FETCH로 requester/receiver를 함께 가져와 N+1을 방지 (findFriendships,findReceivedRequests,findSentRequests).REVERSE_FRIEND_REQUEST_EXISTS(409)를 반환하고 새 row를 만들지 않음 — 노션 명세에 자동 ACCEPT가 명시되지 않아 보수적으로 처리.V4마이그레이션에서(LEAST(requester_id, receiver_id), GREATEST(requester_id, receiver_id))조합에WHERE status = 'PENDING'partial unique index(uq_friend_pending_pair)를 추가해 DB 레벨에서도 방향 무관하게 PENDING row가 한 쌍당 하나만 존재하도록 보장했습니다. 같은 방향 중복은uq_friend_requester_receiver를WHERE status <> 'REJECTED'partial index로 교체해 막았습니다(REJECTED는 이력으로 몇 개든 남을 수 있고, PENDING/ACCEPTED는 방향당 하나만 — 코드 리뷰로 발견된 "거절 후 재요청 영구 불가" 버그의 근본 수정이기도 합니다. 아래 참고).BlockRepository에 조회 메서드만 추가했습니다. 검색과 친구 목록 모두NOT EXISTS서브쿼리로 차단 회원을 DB 레벨에서 제외해 페이지네이션 메타데이터(totalElements/hasNext)가 정확합니다. 친구 요청 전송은 방향에 따라BLOCKED_MEMBER(내가 차단)/MEMBER_NOT_FOUND(상대가 차단, 정보 노출 방지)로 나눠 응답하고, 수락 시점에도 재검증합니다. 받은 요청 목록(GetFriendRequestListService)에는 아직 차단 필터링을 넣지 않았습니다 — 후속 작업 참고.FriendPageResponse<T>를 두었습니다.spring.data.web.pageable.default-page-size=20,max-page-size=50을 전역 설정에 추가해 과도하게 큰 size 요청을 서버 레벨에서 캡핑합니다.MemberRepositoryJPQL에서 정확히 일치 > prefix 일치 > contains 일치 순으로 정렬(Elasticsearch 등 별도 검색 엔진 도입 없이 SQLCASE로 처리). LIKE 이스케이프 로직은MemberRepository의 3-arg 기본 메서드로 캡슐화해 서비스 계층에 SQL 세부사항이 새어나가지 않도록 했습니다.ORDER BY에f.id DESCtie-breaker를 추가해, 같은 초에 여러 row가 생성/수락되어도 페이지 경계에서 누락·중복이 생기지 않도록 했습니다.RespondFriendRequestService가 ACCEPTED 전이 시 항상acceptedAt을 채운다는 전제가 코드 컨벤션으로만 존재했는데,ck_friend_accepted_at CHECK제약으로 DB 레벨에도 못박아FriendResponse.of의requireNotNull이 안전하다는 걸 보장합니다.코드 리뷰 반영 (@cfcromn)
cfcromn님이 12건의 인라인 리뷰를 남겨주셨고, 전부 검토 후 반영했습니다 (커밋98a5015,0af5b2b). 각 코멘트에 답장으로 근거를 남겼습니다. 요약:uq_friend_requester_receiver가 REJECTED row까지 포함해 무조건 unique였던 게 원인. Partial index로 교체해 근본 수정하고, 실제 Postgres로 검증하는 통합 테스트를 추가했습니다.BLOCKED_MEMBER가 상대의 차단 사실을 알려주는 문제) → 방향별로BLOCKED_MEMBER/MEMBER_NOT_FOUND로 분리findFriendships의 친구 목록 차단 필터링이 애플리케이션 레벨 post-filter라 페이지 메타데이터가 부정확했던 문제 → 쿼리 레벨NOT EXISTS로 이동ORDER BY에 tie-breaker 부재, 불필요한status파라미터, 중복 인덱스,DataIntegrityViolationException을 뭉뚱그려 변환하는 문제,requireNotNull500 위험 → 전부 반영support/PostgresIntegrationTest)로 추출 (진행 중 컨테이너 라이프사이클 이슈를 발견해 싱글톤 컨테이너 패턴으로 수정)SearchFriendService의 LIKE 이스케이프 로직을MemberRepository로 캡슐화 (진행 중 Kotlin 인터페이스 기본 메서드가 Spring Data 프록시에서 제대로 디스패치되지 않는 문제를 발견해-Xjvm-default=all컴파일러 옵션을 추가)테스트
다음 명령을 실제로 실행했고 모두 통과했습니다.
./gradlew.bat test --tests "team.cklob.mudda.domain.friend.*" --tests "team.cklob.mudda.domain.member.*" --tests "team.cklob.mudda.domain.block.*"./gradlew.bat test(전체 테스트, 기존 도메인 포함)./gradlew.bat check./gradlew.bat buildDocker Desktop이 사용 가능한 환경이어서 PostgreSQL/PostGIS 기반 Testcontainers 통합 테스트(
FriendRepositoryIntegrationTest,MemberRepositorySearchIntegrationTest)까지 실제로 실행하여 마이그레이션·JPQL 쿼리·unique/CHECK 제약 동작을 검증했습니다.확인 사항
domain/friend/{presentation,application,domain})test/check/build)후속 작업
GetFriendRequestListService(받은/보낸 요청 목록)에는 아직 차단 필터링이 없음 — 현재는 Block API가 없어 실제로 도달 불가능한 경로지만, Block API 구현 시findReceivedRequests/findSentRequests에도 동일한 NOT EXISTS 필터 추가 필요pg_trgmGIN 인덱스로 전환 검토관련 이슈
Closes #21