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

Open
iohyeon wants to merge 42 commits intoLoopers-dev-lab:iohyeonfrom
iohyeon:volume-1
Open

[volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 구현#29
iohyeon wants to merge 42 commits intoLoopers-dev-lab:iohyeonfrom
iohyeon:volume-1

Conversation

@iohyeon
Copy link

@iohyeon iohyeon commented Feb 5, 2026

📌 Summary

  • 배경: Round-1 TDD 과제로 User 도메인 기반 회원가입, 비밀번호 변경, 내 정보 조회 3가지 API 구현이 필요했습니다.
    기존 레이어드 아키텍처 대신 헥사고날 아키텍처를 직접 경험하고, TDD로 견고한 도메인 모델을 설계하고자 했습니다.

  • 목표:
    (1) 헥사고날 아키텍처 적용으로 도메인 계층의 독립성 확보,
    (2) Value Object와 Domain Service로 도메인 규칙 표현,
    (3) TDD로 각 계층별 책임 검증,
    (4) DIP를 통한 인프라 의존성 제거

  • 결과:
    Auth/User 패키지 분리로 관심사 분리 달성, PasswordEncryptor 포트로 Spring Security 의존성 제거,
    PasswordPolicy 도메인 서비스로 교차 검증 구현, 15개 테스트 파일(단위/통합/E2E)로 각 계층 검증 완료.
    통합 테스트에서 Detached Entity 버그를 발견하여 운영 배포 전 수정.

구현 기능

기능 Method Endpoint 인증
회원가입 POST /api/v1/auth/signup X
비밀번호 변경 PUT /api/v1/auth/password O
내 정보 조회 GET /api/v1/users/me O

🧭 Context & Decision

문제 정의

  • 현재 동작/제약:

    • 기존 레이어드 아키텍처는 Controller → Service → Repository 구조로 인프라 의존성이 Service 계층까지 침투
    • 도메인 규칙이 Service 메서드 내부에 산재되어 응집도 저하
    • Password 검증에 생년월일 같은 외부 정보가 필요한 교차 검증 로직의 위치 모호
  • 문제(또는 리스크):

    • Service 계층이 비즈니스 로직과 인프라 관심사를 동시에 처리하여 단일 책임 원칙 위반
    • 도메인 규칙이 분산되어 변경 시 여러 곳을 수정해야 하는 산탄총 수술(Shotgun Surgery) 발생 가능
    • Spring Security에 대한 직접 의존으로 도메인 계층의 테스트 복잡도 증가
  • 성공 기준(완료 정의):

    • 도메인 계층이 인프라(Spring Security, JPA)에 의존하지 않음
    • Value Object로 불변성과 자가 검증 보장
    • 각 계층별 단위/통합/E2E 테스트 통과
    • 3개 API 모두 정상 동작 및 비즈니스 규칙 준수

선택지와 결정

1. PasswordEncryptor 위치 선택

  • 고려한 대안:

    • A: Domain 계층에 구현 클래스 직접 배치 (Spring Security 직접 의존)
    • B: Domain 계층에 인터페이스 정의 + Infrastructure 계층에 어댑터 구현 (DIP 적용)
  • 최종 결정: B 선택 - PasswordEncryptor 인터페이스를 Domain에, BCryptPasswordEncryptorAdapter를 Infrastructure에 배치

  • 트레이드오프:

    • 장점: 도메인 계층이 Spring Security에 독립적이며, 테스트 시 Mock 구현체 주입 용이
    • 단점: 클래스 수 증가(인터페이스 + 어댑터), 간단한 암호화에는 다소 과도할 수 있음
    • 멘토 피드백: "도메인에 두는 것도 자연스러울 수 있음" → 학습 목적으로 엄격한 DIP 적용

2. PasswordPolicy 책임 분리

  • 고려한 대안:

    • A: Password VO 내부에 모든 검증 로직 포함 (생년월일도 생성자 파라미터로 받음)
    • B: Password VO는 자기 자신의 규칙만 검증, 교차 검증은 별도 도메인 서비스로 분리
  • 최종 결정: B 선택 - PasswordPolicy 도메인 서비스로 분리

  • 트레이드오프:

    • 장점: Password VO는 자기 자신의 불변식만 관리하여 SRP 준수, 교차 검증 정책 변경 시 VO 수정 불필요
    • 단점: 정책 클래스 추가로 구조 복잡도 약간 증가
    • 근거: Eric Evans의 DDD - "한 Value Object가 다른 VO에 의존하는 검증은 Domain Service가 적합"

3. Detached Entity 문제 해결

  • 발견한 문제: changePassword() 메서드에서 user.changePassword()save() 호출 없이 반환 시 DB 미반영

  • 최종 결정: userRepository.save(user) 명시적 호출로 변경 내용 영속화

  • 트레이드오프:

    • 통합 테스트가 없었다면 운영 환경에서 발견됐을 치명적 버그
    • JPA Dirty Checking 의존 대신 명시적 save()로 명확한 의도 표현

추후 개선 여지

  • PasswordPolicy를 정적 유틸리티 대신 인스턴스 기반 서비스로 변경하여 정책 확장성 확보
  • BirthDate 형식 검증 전략을 외부 설정으로 분리 (YYYYMMDD, YYMMDD, MMDD 외 추가 패턴 대응)

🏗️ Design Overview

graph TB

    subgraph Interfaces["🌐 Interfaces Layer (외부 세계)"]

        AuthController["AuthV1Controller<br/>회원가입, 비밀번호 변경"]

        UserController["UserV1Controller<br/>내 정보 조회"]

        AuthDto["AuthV1Dto<br/>Request/Response DTO"]

        UserDto["UserV1Dto<br/>Request/Response DTO"]

        ApiAdvice["ApiControllerAdvice<br/>전역 예외 처리"]

    end

  

    subgraph Application["⚙️ Application Layer (유스케이스 조율)"]

        AuthFacade["AuthFacade<br/>회원가입/비밀번호 변경 오케스트레이션"]

        UserFacade["UserFacade<br/>사용자 정보 조회"]

        UserInfo["UserInfo<br/>Application DTO"]

    end

  

    subgraph Domain["🎯 Domain Layer (비즈니스 로직)"]

        User["User (Entity)<br/>사용자 집합근"]

        UserService["UserService<br/>사용자 생성/조회/변경"]

        PasswordPolicy["PasswordPolicy<br/>비밀번호 교차 검증"]

  

        subgraph ValueObjects["Value Objects"]

            LoginId["LoginId<br/>4~20자, 영문+숫자+특수문자"]

            Password["Password<br/>8~20자, 영문+숫자+특수문자"]

            UserName["UserName<br/>2~50자"]

            BirthDate["BirthDate<br/>1900-01-01 ~ 오늘"]

            Email["Email<br/>RFC 5322 형식"]

        end

  

        subgraph Ports["Ports (추상화)"]

            UserRepository["UserRepository<br/>(인터페이스)"]

            PasswordEncryptor["PasswordEncryptor<br/>(인터페이스)"]

        end

    end

  

    subgraph Infrastructure["🔧 Infrastructure Layer (인프라 어댑터)"]

        UserRepositoryImpl["UserRepositoryImpl<br/>JPA 영속성 구현"]

        UserJpaRepo["UserJpaRepository<br/>Spring Data JPA"]

        BCryptAdapter["BCryptPasswordEncryptor<br/>BCrypt 암호화 어댑터"]

    end

  

    %% 의존성 방향 (상위 → 하위)

    AuthController --> AuthFacade

    UserController --> UserFacade

  

    AuthFacade --> UserService

    UserFacade --> UserService

  

    UserService --> User

    UserService --> PasswordPolicy

    UserService --> UserRepository

    UserService --> PasswordEncryptor

  

    User --> LoginId

    User --> Password

    User --> UserName

    User --> BirthDate

    User --> Email

  

    %% Infrastructure → Domain (DIP, 점선)

    UserRepositoryImpl -.implements.-> UserRepository

    BCryptAdapter -.implements.-> PasswordEncryptor

    UserRepositoryImpl --> UserJpaRepo

  

    style Interfaces fill:#e1f5ff

    style Application fill:#fff4e1

    style Domain fill:#ffe1f5

    style Infrastructure fill:#e1ffe1

    style ValueObjects fill:#fff0f0

    style Ports fill:#f0f0ff

Loading

변경 범위

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

    • commerce-api 모듈 전체 (Interfaces, Application, Domain, Infrastructure 계층)
    • User 도메인 (회원가입, 인증, 비밀번호 정책)
  • 신규 추가:

    • Domain 계층: User, LoginId, Password, UserName, BirthDate, Email (VO 5개), PasswordPolicy, PasswordEncryptor 인터페이스, UserService, UserRepository 인터페이스
    • Application 계층: AuthFacade, UserFacade, UserInfo (DTO)
    • Infrastructure 계층: UserJpaRepository (Spring Data JPA), BCryptPasswordEncryptorAdapter
    • Interfaces 계층: AuthV1Controller, UserV1Controller, AuthV1ApiSpec, UserV1ApiSpec, DTO 클래스들 (SignupRequest, ChangePasswordRequest 등)
    • Security 설정: CustomUserDetailsService, SecurityConfig (HTTP Basic Auth)
    • 테스트: 15개 테스트 파일 (7개 VO 단위 테스트, 통합 테스트 4개, E2E 테스트 2개)
  • 제거/대체:

    • 기존 Example 샘플 코드 전체 삭제

주요 컴포넌트 책임

Interfaces Layer (외부 세계 → 내부)

  • AuthV1Controller / UserV1Controller: HTTP 요청 수신, DTO 검증, Facade 호출, 응답 반환
    • 인증(Auth)과 리소스(User) 관심사 분리
    • OpenAPI 스펙(@Tag, @Operation) 문서화

Application Layer (오케스트레이션)

  • AuthFacade: 회원가입, 비밀번호 변경 유스케이스 조율
    • signup(): UserService 호출 → UserInfo DTO 변환
    • changePassword(): 인증 → 비밀번호 변경 트랜잭션 관리
  • UserFacade: 사용자 정보 조회 유스케이스
    • getMyInfo(): loginId로 사용자 조회 → DTO 변환

Domain Layer (비즈니스 규칙)

  • User (Entity): 사용자 집합근, 도메인 로직 캡슐화
    • signup(): 정적 팩토리 메서드, PasswordPolicy 검증 포함
    • changePassword(): 현재 비밀번호 검증 → 새 비밀번호 변경
    • authenticate(): 비밀번호 일치 검증
  • PasswordPolicy (Domain Service): 비밀번호-생년월일 교차 검증
    • 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD 형식)
  • UserService (Domain Service): 사용자 생성/조회/변경 조율
    • Repository와 PasswordEncryptor 포트 사용
  • Value Objects (LoginId, Password, UserName, BirthDate, Email): 불변성, 자가 검증, 도메인 규칙 표현
    • LoginId: 4~20자, 영문+숫자+특수문자(-_)
    • Password: 8~20자, 영문+숫자+특수문자 조합 (암호화된 상태로 저장)
    • UserName: 2~50자
    • BirthDate: 1900-01-01 ~ 오늘, LocalDate 래핑
    • Email: RFC 5322 형식 검증

Infrastructure Layer (인프라 어댑터)

  • UserJpaRepository: JPA 기반 영속성 구현 (Spring Data JPA)
  • BCryptPasswordEncryptorAdapter: BCryptPasswordEncoder 래핑, PasswordEncryptor 포트 구현
  • CustomUserDetailsService: Spring Security 인증을 위한 UserDetailsService 구현

🔁 Flow Diagram

Main Flow: 회원가입 (POST /api/v1/auth/signup)

sequenceDiagram
    autonumber
    actor Client
    participant AuthV1Controller
    participant AuthFacade
    participant UserService
    participant PasswordPolicy
    participant PasswordEncryptor
    participant UserRepository
    participant DB

    Client->>AuthV1Controller: POST /api/v1/auth/signup<br/>{loginId, password, name, birthDate, email}
    AuthV1Controller->>AuthV1Controller: DTO 검증 (@Valid)
    AuthV1Controller->>AuthFacade: signup(loginId, password, ...)
    AuthFacade->>UserService: signup(loginId, password, ...)

    UserService->>UserService: 중복 체크 (findByLoginId)
    UserService->>PasswordPolicy: validate(password, birthDate)
    PasswordPolicy-->>UserService: 검증 완료 (또는 예외)

    UserService->>PasswordEncryptor: encode(password)
    PasswordEncryptor-->>UserService: encodedPassword

    UserService->>UserService: User.signup(..., encodedPassword)
    Note over UserService: VO들 생성 및 자가 검증<br/>(LoginId, Password, UserName, etc.)

    UserService->>UserRepository: save(user)
    UserRepository->>DB: INSERT user
    DB-->>UserRepository: saved user
    UserRepository-->>UserService: user

    UserService-->>AuthFacade: user
    AuthFacade->>AuthFacade: UserInfo.from(user)
    AuthFacade-->>AuthV1Controller: UserInfo
    AuthV1Controller-->>Client: 201 Created<br/>{id, loginId, name, email, ...}
Loading

Main Flow: 비밀번호 변경 (PUT /api/v1/auth/password)

sequenceDiagram
    autonumber
    actor Client
    participant AuthV1Controller
    participant AuthFacade
    participant UserService
    participant PasswordPolicy
    participant PasswordEncryptor
    participant UserRepository
    participant DB

    Client->>AuthV1Controller: PUT /api/v1/auth/password<br/>Authorization: Basic {loginId:headerPassword}<br/>{currentPassword, newPassword}
    AuthV1Controller->>AuthV1Controller: DTO 검증
    AuthV1Controller->>AuthFacade: changePassword(loginId, headerPassword, currentPassword, newPassword)

    AuthFacade->>UserService: authenticate(loginId, headerPassword)
    UserService->>UserRepository: findByLoginId(loginId)
    UserRepository->>DB: SELECT user
    DB-->>UserRepository: user
    UserRepository-->>UserService: user

    UserService->>User: authenticate(headerPassword, encryptor)
    User->>PasswordEncryptor: matches(headerPassword, encodedPassword)
    PasswordEncryptor-->>User: true/false
    User-->>UserService: (인증 성공 또는 예외)
    UserService-->>AuthFacade: user

    AuthFacade->>UserService: changePassword(user, currentPassword, newPassword)
    UserService->>User: changePassword(currentPassword, newPassword, encryptor)
    User->>PasswordEncryptor: matches(currentPassword, encodedPassword)
    PasswordEncryptor-->>User: true/false

    User->>PasswordPolicy: validate(newPassword, birthDate)
    PasswordPolicy-->>User: 검증 완료

    User->>PasswordEncryptor: encode(newPassword)
    PasswordEncryptor-->>User: newEncodedPassword
    User->>User: password = new Password(newEncodedPassword)

    UserService->>UserRepository: save(user)
    Note over UserRepository: Detached Entity 방지를 위해 명시적 save()
    UserRepository->>DB: UPDATE user SET password=...
    DB-->>UserRepository: updated
    UserRepository-->>UserService: user
    UserService-->>AuthFacade: void
    AuthFacade-->>AuthV1Controller: void
    AuthV1Controller-->>Client: 200 OK
Loading

Main Flow: 내 정보 조회 (GET /api/v1/users/me)

sequenceDiagram
    autonumber
    actor Client
    participant UserV1Controller
    participant UserFacade
    participant UserService
    participant UserRepository
    participant DB

    Client->>UserV1Controller: GET /api/v1/users/me<br/>Authorization: Basic {loginId:password}
    Note over UserV1Controller: Spring Security가 인증 처리<br/>SecurityContext에서 loginId 추출

    UserV1Controller->>UserFacade: getMyInfo(loginId)
    UserFacade->>UserService: findByLoginId(loginId)
    UserService->>UserRepository: findByLoginId(loginId)
    UserRepository->>DB: SELECT user WHERE login_id=?
    DB-->>UserRepository: user
    UserRepository-->>UserService: Optional<User>

    UserService-->>UserFacade: user (또는 예외)
    UserFacade->>UserFacade: UserInfo.from(user)
    UserFacade-->>UserV1Controller: UserInfo
    UserV1Controller-->>Client: 200 OK<br/>{id, loginId, name, email, birthDate, ...}
Loading

Exception Flow: 비밀번호에 생년월일 포함 시

sequenceDiagram
    autonumber
    actor Client
    participant Controller
    participant PasswordPolicy
    participant ApiControllerAdvice

    Client->>Controller: 비밀번호에 "19900101" 포함
    Controller->>PasswordPolicy: validate("Pass19900101!", birthDate)
    PasswordPolicy->>PasswordPolicy: 생년월일 패턴 검사<br/>(YYYYMMDD, YYMMDD, MMDD)
    PasswordPolicy->>PasswordPolicy: "19900101" 발견
    PasswordPolicy-->>Controller: CoreException(PASSWORD_CONTAINS_BIRTH_DATE)
    Controller-->>ApiControllerAdvice: 예외 전파
    ApiControllerAdvice-->>Client: 400 Bad Request<br/>{"code": "USER_004", "message": "비밀번호에 생년월일이 포함되어 있습니다"}
Loading

🎯 이번 과제에서 도전한 것

헥사고날 아키텍처 첫 도전

기존에 익숙한 Controller → Service → Repository 레이어드 아키텍처를 따라갈 수 있었지만, 이번 기회에 궁금했던 헥사고날 아키텍처를 직접 경험해보고 싶었습니다. 시간이 더 걸리더라도 "왜 이렇게 구조를 나누는가"를 이해하며 진행했습니다.

TDD와 테스트 코드에 진심

  • 단위 테스트는 100% 손코딩: AI 도움 없이 직접 테스트 케이스를 설계하고 작성했습니다.
  • 엣지케이스까지 고려: 경계값, 예외 상황 등을 꼼꼼히 테스트했습니다.
  • 설계 먼저 → 테스트 작성: Inside-Out 방식으로 VO → Entity → Service → Facade → Controller 순서로 진행했습니다.

테스트로 발견한 버그

통합 테스트 과정에서 비밀번호 변경 시 Detached Entity로 인해 DB에 반영되지 않는 버그를 발견하고 수정했습니다.
테스트 코드가 없었다면 운영에서 발견됐을 문제였습니다.

💬 리뷰 포인트

  1. PasswordEncryptor의 Infrastructure 분리

    • BCryptPasswordEncoder를 Domain에서 직접 사용하지 않고 포트-어댑터 패턴으로 분리했습니다.
    • 멘토님 피드백: "도메인쪽이 더 자연스러울 수 있음" → 학습 목적으로 Infrastructure에 배치했는데, 적절한 선택이었을까요?
  2. PasswordPolicy 도메인 서비스 분리

    • Password VO는 자기 자신의 규칙만 검증하고, 생년월일 포함 여부 같은 교차 검증은 PasswordPolicy가 담당합니다.
    • 이 책임 분리가 적절한지 리뷰 부탁드립니다.
  3. Facade 테스트 범위

    • Facade는 오케스트레이션 역할이라 비즈니스 흐름 검증에 집중했습니다.
    • 테스트 범위와 방식이 적절한지 확인 부탁드립니다.

✅ Checklist

  • 테스트 코드 포함 (단위/통합/E2E)
  • 불필요한 코드 제거 (Example 샘플 코드 삭제)
  • http 파일 문서화 (http/commerce-api/auth-v1.http, user-v1.http)
  • 헥사고날 아키텍처 적용 (Interfaces → Application → Domain → Infrastructure)
  • DIP 적용 (PasswordEncryptor 포트-어댑터)
  • 사용자 도메인 모델 설계 (Value Objects, Domain Service)

📎 기타 참고 사항

테스트 구성

레이어 테스트 종류 파일 목적
Domain VO 단위 테스트 LoginIdTest, PasswordTest, UserNameTest, EmailTest, BirthDateTest VO 자가 검증 로직 검증
Domain Service 단위 테스트 UserServiceTest, PasswordPolicyTest 비즈니스 로직 및 정책 검증
Application 통합 테스트 AuthFacadeIntegrationTest, UserFacadeIntegrationTest 유스케이스 흐름 및 트랜잭션 검증
Infrastructure 통합 테스트 UserRepositoryIntegrationTest 영속성 계층 동작 검증
Interfaces E2E 테스트 AuthV1ApiE2ETest, UserV1ApiE2ETest HTTP API 엔드투엔드 검증

학습 포인트

  • 단위테스트 → 통합테스트 → E2E 테스트의 역할과 범위 차이 이해
  • 헥사고날 아키텍처에서 의존성 방향과 DIP의 실제 적용
  • 테스트 코드가 버그를 잡아주는 실제 경험
  • Value Object의 불변성과 자가 검증의 가치
  • Domain Service를 통한 교차 검증 로직 분리

주요 기술적 결정 사항

  • 패키지 구조: com.loopers.{interfaces, application, domain, infrastructure}
  • DTO 변환 위치: Application 계층 (Facade)에서 Domain → DTO 변환
  • 인증 방식: Spring Security HTTP Basic Auth (학습 목적)
  • 테스트 전략: Given-When-Then 패턴, Testcontainers (MySQL), @transactional 롤백
  • 예외 처리: CoreException + ApiControllerAdvice 중앙 집중식 처리

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 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

@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: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java (1)

33-115: ⚠️ Potential issue | 🟠 Major

필수 헤더 누락이 500으로 반환되고 있다

클라이언트 오류(헤더 누락)가 500 상태 코드로 반환되면 운영 지표와 알람이 왜곡되어 장애 판단과 대응이 지연될 수 있다. 현재 MissingRequestHeaderException이 핸들러에 없어 라인 111-115의 전역 Throwable 핸들러에서 INTERNAL_ERROR로 처리된다. 다른 요청 관련 예외들(MissingServletRequestParameterException, HttpMessageNotReadableException, ServerWebInputException)은 BAD_REQUEST로 처리하고 있으므로, 헤더 누락도 동일하게 처리하도록 MissingRequestHeaderException 전용 핸들러를 추가하길 권장한다.

🛠️ 제안 수정안
 import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.MissingRequestHeaderException;
@@
     `@ExceptionHandler`
     public ResponseEntity<ApiResponse<?>> handleBadRequest(MissingServletRequestParameterException e) {
         String name = e.getParameterName();
         String type = e.getParameterType();
         String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type);
         return failureResponse(CommonErrorType.BAD_REQUEST, message);
     }
+
+    `@ExceptionHandler`
+    public ResponseEntity<ApiResponse<?>> handleBadRequest(MissingRequestHeaderException e) {
+        String name = e.getHeaderName();
+        String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", name);
+        return failureResponse(CommonErrorType.BAD_REQUEST, message);
+    }

E2E 테스트 또는 컨트롤러 어드바이스 테스트로 X-Loopers-LoginId, X-Loopers-LoginPw 등 필수 헤더 누락 시 400 상태 코드와 CommonErrorType.BAD_REQUEST가 반환되는지 확인하길 권장한다.

🤖 Fix all issues with AI agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java`:
- Around line 48-54: The CoreException currently drops the original cause so
BirthDate.parseDate loses the DateTimeParseException; update CoreException to
add a constructor that accepts a Throwable cause and stores it (so getCause()
returns the original), then update BirthDate.parseDate to pass the caught
DateTimeParseException as the cause when throwing CoreException (i.e., throw new
CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 YYYY-MM-DD 형식이어야 합니다.",
e)); finally add the BirthDateTest case named preservesCauseWhenInvalidFormat to
assert that constructing new BirthDate with an invalid string results in a
CoreException whose getCause() is a DateTimeParseException.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java`:
- Around line 19-29: The current validate()/validateBirthDateNotContained
methods can NPE when rawPassword or birthDate is null; add pre-validation in
validate(String rawPassword, LocalDate birthDate) to check if rawPassword is
null/blank or birthDate is null and throw a CoreException with an appropriate
UserErrorType (consistent with existing error handling) before calling
validateBirthDateNotContained; keep validateBirthDateNotContained as-is but
assume non-null inputs. Also add unit tests in PasswordPolicyTest to assert that
null/blank rawPassword and null birthDate produce the expected CoreException.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java`:
- Around line 17-20: Add defensive null handling to
BCryptPasswordEncryptor.matches: check if the incoming raw/current password is
null before delegating to BCryptPasswordEncoder.matches and throw a clear
IllegalArgumentException (or a domain-specific exception) with a descriptive
message (e.g., "currentPassword must not be null") so callers get a predictable
error instead of BCrypt's IllegalArgumentException; update or add unit tests for
BCryptPasswordEncryptor.matches to cover null input; alternatively, if you
prefer validation at the service layer, add an explicit null check in
UserService before calling BCryptPasswordEncryptor.matches (but keep the
encryptor-level check to be defensive).

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java`:
- Around line 8-11: The repository methods findByLoginIdValue and
existsByLoginIdValue will cause full table scans unless the underlying login_id
column is indexed/unique; update the LoginId value field's `@Column` (the value
property inside the LoginId value object) to include unique=true and
nullable=false or add an index on the User entity via `@Table`(indexes =
`@Index`(name="idx_login_id", columnList="login_id", unique=true)) on the User
class; ensure the actual DB schema migration is created/updated so the login_id
column has the index/unique constraint and then run load tests for login
throughput to validate the improvement.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java`:
- Around line 28-31: The ChangePasswordRequest record exposes sensitive data
because its implicit toString() will include currentPassword and newPassword;
override the record's toString() in ChangePasswordRequest to avoid printing raw
passwords (e.g., mask or omit currentPassword and newPassword) so
logging/exception paths cannot leak secrets, and implement the override inside
the ChangePasswordRequest record body (reference: ChangePasswordRequest,
currentPassword, newPassword).
- Around line 9-15: The SignupRequest record's default toString() will include
the password field and can leak credentials; override toString inside the
SignupRequest record to exclude or mask the password (e.g., show "*****" or omit
the password field) so logs never contain the plain password. Add a toString()
method in the SignupRequest record body that returns a string with loginId,
name, birthDate, email and a masked or absent password, leaving the record
components and constructor unchanged.

In
`@apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java`:
- Around line 129-196: Add a test in the ChangePassword nested class that covers
the missing rule: when the new password contains the user’s birth date the
changePassword call should throw a CoreException with error type
UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE; implement it by signing up the user
via authFacade.signup (use variables like loginId, currentPassword, birthDate)
and then calling authFacade.changePassword(loginId, currentPassword,
currentPassword, "<passwordContainingBirthDate>") inside assertThrows, and
assert that exception.getErrorType() equals
UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE.

In `@apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java`:
- Around line 71-82: The tests createsBirthDate_whenExactlyMinAge and
throwsException_whenUnderMinAge are flakey because they call LocalDate.now()
multiple times; fix both tests by capturing LocalDate now once into a local
variable (e.g. LocalDate today = LocalDate.now()), compute the input string with
today.minusYears(14).format(...) and compute the expected LocalDate with the
same today.minusYears(14) (or today.minusYears(14).minusDays(1) for the
under-min test) and use those captured values when constructing new BirthDate
and asserting, ensuring all references to LocalDate.now() in each test are
replaced with the single captured variable.

In `@apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java`:
- Around line 54-65: The test createsLoginId_whenMinLength currently uses a
7-char string ("nahyeon") so it doesn't actually verify the minimum length
boundary; change the test's arranged value variable to a 4-character string
(e.g., "abcd") so the test for LoginId (constructor and getValue) validates the
minimum-length constraint, keeping the test method name
createsLoginId_whenMinLength and class LoginId unchanged; optionally split into
a separate test explicitly for length 4 if you prefer clearer coverage.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java`:
- Around line 152-170: The test currently asserts only the in-memory password
change on User in changesPassword_whenValidRequest(); ensure persistence is
verified by adding a mock verification or re-fetch: if UserService is
responsible for saving, add a Mockito
verify(userRepository).save(ArgumentMatchers.any(User.class)) after calling
userService.changePassword(user, ...); otherwise convert this to an
integration-style test that calls changePassword, then reloads the user via
userRepository.findById(...) and asserts the persisted password equals
"$2a$10$newHash"; also add an extra scenario to cover a detached-entity case
where the entity is detached before changePassword and assert the DB reflects
the update after reloading.
- Around line 42-58: The signup_whenValidInput test currently asserts returned
fields but doesn't verify critical interactions; update
UserServiceTest.signup_whenValidInput to verify that
passwordEncryptor.encode(...) and userRepository.save(...) are each invoked (use
verify(passwordEncryptor, times(1)).encode(anyString()) and
verify(userRepository, times(1)).save(any(User.class))). Additionally capture
the User passed to save using an ArgumentCaptor<User> and assert that the
captured User.getPassword() equals the encoded value returned by
passwordEncryptor.encode to ensure the persisted password is the encoded hash.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java`:
- Around line 114-175: Add an E2E test that asserts 401 is returned when header
authentication fails: inside the existing ChangePassword test class add a new
test method (e.g., returns401_whenHeaderAuthFails) that signs up using
validSignupRequest(), builds headers using authHeaders("nahyeon", "wrongPw1!")
with ContentType set to MediaType.APPLICATION_JSON, creates an
AuthV1Dto.ChangePasswordRequest with the current and new password, calls
testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body,
headers), ApiResponse.class) and asserts the response status is
HttpStatus.UNAUTHORIZED; reference ChangePassword, authHeaders, CHANGE_PW_URL,
AuthV1Dto.ChangePasswordRequest, and testRestTemplate.exchange to locate where
to add the test.
- Around line 112-114: The `@DisplayName` on the ChangePassword nested test class
is incorrect: it says "PATCH /api/v1/auth/password" but the test uses
HttpMethod.PUT in the ChangePassword tests; update the `@DisplayName` value on the
ChangePassword class to "PUT /api/v1/auth/password" (or change the test to use
PATCH if the API is actually PATCH) so the DisplayName matches the HttpMethod
used in the tests (refer to the ChangePassword nested class and the
HttpMethod.PUT usage).

In `@http/http-client.env.json`:
- Around line 4-5: 현재 평문 자격증명이 http-client.env.json에 포함되어 git으로 추적되고 있으므로 이를
분리해야 합니다; 수정 방법은 http-client.env.json에서 민감정보(loginId, loginPw)를 제거하고 대신 새 파일
http-client.private.env.json을 생성하여 해당 민감값을 옮긴 뒤 http-client.private.env.json을
.gitignore에 추가하여 추적 대상에서 제외하세요; 또한 기존 커밋 이력에 남아있는 자격증명은 git filter-branch 또는 BFG
Repo-Cleaner 등으로 제거하고(또는 리포지터리 클린업 절차 수행) 운영팀과 자격증명 재발급 여부를 협의하십시오.

In
`@modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java`:
- Around line 7-13: The Docker image tag used in RedisTestContainersConfig is a
floating tag; update the RedisContainer instantiation to use a fixed, validated
patch version (e.g., "redis:7.4.7-alpine") by extracting the image string into a
private static final constant (e.g., REDIS_IMAGE) and replacing
DockerImageName.parse("redis:7-alpine") with DockerImageName.parse(REDIS_IMAGE).
Ensure the constant is used by the redisContainer field and re-run the
integration tests to verify container startup and Redis connectivity.
🧹 Nitpick comments (16)
apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java (1)

21-40: Embeddable 값 객체에 equals/hashCode가 없으면 JPA 변경 감지가 불안정해질 수 있다
운영 환경에서 컬렉션 중복 제거, 캐시 키, 더티 체킹이 값 기반으로 동작하지 않아 예기치 않은 업데이트 누락이나 중복 저장이 발생할 수 있다.
value 기반 equals/hashCode를 추가하여 값 객체로서의 동등성을 보장하는 수정이 필요하다.
추가 테스트로 동일 날짜 두 객체의 equals/hashCode 일치를 검증하는 단위 테스트를 추가하는 것이 좋다.

🛠️ 수정안
+import java.util.Objects;
@@
 public class BirthDate {
@@
     public LocalDate getValue() {
         return value;
     }
+
+    `@Override`
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        BirthDate birthDate = (BirthDate) o;
+        return Objects.equals(value, birthDate.value);
+    }
+
+    `@Override`
+    public int hashCode() {
+        return Objects.hash(value);
+    }

As per coding guidelines, null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java (1)

20-66: Embeddable 값 객체에 equals/hashCode가 없어 값 비교가 불안정하다
운영에서 Set/Map 키 또는 JPA 더티 체킹이 값 기반으로 동작하지 않아 중복 저장이나 변경 미감지가 발생할 수 있다.
value 기반 equals/hashCode를 추가하여 값 객체 동등성 보장을 권장한다.
추가 테스트로 동일 이메일 두 객체의 equals/hashCode 일치를 검증하는 단위 테스트를 추가하는 것이 좋다.

🛠️ 수정안
+import java.util.Objects;
@@
 public class Email {
@@
     public String getValue() {
         return value;
     }
+
+    `@Override`
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Email email = (Email) o;
+        return Objects.equals(value, email.value);
+    }
+
+    `@Override`
+    public int hashCode() {
+        return Objects.hash(value);
+    }

As per coding guidelines, null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java (1)

21-60: 값 객체 동등성 보장이 없어 운영 비교 로직이 흔들릴 수 있다
값 객체가 equals/hashCode를 제공하지 않으면 캐시 키, 컬렉션 비교, JPA 변경 감지에서 예기치 않은 누락이나 중복이 발생할 수 있다.
value 기반 equals/hashCode를 추가하여 값 객체로서의 일관성을 확보하는 수정이 필요하다.
추가 테스트로 동일 이름 두 객체의 equals/hashCode 일치를 검증하는 케이스를 추가하는 것이 좋다.

🛠️ 수정안
+import java.util.Objects;
@@
 public class UserName {
@@
     public String getMaskedValue() {
         return value.substring(0, value.length() - 1) + "*";
     }
+
+    `@Override`
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        UserName userName = (UserName) o;
+        return Objects.equals(value, userName.value);
+    }
+
+    `@Override`
+    public int hashCode() {
+        return Objects.hash(value);
+    }

As per coding guidelines, null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java (1)

22-120: 값 객체 비교가 필요해질 경우 동등성 정의가 없어 운영 로직이 불안정해질 수 있다
운영 중 캐시 키, 중복 검사, 테스트 비교에서 값 객체 동등성이 보장되지 않아 예기치 않은 중복이나 비교 실패가 발생할 수 있다.
value 기반 equals/hashCode를 추가해 값 객체 일관성을 확보하는 수정이 필요하다.
추가 테스트로 동일 비밀번호 두 객체의 equals/hashCode 일치를 검증하는 케이스를 추가하는 것이 좋다.

🛠️ 수정안
+import java.util.Objects;
@@
 public class Password {
@@
     public String getValue() {
         return value;
     }
+
+    `@Override`
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Password password = (Password) o;
+        return Objects.equals(value, password.value);
+    }
+
+    `@Override`
+    public int hashCode() {
+        return Objects.hash(value);
+    }

As per coding guidelines, null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java (1)

19-54: 값 객체 equals/hashCode 부재로 비교/캐싱 오류 가능하다.

운영 관점에서 값 객체가 equals/hashCode를 제공하지 않으면 컬렉션 조회, 캐시 키, 테스트 비교에서 동일 값이라도 다르게 취급되어 미묘한 버그가 발생할 수 있다.
수정안으로 value 기반 equals/hashCode(toString 포함)를 구현하는 것을 권장한다.
추가 테스트로 LoginIdTest에 동일 value 비교 시 equals/hashCode가 일치함을 검증하는 케이스를 추가해야 한다.

🔧 수정 예시
 `@Embeddable`
 public class LoginId {
@@
     public String getValue() {
         return value;
     }
+
+    `@Override`
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof LoginId that)) return false;
+        return java.util.Objects.equals(value, that.value);
+    }
+
+    `@Override`
+    public int hashCode() {
+        return java.util.Objects.hash(value);
+    }
+
+    `@Override`
+    public String toString() {
+        return value;
+    }
 }

As per coding guidelines, equals/hashCode/toString 구현 안정성을 점검한다.

apps/commerce-api/src/main/java/com/loopers/domain/user/User.java (1)

32-45: 비밀번호가 해시값임을 명시하지 않으면 평문 저장 위험이 있다.

운영 관점에서 User.create/changePassword가 해시 문자열만 받는다는 계약이 명시되지 않으면 상위 계층에서 실수로 평문을 전달해 DB에 저장될 수 있어 보안 사고로 이어질 수 있다.
수정안으로 create/changePassword에 “BCrypt 등 인코딩된 비밀번호만 전달”이라는 Javadoc을 명시하고, 필요하면 메서드명을 encoded 전용으로 더 명확히 하는 방식을 권장한다.
추가 테스트로 UserServiceTest에서 signup/changePassword 시 PasswordEncryptor.encode가 호출되고 저장된 값이 raw와 다름을 검증하는 케이스를 추가해야 한다.

Based on learnings, User 엔티티 생성자의 password 파라미터는 BCrypt 해시를 받는다.

apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java (2)

47-55: 테스트 헬퍼의 BCrypt 해시 값이 유효하지 않다.

"$2a$10$encodedPasswordHash"는 실제 BCrypt 해시 형식(60자)이 아니다. 현재 테스트는 통과하지만, 향후 Password VO나 User 엔티티에서 BCrypt 형식 검증이 추가되면 테스트가 깨질 수 있다.

운영 관점에서 테스트 데이터는 실제 형식을 따르는 것이 안전하다.

♻️ 유효한 BCrypt 해시 사용 제안
     private User createTestUser(String loginIdValue) {
         return User.create(
                 new LoginId(loginIdValue),
-                "$2a$10$encodedPasswordHash",
+                "$2a$10$N9qo8uLOickgx2ZMRZoMye/IjqG4UdRYK4xKJwEqF5p0f1KJ5zJeW",
                 new UserName("홍길동"),
                 new BirthDate("1994-11-15"),
                 new Email(loginIdValue + "@example.com")
         );
     }

57-98: 비밀번호 변경 시 영속성 검증 테스트가 누락되었다.

PR 목표에서 "Detached Entity issue for password change" 버그를 수정했다고 언급했다. 해당 버그의 재발 방지를 위한 회귀 테스트가 Repository 통합 테스트에 포함되어야 한다.

운영 관점에서 수정된 버그에 대한 테스트가 없으면 동일 문제가 재발할 위험이 있다.

🧪 업데이트 영속성 테스트 추가 제안
`@Nested`
`@DisplayName`("save 메서드는")
class Save {
    // 기존 테스트들...

    `@Test`
    `@DisplayName`("기존 사용자의 비밀번호를 변경하면, 변경된 비밀번호가 저장된다")
    void updatesPasswordCorrectly() {
        // arrange
        User user = createTestUser("testuser");
        User savedUser = userRepository.save(user);
        String newEncodedPassword = "$2a$10$newEncodedPasswordHashHere1234567890123456";
        
        // act
        savedUser.changePassword(newEncodedPassword);
        userRepository.save(savedUser);
        
        // assert
        Optional<User> reloadedUser = userRepository.findByLoginId("testuser");
        assertThat(reloadedUser).isPresent();
        assertThat(reloadedUser.get().getPassword()).isEqualTo(newEncodedPassword);
    }
}
apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java (1)

53-91: 인증 관련 경계 케이스 테스트가 부족하다.

현재 테스트는 "유효한 인증"과 "잘못된 비밀번호"만 검증한다. 보안 관점에서 다음 케이스들이 누락되었다:

  1. 인증 헤더가 아예 없는 경우
  2. 존재하지 않는 loginId로 요청하는 경우
  3. 빈 문자열 헤더로 요청하는 경우

운영 환경에서 악의적 요청 패턴을 테스트하지 않으면 예상치 못한 동작이 발생할 수 있다.

🧪 추가 테스트 케이스 제안
`@DisplayName`("인증 헤더가 없으면, 401 Unauthorized 응답을 받는다.")
`@Test`
void returns401_whenNoAuthHeaders() {
    // arrange
    signup(validSignupRequest());

    // act
    ResponseEntity<ApiResponse> response =
            testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), ApiResponse.class);

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

`@DisplayName`("존재하지 않는 로그인ID로 조회하면, 401 Unauthorized 응답을 받는다.")
`@Test`
void returns401_whenLoginIdNotExists() {
    // arrange
    HttpHeaders headers = authHeaders("nonexistent", "Hx7!mK2@");

    // act
    ResponseEntity<ApiResponse> response =
            testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), ApiResponse.class);

    // assert
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java (1)

22-33: null ErrorType 전달 시 동작 테스트가 없다.

CoreException(null) 호출 시 errorType.getMessage()에서 NPE가 발생한다. 의도된 동작인지 명시적인 예외를 던질지 결정하고, 해당 케이스를 테스트로 문서화하는 것이 좋다.

운영 환경에서 예상치 못한 NPE는 디버깅을 어렵게 한다.

`@Test`
`@DisplayName`("ErrorType이 null이면, NullPointerException이 발생한다.")
void throwsNPE_whenErrorTypeIsNull() {
    assertThatThrownBy(() -> new CoreException(null))
            .isInstanceOf(NullPointerException.class);
}
apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java (1)

12-36: 과도한 블록 주석은 테스트 의도를 흐린다

운영 관점에서 불필요한 장문 주석은 유지보수와 리뷰 속도를 저하시켜 장애 대응 시 맥락 파악을 늦춘다. 수정안으로 Line 12-36의 장문 주석을 삭제하고 간결한 테스트 목적 주석만 유지하길 권장한다. 추가 테스트는 문서 정리만 수행되는 변경이므로 기존 테스트를 그대로 유지하면 충분하다.

🧹 제안 수정안
-/**
- - * Red Phase
- - *  1. 먼저 유효한 ID 생성 성공 테스트 작성 Happy Case
- - *  2. 테스트 실행 → 컴파일 에러 확인 (LoginId 클래스 없음)
- - *  3. Red 상태를 진행해봄! - 컴파일 에러 발생 근데 일단 이 컴파일 에러는 TDD에서의 시작이라
- - *  시작이 중요한게 아니라 테스트 케이스를 작성한 다음에 메인 코드가 작성이 잘되는게 테스트 코드 작성의 꽃이다.
- - *  테스트 케이스를 작성하다보면 너무 많아지는데 이 상황에서는 AB CD DE ... 무수히 많은 테스트 코드
- - *  그래서 깔끔하고 응집도 높게 집중할 수 있는 테스트 케이스를 작성하고 검증할 수 있는 테스트 케이스를 작성하면 객체 지향을 준수한
- - *  메인 테스트 코드를 만들 수 있다고 생각한다. 테스트 코드의 장점이다.
- - *  테스트 커버리지ㄴ를 올리기보단 ..
- - *  객체지향적 프로그래밍을 고려해서 테스트 코드를 진행하고싶음
- +/**
- *
- - *  스순환참조가 발생될 수 있는데 서비스간 함수를 두자 상위 레이어로 파사드란 레이어를 위치한다.
- - *  A 파사드는 A 서비스를 호출하면서도 의존하면서도 서비스를 의존받을 수 있다.
- - *  파사드 패턴이란 무엇일가
- - *
- - *  테스트 코드를 작성하기 이전에 설계부터 진행하는게 맞다고 생각해서 일다 나는 설계부터 진행
- - *
- - *  검증 로직은 아직 넣지 않음
- - *
- - *   껍데기 단계에서는 단순 할당만 수행
- - *   → 테스트 작성 후 Red 상태 확인
- - *   → 그 다음 검증 로직 추가 (Green)
- -
- -
apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java (1)

19-22: changePassword의 트랜잭션 경계 검토 필요

authenticatechangePassword가 별도 트랜잭션으로 실행된다. authenticate 성공 후 changePassword 호출 전 사용자가 삭제되거나 비밀번호가 변경되면 예상치 못한 동작이 발생할 수 있다.

현재 구조에서는 UserService.changePassword가 내부에서 다시 비밀번호를 검증하므로 큰 문제는 아니나, 두 작업을 단일 트랜잭션으로 묶는 것이 더 안전하다. Facade 레벨에 @Transactional을 추가하거나 UserService에 통합 메서드를 고려할 수 있다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java (1)

10-10: BCrypt strength 설정값 외부화 고려

현재 기본 strength(10)를 사용한다. 운영 환경에서 성능과 보안 균형을 조절할 필요가 있을 때 설정값을 외부화(application.yml)하면 재배포 없이 조정할 수 있다.

설정 외부화 예시
+import org.springframework.beans.factory.annotation.Value;
+
 `@Component`
 public class BCryptPasswordEncryptor implements PasswordEncryptor {

-    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
+    private final BCryptPasswordEncoder encoder;
+
+    public BCryptPasswordEncryptor(`@Value`("${security.bcrypt.strength:10}") int strength) {
+        this.encoder = new BCryptPasswordEncoder(strength);
+    }
apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java (1)

9-9: 미사용 import 제거

java.time.LocalDate가 import되어 있으나 이 클래스에서 직접 사용되지 않는다.

수정안
-import java.time.LocalDate;
apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java (1)

4-4: 와일드카드 import 사용

com.loopers.domain.user.* 와일드카드 import가 사용되었다. 명시적 import를 사용하면 의존성이 명확해진다.

apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java (1)

56-64: signup 성공 시 응답 본문 검증 부재

201 Created 상태 코드만 검증하고, 응답 본문의 loginId, name, birthDate, email 필드 값은 검증하지 않는다. API 계약 변경 시 조기에 발견하려면 응답 본문 검증을 추가해야 한다.

응답 본문 검증 추가 예시
`@DisplayName`("유효한 정보로 가입하면, 201 Created와 사용자 정보를 반환한다.")
`@Test`
void returns201_whenValidRequest() {
    // act
    ParameterizedTypeReference<ApiResponse<AuthV1Dto.SignupResponse>> type = 
            new ParameterizedTypeReference<>() {};
    ResponseEntity<ApiResponse<AuthV1Dto.SignupResponse>> response = 
            testRestTemplate.exchange(SIGNUP_URL, HttpMethod.POST, 
                    new HttpEntity<>(validSignupRequest()), type);

    // assert
    assertAll(
            () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED),
            () -> assertThat(response.getBody().data().loginId()).isEqualTo("nahyeon"),
            () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동")
    );
}

Comment on lines 48 to 54
private static LocalDate parseDate(String rawValue) {
try {
return LocalDate.parse(rawValue);
} catch (DateTimeParseException e) {
throw new CoreException(UserErrorType.INVALID_BIRTH_DATE,
"생년월일은 YYYY-MM-DD 형식이어야 합니다.");
}
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:

find . -name "CoreException.java" -type f

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

Length of output: 163


🏁 Script executed:

find . -name "BirthDate.java" -type f

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

Length of output: 157


🏁 Script executed:

cd apps/commerce-api && find . -name "*.java" -path "*domain/user*" -type f

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

Length of output: 1182


🏁 Script executed:

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


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java

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

Length of output: 2643


🏁 Script executed:

rg "new CoreException" --type java -A 2 -B 2 | head -50

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

Length of output: 5047


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java

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

Length of output: 6816


CoreException이 원인(cause)을 지원하도록 수정해야 예외 추적이 가능하다

현재 BirthDate.parseDate에서 DateTimeParseException이 발생하면 원인 정보가 완전히 유실되어, 운영 환경에서 장애 원인 파악 및 대응 시간이 증가한다.

그러나 현재 CoreException 생성자는 cause 매개변수를 받지 않으므로, 단순히 throw new CoreException(..., e)로 수정할 수 없다. CoreException을 다음과 같이 수정하여 cause를 지원해야 한다:

public class CoreException extends RuntimeException {
    private final ErrorType errorType;
    private final String customMessage;

    public CoreException(ErrorType errorType) {
        this(errorType, null, null);
    }

    public CoreException(ErrorType errorType, String customMessage) {
        this(errorType, customMessage, null);
    }
    
    public CoreException(ErrorType errorType, String customMessage, Throwable cause) {
        super(customMessage != null ? customMessage : errorType.getMessage(), cause);
        this.errorType = errorType;
        this.customMessage = customMessage;
    }
}

그 후 BirthDate.parseDate에서 다음과 같이 수정한다:

private static LocalDate parseDate(String rawValue) {
    try {
        return LocalDate.parse(rawValue);
    } catch (DateTimeParseException e) {
        throw new CoreException(UserErrorType.INVALID_BIRTH_DATE,
-               "생년월일은 YYYY-MM-DD 형식이어야 합니다.");
+               "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e);
    }
}

추가로 BirthDateTest에서 다음 테스트 케이스를 추가하여 원인 정보가 올바르게 보존되는지 검증한다:

`@DisplayName`("잘못된 형식이면, 예외의 원인으로 DateTimeParseException을 포함한다.")
`@Test`
void preservesCauseWhenInvalidFormat() {
    CoreException exception = assertThrows(CoreException.class, () -> {
        new BirthDate("1994/11/15");
    });
    
    assertThat(exception.getCause()).isInstanceOf(DateTimeParseException.class);
}
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java`
around lines 48 - 54, The CoreException currently drops the original cause so
BirthDate.parseDate loses the DateTimeParseException; update CoreException to
add a constructor that accepts a Throwable cause and stores it (so getCause()
returns the original), then update BirthDate.parseDate to pass the caught
DateTimeParseException as the cause when throwing CoreException (i.e., throw new
CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 YYYY-MM-DD 형식이어야 합니다.",
e)); finally add the BirthDateTest case named preservesCauseWhenInvalidFormat to
assert that constructing new BirthDate with an invalid string results in a
CoreException whose getCause() is a DateTimeParseException.

Comment on lines 19 to 29
public static void validate(String rawPassword, LocalDate birthDate) {
validateBirthDateNotContained(rawPassword, birthDate);
}

private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) {
String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE);
String yymmdd = yyyymmdd.substring(2);
String mmdd = yyyymmdd.substring(4);

if (rawPassword.contains(yyyymmdd) || rawPassword.contains(yymmdd) || rawPassword.contains(mmdd)) {
throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE);
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

null 입력 시 NPE로 500 발생 가능하다.

운영 관점에서 rawPassword 또는 birthDate가 null이면 Line 24~28에서 NPE가 발생해 CoreException 기반의 에러 포맷이 깨질 수 있다.
수정안으로 validate()에서 null/blank를 선검증하고 CoreException으로 일관 처리하는 방식을 권장한다.
추가 테스트로 PasswordPolicyTest에 rawPassword가 null/blank이거나 birthDate가 null인 경우 CoreException 발생을 검증하는 케이스를 추가해야 한다.

🔧 수정 예시
 public static void validate(String rawPassword, LocalDate birthDate) {
+    if (rawPassword == null || rawPassword.isBlank()) {
+        throw new CoreException(UserErrorType.INVALID_PASSWORD, "비밀번호는 필수입니다.");
+    }
+    if (birthDate == null) {
+        throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다.");
+    }
     validateBirthDateNotContained(rawPassword, birthDate);
 }

As per coding guidelines, 예외 흐름이 명확한지 점검한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java`
around lines 19 - 29, The current validate()/validateBirthDateNotContained
methods can NPE when rawPassword or birthDate is null; add pre-validation in
validate(String rawPassword, LocalDate birthDate) to check if rawPassword is
null/blank or birthDate is null and throw a CoreException with an appropriate
UserErrorType (consistent with existing error handling) before calling
validateBirthDateNotContained; keep validateBirthDateNotContained as-is but
assume non-null inputs. Also add unit tests in PasswordPolicyTest to assert that
null/blank rawPassword and null birthDate produce the expected CoreException.

Comment on lines 17 to 20
@Override
public boolean matches(String rawPassword, String encodedPassword) {
return encoder.matches(rawPassword, encodedPassword);
}
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:

#!/bin/bash
# BCryptPasswordEncryptor 관련 테스트 파일 검색
find . -type f -name '*Test*.java' -o -name '*Tests.java' | xargs grep -l "BCryptPasswordEncryptor" 2>/dev/null

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

Length of output: 69


🏁 Script executed:

#!/bin/bash
# BCryptPasswordEncryptor 파일 위치 및 전체 코드 확인
find . -type f -name "BCryptPasswordEncryptor.java" -exec cat -n {} +

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

Length of output: 875


🏁 Script executed:

#!/bin/bash
# BCryptPasswordEncryptor 호출하는 상위 레이어 확인
rg -B5 -A5 "BCryptPasswordEncryptor|\.matches\(" --glob '*.java' | head -100

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

Length of output: 11415


🏁 Script executed:

#!/bin/bash
# PasswordEncryptor 인터페이스 확인
find . -type f -name "PasswordEncryptor.java" -exec cat -n {} +

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

Length of output: 849


🏁 Script executed:

#!/bin/bash
# UserService를 호출하는 컨트롤러/API 레이어 확인
rg -B10 "\.authenticate\(|\.changePassword\(" --glob '*.java' -A3 | grep -E "(authenticate|changePassword|null|@|Controller)" | head -50

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

Length of output: 7259


🏁 Script executed:

#!/bin/bash
# Password.of() 메서드 확인 - null 체크 포함 여부
rg -A10 "class Password|Password\.of\(" --glob '*.java' | head -60

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

Length of output: 5822


🏁 Script executed:

#!/bin/bash
# Password 클래스 구현 확인
find . -type f -name "Password.java" -path "*/domain/user/*" -exec cat -n {} +

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

Length of output: 5018


🏁 Script executed:

#!/bin/bash
# AuthV1Controller의 changePassword 메서드 전체 확인
rg -B5 -A30 "public ApiResponse.*changePassword" --glob '*.java' -t java

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

Length of output: 1810


🏁 Script executed:

#!/bin/bash
# changePassword 요청 DTO 확인
rg -B5 -A10 "class.*ChangePasswordRequest|record.*ChangePasswordRequest" --glob '*.java' -t java

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

Length of output: 1169


BCryptPasswordEncryptor의 null 입력에 대한 방어적 처리 추가 필요

currentPassword(또는 loginPw) 파라미터가 null일 경우 BCryptPasswordEncoder.matches()에서 IllegalArgumentException을 발생시킨다. newPassword는 UserService에서 Password.of()로 검증되지만, currentPassword는 상위 레이어에서 명시적 검증이 이루어지지 않는다. 다음 방식 중 하나로 처리하면 된다:

  • BCryptPasswordEncryptor의 matches() 메서드에 null 체크 추가 후 적절한 예외 발생
  • 또는 UserService에서 matches() 호출 전 null 체크 추가
  • 추가로 null 입력 시나리오에 대한 단위 테스트 작성
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java`
around lines 17 - 20, Add defensive null handling to
BCryptPasswordEncryptor.matches: check if the incoming raw/current password is
null before delegating to BCryptPasswordEncoder.matches and throw a clear
IllegalArgumentException (or a domain-specific exception) with a descriptive
message (e.g., "currentPassword must not be null") so callers get a predictable
error instead of BCrypt's IllegalArgumentException; update or add unit tests for
BCryptPasswordEncryptor.matches to cover null input; alternatively, if you
prefer validation at the service layer, add an explicit null check in
UserService before calling BCryptPasswordEncryptor.matches (but keep the
encryptor-level check to be defensive).

Comment on lines +8 to +11
public interface UserJpaRepository extends JpaRepository<User, Long> {
Optional<User> findByLoginIdValue(String loginId);
boolean existsByLoginIdValue(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:

# UserJpaRepository 파일 위치 확인
fd -t f "UserJpaRepository.java"

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

Length of output: 171


🏁 Script executed:

# User 엔티티 파일 검색 및 내용 확인
fd -t f "User.java" --path "*/infrastructure/user/*" --exec cat {}

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

Length of output: 318


🏁 Script executed:

# LoginId 값 객체 파일 검색 및 내용 확인
fd -t f "LoginId.java" --exec cat {}

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

Length of output: 1507


🏁 Script executed:

# Domain 또는 Entity 패키지에서 User 관련 파일 검색
fd -t f "User.java" --exec head -100 {}

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

Length of output: 1272


loginIdValue 컬럼에 대한 인덱스 또는 unique 제약 설정이 필요하다.

findByLoginIdValue와 existsByLoginIdValue는 로그인 및 회원가입 시 매번 호출되는 쿼리이다. 현재 LoginId의 @Column 정의에 unique=true 또는 인덱스 설정이 없으면, 대량 사용자 환경에서 Full Table Scan이 발생하여 성능 병목이 된다.

LoginId 값 객체의 @Column 정의를 다음과 같이 수정하거나, User 엔티티의 @Table에 인덱스를 정의해야 한다:

`@Column`(name = "login_id", unique = true, nullable = false)
private String value;

또는 User 엔티티에서:

`@Table`(name = "users", indexes = `@Index`(name = "idx_login_id", columnList = "login_id", unique = true))

대량 사용자 환경에서 로그인 성능을 측정하여 인덱스 효과를 검증해야 한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java`
around lines 8 - 11, The repository methods findByLoginIdValue and
existsByLoginIdValue will cause full table scans unless the underlying login_id
column is indexed/unique; update the LoginId value field's `@Column` (the value
property inside the LoginId value object) to include unique=true and
nullable=false or add an index on the User entity via `@Table`(indexes =
`@Index`(name="idx_login_id", columnList="login_id", unique=true)) on the User
class; ensure the actual DB schema migration is created/updated so the login_id
column has the index/unique constraint and then run load tests for login
throughput to validate the improvement.

Comment on lines 9 to 15
public record SignupRequest(
String loginId,
String password,
String name,
String birthDate,
String 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

SignupRequest 레코드의 toString() 메서드가 비밀번호를 노출할 수 있다.

Java record의 기본 toString() 구현은 모든 필드를 포함한다. 디버깅이나 에러 로깅 시 SignupRequest 객체가 로그에 출력되면 비밀번호가 평문으로 노출될 수 있다.

운영 환경에서 로그를 통한 민감정보 유출은 보안 감사 대상이다.

🛡️ toString() 오버라이드 제안
     public record SignupRequest(
             String loginId,
             String password,
             String name,
             String birthDate,
             String email
-    ) {}
+    ) {
+        `@Override`
+        public String toString() {
+            return "SignupRequest[loginId=" + loginId + ", name=" + name + 
+                   ", birthDate=" + birthDate + ", email=" + email + "]";
+        }
+    }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java`
around lines 9 - 15, The SignupRequest record's default toString() will include
the password field and can leak credentials; override toString inside the
SignupRequest record to exclude or mask the password (e.g., show "*****" or omit
the password field) so logs never contain the plain password. Add a toString()
method in the SignupRequest record body that returns a string with loginId,
name, birthDate, email and a masked or absent password, leaving the record
components and constructor unchanged.

Comment on lines 152 to 170
@DisplayName("유효한 요청이면, 비밀번호가 변경된다.")
@Test
void changesPassword_whenValidRequest() {
// arrange
User user = User.create(
new LoginId("nahyeon"), "$2a$10$oldHash",
new UserName("홍길동"), new BirthDate("1994-11-15"),
new Email("nahyeon@example.com")
);
when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true);
when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false);
when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash");

// act
userService.changePassword(user, "Hx7!mK2@", "Nw8@pL3#");

// assert
assertThat(user.getPassword()).isEqualTo("$2a$10$newHash");
}
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

비밀번호 변경 성공 테스트가 영속화 여부를 검증하지 않아 재발 위험이 있다.
운영 관점에서 비밀번호 변경이 메모리에서만 반영되면 다음 로그인에서 실패해 즉시 장애로 이어진다. 수정안으로 changePassword 성공 케이스에서 저장 책임이 UserService에 있다면 userRepository.save 호출을 verify로 검증하고, 그렇지 않다면 통합 테스트에서 변경 후 재조회로 영속화 여부를 확인하라. 추가 테스트로 “Detached Entity 상태에서도 변경이 DB에 반영된다”는 시나리오를 포함하라.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java`
around lines 152 - 170, The test currently asserts only the in-memory password
change on User in changesPassword_whenValidRequest(); ensure persistence is
verified by adding a mock verification or re-fetch: if UserService is
responsible for saving, add a Mockito
verify(userRepository).save(ArgumentMatchers.any(User.class)) after calling
userService.changePassword(user, ...); otherwise convert this to an
integration-style test that calls changePassword, then reloads the user via
userRepository.findById(...) and asserts the persisted password equals
"$2a$10$newHash"; also add an extra scenario to cover a detached-entity case
where the entity is detached before changePassword and assert the DB reflects
the update after reloading.

Comment on lines 112 to 114
@DisplayName("PATCH /api/v1/auth/password")
@Nested
class ChangePassword {
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

@DisplayName HTTP 메서드 오류

@DisplayName("PATCH /api/v1/auth/password")로 표기되어 있으나, 실제 API는 PUT 메서드를 사용한다(Line 127의 HttpMethod.PUT). 테스트 문서와 실제 동작이 불일치한다.

수정안
-    `@DisplayName`("PATCH /api/v1/auth/password")
+    `@DisplayName`("PUT /api/v1/auth/password")
📝 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
@DisplayName("PATCH /api/v1/auth/password")
@Nested
class ChangePassword {
`@DisplayName`("PUT /api/v1/auth/password")
`@Nested`
class ChangePassword {
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java`
around lines 112 - 114, The `@DisplayName` on the ChangePassword nested test class
is incorrect: it says "PATCH /api/v1/auth/password" but the test uses
HttpMethod.PUT in the ChangePassword tests; update the `@DisplayName` value on the
ChangePassword class to "PUT /api/v1/auth/password" (or change the test to use
PATCH if the API is actually PATCH) so the DisplayName matches the HttpMethod
used in the tests (refer to the ChangePassword nested class and the
HttpMethod.PUT usage).

Comment on lines 114 to 175
class ChangePassword {

@DisplayName("유효한 요청이면, 200 OK 응답을 받는다.")
@Test
void returns200_whenValidRequest() {
// arrange
signup(validSignupRequest());
HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@");
headers.setContentType(MediaType.APPLICATION_JSON);
AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#");

// act
ResponseEntity<ApiResponse> response = testRestTemplate.exchange(
CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class);

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

@DisplayName("변경 후 새 비밀번호로 인증되고, 이전 비밀번호로는 실패한다.")
@Test
void authenticatesWithNewPassword_afterChange() {
// arrange - 회원가입 + 비밀번호 변경
signup(validSignupRequest());
HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@");
headers.setContentType(MediaType.APPLICATION_JSON);
AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#");
testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class);

// act - 새 비밀번호로 조회
HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#");
ResponseEntity<ApiResponse> newPwResponse =
testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(newHeaders), ApiResponse.class);

// act - 이전 비밀번호로 조회
HttpHeaders oldHeaders = authHeaders("nahyeon", "Hx7!mK2@");
ResponseEntity<ApiResponse> oldPwResponse =
testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(oldHeaders), ApiResponse.class);

// assert
assertAll(
() -> assertThat(newPwResponse.getStatusCode()).isEqualTo(HttpStatus.OK),
() -> assertThat(oldPwResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)
);
}

@DisplayName("현재 비밀번호와 동일한 새 비밀번호면, 400 Bad Request 응답을 받는다.")
@Test
void returns400_whenSamePassword() {
// arrange
signup(validSignupRequest());
HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@");
headers.setContentType(MediaType.APPLICATION_JSON);
AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@");

// act
ResponseEntity<ApiResponse> response = testRestTemplate.exchange(
CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class);

// 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

changePassword 401 Unauthorized 테스트 누락

헤더 인증(X-Loopers-LoginId, X-Loopers-LoginPw) 실패 시 401 응답을 검증하는 테스트가 없다. AuthFacadeIntegrationTest에는 해당 케이스가 있으나 E2E 레벨에서도 HTTP 상태 코드 검증이 필요하다.

테스트 추가 예시
`@DisplayName`("잘못된 헤더 인증 정보면, 401 Unauthorized 응답을 받는다.")
`@Test`
void returns401_whenHeaderAuthFails() {
    // arrange
    signup(validSignupRequest());
    HttpHeaders headers = authHeaders("nahyeon", "wrongPw1!");
    headers.setContentType(MediaType.APPLICATION_JSON);
    AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#");

    // act
    ResponseEntity<ApiResponse> response = testRestTemplate.exchange(
            CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class);

    // assert
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java`
around lines 114 - 175, Add an E2E test that asserts 401 is returned when header
authentication fails: inside the existing ChangePassword test class add a new
test method (e.g., returns401_whenHeaderAuthFails) that signs up using
validSignupRequest(), builds headers using authHeaders("nahyeon", "wrongPw1!")
with ContentType set to MediaType.APPLICATION_JSON, creates an
AuthV1Dto.ChangePasswordRequest with the current and new password, calls
testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body,
headers), ApiResponse.class) and asserts the response status is
HttpStatus.UNAUTHORIZED; reference ChangePassword, authHeaders, CHANGE_PW_URL,
AuthV1Dto.ChangePasswordRequest, and testRestTemplate.exchange to locate where
to add the test.

Comment on lines 4 to 5
"loginId": "testuser01",
"loginPw": "Test10395!@"
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:

#!/bin/bash

echo "=== 1. http-client.env.json 파일 확인 ==="
if [ -f "http/http-client.env.json" ]; then
  echo "파일 존재함:"
  cat -n http/http-client.env.json
else
  echo "파일을 찾을 수 없음"
fi

echo ""
echo "=== 2. .gitignore 확인 ==="
if [ -f ".gitignore" ]; then
  echo ".gitignore 내용:"
  cat -n .gitignore
else
  echo ".gitignore 파일 없음"
fi

echo ""
echo "=== 3. http-client.private.env.json 확인 ==="
if [ -f "http/http-client.private.env.json" ]; then
  echo "private env 파일 존재함"
else
  echo "private env 파일 없음"
fi

echo ""
echo "=== 4. Git tracked 상태 확인 ==="
git ls-files | grep -E "http-client.*\.json" || echo "git tracked 파일 없음"

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

Length of output: 1209


보안 위험: 자격 증명이 버전 관리 시스템에 평문으로 노출된다.

자격 증명이 http-client.env.json에 평문으로 저장되어 있고, 현재 git으로 추적 중이다. 이는 다음과 같은 운영 문제를 야기한다:

  1. git history에 자격 증명이 영구적으로 저장되어 삭제가 매우 어려움
  2. 추후 레포지토리 공개 시 자격 증명이 즉시 노출됨
  3. 팀원이 실수로 프로덕션 환경에서 이 자격 증명을 재사용할 가능성

수정 방법: http-client.private.env.json 파일을 생성하여 민감 정보를 분리하고, .gitignore에 해당 파일을 추가한다.

  1. http-client.private.env.json 생성 (git 추적 제외):
{
  "local": {
    "loginId": "testuser01",
    "loginPw": "Test10395!@"
  }
}
  1. http-client.env.json 수정 (git 추적):
{
  "local": {
    "commerce-api": "http://localhost:8080"
  }
}
  1. .gitignore에 다음 패턴 추가:
http-client.private.env.json

추가 확인 사항: git history에서 기존 자격 증명 제거 필요. git filter-branch 또는 BFG Repo-Cleaner 사용을 검토하고, 해당 자격 증명의 재설정 여부를 운영팀과 협의한다.

🤖 Prompt for AI Agents
In `@http/http-client.env.json` around lines 4 - 5, 현재 평문 자격증명이
http-client.env.json에 포함되어 git으로 추적되고 있으므로 이를 분리해야 합니다; 수정 방법은
http-client.env.json에서 민감정보(loginId, loginPw)를 제거하고 대신 새 파일
http-client.private.env.json을 생성하여 해당 민감값을 옮긴 뒤 http-client.private.env.json을
.gitignore에 추가하여 추적 대상에서 제외하세요; 또한 기존 커밋 이력에 남아있는 자격증명은 git filter-branch 또는 BFG
Repo-Cleaner 등으로 제거하고(또는 리포지터리 클린업 절차 수행) 운영팀과 자격증명 재발급 여부를 협의하십시오.

Comment on lines +7 to +13
import java.time.Duration;

@Configuration
public class RedisTestContainersConfig {
private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest"));
private static final RedisContainer redisContainer = new RedisContainer(
DockerImageName.parse("redis:7-alpine").asCompatibleSubstituteFor("redis"))
.withStartupTimeout(Duration.ofMinutes(2));
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 -t f "RedisTestContainersConfig.java" modules/redis/

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

Length of output: 178


🏁 Script executed:

cat -n modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java

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

Length of output: 1341


🌐 Web query:

redis 7-alpine latest patch version docker official image

💡 Result:

The latest Redis 7.x Alpine tag in the official redis Docker image is:

  • redis:7.4.7-alpine (and equivalently redis:7-alpine / redis:7.4-alpine) [1]
  • Alpine-pinned variant: redis:7.4.7-alpine3.21 (and redis:7-alpine3.21 / redis:7.4-alpine3.21) [1]

Source: Docker Hub “redis” official image tags list [1].


Redis 이미지 태그를 검증된 패치 버전으로 고정해 테스트 재현성을 보장해야 한다.

redis:7-alpine은 변동 태그로서 레지스트리 업데이트 시 CI와 로컬 테스트의 동작이 변할 수 있으며, 장애 재현과 테스트 안정성이 낮아진다. 현재 최신 안정 버전인 redis:7.4.7-alpine으로 고정하고 상수로 분리해 관리하면 재현성을 높일 수 있다. 변경 후에는 통합 테스트를 재실행해 컨테이너 기동과 Redis 연결이 정상임을 확인해야 한다.

수정 예시
+    private static final String REDIS_IMAGE_VERSION = "redis:7.4.7-alpine";
+
     private static final RedisContainer redisContainer = new RedisContainer(
-            DockerImageName.parse("redis:7-alpine").asCompatibleSubstituteFor("redis"))
+            DockerImageName.parse(REDIS_IMAGE_VERSION).asCompatibleSubstituteFor("redis"))
             .withStartupTimeout(Duration.ofMinutes(2));
📝 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
import java.time.Duration;
@Configuration
public class RedisTestContainersConfig {
private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest"));
private static final RedisContainer redisContainer = new RedisContainer(
DockerImageName.parse("redis:7-alpine").asCompatibleSubstituteFor("redis"))
.withStartupTimeout(Duration.ofMinutes(2));
import java.time.Duration;
`@Configuration`
public class RedisTestContainersConfig {
private static final String REDIS_IMAGE_VERSION = "redis:7.4.7-alpine";
private static final RedisContainer redisContainer = new RedisContainer(
DockerImageName.parse(REDIS_IMAGE_VERSION).asCompatibleSubstituteFor("redis"))
.withStartupTimeout(Duration.ofMinutes(2));
🤖 Prompt for AI Agents
In
`@modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java`
around lines 7 - 13, The Docker image tag used in RedisTestContainersConfig is a
floating tag; update the RedisContainer instantiation to use a fixed, validated
patch version (e.g., "redis:7.4.7-alpine") by extracting the image string into a
private static final constant (e.g., REDIS_IMAGE) and replacing
DockerImageName.parse("redis:7-alpine") with DockerImageName.parse(REDIS_IMAGE).
Ensure the constant is used by the redisContainer field and re-run the
integration tests to verify container startup and Redis connectivity.

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