⚠ 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] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현#36

Open
Praesentia-YKM wants to merge 9 commits intoLoopers-dev-lab:Praesentia-YKMfrom
Praesentia-YKM:volume-1
Open

[Volume 1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현#36
Praesentia-YKM wants to merge 9 commits intoLoopers-dev-lab:Praesentia-YKMfrom
Praesentia-YKM:volume-1

Conversation

@Praesentia-YKM
Copy link

@Praesentia-YKM Praesentia-YKM commented Feb 6, 2026

📌 Summary

배경:

  • 회원 도메인(Member) 기능이 필요하여 회원가입, 내 정보 조회, 비밀번호 변경 API를 구현함.

목표:

  • TDD(Red → Green → Refactor) 사이클을 실제 도메인 개발에 적용하며 체득
  • Claude Code를 활용한 증강 코딩 워크플로우 경험
  • Member 도메인 설계 및 API 구현

결과:

  • 3개 API 구현 완료 (POST /api/v1/members, GET /api/v1/members/me, PATCH /api/v1/members/me/password)
  • Value Object(LoginId, Email, MemberName, Password)를 통한 자기 검증 도메인 모델
  • 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw)
  • Unit / Integration / E2E 3계층 테스트 전략 적용

💬 리뷰 희망 부분

1. Value Object 설계 — String vs VO, 어디까지 감쌀 것인가

배경 및 문제 상황

  • 원시 타입(String)으로 도메인 필드를 관리하면 유효성 검증이 서비스 계층에 흩어짐
  • loginId에 특수문자가 들어오거나, email에 잘못된 형식이 들어와도 엔티티 생성 시점에는 알 수 없음
  • "잘못된 도메인 객체는 존재할 수 없어야 한다"는 원칙을 적용하고자 함

해결 방안

  • LoginId, Email, MemberName을 @Embeddable VO로 캡슐화하여 생성자에서 검증
  • VO 생성 자체가 유효성 검증이 되므로, 서비스 계층에서 별도 검증 로직이 필요 없음

구현 세부사항

  • 각 VO는 생성자에서 null/빈값/형식을 검증하고, 실패 시 CoreException을 던짐
  • JPA @Embeddable + @AttributeOverride로 DB 컬럼 매핑
// LoginId — 영문+숫자만 허용
public LoginId(String value) {
    if (value == null || value.isBlank() || !PATTERN.matcher(value).matches()) {
        throw new CoreException(ErrorType.INVALID_LOGIN_ID);
    }
    this.value = value;
}

고민한 점

  • JPA 매핑 복잡도가 늘어나는 트레이드오프가 있지만, 도메인 무결성이 런타임 수준에서 보장됨
  • VO 자체가 검증의 단일 책임을 가져 테스트가 용이 (VO 단위 테스트만으로 검증 로직 커버 가능)
  • 이 분리 전략(VO 3개 + Password 별도)이 적절한지 피드백 부탁드립니다

2. Password 처리 전략 — 검증용 VO와 저장용 String의 분리

배경 및 문제 상황

  • Password도 다른 VO처럼 @Embeddable로 만들면 자연스러울 것 같지만, DB에는 BCrypt 인코딩된 값이 저장됨
  • 인코딩된 문자열은 "길이 8-16, 생년월일 포함 금지" 같은 원본 비밀번호 정책을 만족하지 않으므로, VO의 생성자 검증과 충돌

해결 방안

  • Password VO는 입력 검증 전용으로만 사용하고, 엔티티에는 인코딩된 String으로 저장
  • 검증 시점에 따라 두 가지 생성 경로를 구분:
시나리오 사용 방식 이유
회원가입 Password.of(rawPw, birthDate) 형식 + 생년월일을 한 번에 검증 (birthDate가 이미 있으므로)
비밀번호 변경 new Password(newRawPw)validateAgainst(birthDate) 형식 검증과 생년월일 검증 사이에 기존 PW 동일 여부 체크가 끼어야 해서 분리

관련 코드

// 팩토리 메서드 — 회원가입 시 한 번에 검증
public static Password of(String value, LocalDate birthDate) {
    Password password = new Password(value);   // 형식 검증
    password.validateAgainst(birthDate);        // 생년월일 검증
    return password;
}

// 생년월일 검증 — YYYYMMDD, YYMMDD 두 형식 모두 체크
public void validateAgainst(LocalDate birthDate) {
    if (birthDate == null) return;
    String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE);
    String yymmdd = yyyymmdd.substring(2);
    if (value.contains(yyyymmdd) || value.contains(yymmdd)) {
        throw new CoreException(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE);
    }
}

고민한 점

  • Password VO가 엔티티에 임베드되지 않아 다른 VO들과 일관성이 깨지는 점이 아쉬움
  • 하지만 "인코딩 전 원본"과 "인코딩 후 저장값"의 책임이 명확히 분리되는 장점이 더 크다고 판단
  • Password를 완전히 VO에서 분리하는 것 vs 더 나은 구조가 있는지 의견 부탁드립니다

3. 도메인 서비스 분리 — 단일 서비스 vs 기능별 분리

배경 및 문제 상황

  • 회원 도메인의 비즈니스 로직이 회원가입, 인증, 비밀번호 변경으로 성격이 다름
  • 단일 MemberService에 모든 로직을 넣으면 클래스가 비대해지고, 의존성이 혼재됨
    • 예: 회원가입은 PasswordEncoder만 필요, 인증은 PasswordEncoder + 조회, 비밀번호 변경은 전부 필요

해결 방안

  • 기능별 3개 도메인 서비스로 분리하고, MemberFacade가 오케스트레이션
서비스 핵심 책임 의존성
MemberSignupService VO 생성 + 중복 검증 + 인코딩 + 저장 Repository, PasswordEncoder
MemberAuthService loginId 조회 + 비밀번호 매칭 Repository, PasswordEncoder
MemberPasswordService 현재 PW 확인 + 정책 검증 + 변경 + 저장 Repository, PasswordEncoder

관련 코드

// MemberFacade — 서비스 오케스트레이션 (비밀번호 변경 예시)
public void changePassword(String loginId, String password,
                           String currentPassword, String newPassword) {
    MemberModel member = memberAuthService.authenticate(loginId, password);
    memberPasswordService.changePassword(member, currentPassword, newPassword);
}

고민한 점

  • 클래스 수가 늘어나지만, 각 서비스의 테스트가 독립적이고 변경 범위가 격리됨
  • 향후 인증 방식이 바뀌어도 MemberAuthService만 수정하면 됨
  • 3개로 분리하는 것이 과도한지, 아니면 적절한 수준인지 의견 부탁드립니다

4. 비밀번호 변경 검증 순서

배경 및 문제 상황

  • 비밀번호 변경 시 4가지 검증이 필요: 현재 PW 일치 확인, 새 PW 형식 검증, 기존 PW와 동일 여부, 생년월일 포함 여부
  • 검증 순서에 따라 사용자가 받는 에러 메시지가 달라지므로, 가장 먼저 실패해야 할 검증이 앞에 와야 함

해결 방안

  • "비용이 낮고, 먼저 알려줘야 하는 검증"을 앞에 배치:
    1. 현재 PW 확인 → 인증 실패는 가장 먼저 차단
    2. 형식 검증 (new Password(newRawPw)) → 형식이 틀리면 이후 검증 불필요
    3. 기존 PW 동일 여부 → BCrypt 비교 (비용 있음), 형식 통과 후에만
    4. 생년월일 검증 (validateAgainst) → 최종 정책 검증

관련 코드

public void changePassword(MemberModel member, String currentPassword, String newRawPassword) {
    if (!member.matchesPassword(currentPassword, passwordEncoder)) {  // Step 1
        throw new CoreException(ErrorType.PASSWORD_MISMATCH);
    }
    Password newPassword = new Password(newRawPassword);              // Step 2
    if (member.matchesPassword(newRawPassword, passwordEncoder)) {    // Step 3
        throw new CoreException(ErrorType.PASSWORD_SAME_AS_OLD);
    }
    newPassword.validateAgainst(member.birthDate());                  // Step 4
    member.changePassword(passwordEncoder.encode(newRawPassword));
    memberRepository.save(member);
}

고민한 점

  • Step 2와 Step 3의 순서: 형식 검증(Step 2)을 BCrypt 비교(Step 3)보다 앞에 둔 이유는, 형식이 틀린 비밀번호를 BCrypt로 비교하는 것은 낭비이기 때문
  • Step 3과 Step 4 사이에서 Password.of()를 사용하지 않은 이유는, of()는 형식+생년월일을 한 번에 검증하지만 여기서는 그 사이에 동일 PW 체크가 끼어야 해서 분리
  • 에러코드를 단계별로 세분화(PASSWORD_MISMATCH / INVALID_PASSWORD / PASSWORD_SAME_AS_OLD / PASSWORD_CONTAINS_BIRTH_DATE)하여 클라이언트가 어떤 검증에서 실패했는지 정확히 알 수 있도록 했는데, 반대로 INVALID_PASSWORD 하나로 통합하면 비밀번호 정책 내부 구현을 숨길 수 있는 장점도 있음. 이 수준의 에러코드 세분화가 적절한지 의견 부탁드립니다
  • **검증 순서가 사용자 경험과 보안 관점에서 적절한지도 궁금합니다.

🏗️ 변경점

신규 추가:

계층 파일 책임
Domain MemberModel 회원 엔티티 (BaseEntity 상속, soft-delete 지원)
Domain LoginId, Email, MemberName @embeddable Value Objects (생성 시 자기 검증)
Domain Password 입력 검증용 VO (길이 8-16, 문자 규칙, 생년월일 포함 금지)
Domain MemberSignupService 회원가입 (VO 생성 + 중복 검증 + 인코딩 + 저장)
Domain MemberAuthService 인증 (loginId 조회 + 비밀번호 매칭)
Domain MemberPasswordService 비밀번호 변경 (현재 PW 확인 + 정책 검증 + 변경 + 저장)
Domain MemberRepository 도메인 레포지토리 인터페이스 (save, findByLoginId)
Application MemberFacade 유스케이스 조합 (서비스 오케스트레이션)
Application MemberInfo 응답용 record (from, fromWithMaskedName)
Interface MemberV1Controller REST API 엔드포인트 (헤더 기반 인증)
Interface MemberV1Dto 요청/응답 record (SignupRequest, MemberResponse, ChangePasswordRequest)
Interface MemberV1ApiSpec Swagger API 스펙 인터페이스
Infra MemberRepositoryImpl 도메인 레포지토리 구현체
Infra MemberJpaRepository Spring Data JPA 인터페이스 (findByLoginIdValue)
Config PasswordEncoderConfig BCryptPasswordEncoder Bean

제거/대체:

  • 없음 (신규 구현)

🔁 Flow Diagram

1. POST /api/v1/members (회원가입)

sequenceDiagram
    participant Client
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant SignupSvc as MemberSignupService
    participant Repository as MemberRepository

    Client->>Controller: POST /api/v1/members<br/>Body: {loginId, password, name, birthDate, email}
    Controller->>Facade: signup(loginId, password, name, birthDate, email)
    Facade->>SignupSvc: signup(loginId, rawPassword, name, birthDate, email)

    Note over SignupSvc: Value Object 생성 (자동 검증)<br/>LoginId(loginId) - 영문+숫자 검증<br/>MemberName(name) - 빈값 검증<br/>Email(email) - 형식 검증<br/>Password.of(rawPw, birthDate) - 길이/문자/생년월일 검증

    alt VO 검증 실패
        SignupSvc-->>Client: CoreException (INVALID_LOGIN_ID / INVALID_PASSWORD / ...)
    else 검증 통과
        SignupSvc->>Repository: findByLoginId(loginId)
        alt loginId 중복
            SignupSvc-->>Client: CoreException (DUPLICATE_LOGIN_ID)
        else 중복 아님
            SignupSvc->>SignupSvc: passwordEncoder.encode(rawPassword)
            SignupSvc->>SignupSvc: new MemberModel(loginIdVo, encodedPw, nameVo, birthDate, emailVo)
            SignupSvc->>Repository: save(memberModel)
            Repository-->>SignupSvc: MemberModel
            SignupSvc-->>Facade: MemberModel
            Facade->>Facade: MemberInfo.from(member)
            Facade-->>Controller: MemberInfo
            Controller-->>Client: ApiResponse {loginId, name, birthDate, email}
        end
    end
Loading

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

sequenceDiagram
    participant Client
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant AuthSvc as MemberAuthService
    participant Repository as MemberRepository

    Client->>Controller: GET /api/v1/members/me<br/>Headers: X-Loopers-LoginId, X-Loopers-LoginPw
    Controller->>Facade: getMyInfo(loginId, password)
    Facade->>AuthSvc: authenticate(loginId, password)
    AuthSvc->>Repository: findByLoginId(loginId)

    alt 회원 없음
        AuthSvc-->>Client: CoreException (MEMBER_NOT_FOUND)
    else 회원 존재
        AuthSvc->>AuthSvc: member.matchesPassword(password, encoder)
        alt 비밀번호 불일치
            AuthSvc-->>Client: CoreException (AUTHENTICATION_FAILED)
        else 인증 성공
            AuthSvc-->>Facade: MemberModel
            Facade->>Facade: MemberInfo.fromWithMaskedName(member)
            Note over Facade: 이름 마스킹 처리 (홍길동 → 홍길*)
            Facade-->>Controller: MemberInfo
            Controller-->>Client: ApiResponse {loginId, maskedName, birthDate, email}
        end
    end
Loading

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

sequenceDiagram
    participant Client
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant AuthSvc as MemberAuthService
    participant PwSvc as MemberPasswordService
    participant Repository as MemberRepository

    Client->>Controller: PATCH /api/v1/members/me/password<br/>Headers: X-Loopers-LoginId, X-Loopers-LoginPw<br/>Body: {currentPassword, newPassword}
    Controller->>Facade: changePassword(loginId, password, currentPw, newPw)

    Facade->>AuthSvc: authenticate(loginId, password)
    Note over AuthSvc: 헤더 기반 인증 (loginId 조회 + PW 매칭)
    AuthSvc-->>Facade: MemberModel (인증된 회원)

    Facade->>PwSvc: changePassword(member, currentPw, newRawPw)

    PwSvc->>PwSvc: member.matchesPassword(currentPw, encoder)
    alt 현재 비밀번호 불일치
        PwSvc-->>Client: CoreException (PASSWORD_MISMATCH)
    else 일치
        PwSvc->>PwSvc: new Password(newRawPw) - 형식 검증
        PwSvc->>PwSvc: member.matchesPassword(newRawPw, encoder)
        alt 새 비밀번호 == 기존 비밀번호
            PwSvc-->>Client: CoreException (PASSWORD_SAME_AS_OLD)
        else 다름
            PwSvc->>PwSvc: password.validateAgainst(member.birthDate()) - 생년월일 검증
            alt 생년월일 포함
                PwSvc-->>Client: CoreException (PASSWORD_CONTAINS_BIRTH_DATE)
            else 검증 통과
                PwSvc->>PwSvc: member.changePassword(encoder.encode(newRawPw))
                PwSvc->>Repository: save(member)
                PwSvc-->>Facade: void
                Facade-->>Controller: void
                Controller-->>Client: ApiResponse SUCCESS
            end
        end
    end
Loading

API 요약

API Method 인증 주요 흐름
/api/v1/members POST 없음 VO 검증 → 중복 체크 → BCrypt 인코딩 → 저장
/api/v1/members/me GET 헤더 (LoginId + LoginPw) 인증 → 이름 마스킹 → MemberInfo 반환
/api/v1/members/me/password PATCH 헤더 (LoginId + LoginPw) 인증 → 현재 PW 확인 → 형식 검증 → 동일 PW 거부 → 생년월일 검증 → 변경

🧪 테스트 전략

3계층 테스트 구조

계층 테스트 클래스 테스트 수 방식
Unit EmailTest, LoginIdTest, MemberNameTest, PasswordTest ~27개 JUnit 5 + Parameterized
Unit MemberModelTest ~3개 Mockito (PasswordEncoder mock)
Unit MemberSignupServiceTest, MemberAuthServiceTest, MemberPasswordServiceTest ~15개 Mockito
Unit MemberFacadeTest ~5개 Mockito
Integration MemberRepositoryTest ~4개 @SpringBootTest + TestContainers
Integration MemberSignupServiceIntegrationTest, MemberAuthServiceIntegrationTest, MemberPasswordServiceIntegrationTest ~15개 @SpringBootTest + DB
E2E MemberV1ApiE2ETest ~12개 TestRestTemplate + RANDOM_PORT

주요 검증 시나리오

  • Value Object 유효성 검증 (null, 빈값, 형식, 경계값)
  • 비밀번호 정책 (길이 8-16, 생년월일 포함 금지 YYYYMMDD/YYMMDD)
  • 중복 loginId 거부
  • 인증 실패 (존재하지 않는 회원, 비밀번호 불일치)
  • 비밀번호 변경 (현재 PW 불일치, 새 PW == 기존 PW 거부, 생년월일 포함 거부)
  • API 응답 형식 (ApiResponse 래핑, 에러 코드)

✅ Checklist

  • Unit 테스트: VO 검증 18개 + 도메인 서비스 11개 + Facade 3개 + Model 3개
  • Integration 테스트: Repository 3개 + 서비스 통합 13개
  • E2E 테스트: API 시나리오 8개 (회원가입/조회/비밀번호 변경 + 에러 케이스)
  • TDD 기반 개발 (Red → Green → Refactor)

📎 기타 참고 사항

  • BaseEntity 상속으로 soft-delete(deletedAt) 지원
  • BCryptPasswordEncoder 사용 (기본 cost factor)
  • ErrorType에 Member 도메인 전용 에러코드 10개 추가 (INVALID_LOGIN_ID, INVALID_PASSWORD, INVALID_EMAIL, INVALID_NAME, DUPLICATE_LOGIN_ID, PASSWORD_MISMATCH, PASSWORD_SAME_AS_OLD, PASSWORD_CONTAINS_BIRTH_DATE, MEMBER_NOT_FOUND, AUTHENTICATION_FAILED)

hanyoung-kurly and others added 9 commits February 2, 2026 01:26
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- spring-security-crypto 의존성 추가 (BCryptPasswordEncoder)
- PasswordEncoderConfig 빈 등록
- ErrorType에 UNAUTHORIZED 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 4개 VO 구현 (LoginId, Password, MemberName, Email)
- MemberModel 엔티티 (@Embedded VO, matchesPassword 행위 메서드)
- MemberRepository 인터페이스 및 JPA 구현
- ErrorType 도메인 에러 코드 추가 (10개)
- 단위 테스트: VO 검증 + MemberModel + Repository 통합테스트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberSignupService (중복 체크, 비밀번호 암호화, 저장)
- 단위 테스트: Mock을 활용한 동작 검증
- 통합 테스트: 실제 DB 연동 검증

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberAuthService (loginId/password 검증, 회원 조회)
- 단위 테스트: Mock을 활용한 동작 검증
- 통합 테스트: 실제 DB 연동 검증

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberPasswordService (현재 비밀번호 검증, 새 비밀번호 암호화 저장)
- 단위 테스트: Mock을 활용한 동작 검증
- 통합 테스트: 실제 DB 연동 검증

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberFacade (signup, getMyInfo, changePassword)
- MemberInfo 응답 DTO (이름 마스킹 포함)
- MemberV1Controller (POST /members, GET /me, PATCH /me/password)
- MemberV1Dto (SignupRequest, MemberResponse, ChangePasswordRequest)
- E2E 테스트: MemberV1ApiE2ETest
- MemberFacadeTest 단위 테스트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Example/Core 테스트 DisplayName 자연스럽게 개선
- TEST-README.md 테스트 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@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.

@Praesentia-YKM Praesentia-YKM changed the title Volume 1 [Volume 1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현 Feb 6, 2026
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.

2 participants