[volume-1] 회원가입, 내정보 조회, 비밀번호 변경 API 구현 및 테스트 작성.#35
[volume-1] 회원가입, 내정보 조회, 비밀번호 변경 API 구현 및 테스트 작성.#35YoHanKi merged 15 commits intoLoopers-dev-lab:YoHanKifrom
Conversation
- 회원가입 로직 구현 (비밀번호 검증, 중복 체크, 암호화)
- ErrorType 개선: 중복 ID는 CONFLICT(409), 헤더 인증 실패는 UNAUTHORIZED(401)로 분리 (본문 검증은 BAD_REQUEST(400) 유지) - password @Getter 제거: 해시된 비밀번호 직접 조회를 막고 verifyPassword(passwordHasher, rawPw) 행위 메서드로만 검증하도록 제한 - 생성자 접근 제한: telescoping 생성자 5개를 package-private으로 전환하고 create() 팩토리만 public API로 유지 - 비밀번호 변경 저장 방식: dirty checking에 더해 changePassword 이후 repository.save(member)를 명시적으로 호출하도록 변경
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the
✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This pull request implements the core Member domain functionality with a robust Value Object-based design, comprehensive test coverage (22 tests across unit, integration, and E2E levels), and proper HTTP semantic error handling. The implementation follows DDD principles with clean separation of concerns across domain, infrastructure, and interface layers.
Changes:
- Implemented Member domain with 4 Value Objects (MemberId, Email, BirthDate, Name) providing validation at construction
- Added complete Member API endpoints (registration, profile lookup, password change) with header-based authentication
- Segregated error types (CONFLICT/UNAUTHORIZED/BAD_REQUEST) for HTTP-semantic responses
- Encapsulated password field with behavior methods (verifyPassword, changePassword) instead of exposing getters
- Introduced MemberReader component to separate read-only operations from business logic
- Comprehensive documentation including CLAUDE.md and .claude/skills guides
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| MemberModel.java | Entity with static factory pattern, password behavior methods, validation logic |
| MemberService.java | Business logic layer handling registration, authentication, password changes |
| MemberReader.java | Read-only query component with VO conversion and getOrThrow pattern |
| MemberId/Email/BirthDate/Name.java | Value Objects with compact constructor validation |
| MemberRepository*.java | Repository interface and JPA implementations |
| PasswordHasher.java, BCryptPasswordHasher.java | Password hashing abstraction and BCrypt implementation |
| *Converter.java | JPA AttributeConverters for Value Object persistence |
| MemberV1Controller.java | REST API endpoints with header-based authentication |
| MemberV1Dto.java | API DTOs with static factory methods for domain-to-DTO conversion |
| MemberV1ApiSpec.java | Swagger/OpenAPI documentation interface |
| ErrorType.java | Added UNAUTHORIZED enum value for 401 responses |
| ApiControllerAdvice.java | Global exception handling with MethodArgumentNotValidException support |
| *Test.java | Unit (4), Integration (10), and E2E (8) tests covering all functionality |
| README.md, CLAUDE.md, .claude/* | Comprehensive project documentation and development guidelines |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) { | ||
| String message = e.getBindingResult().getFieldErrors().stream() | ||
| .map(error -> String.format("필드 '%s' (%s)의 값 '%s'이(가) 잘못되었습니다.", error.getField(), error.getRejectedValue(), error.getDefaultMessage())) |
There was a problem hiding this comment.
The error message format in the MethodArgumentNotValidException handler is incorrect. The format string has the parameter order wrong - it shows "필드 '%s' (%s)의 값 '%s'" but maps field, rejectedValue, defaultMessage. This should be "필드 '%s'의 값 '%s'이(가) 잘못되었습니다. (%s)" with field, rejectedValue, and defaultMessage in that order, where defaultMessage contains the validation error reason.
| .map(error -> String.format("필드 '%s' (%s)의 값 '%s'이(가) 잘못되었습니다.", error.getField(), error.getRejectedValue(), error.getDefaultMessage())) | |
| .map(error -> String.format("필드 '%s'의 값 '%s'이(가) 잘못되었습니다. (%s)", error.getField(), error.getRejectedValue(), error.getDefaultMessage())) |
| throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); | ||
| } | ||
| member.changePassword(currentPassword, newPassword, passwordHasher); | ||
| memberRepository.save(member); |
There was a problem hiding this comment.
The explicit memberRepository.save(member) call on line 44 appears unnecessary. Since changePassword is annotated with @Transactional and MemberModel is a managed JPA entity, JPA dirty checking should automatically persist the password change when the transaction commits. The explicit save call is redundant and contradicts the PR description which mentions "비밀번호 변경은 JPA dirty checking 기반으로 save 없이 반영되는 구조가 적합". Consider removing this line to rely on JPA's automatic change detection.
| memberRepository.save(member); |
📌 Summary
🧭 Context & Decision
문제 정의
현재 동작/제약:
문제(또는 리스크):
성공 기준(완료 정의):
선택지와 결정
고려한 대안:
최종 결정: B 선택
트레이드오프:
추후 개선 여지(있다면):
🏗️ Design Overview
변경 범위
영향 받는 모듈/도메인:
domain/member(MemberModel, MemberService, MemberReader, VO, ErrorType 연계)interfaces/api/member(Controller, DTO, ApiSpec)infrastructure/security,infrastructure/member,jpa/converter신규 추가:
제거/대체:
주요 컴포넌트 책임
MemberModel:MemberService:MemberReader:MemberRepository+MemberRepositoryImpl+MemberJpaRepository:ApiControllerAdvice:ApiResponse래퍼 유지)🔁 Flow Diagram
Main Flow
1) 회원가입
POST /api/v1/members/registersequenceDiagram autonumber participant Client participant Controller as MemberV1Controller participant Service as MemberService participant Reader as MemberReader participant Repo as MemberRepository participant Model as MemberModel participant Hasher as PasswordHasher participant DB Client->>Controller: POST /api/v1/members/register\nRegisterRequest Controller->>Service: register(cmd) Service->>Reader: existsByMemberId(memberId) Reader->>Repo: existsByMemberId(memberId) Repo->>DB: SELECT EXISTS ... DB-->>Repo: true/false Repo-->>Reader: true/false Reader-->>Service: true/false alt duplicated memberId Service-->>Controller: throw CoreException(CONFLICT) Controller-->>Client: 409 ApiResponse FAIL else not duplicated Service->>Model: create(raw..., gender, birthDate, ...) Model->>Model: validateRawPassword() Model->>Model: validatePasswordNotContainsBirthDate() Model->>Model: validateGender() Model->>Hasher: hash(rawPassword) Hasher-->>Model: hashedPassword Service->>Repo: save(member) Repo->>DB: INSERT ... DB-->>Repo: saved entity Repo-->>Service: saved entity Service-->>Controller: MemberResponse Controller-->>Client: 200 ApiResponse SUCCESS end2) 내 정보 조회
GET /api/v1/members/mesequenceDiagram autonumber participant Client participant Controller as MemberV1Controller participant Service as MemberService participant Reader as MemberReader participant Repo as MemberRepository participant Model as MemberModel participant Hasher as PasswordHasher participant DB Client->>Controller: GET /api/v1/members/me\nHeaders: X-Loopers-LoginId, X-Loopers-LoginPw Controller->>Service: authenticate(loginId, loginPw) Service->>Reader: getOrThrow(loginId) Reader->>Repo: findByMemberId(loginId) Repo->>DB: SELECT ... DB-->>Repo: member row / empty Repo-->>Reader: member / null alt member not found Reader-->>Service: throw CoreException(NOT_FOUND) Service-->>Controller: exception Controller-->>Client: 404 ApiResponse FAIL else member found Service->>Model: verifyPassword(hasher, loginPw) Model->>Hasher: matches(loginPw, hashed) Hasher-->>Model: true/false alt password mismatch (auth) Model-->>Service: false Service-->>Controller: throw CoreException(UNAUTHORIZED) Controller-->>Client: 401 ApiResponse FAIL else ok Service-->>Controller: MeResponse (masked name) Controller-->>Client: 200 ApiResponse SUCCESS end end3) 비밀번호 변경
PATCH /api/v1/members/me/passwordsequenceDiagram autonumber participant Client participant Controller as MemberV1Controller participant Service as MemberService participant Reader as MemberReader participant Repo as MemberRepository participant Model as MemberModel participant Hasher as PasswordHasher participant DB Client->>Controller: PATCH /api/v1/members/me/password\nHeaders: LoginId/LoginPw\nBody: {currentPassword, newPassword} Controller->>Service: changePassword(loginId, loginPw, req) Service->>Reader: getOrThrow(loginId) Reader->>Repo: findByMemberId(loginId) Repo->>DB: SELECT ... DB-->>Repo: member / empty Repo-->>Reader: member / null alt member not found Reader-->>Service: throw CoreException(NOT_FOUND) Service-->>Controller: exception Controller-->>Client: 404 ApiResponse FAIL else member found Service->>Model: verifyPassword(hasher, loginPw) Model->>Hasher: matches(loginPw, hashed) Hasher-->>Model: true/false alt header auth failed Service-->>Controller: throw CoreException(UNAUTHORIZED) Controller-->>Client: 401 ApiResponse FAIL else header auth ok Service->>Model: changePassword(hasher, currentPw, newPw) Model->>Hasher: matches(currentPw, hashed) Hasher-->>Model: true/false alt currentPassword mismatch Model-->>Service: throw CoreException(BAD_REQUEST) Service-->>Controller: exception Controller-->>Client: 400 ApiResponse FAIL else currentPassword ok Model->>Hasher: matches(newPw, hashed) Hasher-->>Model: true/false alt newPassword equals old Model-->>Service: throw CoreException(BAD_REQUEST) Service-->>Controller: exception Controller-->>Client: 400 ApiResponse FAIL else newPassword differs Model->>Model: validateRawPassword(newPw) Model->>Model: validatePasswordNotContainsBirthDate(newPw) Model->>Hasher: hash(newPw) Hasher-->>Model: newHashed Model->>Model: set password = newHashed Service->>DB: TX commit (dirty checking flush) DB-->>Service: updated Service-->>Controller: success(null) Controller-->>Client: 200 ApiResponse SUCCESS end end end end예외 흐름