⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

[volume-1] 회원가입, 내정보 조회, 비밀번호 변경 API 구현 및 테스트 작성.#35

Merged
YoHanKi merged 15 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:feat/week1-register
Feb 7, 2026
Merged

[volume-1] 회원가입, 내정보 조회, 비밀번호 변경 API 구현 및 테스트 작성.#35
YoHanKi merged 15 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:feat/week1-register

Conversation

@YoHanKi
Copy link

@YoHanKi YoHanKi commented Feb 6, 2026

📌 Summary

  • 배경: Member 도메인의 핵심 기능(회원가입/내정보조회/비밀번호변경)을 Value Object 기반으로 설계했지만, 서비스 레이어에 규칙이 흩어지고 예외 시맨틱이 단일화(BAD_REQUEST)되어 있어 확장/유지보수 및 API 의미 전달이 약했다.
  • 목표: 도메인 검증을 VO/Model로 캡슐화하고, REST API + 통합/E2E 테스트까지 포함한 “전 레이어” 구현을 완결한다. 또한 에러 타입을 HTTP 시맨틱에 맞게 정리하고 password 접근을 행위 중심으로 제한한다.
  • 결과: VO 4종 및 검증 테스트 구축, 회원가입/내정보조회/비밀번호변경 API 구현, ErrorType(CONFLICT/UNAUTHORIZED) 분리, password getter 제거 및 changePassword() 도메인 행위 도입, 통합/E2E 총 22개 테스트 통과.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약:

    • Member 엔티티 필드 검증이 원시 타입 중심이면 검증 로직이 서비스/컨트롤러로 퍼질 수 있음.
    • 예외가 BAD_REQUEST로 뭉치면 API 사용자가 “왜 실패했는지”를 HTTP 레벨에서 구분하기 어려움.
    • password getter로 해시가 외부로 노출되면 테스트/코드가 내부 구현에 결합되기 쉬움.
    • 비밀번호 변경은 JPA dirty checking 기반으로 save 없이 반영되는 구조가 적합.
  • 문제(또는 리스크):

    • 생성/변경 규칙이 서비스에 남아 있으면 도메인 모델이 빈약해지고 규칙 누락/우회 가능성이 생김.
    • 인증 실패(401)와 입력 오류(400), 리소스 충돌(409)이 섞이면 오류 처리/클라이언트 UX가 난해해짐.
    • Password를 VO로 만들면 “해시 값이 VO 검증을 통과하지 못하는 문제”가 구조적으로 발생할 수 있음.
  • 성공 기준(완료 정의):

    • VO 기반 검증 + MemberModel의 도메인 행위(create/changePassword/verifyPassword)로 규칙이 캡슐화된다.
    • 회원가입/내정보조회/비밀번호변경 API가 동작하고, 통합/E2E 테스트가 모두 통과한다.
    • ErrorType이 CONFLICT/UNAUTHORIZED 등을 포함해 HTTP 의미에 맞게 분리된다.
    • password 값의 직접 조회(getter)가 제거되고 행위 메서드로만 검증한다.

선택지와 결정

  • 고려한 대안:

    • A: password도 Value Object로 래핑하고 Converter로 영속화한다.
    • B: password는 엔티티 내부 String(해시)로 유지하고, 외부 노출은 행위 메서드로 제한한다.
  • 최종 결정: B 선택

  • 트레이드오프:

    • VO로 통일된 “타입 안정성”은 포기하지만, 해시 저장/조회라는 특수성을 깨끗하게 처리할 수 있다.
    • 검증 책임은 “raw 입력 시점”에만 적용되므로 create/changePassword 경로를 강제하는 설계가 중요해진다.
  • 추후 개선 여지(있다면):

    • 헤더 기반 인증은 과제용으로 유지하되, 운영 수준에서는 JWT/세션으로 교체하고 헤더 민감정보 로깅 방지 정책을 추가한다.
    • VO의 검색(LIKE/부분검색) 요구가 생기면 Search 전용 타입/정책(예: SearchMemberId)을 별도로 설계한다.

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인:

    • domain/member (MemberModel, MemberService, MemberReader, VO, ErrorType 연계)
    • interfaces/api/member (Controller, DTO, ApiSpec)
    • infrastructure/security, infrastructure/member, jpa/converter
    • 통합/E2E 테스트 영역
  • 신규 추가:

    • Value Object 4종 및 Converter
    • 비밀번호 변경 API(DTO/Spec/Controller)와 도메인 행위(changePassword)
    • ErrorType: UNAUTHORIZED(401) 등 시맨틱 분리
  • 제거/대체:

    • password getter 제거 → verifyPassword() 행위 메서드로 대체
    • public telescoping constructors → package-private로 제한, create()만 public

주요 컴포넌트 책임

  • MemberModel:

    • 단일 엔티티 규칙 캡슐화(create(), verifyPassword(), changePassword())
    • 비밀번호 형식/생년월일 포함 여부/성별 null 검증 수행
    • password 필드 외부 노출 금지(행위 메서드만 제공)
  • MemberService:

    • 유스케이스 오케스트레이션(register/authenticate/changePassword)
    • 교차 엔티티 규칙(중복 체크 등)과 트랜잭션 경계 담당
  • MemberReader:

    • 조회 전용 컴포넌트(getOrThrow/existsByMemberId 등), 읽기 정책 분리
  • MemberRepository + MemberRepositoryImpl + MemberJpaRepository:

    • 도메인/인프라 경계 유지, 영속화 구현 분리
  • ApiControllerAdvice:

    • CoreException(ErrorType)을 HTTP 응답으로 표준화(ApiResponse 래퍼 유지)

🔁 Flow Diagram

Main Flow

1) 회원가입 POST /api/v1/members/register

sequenceDiagram
  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
  end
Loading

2) 내 정보 조회 GET /api/v1/members/me

sequenceDiagram
  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
  end
Loading

3) 비밀번호 변경 PATCH /api/v1/members/me/password

sequenceDiagram
  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
Loading

예외 흐름

sequenceDiagram
  autonumber
  participant App as ApiControllerAdvice
  participant Client

  Note over App: CoreException(ErrorType) 발생 시
  App-->>Client: 400 BAD_REQUEST
  App-->>Client: 401 UNAUTHORIZED
  App-->>Client: 404 NOT_FOUND
  App-->>Client: 409 CONFLICT
Loading

- 회원가입 로직 구현 (비밀번호 검증, 중복 체크, 암호화)
- 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)를 명시적으로 호출하도록 변경
Copilot AI review requested due to automatic review settings February 6, 2026 02:30
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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()))
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
.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()))

Copilot uses AI. Check for mistakes.
throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다.");
}
member.changePassword(currentPassword, newPassword, passwordHasher);
memberRepository.save(member);
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
memberRepository.save(member);

Copilot uses AI. Check for mistakes.
@YoHanKi YoHanKi merged commit 677e53e into Loopers-dev-lab:YoHanKi Feb 7, 2026
13 checks passed
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.

1 participant