Skip to content

Conversation

@letter333
Copy link

@letter333 letter333 commented Feb 5, 2026

📌 Summary

  • 배경: 커머스 서비스에서 회원 관리 기능이 필요함
  • 목표: 회원가입, 내 정보 조회, 비밀번호 변경 API를 TDD 방식으로 구현
  • 결과:
    • 회원 도메인 CRUD API 완성 (가입/조회/비밀번호 변경)
    • Bean Validation을 통한 입력값 검증 체계 구축
    • 도메인 레이어는 비즈니스 규칙만 담당하도록 책임 분리

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 회원 관련 API가 존재하지 않음
  • 문제(또는 리스크): 회원 인증 및 개인정보 관리 불가
  • 성공 기준(완료 정의): 회원가입, 내 정보 조회, 비밀번호 변경 API가 정상 동작하고 모든 테스트 통과

선택지와 결정

  • 고려한 대안:
    • A: 도메인 엔티티에서 모든 검증 수행 (형식 + 비즈니스 규칙)
    • B: DTO에서 Bean Validation으로 형식 검증, 도메인에서 비즈니스 규칙만 검증
  • 최종 결정: B안 채택 - 검증 책임 분리
  • 트레이드오프: 검증 로직이 두 레이어에 분산되지만, 각 레이어의 책임이 명확해짐
  • 추후 개선 여지: Spring Security 도입 시 인증 로직 분리

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api 모듈, member 도메인
  • 신규 추가:
    • Domain: Member 엔티티
    • Infrastructure: MemberEntity, MemberRepository, MemberRepositoryImpl, MemberJpaRepository
    • Application: MemberFacade, MemberInfo
    • Interfaces: MemberV1Controller, MemberV1Dto, MemberV1ApiSpec
    • Support: PasswordEncoderConfig, Bean Validation 예외 핸들러
  • 제거/대체: 없음

주요 컴포넌트 책임

  • Member: 도메인 엔티티, 비즈니스 규칙 검증 (생년월일 미래 불가, 비밀번호에 생년월일 포함 불가)
  • MemberService: 회원가입, 인증, 비밀번호 변경 비즈니스 로직, BCrypt 암호화
  • MemberFacade: 유즈케이스 오케스트레이션, 이름 마스킹 처리
  • MemberV1Dto: Bean Validation으로 입력값 형식 검증 (길이, 패턴, 필수값)
  • MemberV1Controller: REST 엔드포인트, 헤더 기반 인증 처리

🔁 Flow Diagram

회원가입 Flow

sequenceDiagram                                                                                                                                                      
autonumber
participant Client                                                                                                                                                 
participant Controller                                                                                                                                             
participant Facade                                                                                                                                                 
participant Service                                                                                                                                                
participant Repository                                                                                                                                             
participant DB

    Client->>Controller: POST /api/v1/members                                                                                                                          
    Note over Controller: @Valid로 DTO 검증                                                                                                                            
    Controller->>Facade: signUp(loginId, password, ...)                                                                                                                
    Facade->>Service: signUp(...)                                                                                                                                      
    Service->>Service: Member 생성 (도메인 검증)                                                                                                                       
    Service->>Service: 비밀번호 BCrypt 암호화                                                                                                                          
    Service->>Repository: save(member)                                                                                                                                 
    Repository->>DB: INSERT                                                                                                                                            
    DB-->>Repository: member with ID                                                                                                                                   
    Repository-->>Service: Member                                                                                                                                      
    Service-->>Facade: Member                                                                                                                                          
    Facade-->>Controller: MemberInfo                                                                                                                                   
    Controller-->>Client: 201 Created                                                                                                                                

Loading

내 정보 조회 Flow

sequenceDiagram                                                                                                                                                      
autonumber
participant Client                                                                                                                                                 
participant Controller                                                                                                                                             
participant Facade                                                                                                                                                 
participant Service                                                                                                                                                
participant Repository

    Client->>Controller: GET /api/v1/members/me                                                                                                                        
    Note over Controller: X-Loopers-LoginId/LoginPw 헤더                                                                                                               
    Controller->>Facade: getMyInfo(loginId, password)                                                                                                                  
    Facade->>Service: authenticate(loginId, password)                                                                                                                  
    Service->>Repository: findByLoginId(loginId)                                                                                                                       
    Repository-->>Service: Member                                                                                                                                      
    Service->>Service: BCrypt 비밀번호 검증                                                                                                                            
    Service-->>Facade: Member                                                                                                                                          
    Facade->>Facade: 이름 마스킹 (홍길동 → 홍길*)                                                                                                                      
    Facade-->>Controller: MemberInfo                                                                                                                                   
    Controller-->>Client: 200 OK
Loading

비밀번호 변경 Flow

sequenceDiagram                                                                                                                                                      
autonumber
participant Client                                                                                                                                                 
participant Controller                                                                                                                                             
participant Facade                                                                                                                                                 
participant Service                                                                                                                                                
participant Member                                                                                                                                                 
participant Repository

    Client->>Controller: PATCH /api/v1/members/me/password                                                                                                             
    Note over Controller: 헤더 PW == Body currentPassword 검증                                                                                                         
    Controller->>Facade: updatePassword(loginId, current, new)                                                                                                         
    Facade->>Service: updatePassword(...)                                                                                                                              
    Service->>Repository: findByLoginId(loginId)                                                                                                                       
    Repository-->>Service: Member                                                                                                                                      
    Service->>Service: 현재 비밀번호 검증 (BCrypt)                                                                                                                     
    Service->>Service: 새 비밀번호 != 현재 비밀번호 검증                                                                                                               
    Service->>Member: changePassword(newRaw, newEncoded)                                                                                                               
    Note over Member: 생년월일 포함 여부 검증                                                                                                                          
    Service->>Repository: updatePassword(loginId, encoded)                                                                                                             
    Repository-->>Service: void                                                                                                                                        
    Service-->>Facade: void                                                                                                                                            
    Facade-->>Controller: void                                                                                                                                         
    Controller-->>Client: 200 OK
Loading

✅ Checklist

  • 회원가입 API (POST /api/v1/members)
  • 내 정보 조회 API (GET /api/v1/members/me)
  • 비밀번호 변경 API (PATCH /api/v1/members/me/password)
  • Bean Validation 적용 (DTO 레이어)
  • 도메인 검증 책임 분리 (비즈니스 규칙만)
  • 이름 마스킹 기능 (마지막 글자 * 처리)
  • 로그인 ID 영문/숫자만 허용
  • 단위 테스트 및 E2E 테스트 작성
  • 모든 테스트 통과 확인

변경 목적: 회원 관리 API 3개(회원가입, 내 정보 조회, 비밀번호 수정)를 TDD 기반으로 구현하고, 도메인 계층에서 비즈니스 규칙, DTO에서 형식 검증을 분리하여 책임을 명확히 함.

핵심 변경점: 도메인 Member 엔티티(생년월일 미래 불가, 비밀번호에 생년월일 미포함 검증), MemberService(회원가입·인증·비밀번호 변경), MemberFacade(서비스 오케스트레이션), 3개 REST 엔드포인트(가입/조회/수정), BCryptPasswordEncoder 설정, DTO 빈 검증 추가, 예외 핸들러 확장(헤더/검증 에러 처리).

리스크/주의사항: 비밀번호 변경 시 헤더(X-Loopers-LoginPw)와 request body(currentPassword)의 값이 문자열 equals로 비교되고 있는데, 양쪽이 일치해야만 진행되는 로직 설계가 의도한 것인지 확인 필요. 프로덕션 환경에서는 헤더 기반 비밀번호 전송에 HTTPS/TLS 필수. 도메인 검증(생년월일, 비밀번호 규칙) vs DTO 검증(형식, 길이, 패턴) 책임 분리가 일관성 있게 적용되었는지 확인 권장.

테스트/검증: 도메인 유닛 테스트, 서비스 통합 테스트, 리포지토리 통합 테스트, E2E 테스트 포함하여 9개 테스트 파일 구성. E2E 테스트에서 비밀번호 변경 후 신규 비밀번호로의 로그인 성공 시나리오 검증 완료. 모든 테스트 통과 상태로 PR 설명 기반.

letter333 and others added 27 commits February 1, 2026 20:43
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- member-erd.md: 회원 테이블 ERD 설계
- member-signup-design.md: 시퀀스/클래스 다이어그램, 패키지 구조
- CLAUDE.md: 개발 규칙 및 문서 작성 가이드 반영

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Member 도메인 객체 구현 (순수 Java, JPA 어노테이션 없음)
- 필드 검증: loginId, password, name, birthday, email
- 비밀번호 규칙: 8~16자, 영문+숫자+특수문자, 생년월일 포함 불가
- encryptPassword()로 암호화된 비밀번호 교체 지원

test: add MemberTest with 14 unit test cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
… entity

Member 도메인의 Infrastructure 레이어 구현:
- MemberEntity (JPA 영속성 엔티티, Domain↔Entity 변환)
- MemberRepository 인터페이스 (도메인 레이어)
- MemberJpaRepository (Spring Data JPA)
- MemberRepositoryImpl (Repository 구현체)
- MemberEntityTest, MemberRepositoryImplIntegrationTest
- spring-security-crypto 의존성 추가
- docker-java.properties (Docker Engine 29 TestContainers 호환)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberService.signUp(): 중복 검사 → 도메인 생성 → 비밀번호 암호화 → 저장
- PasswordEncoderConfig: BCryptPasswordEncoder Bean 등록
- MemberServiceTest: 정상 가입, loginId 중복, email 중복 통합 테스트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberInfo: Domain → 응답 변환 record (password, birthday 제외)
- MemberFacade: MemberService 위임 및 MemberInfo 변환
- MemberInfoTest, MemberFacadeTest 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- POST /api/v1/members → 201 Created
- MemberV1Dto: SignUpRequest/SignUpResponse record
- MemberV1ApiSpec: Swagger 스펙 인터페이스
- MemberV1Controller: Facade 위임 및 응답 변환
- MemberV1ApiE2ETest: 정상 가입(201), 검증 실패(400), 중복(409) E2E 테스트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Controller에서 birthday null/빈 문자열/잘못된 형식 시 400 BAD_REQUEST 반환
- birthday 관련 E2E 테스트 2건 추가
- 회원가입 API .http 파일 생성
- CLAUDE.md 프로젝트 규칙 보강

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 내 정보 조회 기능 시퀀스/클래스 다이어그램 작성
- 회원가입 시퀀스 다이어그램 Entity 반환 화살표 누락 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberRepository 인터페이스에 findByLoginId 추가
- MemberJpaRepository, MemberRepositoryImpl 구현
- 통합 테스트 2건 추가 (존재/미존재 케이스)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberService.authenticate() 메서드 추가 (loginId 조회 + 비밀번호 검증)
- ErrorType에 UNAUTHORIZED(401) 추가
- 통합 테스트 3건 추가 (성공, 회원 미존재, 비밀번호 불일치)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- GET /api/v1/members/me 엔드포인트 추가 (X-Loopers-LoginId, X-Loopers-LoginPw 헤더 인증)
- MemberInfo에 birthday 필드 추가, MyInfoResponse DTO 추가
- MemberFacade.getMyInfo(), MemberV1ApiSpec, MemberV1Controller 구현
- ApiControllerAdvice에 MissingRequestHeaderException 핸들러 추가
- E2E 테스트 4건 추가 (200, 401×2, 400)
- 통합 테스트 생성자 주입으로 리팩터링 (필드 주입 → 생성자 주입)
- member-v1.http에 내 정보 조회 요청 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- password MIN(8자), MAX(16자) 성공 테스트 추가
- name MIN(한글 2자), MAX(한글 20자) 성공 테스트 추가
- birthday 오늘 날짜(경계값) 성공 테스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Member.changePassword() 메서드를 통해 현재 비밀번호 검증, 동일 비밀번호 방지,
새 비밀번호 룰 검증(길이/패턴/생년월일), 암호화까지 도메인 엔티티에서 캡슐화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
MemberService.updatePassword() 추가, MemberRepository에 updatePassword 메서드 정의,
MemberRepositoryImpl에서 JPA dirty checking 기반 UPDATE 구현, 통합 테스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
PATCH /api/v1/members/me/password 엔드포인트 추가,
헤더 PW와 Body currentPassword 일치 검증, E2E 테스트 8건 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
API 응답 규칙, 의존성 방향, 인증 헤더 규칙, TDD 단계별 진행 규칙,
테스트 경계값 케이스 가이드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
도메인 계층의 PasswordEncoder 의존성을 제거하고,
유즈케이스 검증(현재 비밀번호 확인, 동일 비밀번호 확인)을
MemberService로 이동하여 의존성 방향 규칙 준수

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
refactor: 비밀번호 변경 검증 책임을 서비스 레이어로 분리
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: 회원 도메인 기능 구현 (가입, 조회, 비밀번호 변경)
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

회원 관리 기능을 위한 도메인 엔티티, 서비스, 레포지토리, API 컨트롤러, DTO 및 관련 테스트를 새로 추가한다. 회원가입, 인증, 비밀번호 변경 기능을 포함한다.

Changes

Cohort / File(s) Summary
프로젝트 설정
.gitignore, apps/commerce-api/build.gradle.kts
docs/study/ 경로 추가 및 spring-security-crypto, spring-boot-starter-validation 의존성 추가
도메인 레이어
apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java, MemberRepository.java, MemberService.java
Member 도메인 엔티티(생일 검증, 비밀번호 검증), MemberRepository 인터페이스, MemberService 비즈니스 로직(회원가입, 인증, 비밀번호 변경)
인프라 레이어
apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java, MemberJpaRepository.java, MemberRepositoryImpl.java
JPA 엔티티, Spring Data JPA 리포지토리, 도메인 리포지토리 구현체(매핑 처리)
애플리케이션 레이어
apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java, MemberInfo.java
MemberFacade 퍼사드(회원가입, 인증, 비밀번호 변경 위임), MemberInfo DTO(도메인 변환, 이름 마스킹)
인터페이스/API 레이어
apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java, MemberV1Controller.java, MemberV1Dto.java
API 스펙 인터페이스, 회원 V1 REST 컨트롤러(/api/v1/members 엔드포인트), 요청/응답 DTO(필드 검증 포함)
에러 처리 및 설정
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java, apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java, apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
MissingRequestHeaderException, MethodArgumentNotValidException 핸들러 추가, BCryptPasswordEncoder 빈 제공, ErrorType.UNAUTHORIZED 상수 추가
테스트
apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java, MemberInfoTest.java, apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java, MemberTest.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java, MemberRepositoryImplIntegrationTest.java, apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java
각 계층별 단위 및 통합 테스트(회원가입, 인증, 비밀번호 변경, 마스킹, E2E API 테스트)
테스트 리소스 및 API 문서
apps/commerce-api/src/test/resources/docker-java.properties, http/commerce-api/member-v1.http
Docker Java 버전 설정, HTTP 클라이언트 API 요청 문서

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant Service as MemberService
    participant Repository as MemberRepository
    participant DB as Database
    participant Encoder as PasswordEncoder

    Client->>Controller: POST /api/v1/members (SignUpRequest)
    Controller->>Facade: signUp(loginId, password, name, birthday, email)
    Facade->>Service: signUp(loginId, password, name, birthday, email)
    Service->>Repository: existsByLoginId(loginId)
    Repository->>DB: SELECT * FROM member WHERE login_id=?
    DB-->>Repository: 0 rows
    Repository-->>Service: false
    Service->>Repository: existsByEmail(email)
    Repository->>DB: SELECT * FROM member WHERE email=?
    DB-->>Repository: 0 rows
    Repository-->>Service: false
    Service->>Encoder: encode(password)
    Encoder-->>Service: encoded_password
    Service->>Repository: save(Member)
    Repository->>DB: INSERT INTO member VALUES(...)
    DB-->>Repository: saved_entity
    Repository-->>Service: Member
    Service-->>Facade: Member
    Facade-->>Controller: MemberInfo
    Controller-->>Client: ApiResponse(SignUpResponse)

    Client->>Controller: GET /api/v1/members/me (headers: loginId, password)
    Controller->>Facade: getMyInfo(loginId, password)
    Facade->>Service: authenticate(loginId, password)
    Service->>Repository: findByLoginId(loginId)
    Repository->>DB: SELECT * FROM member WHERE login_id=?
    DB-->>Repository: member_entity
    Repository-->>Service: Member
    Service->>Encoder: matches(password, encoded_password)
    Encoder-->>Service: true
    Service-->>Facade: Member
    Facade->>Facade: withMaskedName()
    Facade-->>Controller: MemberInfo
    Controller-->>Client: ApiResponse(MyInfoResponse)

    Client->>Controller: PATCH /api/v1/members/me/password (headers: loginId, password)
    Controller->>Facade: updatePassword(loginId, currentPassword, newPassword)
    Facade->>Service: updatePassword(loginId, currentPassword, newPassword)
    Service->>Repository: findByLoginId(loginId)
    Repository->>DB: SELECT * FROM member WHERE login_id=?
    DB-->>Repository: member_entity
    Repository-->>Service: Member
    Service->>Encoder: matches(currentPassword, encoded_password)
    Encoder-->>Service: true
    Service->>Encoder: encode(newPassword)
    Encoder-->>Service: encoded_new_password
    Service->>Repository: updatePassword(loginId, encoded_new_password)
    Repository->>DB: UPDATE member SET password=? WHERE login_id=?
    DB-->>Repository: success
    Repository-->>Service: void
    Service-->>Facade: void
    Facade-->>Controller: void
    Controller-->>Client: ApiResponse(success)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 PR의 핵심 변경사항(회원가입, 내 정보조회, 비밀번호 수정 API 구현)을 정확하게 요약하고 있다.
Description check ✅ Passed PR 설명서가 템플릿의 모든 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram, Checklist)을 완벽하게 포함하고 있으며, 각 섹션에서 요구되는 세부사항이 충분히 기술되어 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 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.

letter333 and others added 2 commits February 5, 2026 22:48
MemberInfo에 withMaskedName() 메서드 추가하여
이름의 마지막 글자를 *로 마스킹하는 기능 구현

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: 내 정보 조회 시 이름 마스킹 기능 추가
letter333 and others added 5 commits February 5, 2026 22:59
LOGIN_ID_PATTERN을 추가하여 영문 대소문자와 숫자만 허용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: 로그인 ID 영문/숫자만 허용하는 검증 규칙 추가
- DTO: @Valid + Bean Validation으로 형식 검증 (null, 패턴, 길이)
- Domain: 비즈니스 규칙만 유지 (생년월일 미래 불가, 비밀번호에 생년월일 포함 불가)
- ApiControllerAdvice에 MethodArgumentNotValidException 핸들러 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- main 브랜치의 비밀번호 변경 기능과 Bean Validation 리팩토링 통합
- UpdatePasswordRequest에 Bean Validation 추가
- changePassword()에서 생년월일 포함 여부만 검증

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
refactor: DTO에 Bean Validation 적용 및 도메인 검증 책임 분리
@letter333
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java`:
- Around line 18-20: withMaskedName currently calls name.substring(...) without
defensive checks causing NPE or StringIndexOutOfBounds; update
MemberInfo.withMaskedName to: if name is null or blank return this (or a new
MemberInfo with same fields) unchanged; if name.length() == 1 return new
MemberInfo(..., "*", ...); if name.length() >= 2 build maskedName by replacing
only the last character with "*" and return new MemberInfo(..., maskedName,
...). Add unit tests in MemberInfoTest covering null name, empty string name,
and single-character name to assert correct returned value and no exceptions.

In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java`:
- Around line 31-38: The public all-args constructor on Member can be misused
and bypass domain validation; make that constructor non-public (package-private
or private) and introduce a clearly-named static factory for DB rehydration such
as Member.reconstructForPersistence(Long id, String loginId, String password,
String name, LocalDate birthday, String email) (or Member.fromDatabase(...)) so
intent is explicit; keep the normal public factory/constructor that enforces
validations separate, document/annotate the reconstructForPersistence method as
internal/for-persistence-only, restrict its visibility to the persistence
package if possible, and add a unit test that ensures normal creation runs
validations while reconstructForPersistence is only used by the persistence
layer.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`:
- Around line 57-59: MemberService currently encodes the new password, mutates
the Member, then calls memberRepository.updatePassword(loginId,
member.getPassword()), which causes MemberRepositoryImpl.updatePassword to
re-query by loginId and creates duplicate queries and a TOCTOU risk; change the
repository API to accept the domain object (e.g. add/replace with
MemberRepository.updatePassword(Member member) and implement
MemberRepositoryImpl.updatePassword(Member member) to persist the passed member
(using the ORM save/flush or entityManager.merge) so MemberService can call
memberRepository.updatePassword(member) directly after
member.changePassword(...), eliminating the extra findByLoginId; update tests to
cover concurrent password-change scenarios.
- Around line 40-42: The MemberService is instantiating Member with the raw
password (Member constructor) then calling
member.encryptPassword(passwordEncoder.encode(password)), which temporarily
stores sensitive plaintext and risks exposure; instead, perform
validatePasswordNotContainsBirthday against the raw password inside
MemberService, then immediately encrypt with passwordEncoder.encode and create
the Member without passing the raw password (or with a constructor that accepts
only the encoded password), remove or relocate the
validatePasswordNotContainsBirthday logic out of the Member constructor, and
apply the same pattern to updatePassword/changePassword so password validation
happens in the service and only the encrypted password is stored on the domain
object.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java`:
- Around line 8-10: The repository currently allows a race between
existsByLoginId/existsByEmail and save(), so update MemberRepositoryImpl.save()
to catch org.springframework.dao.DataIntegrityViolationException and translate
it into an HTTP 409 conflict (e.g., throw a custom ConflictException or
ResponseStatusException(HttpStatus.CONFLICT)), or alternatively add a handler
method in ApiControllerAdvice that maps DataIntegrityViolationException -> 409;
then add a concurrency integration test in MemberRepositoryImplIntegrationTest
that runs concurrent save attempts with the same loginId/email (using
ExecutorService/CountDownLatch or similar) and asserts one succeeds while the
other results in a 409/conflict condition.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java`:
- Line 35: Wrap the LocalDate.parse(request.birthday()) call in
MemberV1Controller with a try-catch that catches
java.time.format.DateTimeParseException and converts it into the project's
standard error response (e.g., throw a CoreException or return a BAD_REQUEST
error via existing error handling) so invalid-but-format-matching dates like
"2024-02-30" yield a consistent client-facing error; alternatively add a
ControllerAdvice handler for DateTimeParseException to map it to BAD_REQUEST;
add unit tests for MemberV1Controller signup using invalid dates ("2024-02-30",
"2024-13-01") to assert a 400 response.
- Around line 65-67: Move the password-equality business check out of
MemberV1Controller and into the service/facade layer: remove the headerPassword
vs request.currentPassword() comparison from MemberV1Controller and add a
validation in MemberFacade (e.g., add/update a method updatePassword(String
loginId, String headerPassword, String currentPassword, String newPassword))
that throws CoreException(ErrorType.BAD_REQUEST, "인증 정보가 일치하지 않습니다.") when
headerPassword and currentPassword differ, then delegate to
memberService.updatePassword(loginId, currentPassword, newPassword); also add a
unit test for MemberFacade to assert the exception is thrown on mismatch.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java`:
- Around line 3-55: Remove all Jakarta Bean Validation annotations from
MemberV1Dto.SignUpRequest and MemberV1Dto.UpdatePasswordRequest and move their
validation into the domain layer (e.g., Member constructor or new Value Objects
like LoginId, Password, Name, Birthday, Email) so that format/length/pattern
checks (loginId pattern, password length & allowed chars, name Korean chars,
birthday yyyy-MM-dd, email format) throw CoreException with
ErrorType.BAD_REQUEST; update Member creation flow to call these
validators/value-object constructors and keep existing MemberInfo/SignUpResponse
mapping unchanged; also ensure duplicate loginId/email checks remain in the
service layer and add/adjust integration tests to assert
CoreException/ErrorType.BAD_REQUEST and the standardized API error response for
each invalid field and duplicate cases.

In `@apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java`:
- Around line 68-89: Tests and Member.validateBirthday() both call
LocalDate.now(), causing flakiness at midnight; modify Member to accept a Clock
(e.g., add a constructor overload or field used by validateBirthday()) so
birthday validation uses LocalDate.now(clock), and update the tests in
MemberTest (createsSuccessfully_whenBirthdayIsToday and
throwsBadRequest_whenBirthdayIsFuture) to construct Members with a fixed Clock
(or use a test factory that supplies a fixed Clock) so the "today" and "future"
dates are deterministic; ensure validateBirthday() and Member constructors
consistently use the injected Clock.

In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java`:
- Around line 39-158: Add integration tests in
MemberRepositoryImplIntegrationTest to cover updatePassword success and failure:
create two `@Test` methods—one that saves a Member (use createMember), calls
memberRepository.updatePassword("testuser1", "newEncodedHash"), then reloads
with memberRepository.findByLoginId("testuser1") and asserts the
saved.getPassword() equals the new hash and does not equal the old one (and
optionally assert old-password-based auth fails if an auth method exists); and
another that calls memberRepository.updatePassword("nonexistent", "hash") and
asserts it results in an UNAUTHORIZED flow (expect the specific exception the
repository uses, e.g., UnauthorizedException, or the repository's defined error
behavior). Ensure tests reference updatePassword, memberRepository.save, and
memberRepository.findByLoginId so they exercise persistence and error handling.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java`:
- Around line 56-154: The SignUp nested tests in MemberV1ApiE2ETest cover some
negative flows but miss validation failure cases for
loginId/password/name/email; add tests in the SignUp class that post requests
with (1) non-alphanumeric loginId, (2) password violating allowed characters and
length, (3) name outside the expected Korean character range, and (4) malformed
email; for each new test (e.g., returnsBadRequest_whenLoginIdInvalid,
returnsBadRequest_whenPasswordInvalid, returnsBadRequest_whenNameInvalid,
returnsBadRequest_whenEmailInvalid) send the request via
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request),
new ParameterizedTypeReference<ApiResponse<Object>>() {}) and assert HTTP 400
plus that response.getBody() contains the standardized error code/message (use
the ApiResponse error fields returned by your controller) to ensure consistent
validation error responses.

In `@http/commerce-api/member-v1.http`:
- Around line 14-22: MemberV1Controller is currently reading plain passwords
from the X-Loopers-LoginPw header in getMyInfo() and updatePassword(), which is
insecure; change authentication to JWT Bearer tokens by removing reliance on
X-Loopers-LoginPw and instead validate Authorization: Bearer <token> in those
endpoints (update authentication middleware/guards used by MemberV1Controller),
ensure updatePassword() accepts the new password only in the request body over
HTTPS and requires a valid JWT or re-auth flow, enforce HTTPS in server config,
and update E2E tests to send a valid JWT for protected calls and to verify no
plaintext password appears in logs or headers.
🧹 Nitpick comments (4)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java (1)

58-65: MethodArgumentNotValidException 핸들러가 기존 프로젝트 학습 내용과 충돌한다.

이전 PR #6에서 "Bean Validation 핸들링을 도입하지 않는다"는 학습이 기록되어 있다. 그러나 현재 PR 목표에서는 "DTOs use Bean Validation for format checks; domain enforces business rules only"를 명시적으로 선택했다.

운영 관점 문제: findFirst()로 첫 번째 에러만 반환하면, 클라이언트가 여러 필드 오류를 한 번에 확인할 수 없어 반복적인 요청이 발생할 수 있다.

♻️ 모든 검증 오류를 반환하는 수정안
 `@ExceptionHandler`
 public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException e) {
-    String message = e.getBindingResult().getFieldErrors().stream()
-        .findFirst()
-        .map(error -> error.getDefaultMessage())
-        .orElse("입력값이 올바르지 않습니다.");
+    String message = e.getBindingResult().getFieldErrors().stream()
+        .map(error -> String.format("[%s] %s", error.getField(), error.getDefaultMessage()))
+        .collect(Collectors.joining(", "));
+    if (message.isEmpty()) {
+        message = "입력값이 올바르지 않습니다.";
+    }
     return failureResponse(ErrorType.BAD_REQUEST, message);
 }

,

기존 학습 내용과의 충돌이 의도된 설계 변경인지 확인이 필요하다. Based on learnings: "Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns."

apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java (2)

49-53: LocalDate.now() 직접 호출은 테스트 격리를 어렵게 한다.

운영 관점: 경계 조건(오늘 날짜가 생년월일인 경우) 테스트 시 시스템 시간에 의존하게 된다.

♻️ Clock 주입으로 테스트 용이성 개선
+    private static Clock clock = Clock.systemDefaultZone();
+
+    // 테스트용 Clock 설정 메서드 (package-private)
+    static void setClock(Clock testClock) {
+        clock = testClock;
+    }
+
     private void validateBirthday(LocalDate birthday) {
-        if (birthday != null && birthday.isAfter(LocalDate.now())) {
+        if (birthday != null && birthday.isAfter(LocalDate.now(clock))) {
             throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다.");
         }
     }

9-87: equals/hashCode 미구현으로 컬렉션/캐시 사용 시 문제가 발생할 수 있다.

운영 관점: Member 객체를 Set, Map 키, 또는 캐시에 저장할 경우 Object의 기본 참조 비교로 인해 예기치 않은 동작이 발생할 수 있다.

수정안: 식별자(id) 기반 equals/hashCode를 구현한다.

♻️ id 기반 equals/hashCode 추가
`@Override`
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Member member = (Member) o;
    return id != null && id.equals(member.id);
}

`@Override`
public int hashCode() {
    return getClass().hashCode();
}

추가 테스트: 동일 id를 가진 Member 객체의 equals 동작을 검증하는 테스트를 추가한다. As per coding guidelines: "equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다."

apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java (1)

12-14: @Component 대신 @Service 사용을 권장한다.

기능적으로 동일하나, @Service는 서비스 레이어임을 명시적으로 표현하여 계층 구분이 명확해진다. Spring의 stereotype annotation 관례를 따르는 것이 코드 가독성과 일관성에 유리하다.

♻️ 수정 제안
 `@RequiredArgsConstructor`
-@Component
+@Service
 public class MemberService {

Comment on lines +18 to +20
public MemberInfo withMaskedName() {
String maskedName = name.substring(0, name.length() - 1) + "*";
return new MemberInfo(id, loginId, maskedName, birthday, email);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -a "MemberInfo.java" --type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 182


🏁 Script executed:

fd -a "Member.java" --type f | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 173


🏁 Script executed:

fd -a "MemberV1Dto.java" --type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 186


🏁 Script executed:

cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 898


🏁 Script executed:

cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3138


🏁 Script executed:

cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2897


🏁 Script executed:

fd -a "*Test.java" -o "*Tests.java" | xargs rg -l "withMaskedName|MemberInfo" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 810


🏁 Script executed:

rg -n "withMaskedName" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 630


🏁 Script executed:

cat -n /home/jailuser/git/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2928


🏁 Script executed:

cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1274


withMaskedName()에서 이름 필드의 null/빈 문자열 방어 로직이 필요하다

현재 코드는 name.substring(0, name.length() - 1)을 검증 없이 호출한다. Member 엔티티의 생성자는 name 필드를 검증하지 않으므로, 데이터베이스에서 로드된 데이터나 다른 코드 경로에서 null 또는 빈 문자열을 가질 수 있다. 이 경우 NullPointerException 또는 StringIndexOutOfBoundsException이 발생하여 /members/me 조회가 500으로 실패한다.

다음과 같은 방어 로직을 추가해야 한다:

  • name이 null 또는 빈 문자열이면 그대로 반환
  • name이 1자이면 "*"로 마스킹
  • 2자 이상이면 마지막 글자만 마스킹

추가로 null, 빈 문자열, 1자 이름에 대한 테스트 케이스를 MemberInfoTest에 보강해야 한다.

수정안 예시
 public MemberInfo withMaskedName() {
+    if (name == null || name.isEmpty()) {
+        return this;
+    }
+    if (name.length() == 1) {
+        return new MemberInfo(id, loginId, "*", birthday, email);
+    }
     String maskedName = name.substring(0, name.length() - 1) + "*";
     return new MemberInfo(id, loginId, maskedName, birthday, email);
 }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java`
around lines 18 - 20, withMaskedName currently calls name.substring(...) without
defensive checks causing NPE or StringIndexOutOfBounds; update
MemberInfo.withMaskedName to: if name is null or blank return this (or a new
MemberInfo with same fields) unchanged; if name.length() == 1 return new
MemberInfo(..., "*", ...); if name.length() >= 2 build maskedName by replacing
only the last character with "*" and return new MemberInfo(..., maskedName,
...). Add unit tests in MemberInfoTest covering null name, empty string name,
and single-character name to assert correct returned value and no exceptions.

Comment on lines +31 to +38
public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) {
this.id = id;
this.loginId = loginId;
this.password = password;
this.name = name;
this.birthday = birthday;
this.email = email;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

DB 재구성용 생성자가 검증을 건너뛰어 도메인 무결성이 깨질 수 있다.

운영 관점 문제: 이 생성자가 DB 재구성 외 다른 용도로 사용되면 생년월일 미래 날짜, 비밀번호 내 생년월일 포함 등 무효한 상태의 Member 객체가 생성될 수 있다.

수정안: 재구성용 생성자임을 명확히 하고, 의도치 않은 사용을 방지한다.

♻️ 정적 팩토리 메서드로 명확화
-    public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) {
-        this.id = id;
-        this.loginId = loginId;
-        this.password = password;
-        this.name = name;
-        this.birthday = birthday;
-        this.email = email;
-    }
+    /**
+     * 영속성 계층에서 기존 데이터 재구성 시에만 사용한다.
+     * 새 Member 생성 시에는 5개 인자 생성자를 사용한다.
+     */
+    public static Member reconstitute(Long id, String loginId, String password, String name, LocalDate birthday, String email) {
+        Member member = new Member(loginId, password, name, birthday, email);
+        member.id = id;
+        return member;
+    }

추가 테스트: 재구성 메서드 사용 시에도 검증이 적용되는지 테스트를 추가한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java` around
lines 31 - 38, The public all-args constructor on Member can be misused and
bypass domain validation; make that constructor non-public (package-private or
private) and introduce a clearly-named static factory for DB rehydration such as
Member.reconstructForPersistence(Long id, String loginId, String password,
String name, LocalDate birthday, String email) (or Member.fromDatabase(...)) so
intent is explicit; keep the normal public factory/constructor that enforces
validations separate, document/annotate the reconstructForPersistence method as
internal/for-persistence-only, restrict its visibility to the persistence
package if possible, and add a unit test that ensures normal creation runs
validations while reconstructForPersistence is only used by the persistence
layer.

Comment on lines +40 to +42
Member member = new Member(loginId, password, name, birthday, email);
member.encryptPassword(passwordEncoder.encode(password));
return memberRepository.save(member);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "Member.java" | grep -E "(domain|member)" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 156


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3138


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java | head -60

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2859


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java | sed -n '45,60p'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1010


Member 생성자에 raw password가 전달되어 도메인 객체에 일시적으로 저장되는 구조는 보안 위험이 있다.

40행에서 raw password로 Member 객체를 생성한 후 41행에서 암호화된 비밀번호로 덮어쓰는 방식이다. 이 사이에 예외 발생, 메모리 덤프, 디버깅 시 raw password가 노출될 수 있으며, changePassword도 같은 방식으로 동일한 위험을 가진다.

도메인 모델에서는 Member 생성자의 validatePasswordNotContainsBirthday 검증이 raw password 기반이므로, 단순히 암호화 후 전달하는 방식은 작동하지 않는다. 대신 서비스에서 검증을 먼저 수행한 후 암호화하고, Member 생성 시 raw password를 전달하지 않는 구조로 개선해야 한다.

🔒 수정 제안: 서비스 계층에서 검증 후 암호화
`@Transactional`
public Member signUp(String loginId, String password, String name, LocalDate birthday, String email) {
    if (memberRepository.existsByLoginId(loginId)) {
        throw new CoreException(ErrorType.CONFLICT);
    }
    if (memberRepository.existsByEmail(email)) {
        throw new CoreException(ErrorType.CONFLICT);
    }
    
+   // 서비스 계층에서 raw password 검증 수행
+   validatePasswordNotContainsBirthday(password, birthday);
+   String encodedPassword = passwordEncoder.encode(password);
+   
-   Member member = new Member(loginId, password, name, birthday, email);
-   member.encryptPassword(passwordEncoder.encode(password));
+   Member member = new Member(loginId, encodedPassword, name, birthday, email);
    return memberRepository.save(member);
}

+private void validatePasswordNotContainsBirthday(String password, LocalDate birthday) {
+    if (password != null && birthday != null) {
+        String birthdayStr = birthday.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+        if (password.contains(birthdayStr)) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다.");
+        }
+    }
+}

그리고 Member 생성자에서 검증 로직을 제거하여 도메인 규칙과 인프라 관심사를 명확히 분리한다. updatePassword의 changePassword 호출도 동일한 방식으로 개선한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 40 - 42, The MemberService is instantiating Member with the raw
password (Member constructor) then calling
member.encryptPassword(passwordEncoder.encode(password)), which temporarily
stores sensitive plaintext and risks exposure; instead, perform
validatePasswordNotContainsBirthday against the raw password inside
MemberService, then immediately encrypt with passwordEncoder.encode and create
the Member without passing the raw password (or with a constructor that accepts
only the encoded password), remove or relocate the
validatePasswordNotContainsBirthday logic out of the Member constructor, and
apply the same pattern to updatePassword/changePassword so password validation
happens in the service and only the encrypted password is stored on the domain
object.

Comment on lines +57 to +59
String encodedNewPassword = passwordEncoder.encode(newPassword);
member.changePassword(newPassword, encodedNewPassword);
memberRepository.updatePassword(loginId, member.getPassword());
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

동일 트랜잭션 내에서 중복 조회가 발생하며, TOCTOU 위험이 있다.

MemberRepositoryImpl.updatePassword가 내부적으로 findByLoginId를 다시 호출한다(relevant snippet 참조). 이로 인해:

  1. 동일 트랜잭션 내 불필요한 중복 쿼리 발생
  2. 첫 번째 조회와 두 번째 조회 사이에 데이터 변경 시 일관성 문제(TOCTOU)

운영 환경에서 불필요한 쿼리는 DB 부하를 증가시키고, 동시성 상황에서 예측하기 어려운 에러가 발생할 수 있다.

♻️ 수정 제안: Repository에 Member 엔티티 기반 업데이트 메서드 추가

Repository 계층에서 Member 도메인 객체를 받아 업데이트하도록 변경하면 중복 조회를 제거할 수 있다:

-        member.changePassword(newPassword, encodedNewPassword);
-        memberRepository.updatePassword(loginId, member.getPassword());
+        member.changePassword(newPassword, encodedNewPassword);
+        memberRepository.save(member);

또는 기존 updatePassword 메서드가 Member 객체를 받도록 수정:

// MemberRepository
void updatePassword(Member member);

테스트 시 동시 요청 상황에서 비밀번호 변경이 정상 처리되는지 검증 필요하다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String encodedNewPassword = passwordEncoder.encode(newPassword);
member.changePassword(newPassword, encodedNewPassword);
memberRepository.updatePassword(loginId, member.getPassword());
String encodedNewPassword = passwordEncoder.encode(newPassword);
member.changePassword(newPassword, encodedNewPassword);
memberRepository.save(member);
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 57 - 59, MemberService currently encodes the new password, mutates
the Member, then calls memberRepository.updatePassword(loginId,
member.getPassword()), which causes MemberRepositoryImpl.updatePassword to
re-query by loginId and creates duplicate queries and a TOCTOU risk; change the
repository API to accept the domain object (e.g. add/replace with
MemberRepository.updatePassword(Member member) and implement
MemberRepositoryImpl.updatePassword(Member member) to persist the passed member
(using the ORM save/flush or entityManager.merge) so MemberService can call
memberRepository.updatePassword(member) directly after
member.changePassword(...), eliminating the extra findByLoginId; update tests to
cover concurrent password-change scenarios.

Comment on lines +8 to +10
boolean existsByLoginId(String loginId);
boolean existsByEmail(String email);
Optional<MemberEntity> findByLoginId(String loginId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# MemberEntity 및 관련 파일 찾기
fd -type f -name "MemberEntity.java" -o -name "MemberService.java" -o -name "MemberRepository.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

# Repository 파일 패턴 확인
fd -type f -name "*Member*" | grep -E "(java|sql)$" | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

# Find Member-related Java files
fd "Member.*\.java$" --extension java | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1606


🏁 Script executed:

# Search in apps directory for repository files
find apps -name "*Member*" -type f | grep -E "\.java$"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1606


🏁 Script executed:

# Check MemberEntity for unique constraints
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2272


🏁 Script executed:

# Check MemberService for business logic
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2868


🏁 Script executed:

# Check MemberRepositoryImpl for exception handling
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1965


🏁 Script executed:

# Search for exception handling in related files
rg -n "DataIntegrityViolationException|ExceptionHandler|ControllerAdvice" apps/commerce-api/src/main/java/com/loopers/infrastructure/member/ apps/commerce-api/src/main/java/com/loopers/application/member/ apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check integration tests for concurrent scenarios
cat -n apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6202


🏁 Script executed:

# Search for global exception handlers
find apps/commerce-api -name "*Exception*" -o -name "*Advice*" -o -name "*Handler*" | grep -E "\.java$"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 326


🏁 Script executed:

# Check exception handling in ApiControllerAdvice
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 7765


🏁 Script executed:

# Check CoreException
cat -n apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 741


DataIntegrityViolationException을 CONFLICT로 매핑하고 동시성 테스트를 추가해야 한다

DB 유니크 제약은 이미 설정되어 있으나, 예외 처리와 테스트가 부족하다. 현재 코드는 existsByLoginId/Email 체크와 저장 사이의 경쟁 조건에서 동시 가입이 발생할 경우 DataIntegrityViolationException이 발생하지만, 이를 처리하는 핸들러가 없어 사용자가 500 오류를 받게 된다.

MemberRepositoryImpl의 save() 메서드에서 DataIntegrityViolationException을 catch하여 CONFLICT 예외로 변환하거나, ApiControllerAdvice에 DataIntegrityViolationException 핸들러를 추가해 409 응답을 반환해야 한다. 또한 MemberRepositoryImplIntegrationTest에 같은 loginId/email로 동시에 가입 요청하는 시나리오에 대한 통합 테스트를 추가해야 한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java`
around lines 8 - 10, The repository currently allows a race between
existsByLoginId/existsByEmail and save(), so update MemberRepositoryImpl.save()
to catch org.springframework.dao.DataIntegrityViolationException and translate
it into an HTTP 409 conflict (e.g., throw a custom ConflictException or
ResponseStatusException(HttpStatus.CONFLICT)), or alternatively add a handler
method in ApiControllerAdvice that maps DataIntegrityViolationException -> 409;
then add a concurrency integration test in MemberRepositoryImplIntegrationTest
that runs concurrent save attempts with the same loginId/email (using
ExecutorService/CountDownLatch or similar) and asserts one succeeds while the
other results in a 409/conflict condition.

Comment on lines +68 to +89
@DisplayName("오늘 날짜(경계값)이면, 정상적으로 생성된다.")
@Test
void createsSuccessfully_whenBirthdayIsToday() {
// arrange
LocalDate today = LocalDate.now();

// act
Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, today, VALID_EMAIL);

// assert
assertThat(member.getBirthday()).isEqualTo(today);
}

@DisplayName("미래 날짜이면, BAD_REQUEST 예외가 발생한다.")
@Test
void throwsBadRequest_whenBirthdayIsFuture() {
// arrange
LocalDate futureDate = LocalDate.now().plusDays(1);

// act
CoreException result = assertThrows(CoreException.class, () ->
new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, futureDate, VALID_EMAIL)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1. Member.java 파일 위치 및 내용 확인
fd "Member.java" -type f | grep -v Test

# 2. Member.java에서 LocalDate.now() 및 Clock 사용 확인
fd "Member.java" -type f | grep -v Test | xargs -I {} sh -c 'echo "=== {} ===" && rg -n "LocalDate\.now|Clock|java\.time\." {}'

# 3. Member 생성자 시그니처 확인
fd "Member.java" -type f | grep -v Test | xargs -I {} sh -c 'echo "=== Constructor in {} ===" && ast-grep --pattern "class Member { $$$ }" {}'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 597


🏁 Script executed:

#!/bin/bash
# MemberTest.java 파일 전체 구조 및 테스트 유형 확인
fd "MemberTest.java" -type f && wc -l $(fd "MemberTest.java" -type f)

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

#!/bin/bash
# 테스트 클래스의 Spring annotation 및 통합 테스트 여부 확인
fd "MemberTest.java" -type f | xargs -I {} sh -c 'echo "=== First 50 lines of {} ===" && head -50 {}'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

#!/bin/bash
# Member.java 파일 위치 찾기
fd "Member.java" | grep -v Test

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 154


🏁 Script executed:

#!/bin/bash
# MemberTest.java 파일 위치 찾기
fd "MemberTest.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 158


🏁 Script executed:

#!/bin/bash
# 도메인 폴더 구조 확인
fd "Member" --extension java | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1606


🏁 Script executed:

#!/bin/bash
# Member.java 전체 내용 확인 (도메인 모델의 시간 처리 방식)
cat -n "apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3138


🏁 Script executed:

#!/bin/bash
# MemberTest.java 전체 내용 확인 (테스트 구조 및 테스트 유형)
cat -n "apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6106


단위 테스트의 자정 경계 플래키 위험을 제거하려면 시간을 고정해야 한다

Member 클래스가 validateBirthday() 메서드 내부에서 LocalDate.now()를 호출하고, 테스트도 LocalDate.now()에 의존하면 자정 전후에 두 호출의 결과가 달라져 테스트가 실패할 수 있다. CI 파이프라인이 24시간 운영되면 언젠가는 이 시나리오가 발생한다.

Member 생성자에 Clock을 주입하는 방식이나, 테스트 내에서 고정된 기준 시간을 설정하는 팩토리 메서드를 도입해 검증 시간을 명시적으로 통제해야 한다. 추가로 고정 Clock을 사용해 자정 전후의 경계값 테스트를 보강하면 회귀를 방지할 수 있다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java`
around lines 68 - 89, Tests and Member.validateBirthday() both call
LocalDate.now(), causing flakiness at midnight; modify Member to accept a Clock
(e.g., add a constructor overload or field used by validateBirthday()) so
birthday validation uses LocalDate.now(clock), and update the tests in
MemberTest (createsSuccessfully_whenBirthdayIsToday and
throwsBadRequest_whenBirthdayIsFuture) to construct Members with a fixed Clock
(or use a test factory that supplies a fixed Clock) so the "today" and "future"
dates are deterministic; ensure validateBirthday() and Member constructors
consistently use the injected Clock.

Comment on lines +39 to +158
private Member createMember(String loginId, String email) {
Member member = new Member(loginId, "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), email);
member.encryptPassword("$2a$10$encodedHash");
return member;
}

@DisplayName("회원을 저장할 때,")
@Nested
class Save {

@DisplayName("정상적으로 저장되고, ID가 부여된다.")
@Test
void savesMember_andAssignsId() {
// arrange
Member member = createMember("testuser1", "test@example.com");

// act
Member saved = memberRepository.save(member);

// assert
assertAll(
() -> assertThat(saved.getId()).isNotNull(),
() -> assertThat(saved.getLoginId()).isEqualTo("testuser1"),
() -> assertThat(saved.getPassword()).isEqualTo("$2a$10$encodedHash"),
() -> assertThat(saved.getName()).isEqualTo("홍길동"),
() -> assertThat(saved.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)),
() -> assertThat(saved.getEmail()).isEqualTo("test@example.com")
);
}
}

@DisplayName("로그인 ID 중복을 확인할 때,")
@Nested
class ExistsByLoginId {

@DisplayName("존재하는 loginId이면, true를 반환한다.")
@Test
void returnsTrue_whenLoginIdExists() {
// arrange
memberRepository.save(createMember("testuser1", "test@example.com"));

// act
boolean result = memberRepository.existsByLoginId("testuser1");

// assert
assertThat(result).isTrue();
}

@DisplayName("존재하지 않는 loginId이면, false를 반환한다.")
@Test
void returnsFalse_whenLoginIdDoesNotExist() {
// act
boolean result = memberRepository.existsByLoginId("nonexistent");

// assert
assertThat(result).isFalse();
}
}

@DisplayName("로그인 ID로 회원을 조회할 때,")
@Nested
class FindByLoginId {

@DisplayName("존재하는 loginId이면, 회원을 반환한다.")
@Test
void returnsMember_whenLoginIdExists() {
// arrange
memberRepository.save(createMember("testuser1", "test@example.com"));

// act
Optional<Member> result = memberRepository.findByLoginId("testuser1");

// assert
assertAll(
() -> assertThat(result).isPresent(),
() -> assertThat(result.get().getLoginId()).isEqualTo("testuser1"),
() -> assertThat(result.get().getName()).isEqualTo("홍길동"),
() -> assertThat(result.get().getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)),
() -> assertThat(result.get().getEmail()).isEqualTo("test@example.com")
);
}

@DisplayName("존재하지 않는 loginId이면, 빈 Optional을 반환한다.")
@Test
void returnsEmpty_whenLoginIdDoesNotExist() {
// act
Optional<Member> result = memberRepository.findByLoginId("nonexistent");

// assert
assertThat(result).isEmpty();
}
}

@DisplayName("이메일 중복을 확인할 때,")
@Nested
class ExistsByEmail {

@DisplayName("존재하는 email이면, true를 반환한다.")
@Test
void returnsTrue_whenEmailExists() {
// arrange
memberRepository.save(createMember("testuser1", "test@example.com"));

// act
boolean result = memberRepository.existsByEmail("test@example.com");

// assert
assertThat(result).isTrue();
}

@DisplayName("존재하지 않는 email이면, false를 반환한다.")
@Test
void returnsFalse_whenEmailDoesNotExist() {
// act
boolean result = memberRepository.existsByEmail("nonexistent@example.com");

// assert
assertThat(result).isFalse();
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

updatePassword 통합 테스트가 누락되었다
운영에서 비밀번호 변경이 반영되지 않거나 예외 처리 흐름이 깨져도 현재 통합 테스트로는 회귀를 잡기 어렵다.
수정안: updatePassword 성공 케이스(비밀번호 변경 후 조회 시 반영)와 loginId 미존재 시 UNAUTHORIZED 예외 케이스를 추가하는 것이 좋다.
추가 테스트: 변경된 비밀번호가 실제로 저장되었는지, 기존 비밀번호로는 인증되지 않는지도 함께 검증하는 것이 좋다.
As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java`
around lines 39 - 158, Add integration tests in
MemberRepositoryImplIntegrationTest to cover updatePassword success and failure:
create two `@Test` methods—one that saves a Member (use createMember), calls
memberRepository.updatePassword("testuser1", "newEncodedHash"), then reloads
with memberRepository.findByLoginId("testuser1") and asserts the
saved.getPassword() equals the new hash and does not equal the old one (and
optionally assert old-password-based auth fails if an auth method exists); and
another that calls memberRepository.updatePassword("nonexistent", "hash") and
asserts it results in an UNAUTHORIZED flow (expect the specific exception the
repository uses, e.g., UnauthorizedException, or the repository's defined error
behavior). Ensure tests reference updatePassword, memberRepository.save, and
memberRepository.findByLoginId so they exercise persistence and error handling.

Comment on lines +56 to +154
@DisplayName("POST /api/v1/members")
@Nested
class SignUp {

@DisplayName("정상적인 정보로 가입하면, 201 Created와 회원 정보를 반환한다.")
@Test
void returnsCreated_whenValidRequest() {
// arrange
MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest(
"testuser1", "Test1234!", "홍길동", "1995-03-15", "test@example.com"
);

// act
ParameterizedTypeReference<ApiResponse<MemberV1Dto.SignUpResponse>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<MemberV1Dto.SignUpResponse>> response =
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType);

// assert
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED),
() -> assertThat(response.getBody().data().id()).isNotNull(),
() -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"),
() -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"),
() -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com")
);
}

@DisplayName("필수 필드가 누락되면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenFieldMissing() {
// arrange
MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest(
"testuser1", "Test1234!", "홍길동", "1995-03-15", null
);

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response =
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@DisplayName("생년월일이 누락되면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenBirthdayMissing() {
// arrange
MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest(
"testuser1", "Test1234!", "홍길동", null, "test@example.com"
);

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response =
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@DisplayName("생년월일 형식이 잘못되면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenBirthdayFormatInvalid() {
// arrange
MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest(
"testuser1", "Test1234!", "홍길동", "19950315", "test@example.com"
);

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response =
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@DisplayName("이미 존재하는 loginId로 가입하면, 409 Conflict를 반환한다.")
@Test
void returnsConflict_whenDuplicateLoginId() {
// arrange
Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com");
existing.encryptPassword(passwordEncoder.encode("Test1234!"));
memberRepository.save(existing);

MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest(
"testuser1", "Other1234!", "김철수", "1990-01-01", "other@example.com"
);

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response =
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

회원가입 입력 검증 실패 케이스가 일부 부족하다
운영에서 형식이 잘못된 로그인 ID/비밀번호/이름/이메일이 통과하면 DB 제약 오류나 잘못된 계정 생성으로 이어져 장애 원인이 된다.
수정안: 로그인 ID 비영문/숫자, 비밀번호 허용 문자 위반·길이 위반, 이름 한글 범위 위반, 이메일 형식 오류 케이스를 추가하는 것이 좋다.
추가 테스트: 각 케이스에서 400과 표준 ApiResponse 오류 코드/메시지가 반환되는지 검증하는 테스트를 추가하는 것이 좋다.
As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java`
around lines 56 - 154, The SignUp nested tests in MemberV1ApiE2ETest cover some
negative flows but miss validation failure cases for
loginId/password/name/email; add tests in the SignUp class that post requests
with (1) non-alphanumeric loginId, (2) password violating allowed characters and
length, (3) name outside the expected Korean character range, and (4) malformed
email; for each new test (e.g., returnsBadRequest_whenLoginIdInvalid,
returnsBadRequest_whenPasswordInvalid, returnsBadRequest_whenNameInvalid,
returnsBadRequest_whenEmailInvalid) send the request via
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request),
new ParameterizedTypeReference<ApiResponse<Object>>() {}) and assert HTTP 400
plus that response.getBody() contains the standardized error code/message (use
the ApiResponse error fields returned by your controller) to ensure consistent
validation error responses.

Comment on lines +234 to +405
@DisplayName("PATCH /api/v1/members/me/password")
@Nested
class UpdatePassword {

private Member saveMember(String loginId, String rawPassword) {
Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com");
member.encryptPassword(passwordEncoder.encode(rawPassword));
return memberRepository.save(member);
}

private HttpEntity<MemberV1Dto.UpdatePasswordRequest> createRequest(
String loginId, String headerPassword, String currentPassword, String newPassword
) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Loopers-LoginId", loginId);
headers.set("X-Loopers-LoginPw", headerPassword);
headers.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(new MemberV1Dto.UpdatePasswordRequest(currentPassword, newPassword), headers);
}

@DisplayName("올바른 인증 정보와 유효한 새 비밀번호로 변경하면, 200 OK를 반환한다.")
@Test
void returnsOk_whenValidRequest() {
// arrange
saveMember("testuser1", "Test1234!");

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"),
responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}

@DisplayName("비밀번호 변경 후, 새 비밀번호로 내 정보 조회가 가능하다.")
@Test
void canLoginWithNewPassword_afterPasswordUpdate() {
// arrange
saveMember("testuser1", "Test1234!");
testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"),
new ParameterizedTypeReference<ApiResponse<Object>>() {}
);

// act
HttpHeaders headers = new HttpHeaders();
headers.set("X-Loopers-LoginId", "testuser1");
headers.set("X-Loopers-LoginPw", "NewPass123!");
ParameterizedTypeReference<ApiResponse<MemberV1Dto.MyInfoResponse>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<MemberV1Dto.MyInfoResponse>> response = testRestTemplate.exchange(
ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(headers), responseType
);

// assert
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
() -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1")
);
}

@DisplayName("존재하지 않는 loginId로 변경하면, 401 Unauthorized를 반환한다.")
@Test
void returnsUnauthorized_whenLoginIdNotFound() {
// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("nonexistent", "Test1234!", "Test1234!", "NewPass123!"),
responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}

@DisplayName("현재 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.")
@Test
void returnsUnauthorized_whenCurrentPasswordWrong() {
// arrange
saveMember("testuser1", "Test1234!");

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("testuser1", "Wrong1234!", "Wrong1234!", "NewPass123!"),
responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}

@DisplayName("헤더 비밀번호와 Body currentPassword가 다르면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenHeaderPasswordMismatchesBody() {
// arrange
saveMember("testuser1", "Test1234!");

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("testuser1", "Test1234!", "Different1!", "NewPass123!"),
responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenNewPasswordSameAsCurrent() {
// arrange
saveMember("testuser1", "Test1234!");

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("testuser1", "Test1234!", "Test1234!", "Test1234!"),
responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@DisplayName("새 비밀번호가 8자 미만이면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenNewPasswordTooShort() {
// arrange
saveMember("testuser1", "Test1234!");

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH,
createRequest("testuser1", "Test1234!", "Test1234!", "New12!"),
responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.")
@Test
void returnsBadRequest_whenHeaderMissing() {
// arrange
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<MemberV1Dto.UpdatePasswordRequest> request = new HttpEntity<>(
new MemberV1Dto.UpdatePasswordRequest("Test1234!", "NewPass123!"), headers
);

// act
ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange(
ENDPOINT + "/me/password", HttpMethod.PATCH, request, responseType
);

// assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

생년월일 포함 금지 규칙을 E2E에서 검증하지 않는다
운영에서 해당 규칙이 회귀되면 취약한 비밀번호가 허용되어 보안 사고로 이어질 수 있다.
수정안: 새 비밀번호에 생년월일(yyyyMMdd)이 포함된 케이스를 추가하고 400을 기대하는 테스트를 추가하는 것이 좋다.
추가 테스트: 오류 응답의 표준 ApiResponse 코드/메시지도 함께 검증하는 테스트를 추가하는 것이 좋다.
As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다다.

Comment on lines +14 to +22
GET {{commerce-api}}/api/v1/members/me
X-Loopers-LoginId: testuser1
X-Loopers-LoginPw: Test1234!

### 비밀번호 변경
PATCH {{commerce-api}}/api/v1/members/me/password
X-Loopers-LoginId: testuser1
X-Loopers-LoginPw: Test1234!
Content-Type: application/json
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Step 1: Search for X-Loopers-LoginPw usage in backend code
rg -n "X-Loopers-LoginPw" --type java -C 3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5249


🏁 Script executed:

# Step 2: Search for X-Loopers-LoginId usage in backend code
rg -n "X-Loopers-LoginId" --type java -C 3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5157


🏁 Script executed:

# Step 3: Explore the http directory structure to understand its purpose
find . -path "*/http/*" -type f | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 183


🏁 Script executed:

# Step 4: Search for authentication-related patterns in controllers
rg -n "@PostMapping|@GetMapping|@PatchMapping" --type java -C 1 | grep -i -E "auth|login|member|password" -A 2 -B 2 | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1184


🏁 Script executed:

# Step 5: Look for JWT or session-based auth implementation
rg -n "JWT|Bearer|Authorization|session" --type java | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 430


X-Loopers-LoginPw 헤더 기반 인증 방식은 운영 환경에서 보안 위험을 초래한다.

MemberV1Controller의 getMyInfo(), updatePassword() 메서드에서 평문 비밀번호를 HTTP 헤더로 전송하도록 구현되어 있다. 이는 다음 문제가 있다:

  1. 웹서버/프록시 로그 노출: X-Loopers-LoginPw 헤더가 접근 로그에 기록되어 평문 비밀번호가 노출된다
  2. 중간자 공격: HTTPS 미적용 또는 인증서 문제 발생 시 탈취 가능하다
  3. 보안 표준 미준수: OAuth 2.0, JWT 등 업계 표준 인증 방식을 사용하지 않는다

수정 방안:

  • JWT 기반 인증으로 전환하여 Authorization 헤더의 Bearer 토큰 방식을 사용한다
  • 민감한 정보(비밀번호)는 요청 바디가 아닌 다른 채널로 전달하거나 초기 로그인 시에만 사용한다
  • 모든 운영 환경에 HTTPS를 강제한다

추가 테스트:

  • E2E 테스트에서 암호화된 토큰 검증 케이스를 추가한다
  • 헤더 노출 시 비밀번호가 평문으로 기록되지 않는지 로그 수준에서 검증한다
🤖 Prompt for AI Agents
In `@http/commerce-api/member-v1.http` around lines 14 - 22, MemberV1Controller is
currently reading plain passwords from the X-Loopers-LoginPw header in
getMyInfo() and updatePassword(), which is insecure; change authentication to
JWT Bearer tokens by removing reliance on X-Loopers-LoginPw and instead validate
Authorization: Bearer <token> in those endpoints (update authentication
middleware/guards used by MemberV1Controller), ensure updatePassword() accepts
the new password only in the request body over HTTPS and requires a valid JWT or
re-auth flow, enforce HTTPS in server config, and update E2E tests to send a
valid JWT for protected calls and to verify no plaintext password appears in
logs or headers.

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