Skip to content

회원 프로필 조회 및 수정 API 구현 - #18

Merged
hej090224 merged 15 commits into
developfrom
feature/17-member-profile
Aug 1, 2026
Merged

회원 프로필 조회 및 수정 API 구현#18
hej090224 merged 15 commits into
developfrom
feature/17-member-profile

Conversation

@hej090224

Copy link
Copy Markdown
Member

✨ 작업 내용

Member 도메인의 회원 정보 조회·수정 및 프로필 조회 API 세 가지를 구현하였습니다.

  • GET /api/v1/member/me — 로그인한 회원의 내 정보 조회
  • PATCH /api/v1/member/me — 로그인한 회원의 정보 부분 수정
  • GET /api/v1/member/{memberId} — 다른 회원의 프로필 조회 (공개 범위 + 친구 상태 계산 포함)

Member 응답 필드

  • /me: memberId, name, nickname, gender, birthYear, profileImageUrl, bio, profileVisibility, createdAt, updatedAt
  • /{memberId}: memberId, name, nickname, gender, birthYear, profileImageUrl, bio, friendStatus, createdAt
  • 두 응답 모두 email, oauthProvider, providerId 등 Auth 식별 정보는 포함하지 않았습니다.

🔍 리뷰 시 참고사항

age 대신 birthYear를 사용한 이유
Notion 명세 일부에는 age가 있으나, 현재 Auth 회원가입과 Member 엔티티는 이미 birthYear만 저장하고 있습니다. 출생 연도만으로는 정확한 나이를 계산할 수 없어 age를 임의로 계산해 추가하지 않고, 저장·응답 모두 birthYear로 통일했습니다.

profileImageUrl을 사용하고 profileMedia/Media 구현을 제외한 이유
Notion 명세의 profileMedia(mediaId, url, type) 객체는 현재 Media 엔티티가 TimeCapsule에 종속된 구조와 맞지 않습니다. Member 엔티티와 DB에는 이미 profileImageUrl 컬럼이 존재하므로 이를 그대로 사용했고, S3 업로드·파일 검증·Media 도메인 구조 변경은 이번 작업 범위에서 제외했습니다.

profileVisibility 문자열 → enum 전환
Member.profileVisibility가 문자열로 관리되고 있어 ProfileVisibility(PUBLIC/FRIEND/PRIVATE) enum을 추가하고 @Enumerated(EnumType.STRING)으로 매핑했습니다. 컬럼(VARCHAR(20))과 기존 PUBLIC 데이터는 그대로 호환되므로 별도 Flyway 마이그레이션은 추가하지 않았습니다. LoginAuthService의 신규 회원 생성 로직도 문자열 상수 대신 이 enum을 사용하도록 최소 범위로 변경했고, 타입이 바뀐 만큼 기존 Auth 테스트 3개(LoginAuthServiceTest, SignupAuthServiceTest, WithdrawAuthServiceTest)의 Member(...) 생성부도 함께 수정했습니다.

공개 범위 정책

  • 본인 프로필: 공개 범위와 무관하게 항상 조회 가능
  • PUBLIC: 인증된 모든 회원이 조회 가능
  • FRIEND: FriendRequestStatus.ACCEPTED 관계인 회원만 조회 가능
  • PRIVATE: 본인 외 조회 불가, 접근 불가 시 403 Forbidden
  • 판단 로직은 Controller가 아닌 ProfileAccessPolicy(작은 정책 객체)로 분리했습니다.

친구 상태 계산 정책
FriendRepositoryfindByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId를 추가해 requester/receiver 양방향을 한 번에 조회하도록 했습니다.

  • 관계 없음 또는 REJECTED: NONE
  • ACCEPTED: FRIEND
  • PENDING이고 조회자가 requester: REQUESTED
  • PENDING이고 조회자가 receiver: RECEIVED
  • 본인 프로필 조회 시: 관계 조회 없이 NONE

GET /member/{memberId}에서 탈퇴/가입 미완료 회원 처리 관련 해석
"탈퇴한 회원 또는 가입 미완료 회원은 외부 프로필에서 조회할 수 없다"는 요구사항을, 조회자가 본인인지 여부와 무관하게 조회 대상(target) 회원이 탈퇴했거나(withdrawnAt != null) 가입을 완료하지 않은 경우(nickname == null) 이 엔드포인트에서는 항상 404를 반환하는 것으로 해석해 구현했습니다. (/me는 이 제약이 없고 탈퇴 여부만 확인합니다.) 이견이 있다면 알려주세요.

추가한 오류 처리

  • MEMBER_NOT_FOUND (M002, 404) — 회원 없음/탈퇴/가입 미완료 대상 조회
  • PROFILE_ACCESS_DENIED (M003, 403) — 프로필 공개 범위 위반
  • 기존 NICKNAME_ALREADY_EXISTS (M001, 409) 재사용 — 애플리케이션 레벨 중복 검사 + saveAndFlush 시점의 DataIntegrityViolationException을 잡아 DB unique constraint 경합도 동일한 409로 변환
  • 요청 필드 전체 공백, name/nickname 공백 값은 기존 INVALID_INPUT (400) 재사용

PATCH 부분 수정 동작

  • 요청에 없거나 null인 필드는 변경하지 않음
  • name, nickname은 전달된 경우 공백 불가 (빈 문자열 포함)
  • bio, profileImageUrl은 빈 문자열 전달 시 null로 정규화(제거)하고, 값이 있으면 그대로 저장
  • email, oauthProvider, providerId, withdrawnAt은 요청 DTO에 아예 포함하지 않아 수정 불가능

✅ 작성한 테스트

  • GetMyMemberServiceTest — 정상 조회, 존재하지 않는 회원, 탈퇴 회원
  • UpdateMyMemberServiceTest — 부분 수정, 동일 닉네임 허용, 중복 닉네임 거부, 전체 공백 요청 거부, 이름/닉네임 공백 검증, bio/profileImageUrl null 정규화, Auth 필드 불변, 탈퇴 회원 거부, DB 레벨 닉네임 경합
  • GetMemberProfileServiceTestPUBLIC/PRIVATE/FRIEND 공개 범위 전체 시나리오(본인/타인), PENDING 요청 방향별 REQUESTED/RECEIVED, ACCEPTEDFRIEND, 관계 없음·REJECTEDNONE, 탈퇴/가입 미완료/존재하지 않는 회원 거부
  • MemberControllerTest — 세 API의 정상 응답과 공통 응답 구조(ApiResponse), 인증되지 않은 요청 401, 요청 DTO 검증 실패(birthYear 범위) 400, 예외별 상태 코드(404/403/409) 및 오류 응답

🧪 실행한 검증

  • git diff --check — 통과 (공백/개행 이슈 없음)
  • ./gradlew test — Member/Auth 도메인 테스트 전원 통과. RedisAuthAdaptersIntegrationTest, MuddaApplicationTests는 이 환경에 Docker 데몬이 없어 Testcontainers를 기동하지 못해 실패하며, develop에서도 동일하게 실패하는 기존 상태입니다(이번 변경과 무관).
  • ./gradlew build — 위와 동일한 사유로 test 단계에서 실패하나, 컴파일/패키징(bootJar)은 정상 완료됩니다.
  • 저장소에 ktlint 등 별도 검사 Task가 구성되어 있지 않아(AGENTS.md에도 "도입 예정"으로 명시) 추가로 실행하지 않았습니다.

🔎 셀프 코드리뷰

1차 리뷰

  • API 요구사항, Auth 인증 구조 연동(@LoginUser, JWT 필터, ApiResponse), 탈퇴/가입 미완료 회원 처리, 개인정보 노출 여부, 공개 범위 우회 가능성, 친구 요청 방향 계산, 닉네임 중복+DB 경합, PATCH 부분 수정, 트랜잭션/Lazy Loading, 예외 코드-HTTP 상태 일관성을 항목별로 재확인했습니다. 이 과정에서 발견한 개선점을 반영해 처음에 MemberRepository.findActiveById() 기본 메서드로 탈퇴 필터링을 위임했던 구조를, 각 Service가 findByIdwithdrawnAt을 직접 검사하도록 변경했습니다 — Repository의 필터 로직 자체는 테스트하기 어려운 반면, Service에서 명시적으로 검사하면 MockK로 탈퇴 회원 시나리오를 실제로 검증할 수 있기 때문입니다.

2차 리뷰

  • AGENTS.md/CLAUDE.md 패키지 구조·네이밍 규칙 준수 여부, Service 단일 책임, 불필요한 추상화·중복, 무관한 의존성/설정 변경, 기존 Auth/Member 코드의 불필요한 수정, Flyway 마이그레이션 파일 미변경 여부, API 범위 초과 여부, 테스트 실효성, 민감 정보 노출, import 정리 상태, 변경 파일이 이슈 범위에만 속하는지를 재검토했습니다. 추가로 수정할 사항은 발견되지 않았습니다.

🙅 제외한 작업

  • 프로필 이미지 Multipart 업로드, S3 업로드/삭제
  • Media 도메인 구현 및 구조 변경
  • 활동 통계, 뱃지/업적, 회원 검색
  • 친구 요청/수락/거절/삭제 API
  • 회원 차단 API, 타임캡슐 API, 피드/알림 연동, Redis 캐시
  • API 문서 전체 개편, Auth 도메인의 무관한 리팩터링, 기존 코드 대규모 포맷 변경

📌 후속 작업(범위 밖, 참고용)

  • @PathVariable memberId: Long에 숫자가 아닌 값이 들어오면 GlobalExceptionHandlerMethodArgumentTypeMismatchException을 별도로 처리하지 않아 500으로 응답합니다. AuthController{provider} 경로 변수에도 동일하게 존재하는 기존 갭이라 이번 PR에서는 건드리지 않았습니다.

✅ 체크리스트

  • 문서(README, .env.example 등) 변경이 필요한 경우 작성 또는 수정했나요?
  • 작업한 코드가 정상적으로 동작하는 것을 직접 확인했나요? (단위 테스트로 확인, Docker 미가용으로 통합 테스트는 미실행)
  • 필요한 경우 테스트 코드를 작성하거나 수정했나요?
  • Merge 대상 브랜치를 올바르게 설정했나요?
  • PR에 관련 없는 작업이 포함되지 않았나요?
  • 적절한 라벨과 리뷰어를 설정했나요?

📎 관련 이슈(선택)

@cfcromn
cfcromn marked this pull request as ready for review July 31, 2026 12:59
@hej090224
hej090224 requested a review from cfcromn July 31, 2026 13:15
@hej090224 hej090224 self-assigned this Jul 31, 2026
@hej090224 hej090224 added the ✨ Feature 신규 기능 label Jul 31, 2026

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

컨벤션 준수도가 높고 셀프 리뷰 기록과 스펙 이탈 근거가 명확해서 읽기 편했습니다. {Action}{Domain}Service + execute(), presentation/request·response, MockK/@MockkBean, AuthControllerTest와 동일한 @WebMvcTest 셋업까지 그대로 지켜졌고, 서비스 테스트 시나리오도 촘촘합니다. age 대신 birthYear, profileMedia 제외 판단 모두 동의합니다.

머지 전에 봐주시면 좋을 것 세 가지:

  1. (Bug) FriendRepository의 양방향 조회가 Optional인데, uq_friend_requester_receiver(requester_id, receiver_id) 한 방향만 막기 때문에 (1,2)/(2,1) 두 행이 공존할 수 있고 그러면 프로필 조회가 500이 됩니다. 친구 요청 API가 들어오는 시점에 터집니다.
  2. (Logic) 가입 미완료 회원(닉네임 null)이 PATCH /member/me로 닉네임을 설정해 /auth/signup의 필수 필드 요구 + ALREADY_SIGNED_UP 가드를 우회할 수 있습니다.
  3. (Security) profileImageUrl이 검증 없이 저장되어 다른 회원의 클라이언트에 그대로 렌더링됩니다.

그 외에 PRIVATE 프로필의 403 vs 404, ProfileAccessPolicyelse -> false, 내 계정 오류 코드와 auth 도메인의 불일치는 판단이 필요한 부분이라 각 라인에 근거를 적어뒀습니다. 나머지는 취향·일관성 영역이니 편하게 골라 반영하시면 됩니다.

Comment thread src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt Outdated
@hej090224
hej090224 merged commit 2f3e2b9 into develop Aug 1, 2026
2 checks passed
@hej090224
hej090224 deleted the feature/17-member-profile branch August 1, 2026 04:17
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