Skip to content

Friend 도메인 API 구현 - #22

Merged
hej090224 merged 5 commits into
developfrom
feature/21-friend-domain
Aug 5, 2026
Merged

Friend 도메인 API 구현#22
hej090224 merged 5 commits into
developfrom
feature/21-friend-domain

Conversation

@hej090224

@hej090224 hej090224 commented Aug 5, 2026

Copy link
Copy Markdown
Member

개요

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/FriendRequestType enum 재사용
  • Friend 전용 ErrorCode(F001~F010) 추가
  • MemberRepository/BlockRepository에 Friend 기능에 필요한 최소 조회 메서드 추가
  • Swagger 문서화 (@Tag, @Operation, 주요 응답 코드)
  • 단위·Controller·Repository 통합 테스트 작성
  • Flyway V4 마이그레이션 추가

주요 설계 결정

  • Friend row 방향 처리: 기존 FriendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId를 그대로 재사용해 양방향 조회. requester/receiver 어느 쪽이 로그인 사용자인지에 따라 응답의 "상대방"을 결정.
  • ACCEPTED 관계 조회: JOIN FETCH로 requester/receiver를 함께 가져와 N+1을 방지 (findFriendships, findReceivedRequests, findSentRequests).
  • 반대 방향 PENDING 요청 처리: 자동 ACCEPT 대신 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_receiverWHERE status <> 'REJECTED' partial index로 교체해 막았습니다(REJECTED는 이력으로 몇 개든 남을 수 있고, PENDING/ACCEPTED는 방향당 하나만 — 코드 리뷰로 발견된 "거절 후 재요청 영구 불가" 버그의 근본 수정이기도 합니다. 아래 참고).
  • Block 연동 범위: Block API 자체는 구현하지 않고, 기존 BlockRepository에 조회 메서드만 추가했습니다. 검색과 친구 목록 모두 NOT EXISTS 서브쿼리로 차단 회원을 DB 레벨에서 제외해 페이지네이션 메타데이터(totalElements/hasNext)가 정확합니다. 친구 요청 전송은 방향에 따라 BLOCKED_MEMBER(내가 차단)/MEMBER_NOT_FOUND(상대가 차단, 정보 노출 방지)로 나눠 응답하고, 수락 시점에도 재검증합니다. 받은 요청 목록(GetFriendRequestListService)에는 아직 차단 필터링을 넣지 않았습니다 — 후속 작업 참고.
  • 페이지네이션: 프로젝트에 기존 공통 페이지 응답이 없어 Friend 도메인 전용 최소 FriendPageResponse<T>를 두었습니다. spring.data.web.pageable.default-page-size=20, max-page-size=50을 전역 설정에 추가해 과도하게 큰 size 요청을 서버 레벨에서 캡핑합니다.
  • 검색 우선순위: MemberRepository JPQL에서 정확히 일치 > prefix 일치 > contains 일치 순으로 정렬(Elasticsearch 등 별도 검색 엔진 도입 없이 SQL CASE로 처리). LIKE 이스케이프 로직은 MemberRepository의 3-arg 기본 메서드로 캡슐화해 서비스 계층에 SQL 세부사항이 새어나가지 않도록 했습니다.
  • 정렬 안정성: 모든 목록 쿼리의 ORDER BYf.id DESC tie-breaker를 추가해, 같은 초에 여러 row가 생성/수락되어도 페이지 경계에서 누락·중복이 생기지 않도록 했습니다.
  • 불변식의 DB 강제: RespondFriendRequestService가 ACCEPTED 전이 시 항상 acceptedAt을 채운다는 전제가 코드 컨벤션으로만 존재했는데, ck_friend_accepted_at CHECK 제약으로 DB 레벨에도 못박아 FriendResponse.ofrequireNotNull이 안전하다는 걸 보장합니다.

코드 리뷰 반영 (@cfcromn)

cfcromn님이 12건의 인라인 리뷰를 남겨주셨고, 전부 검토 후 반영했습니다 (커밋 98a5015, 0af5b2b). 각 코멘트에 답장으로 근거를 남겼습니다. 요약:

  • [Blocker] 거절 후 재요청이 DB 제약으로 영구히 막히는 버그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을 뭉뚱그려 변환하는 문제, requireNotNull 500 위험 → 전부 반영
  • 두 Testcontainers IT 클래스의 중복 설정을 공유 베이스 클래스(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 build

Docker Desktop이 사용 가능한 환경이어서 PostgreSQL/PostGIS 기반 Testcontainers 통합 테스트(FriendRepositoryIntegrationTest, MemberRepositorySearchIntegrationTest)까지 실제로 실행하여 마이그레이션·JPQL 쿼리·unique/CHECK 제약 동작을 검증했습니다.

확인 사항

  • 기존 패키지 구조 준수 (domain/friend/{presentation,application,domain})
  • 요청자/수신자 권한 검증 (요청 수신자만 수락·거절 가능)
  • 중복 요청 방지 (같은 방향/반대 방향 모두 애플리케이션 + DB 레벨, 거절 후 재요청도 가능)
  • 양방향 친구 관계 처리
  • 예외 처리 (Friend 전용 ErrorCode 10개, 기존 코드 재사용 가능한 곳은 재사용)
  • Swagger 문서화
  • 테스트 작성 (단위/Controller/Repository 통합 테스트)
  • 전체 테스트 통과 (test/check/build)
  • 셀프 코드 리뷰 2회 + 팀 코드 리뷰(@cfcromn) 12건 전체 반영 완료

후속 작업

  • Notification 도메인 구현 후 친구 요청·수락 이벤트에 FCM 알림 연결
  • Block 도메인 API(차단/차단 해제) 구현 후 차단 정책 전반 재검증
  • TimeCapsule 수신자 지정 및 FRIEND 공개 범위 조회에서 이번 Friend 관계 조회 로직 연동
  • GetFriendRequestListService(받은/보낸 요청 목록)에는 아직 차단 필터링이 없음 — 현재는 Block API가 없어 실제로 도달 불가능한 경로지만, Block API 구현 시 findReceivedRequests/findSentRequests에도 동일한 NOT EXISTS 필터 추가 필요
  • 닉네임 검색이 leading-wildcard LIKE라 인덱스를 못 타는 점(현재 규모에서는 문제없음) — 검색 트래픽/회원 수가 커지면 pg_trgm GIN 인덱스로 전환 검토

관련 이슈

Closes #21

@hej090224 hej090224 added the ✨ Feature 신규 기능 label Aug 5, 2026
@hej090224 hej090224 self-assigned this Aug 5, 2026
@hej090224
hej090224 requested a review from cfcromn August 5, 2026 08:24
@hej090224
hej090224 marked this pull request as ready for review August 5, 2026 08:41

@cfcromn cfcromn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

총평

Friend 도메인 6개 API를 패키지 구조(domain/friend/{presentation,application,domain}), {Action}{Domain}Service + execute() 컨벤션, MockK/@MockkBean/Testcontainers 테스트 규칙까지 AGENTS.md에 정의된 대로 정확히 지켜서 구현하셨습니다. 특히 좋았던 부분:

  • 역방향 동시 요청 레이스를 애플리케이션 검증만으로 끝내지 않고 LEAST/GREATEST partial 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_receiveridx_friend_receiver_status와 중복 → DROP INDEX
  • 닉네임 검색이 선행 와일드카드 LIKE라 풀스캔 (나중에 pg_trgm)
  • requireNotNull(friend.acceptedAt) 하나가 페이지 전체를 500으로 만듦 → CHECK 제약 또는 fallback
  • escapeLike가 서비스에 노출 → repository 기본 구현으로 캡슐화
  • PostgisContainer + 컨테이너 설정이 두 IT 파일에 중복 → 추상 베이스 클래스로 추출

각 항목은 해당 라인 코멘트에 이유와 수정 예시를 적어뒀습니다. 반박하실 부분 있으면 편하게 말씀해주세요 — 특히 차단 정책과 재요청 시 createdAt 처리 방식은 제품 판단이 필요한 영역이라 하민님 결정을 따르겠습니다.

Comment thread src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt
- 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).
@hej090224
hej090224 merged commit 27b3065 into develop Aug 5, 2026
2 checks passed
@hej090224
hej090224 deleted the feature/21-friend-domain branch August 5, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 신규 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants