Skip to content

Conversation

@zeexzeex
Copy link

@zeexzeex zeexzeex commented Feb 6, 2026

📌 Summary

배경: 회원가입 시 비밀번호 검증 로직이 없어 생년월일이 포함된 취약한 비밀번호 생성이 가능하며, 평문 비밀번호 저장은 보안상 심각한 위험이 존재함.

목표: Password Value Object를 도입하여 생성 시점에 길이 및 생년월일 포함 여부를 검증하고, BCrypt 기반 단방향 암호화를 적용하여 도메인 응집도와 보안성을 강화함.

결과: yyyy-MM-dd, yyyyMMdd, yyMMdd 3가지 형식을 모두 검증하여 우회 가능성을 차단하고, 암호화된 비밀번호만 Entity에 저장하도록 강제하여 평문 노출 리스크를 원천 차단함

🧭 Context & Decision

문제 정의

현재 동작/제약:

  • 회원가입 시 비밀번호에 대한 도메인 검증 로직이 없음
  • 검증 로직이 분산될 경우 일관성 유지가 어려움

문제(또는 리스크):

  • Pass19900115! 같은 생년월일 기반 비밀번호 사용 가능
  • 1990-01-15, 19900115, 900115 형식 변형을 통한 검증 우회 가능
  • 평문 비밀번호 저장 시 DB 유출 시 전체 계정 탈취 위험

성공 기준(완료 정의):

  • 8~16자 길이 제한 강제
  • 3가지 생년월일 형식 모두 검증
  • BCrypt 기반 단방향 암호화 적용
  • UserModel에는 암호화된 비밀번호만 저장

선택지와 결정

  • 고려한 대안:
    • A: Controller/Service에서 비밀번호 검증 (구현은 단순하지만 도메인 규칙이 응용 계층에 누수되고 중복 위험 존재)
    • B: Password VO 도입 후 생성 시점 검증 (도메인 규칙을 한 곳에 응집하고 불변 객체로 관리)
  • 최종 결정: B 선택. Password.of() 팩토리 메서드에서 생성 시점에 모든 검증을 수행하고, encrypt()를 통해 BCrypt 단방향 암호화를 적용함.
  • 트레이드오프:
    • VO 객체 추가로 클래스 수 증가
    • 생년월일 3형식 검증으로 미세한 성능 오버헤드 발생
  • 추후 개선 여지(있다면):
    • 비밀번호 복잡도 정책 확장
    • 비밀번호 변경 이력 관리
    • 비밀번호 만료 정책 추가

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: apps/commerce-api > domain.user
  • 신규 추가:
    • Password.java (비밀번호 검증 및 암호화 Value Object)
    • PasswordTest.java (단위 테스트)
  • 제거/대체: x

주요 컴포넌트 책임

  • ComponentA:
    • Password
      • 8~16자 길이 검증
      • 생년월일 3가지 형식 포함 여부 검증
      • BCrypt 단방향 암호화 제공
      • 암호화 비밀번호 매칭 기능 제공
      • 불변 객체로 설계
  • ComponentB:
    • BirthDate
      • yyyy-MM-dd 형식 검증
      • Password 검증 시 비교 기준 제공
  • ComponentC:
    • UserModel
      • Password.encrypt() 결과를 encryptedPassword 필드에 저장
      • 평문 비밀번호를 저장하지 않도록 보안 보장

🔁 Flow Diagram

Main Flow

sequenceDiagram
  autonumber
  participant Client
  participant UserV1Controller
  participant UserFacade
  participant Password
  participant UserModel
  participant DB

  Client->>UserV1Controller: signUp(SignUpRequest)
  UserV1Controller->>UserFacade: signUp(...)
  UserFacade->>Password: Password.of(rawPassword, birthDate)

  alt 길이 또는 생년월일 검증 실패
    Password-->>UserFacade: IllegalArgumentException
    UserFacade-->>UserV1Controller: BAD_REQUEST
  else 검증 통과
    Password-->>UserFacade: Password 객체 반환
    UserFacade->>UserModel: UserModel.create(...)
    UserModel->>Password: encrypt()
    Password-->>UserModel: encryptedPassword
    UserModel->>DB: INSERT users
    DB-->>UserModel: 저장 완료
    UserModel-->>UserFacade: UserModel 반환
    UserFacade-->>UserV1Controller: 성공 응답
    UserV1Controller-->>Client: Response
  end
Loading

변경 목적: 회원 관리 기능 구현 및 비밀번호 보안 강화. BCrypt 기반 단방향 암호화와 길이(8~16자) 및 생년월일 포함 여부 검증을 적용하여 평문 저장을 방지하고, 회원가입/내 정보 조회/비밀번호 수정 기능을 추가.

핵심 변경점: (1) Password, Email, BirthDate, Gender 등 도메인 VO 신규 추가로 검증 로직 중앙화, (2) UserModel 엔티티의 encryptedPassword 필드에 암호화된 비밀번호만 저장, (3) UserFacade를 통한 계층 간 데이터 변환 및 UserV1Controller로 REST API 제공, (4) 생년월일 3가지 형식(yyyy-MM-dd, yyyyMMdd, yyMMdd) 검증으로 숨겨진 생년월일 우회 방지.

리스크/주의사항: (1) 기존 사용자 테이블 있을 경우 encryptedPassword 컬럼 추가 및 마이그레이션 필요 여부 확인 필요, (2) VO 및 클래스 추가로 인한 복잡도 증가, (3) 요청 헤더(X-Loopers-LoginId, X-USER-ID)가 정해진 규약인지 확인 필요.

테스트/검증: 13개의 단위/통합/E2E 테스트 추가로 Password 암호화 및 검증, UserModel 생성/업데이트, 중복 사용자 처리, 비밀번호 변경 실패 케이스 등을 포괄적으로 검증. 이름 마스킹(NameMaskingPolicy)도 테스트되어 사용자 정보 보호 적용 확인.

zeexzeex and others added 28 commits February 6, 2026 06:15
- AGENTS.md: AI 에이전트 개발 가이드 및 제약사항
- TDD.md: 테스트 주도 개발 구현 가이드

Co-authored-by: Cursor <cursoragent@cursor.com>
- domain/user: 도메인 모델 및 비즈니스 로직
- application/user: 애플리케이션 서비스 및 파사드
- infrastructure/user: JPA 리포지토리 구현
- interfaces/api/user: REST API 컨트롤러
- 각 레이어별 테스트 패키지 생성

Co-authored-by: Cursor <cursoragent@cursor.com>
- Email VO: 이메일 형식 검증 (xx@yy.zz)
- BirthDate VO: 날짜 형식 및 미래 날짜 검증 (yyyy-MM-dd)
- Password VO: 길이(8-16자) 및 생년월일 포함 여부 검증, BCrypt 암호화
- Gender Enum: MALE/FEMALE 값 검증
- Spring Security Crypto 의존성 추가 (비밀번호 암호화)
- 총 17개 Unit Tests 작성 및 통과

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserModel Entity 구현 (BaseEntity 상속)
- 필드: userId, email, birthDate, encryptedPassword, gender, points
- VO의 value 추출하여 String으로 저장 (AGENTS.md 원칙)
- Password 자동 암호화 및 포인트 초기값 0L 설정
- 팩토리 메서드 패턴 (create) 적용
- 4개 Unit Tests 작성 및 통과
- 총 21개 Domain Unit Tests 완료 (100% 통과)

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserRepository 인터페이스 정의 (save, findByUserId, existsByUserId)
- UserService 비즈니스 로직 구현
  - 중복 사용자 ID 체크 및 CONFLICT 예외 처리
  - @transactional 적용
  - 회원 생성 및 저장

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserJpaRepository (Spring Data JPA 인터페이스)
  - findByUserId, existsByUserId 쿼리 메서드
- UserRepositoryImpl (Repository 구현체)
  - Domain의 UserRepository 인터페이스 구현
  - UserJpaRepository 위임

Co-authored-by: Cursor <cursoragent@cursor.com>
- AGENTS.md: Lombok 사용 가이드라인 업데이트
  - VO/DTO: Java record 사용 (Lombok 불필요)
  - Entity: Lombok 허용 (@Getter, @NoArgsConstructor)
  - Exception/Enum: Lombok 허용
- UserModel: Lombok 적용으로 보일러플레이트 제거 (-26줄)
  - @Getter: 모든 필드 getter 자동 생성
  - @NoArgsConstructor(access = PROTECTED): JPA용 기본 생성자

Co-authored-by: Cursor <cursoragent@cursor.com>
- 중복 사용자 ID 가입 시 CONFLICT 예외 검증
- 동시 회원가입 처리 검증 (1개만 성공, 나머지 실패)
- UserRepository.save() 호출 검증
- 비밀번호 암호화 저장 검증
- BCrypt 알고리즘 검증
- 5개 Integration Tests 작성
- 참고: Testcontainers 환경 설정 필요 (Docker)

Co-authored-by: Cursor <cursoragent@cursor.com>
- Testcontainers 설정 추가로 테스트 환경 격리
- DB 컬럼 길이 명시 (email: 255, birth_date: 10, encrypted_password: 255)
- DataIntegrityViolationException 처리로 동시성 안전성 강화
- 테스트 데이터 userId를 10자 이하로 수정하여 데이터 truncation 에러 해결

Co-authored-by: Cursor <cursoragent@cursor.com>
- E2E 테스트: validation, 성공 케이스, 중복 ID 처리
- Application Layer: UserFacade (VO 변환 및 orchestration), UserInfo (응답 DTO)
- Interface Layer: UserV1Controller, UserV1Dto, UserV1ApiSpec
- Validation 예외 처리: MethodArgumentNotValidException 핸들러 추가
- 모든 테스트 통과: Unit + Integration + E2E

Co-authored-by: Cursor <cursoragent@cursor.com>
- null, 빈 문자열, 공백, 1글자 이름 예외 케이스
- 2글자, 3글자 이상 한글 이름 마스킹 케이스
- 영문 이름 마스킹 케이스

Co-authored-by: Cursor <cursoragent@cursor.com>
- 이름 마지막 글자를 *로 마스킹
- 입력 검증: null, 빈 문자열, 1글자 이하 예외 처리

Co-authored-by: Cursor <cursoragent@cursor.com>
- 존재하지 않는 userId 조회 시 null 반환
- 존재하는 userId 조회 시 사용자 정보 반환
- 이름 마스킹 적용 확인

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserService.getMyInfo(): userId로 사용자 조회 후 UserInfo 반환
- UserInfo: name 필드 추가 (마스킹된 userId)
- UserFacade.getMyInfo(): Application Layer orchestration

Co-authored-by: Cursor <cursoragent@cursor.com>
- X-Loopers-LoginId 헤더 누락 시 401 UNAUTHORIZED
- 존재하지 않는 userId 조회 시 404 NOT_FOUND
- 유효한 요청 시 200 OK 및 마스킹된 정보 반환

Co-authored-by: Cursor <cursoragent@cursor.com>
- GET /customer/v1/users/me 엔드포인트
- X-Loopers-LoginId 헤더 인증
- MyInfoResponse DTO 추가
- OpenAPI 명세 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
- 401 인증 필요 에러 처리를 위해 추가
- X-Loopers-LoginId 헤더 누락 시 사용

Co-authored-by: Cursor <cursoragent@cursor.com>
- 존재하지 않는 userId 조회 시 null 반환
- 존재하는 userId 조회 시 포인트 반환 (기본값 0L)

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserService.getPoints(): userId로 포인트 조회
- PointsInfo: userId와 points를 포함하는 DTO
- UserFacade.getPoints(): Application Layer orchestration

Co-authored-by: Cursor <cursoragent@cursor.com>
- X-USER-ID 헤더 누락 시 400 BAD_REQUEST
- 존재하지 않는 userId 조회 시 404 NOT_FOUND
- 유효한 요청 시 200 OK 및 포인트 정보 반환

Co-authored-by: Cursor <cursoragent@cursor.com>
- GET /customer/v1/users/me/points 엔드포인트
- X-USER-ID 헤더 인증
- PointsResponse DTO 추가
- OpenAPI 명세 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
Password.matches():
- 올바른 비밀번호 검증 시 true 반환
- 잘못된 비밀번호 검증 시 false 반환

UserModel.updatePassword():
- 현재 비밀번호 불일치 시 예외 발생
- 새 비밀번호 == 현재 비밀번호 시 예외 발생
- 올바른 입력 시 비밀번호 변경 성공

Co-authored-by: Cursor <cursoragent@cursor.com>
- Password.matches(): 비밀번호 검증 메서드
- UserModel.updatePassword(): 비밀번호 변경 메서드
  - 현재 비밀번호 검증
  - 새 비밀번호 != 현재 비밀번호 검증
  - 새 비밀번호 유효성 검증 (생년월일 포함 여부)
  - 비밀번호 암호화 및 저장

Co-authored-by: Cursor <cursoragent@cursor.com>
- 존재하지 않는 userId 시 NOT_FOUND
- 현재 비밀번호 불일치 시 BAD_REQUEST
- 새 비밀번호 == 현재 비밀번호 시 BAD_REQUEST
- 올바른 입력 시 비밀번호 변경 성공

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserService.updatePassword(): 비밀번호 변경 서비스 로직
  - 사용자 조회 (없으면 NOT_FOUND)
  - UserModel.updatePassword() 호출
  - IllegalArgumentException → BAD_REQUEST 변환
- UserFacade.updatePassword(): Application Layer orchestration

Co-authored-by: Cursor <cursoragent@cursor.com>
- X-Loopers-LoginId 헤더 누락 시 401 UNAUTHORIZED
- 존재하지 않는 userId 시 404 NOT_FOUND
- 현재 비밀번호 불일치 시 400 BAD_REQUEST
- 새 비밀번호 == 현재 비밀번호 시 400 BAD_REQUEST
- 유효한 요청 시 200 OK

Co-authored-by: Cursor <cursoragent@cursor.com>
- PATCH /customer/v1/users/me/password 엔드포인트
- X-Loopers-LoginId 헤더 인증
- UpdatePasswordRequest DTO 추가
- OpenAPI 명세 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@zeexzeex zeexzeex self-assigned this Feb 6, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

Important

Review skipped

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

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

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger 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.

@zeexzeex zeexzeex changed the title Volume 1 [volume-1] 회원가입, 내 정보 조회, 비밀번호 수정 기능 구현 Feb 6, 2026
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: 17

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)

11-56: ⚠️ Potential issue | 🟠 Major

MethodArgumentNotValidException 핸들러 추가는 에러 응답 통일성을 깨뜨린다
운영 관점에서 CoreException 경로와 별도 검증 경로가 공존하면 응답 포맷이 분기되어 클라이언트 호환성과 모니터링/알림 집계가 흔들린다. 수정안으로 본 핸들러와 관련 import를 제거하고, 검증 실패는 도메인 계층에서 CoreException으로 변환되어 기존 handle(CoreException) 경로를 타도록 정리하는 것이 필요하다. 추가 테스트로 잘못된 요청 입력에 대해 항상 CoreException 기반 ApiResponse 포맷이 유지되는지 통합 테스트를 추가하는 것이 안전하다.

🛠️ 수정안(diff)
-import org.springframework.validation.FieldError;
-import org.springframework.web.bind.MethodArgumentNotValidException;
@@
-    `@ExceptionHandler`
-    public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) {
-        FieldError fieldError = e.getBindingResult().getFieldError();
-        String message = fieldError != null ? fieldError.getDefaultMessage() : "입력값 검증에 실패했습니다.";
-        return failureResponse(ErrorType.BAD_REQUEST, message);
-    }
Based on learnings In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/).
🤖 Fix all issues with AI agents
In `@apps/commerce-api/build.gradle.kts`:
- Around line 13-15: The build currently pulls spring-security-crypto without a
fixed version
(implementation("org.springframework.security:spring-security-crypto")), relying
on the Spring Boot BOM which may resolve to vulnerable versions; update the
Gradle setup to explicitly pin the spring-security artifacts to a non-vulnerable
version (e.g., 6.4.5 or later) or enable dependency locking so the resolved
version is deterministic and safe, and add an automated vulnerability scan task
to CI (e.g., OWASP Dependency-Check or the Gradle vulnerability plugin) to fail
builds on known CVEs; locate and update the dependency declaration for
spring-security-crypto and the Gradle configuration that manages BOM/dependency
locking and CI build scripts to add the scan.

In
`@apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java`:
- Around line 31-33: getMyInfo currently returns null if
UserService.getMyInfo(userId) can't find a user; instead, change
UserFacade.getMyInfo to check the result of userService.getMyInfo(userId) and if
it's null throw a CoreException with the NOT_FOUND status; update the method to
call userService.getMyInfo(userId), assign to a local UserInfo (or similar)
variable, and throw new CoreException(NOT_FOUND, "User not found") (or the
project's standard NOT_FOUND message) so controllers never receive null.
- Around line 35-41: The getPoints method in UserFacade returns null when
userService.getPoints(userId) returns null; instead, throw the same
NOT_FOUND-style exception used elsewhere in this class. Locate getPoints in
UserFacade and replace the null return path with throwing a NOT_FOUND exception
(e.g., the existing UserNotFoundException or a ResponseStatusException with
HttpStatus.NOT_FOUND) including a clear message with the userId; keep the rest
of the method returning new PointsInfo(userId, points) when points != null.
- Around line 22-25: When constructing VOs in UserFacade (Email, BirthDate,
Password.of, Gender.from) wrap the VO creation block in a try/catch that catches
IllegalArgumentException and rethrows a CoreException with a clear message and
the caught exception as the cause so all validation failures are routed through
CoreException (ensuring ApiControllerAdvice handles them). Keep the original
IllegalArgumentException as the cause when creating the CoreException to
preserve stack trace and context.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java`:
- Around line 15-20: BirthDate's parsing catch block drops the original
DateTimeParseException, losing root-cause information; update the catch in the
BirthDate constructor (where LocalDate.parse(value, FORMATTER) is called) to
rethrow an IllegalArgumentException with the original exception as the cause
(pass the caught DateTimeParseException into the IllegalArgumentException), and
add a unit test in BirthDateTest to assert that providing an invalid format
results in an IllegalArgumentException whose getCause() is a
DateTimeParseException.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java`:
- Around line 3-16: The Email record's compact constructor currently allows
empty/whitespace local parts and doesn't reject multiple '@' characters; update
the validation in the public Email record constructor to (1) ensure exactly one
'@' exists (reject inputs like "user@domain@com") and (2) validate that parts[0]
(the local part) is not null or blank (reject "@example.com" and " 
`@example.com`"). Modify the split/validation logic around parts (used in the
Email constructor) to check parts.length == 2, parts[0].isBlank() == false, and
parts[1].isBlank() == false, and add unit tests covering "@example.com", " 
`@example.com`", and "user@domain@com" to confirm failures.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java`:
- Around line 12-16: The catch block that currently throws a new
IllegalArgumentException ("성별은 MALE 또는 FEMALE이어야 합니다.") must preserve the
original cause and separate user-facing message from logging: when catching
IllegalArgumentException in the Gender parsing code (the block that calls
Gender.valueOf(value.toUpperCase())), rethrow a new IllegalArgumentException
with the user-friendly message but pass the caught exception as the cause (e.g.,
new IllegalArgumentException(userMessage, e)); additionally, log the original
exception at an appropriate level (using the class/logger used in this file) so
logs contain the root cause, and add a unit test asserting that invalid input
results in IllegalArgumentException whose getCause() is not null.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java`:
- Around line 35-54: validateNotContainsBirthDate currently calls
birthDate.value() without checking for null, causing an NPE when callers pass
null (e.g., via Password.of). Fix by adding a null guard: either validate and
throw a clear IllegalArgumentException in Password.of when the BirthDate
parameter is null, or add an early null check inside
validateNotContainsBirthDate (e.g., if birthDate == null return or throw) before
calling birthDate.value(); reference the validateNotContainsBirthDate method and
the Password.of entry point so the null check is applied where null BirthDate
may be passed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java`:
- Around line 72-87: In updatePassword, add null/empty checks and safe equality
comparison for newRawPassword (e.g., if newRawPassword == null ||
newRawPassword.isBlank() throw CoreException(...); if
(newRawPassword.equals(currentRawPassword) -> use Objects.equals to avoid NPE)
before creating Password), and wrap the Password.of(...) and
newPassword.encrypt() calls in a try/catch that converts any
IllegalArgumentException thrown by Password.of or validation into a
CoreException so error handling remains consistent; reference methods/classes:
updatePassword, Password.matches, Password.of, BirthDate, encryptedPassword,
IllegalArgumentException -> CoreException.
- Around line 18-37: UserInfo.from(UserModel) is assigning
NameMaskingPolicy.mask(user.getUserId()) into the name field which corrupts data
semantics; either add a proper name property to UserModel (e.g., private String
name with `@Column` and getter/setter) and change UserInfo.from(...) to set name =
user.getName(), or change the DTO/field semantics by renaming UserInfo.name to
maskedUserId (and set maskedUserId = NameMaskingPolicy.mask(user.getUserId()))
and update all consumers; update persistence (UserModel), accessor methods
(getName), and the mapping in UserInfo.from(UserModel) and related
serializers/clients accordingly.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java`:
- Around line 31-36: Update CoreException to accept and store a cause (add a
constructor taking ErrorType, message, Throwable) and then modify the service
throw sites to pass the original exception as that cause: when catching
DataIntegrityViolationException in the user creation flow (the catch around
UserModel.create/userRepository.save) and the other catch handling
password-change failures, call new CoreException(ErrorType.CONFLICT,
"<user-facing message without userId>", e) so the original exception is
preserved; remove userId from all user-facing error messages (replace messages
like "이미 존재하는 사용자 ID입니다: " + userId with a generic message) and instead log the
detailed context (including userId and the caught exception) to the logger
before throwing. Also add/update tests to assert CoreException.getCause()
returns the original exception and that thrown messages do not contain the
userId.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java`:
- Around line 17-20: Remove Bean Validation usage from the API layer: delete
`@Valid` on the signUp method in UserV1ApiSpec and any `@Valid` usages in
UserV1Controller, and remove `@NotBlank/`@Pattern annotations from
UserV1Dto.SignUpRequest and UserV1Dto.UpdatePasswordRequest; instead implement
the checks in the domain/value-object layer (e.g., enforce userId format in the
User constructor, enforce password rules in PasswordPolicy, validate email and
birthdate in their value objects) and have those domain validations throw
CoreException so ApiControllerAdvice will unify responses into the ApiResponse
FAIL format; update or add E2E tests to assert that missing/empty/invalid fields
produce ApiResponse FAIL payloads and the expected HTTP status codes.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java`:
- Around line 26-28: Remove Bean Validation from controller methods and DTOs so
validation flows through domain value objects and CoreException handlers: delete
the `@Valid` annotation on UserV1Controller.signUp and
UserV1Controller.updatePassword, remove `@Valid` on the corresponding methods in
UserV1ApiSpec (signUp and updatePassword), and strip all javax/hibernate
validation annotations (e.g., `@NotBlank`, `@Size`, `@Email`, etc.) from UserV1Dto
fields and request classes so missing/invalid fields are validated by domain
types (Email, Password, UserModel) and surface errors as CoreException-managed
ApiResponse FAIL with BAD_REQUEST; update any tests to assert E2E returns the
unified ApiResponse FAIL/BAD_REQUEST when required fields are missing.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java`:
- Around line 76-83: The UpdatePasswordRequest record in UserV1Dto currently
carries Bean Validation annotations (`@NotBlank` on currentPassword and
newPassword); remove these annotations from the UpdatePasswordRequest
declaration so DTO remains a simple transport object, and move password
validation logic into the domain/service layer (e.g., the User domain model or
PasswordPolicy validator used by methods that handle password changes) ensuring
the controller accepts the raw DTO and domain layer enforces rules.
- Around line 10-27: Remove Bean Validation annotations from the SignUpRequest
record (the `@NotBlank` and `@Pattern` annotations applied to fields userId,
password, email, birthDate, gender) and revert SignUpRequest to a plain DTO;
instead ensure callers (controller/service) construct and validate domain value
objects (e.g., Email, BirthDate, Password) from the DTO fields so validation
happens in the domain layer and any failures are translated into CoreException
handled by ApiControllerAdvice; update any tests or controller code that relied
on MethodArgumentNotValidException to construct the VOs and propagate domain
validation errors instead.

In `@apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java`:
- Around line 14-67: Add two failing test cases in BirthDateTest under the
Create nested class to cover null/blank inputs: implement
create_withNull_shouldFail which asserts that new BirthDate(null) throws
IllegalArgumentException, and implement create_withBlank_shouldFail which
asserts that new BirthDate("") and new BirthDate("   ") (or separate assertions)
throw IllegalArgumentException; use assertThrows(IllegalArgumentException.class,
() -> new BirthDate(...)) and name the tests exactly create_withNull_shouldFail
and create_withBlank_shouldFail to match the review guidance.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java`:
- Around line 76-115: Add a timeout to the concurrency test to avoid hanging CI:
in signUp_withConcurrentSameUserId_shouldOnlyOneSucceed, replace the
unconditional latch.await() with latch.await(<reasonable timeout>,
TimeUnit.SECONDS) (add import java.util.concurrent.TimeUnit), capture the
boolean result and assert it completed (fail the test if false), call
executorService.shutdown() and then call
executorService.awaitTermination(<reasonable timeout>, TimeUnit.SECONDS) and
assert termination succeeded (fail if not); keep the existing
successCount/failCount assertions after confirming the waits completed.
🧹 Nitpick comments (9)
apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java (1)

10-51: 경계값 실패 케이스가 누락되어 회귀 위험이 크다
운영 관점에서 도메인 검증 규칙이 변경될 때 테스트가 방어하지 못하면 잘못된 이메일이 저장되어 장애성 이슈로 이어질 수 있다. 수정안으로 null/blank 및 로컬 파트 공백("@example.com") 케이스를 추가하는 것이 필요하다. 추가 테스트로 위 입력들이 모두 IllegalArgumentException을 던지는지 검증하는 테스트를 포함하는 것이 안전하다.

✅ 추가 테스트 예시(diff)
     `@DisplayName`("도메인 형식이 잘못되면, IllegalArgumentException이 발생한다.")
     `@Test`
     void create_withInvalidDomain_shouldFail() {
@@
     }
+
+    `@DisplayName`("로컬 파트가 비어있으면, IllegalArgumentException이 발생한다.")
+    `@Test`
+    void create_withBlankLocalPart_shouldFail() {
+        assertThrows(IllegalArgumentException.class, () -> new Email("@example.com"));
+    }
+
+    `@DisplayName`("null 또는 공백이면, IllegalArgumentException이 발생한다.")
+    `@Test`
+    void create_withNullOrBlank_shouldFail() {
+        assertThrows(IllegalArgumentException.class, () -> new Email(null));
+        assertThrows(IllegalArgumentException.class, () -> new Email("   "));
+    }
apps/commerce-api/src/test/java/com/loopers/domain/user/GenderTest.java (1)

10-52: null/blank 경계값 테스트가 빠져 회귀 방어가 약하다
운영 관점에서 입력 검증 규칙이 변경될 때 null/blank가 누락되면 불량 데이터가 저장되어 후속 장애로 이어질 수 있다. 수정안으로 null 및 공백 문자열에 대한 실패 케이스 테스트를 추가하는 것이 필요하다. 추가 테스트로 null/blank 입력이 모두 IllegalArgumentException을 던지는지 검증하는 케이스를 포함하는 것이 안전하다.

✅ 추가 테스트 예시(diff)
     `@DisplayName`("잘못된 문자열 형식이면, IllegalArgumentException이 발생한다.")
     `@Test`
     void create_withInvalidString_shouldFail() {
@@
     }
+
+    `@DisplayName`("null 또는 공백이면, IllegalArgumentException이 발생한다.")
+    `@Test`
+    void create_withNullOrBlank_shouldFail() {
+        assertThrows(IllegalArgumentException.class, () -> Gender.from(null));
+        assertThrows(IllegalArgumentException.class, () -> Gender.from("   "));
+    }
apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskingPolicyTest.java (2)

47-55: 앞뒤 공백이 있는 유효한 이름에 대한 테스트가 누락되었다.

NameMaskingPolicy.mask()는 입력값을 trim() 처리 후 검증한다. 그러나 " 김철 "과 같이 공백 포함 후 trim 시 유효한 이름이 되는 케이스가 테스트되지 않았다. 운영 환경에서 사용자 입력에 의도치 않은 공백이 포함될 수 있으므로, 해당 케이스를 추가하여 trim 동작이 의도대로 작동하는지 검증해야 한다.

테스트 케이스 추가 제안
`@DisplayName`("앞뒤 공백이 있는 이름은 trim 후 마스킹된다.")
`@Test`
void mask_withLeadingAndTrailingWhitespace_shouldTrimAndMask() {
    // when
    String masked = NameMaskingPolicy.mask("  김철  ");

    // then
    assertThat(masked).isEqualTo("김*");
}

13-17: 예외 메시지 검증이 누락되었다.

assertThrows만으로는 예외 발생 여부만 확인할 수 있다. 예외 메시지가 변경되어 사용자에게 잘못된 안내가 전달되는 경우를 방지하려면, 메시지 내용도 검증해야 한다. AssertJ의 assertThatThrownBy를 사용하면 메시지 검증이 용이하다.

예외 메시지 검증 추가 예시
-    `@Test`
-    void mask_withNull_shouldThrowException() {
-        // when & then
-        assertThrows(IllegalArgumentException.class, () -> {
-            NameMaskingPolicy.mask(null);
-        });
-    }
+    `@Test`
+    void mask_withNull_shouldThrowException() {
+        // when & then
+        assertThatThrownBy(() -> NameMaskingPolicy.mask(null))
+            .isInstanceOf(IllegalArgumentException.class)
+            .hasMessage("이름은 null일 수 없습니다.");
+    }
apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java (2)

60-62: matches() 메서드의 null 입력에 대한 방어 로직이 없다.

rawPassword 또는 encryptedPassword가 null인 경우 BCryptPasswordEncoder.matches()의 동작에 의존하게 된다. 운영 환경에서 null 입력 시 예외 메시지가 불명확해질 수 있으므로, 명시적인 null 검증과 함께 명확한 오류 메시지를 제공하는 것이 디버깅에 유리하다.

방어 로직 추가 제안
 public static boolean matches(String rawPassword, String encryptedPassword) {
+    if (rawPassword == null || encryptedPassword == null) {
+        return false;
+    }
     return passwordEncoder.matches(rawPassword, encryptedPassword);
 }

6-9: 도메인 객체에 Spring Security 의존성이 직접 포함되어 있다.

Password VO가 BCryptPasswordEncoder를 직접 참조하여 도메인 계층이 인프라 계층에 의존하게 된다. 현재 구조에서 암호화 알고리즘 변경 시 도메인 객체를 수정해야 하며, 단위 테스트 시 암호화 로직을 모킹하기 어렵다. 향후 확장성을 고려하여 인터페이스 분리를 검토할 수 있다.

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

14-27: 경계값 테스트가 누락되었다.

8자 미만과 16자 초과에 대한 실패 케이스는 있으나, 정확히 8자 및 16자인 경우의 성공 케이스가 누락되었다. 경계값 테스트는 off-by-one 오류를 방지하는 데 중요하다.

경계값 성공 케이스 추가 제안
`@DisplayName`("정확히 8자이면, 정상적으로 생성된다.")
`@Test`
void create_withExactly8Characters_shouldSuccess() {
    // given
    String password = "Pass12!a"; // exactly 8 chars
    BirthDate birthDate = new BirthDate("1990-01-15");

    // when
    Password result = Password.of(password, birthDate);

    // then
    assertThat(result).isNotNull();
}

`@DisplayName`("정확히 16자이면, 정상적으로 생성된다.")
`@Test`
void create_withExactly16Characters_shouldSuccess() {
    // given
    String password = "Pass12!abcdefgh1"; // exactly 16 chars
    BirthDate birthDate = new BirthDate("1990-01-15");

    // when
    Password result = Password.of(password, birthDate);

    // then
    assertThat(result).isNotNull();
}

14-94: null 및 빈 문자열 비밀번호에 대한 테스트가 누락되었다.

Password.validateLength()에서 null과 blank 검증 로직이 있으나, 이에 대한 테스트가 없다. 실제 운영 환경에서 빈 비밀번호 제출 시도가 발생할 수 있으므로 해당 케이스를 검증해야 한다.

null/blank 테스트 추가 제안
`@DisplayName`("null 비밀번호이면, IllegalArgumentException이 발생한다.")
`@Test`
void create_withNullPassword_shouldFail() {
    // given
    BirthDate birthDate = new BirthDate("1990-01-15");

    // when & then
    assertThrows(IllegalArgumentException.class, () -> {
        Password.of(null, birthDate);
    });
}

`@DisplayName`("빈 문자열 비밀번호이면, IllegalArgumentException이 발생한다.")
`@Test`
void create_withBlankPassword_shouldFail() {
    // given
    BirthDate birthDate = new BirthDate("1990-01-15");

    // when & then
    assertThrows(IllegalArgumentException.class, () -> {
        Password.of("   ", birthDate);
    });
}
apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java (1)

55-70: create() 메서드에서 userId에 대한 null 검증이 없다.

email, birthDate, password는 VO 생성 시 검증되지만, userId는 검증 없이 그대로 저장된다. null 또는 빈 문자열 userId가 전달되면 DB 제약조건(nullable = false)에 의해 예외가 발생하지만, 도메인 레벨에서 명시적으로 검증하는 것이 오류 원인 파악에 유리하다.

userId 검증 추가 제안
 public static UserModel create(
     String userId,
     Email email,
     BirthDate birthDate,
     Password password,
     Gender gender
 ) {
+    if (userId == null || userId.isBlank()) {
+        throw new IllegalArgumentException("사용자 ID는 비어있을 수 없습니다.");
+    }
     return new UserModel(
         userId,
         email.value(),

Comment on lines +13 to +15

// security
implementation("org.springframework.security:spring-security-crypto")
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 . -path "**/apps/commerce-api/build.gradle.kts" -type f | head -5

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

Length of output: 121


🏁 Script executed:

cat -n apps/commerce-api/build.gradle.kts | head -30

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

Length of output: 1273


🌐 Web query:

spring-security-crypto latest version CVE security vulnerabilities 2025 2026

💡 Result:

Latest org.springframework.security:spring-security-crypto version (as of Feb 6, 2026)

  • Latest stable: 7.0.2 (released Dec 15, 2025 on Maven Central) [1]
  • Latest pre-release: 7.1.0-M1 (milestone) [1]

Spring Security Crypto CVEs in 2025

CVE-2025-22228 — BCrypt password length / authentication bypass

  • Issue: BCryptPasswordEncoder.matches(...) could return true for passwords >72 chars when the first 72 chars match. [2]
  • Fixed by upgrading to patched Spring Security versions (per Spring advisory referenced by NVD). [2]

CVE-2025-22234 — Timing attack mitigation regression (username enumeration signal)

  • Issue: A fix related to CVE-2025-22228 broke DaoAuthenticationProvider constant-time behavior in certain versions when using BCrypt. [3]
  • Affected versions (not exhaustive): 5.7.16, 5.8.18, 6.0.16, 6.1.14, 6.2.10, 6.3.8, 6.4.4 (and older unsupported) [3]
  • Fix versions called out by Spring: 6.3.9 and 6.4.5 (OSS lines), plus additional enterprise-supported fixes for older lines. [3]
  • Snyk guidance: upgrade spring-security-crypto to 6.3.9 / 6.4.5 or higher. [4]

2026 CVEs

  • On Spring’s official advisory list (through Jan 13, 2026), the only 2026 CVE shown is CVE-2026-22718, which is for a VSCode extension for Spring CLI, not spring-security-crypto. [5]
  • I did not find a Spring-published 2026 CVE specifically targeting spring-security-crypto in the same advisory stream. [5]

Sources: Maven Central listing [1], NVD entry for CVE-2025-22228 [2], Spring advisory for CVE-2025-22234 [3], Snyk advisory [4], Spring Security advisories index [5].


🏁 Script executed:

find . -maxdepth 3 -name "build.gradle.kts" -o -name "build.gradle" | grep -E "(^./build|^./gradle|root)" | head -10

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

Length of output: 103


🏁 Script executed:

cat -n build.gradle.kts 2>/dev/null | head -50

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

Length of output: 1713


🏁 Script executed:

cd apps/commerce-api && cat -n build.gradle.kts | grep -A 5 -B 5 "spring\|bom\|version" | head -40

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

Length of output: 940


🏁 Script executed:

find . -name "gradle.properties" -type f | head -5

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

Length of output: 104


🏁 Script executed:

cat gradle.properties

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

Length of output: 621


BOM 버전 확인 및 보안 취약점 스캔 추가 필수

spring-security-crypto는 버전을 고정하지 않아 Spring Boot BOM에 의존하고 있다. 2025년 중요 보안 취약점 2개(CVE-2025-22228: BCrypt 인증 우회, CVE-2025-22234: 타이밍 공격 회귀)가 보고되었으므로, 프로젝트가 취약한 버전을 사용하지 않는지 반드시 확인해야 한다.

Spring Boot 3.4.4가 제공하는 실제 spring-security 버전을 확인하고, 필요시 명시적으로 6.4.5 이상으로 고정하거나 의존성 잠금(dependency locking)을 적용하는 것이 안전하다. 추가로 CI에 OWASP Dependency-Check나 Gradle 취약점 플러그인 같은 자동화된 취약점 스캔 태스크를 포함하여 향후 유사 리스크를 사전에 감지하도록 한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/build.gradle.kts` around lines 13 - 15, The build currently
pulls spring-security-crypto without a fixed version
(implementation("org.springframework.security:spring-security-crypto")), relying
on the Spring Boot BOM which may resolve to vulnerable versions; update the
Gradle setup to explicitly pin the spring-security artifacts to a non-vulnerable
version (e.g., 6.4.5 or later) or enable dependency locking so the resolved
version is deterministic and safe, and add an automated vulnerability scan task
to CI (e.g., OWASP Dependency-Check or the Gradle vulnerability plugin) to fail
builds on known CVEs; locate and update the dependency declaration for
spring-security-crypto and the Gradle configuration that manages BOM/dependency
locking and CI build scripts to add the scan.

Comment on lines +22 to +25
Email emailVO = new Email(email);
BirthDate birthDateVO = new BirthDate(birthDate);
Password passwordVO = Password.of(password, birthDateVO);
Gender gender = Gender.from(genderValue);
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

VO 생성 시 발생하는 IllegalArgumentExceptionCoreException으로 변환되지 않는다.

Email, BirthDate, Password, Gender VO 생성 시 유효성 검증 실패로 IllegalArgumentException이 발생할 수 있다. 프로젝트 가이드라인에 따르면 모든 에러는 CoreException을 통해 처리되어야 일관된 응답 형식이 보장된다. 현재 구조에서는 IllegalArgumentException이 직접 전파되어 에러 응답 형식이 일관되지 않을 수 있다.

예외 변환 로직 추가 제안
 public UserInfo signUp(
     String userId,
     String password,
     String email,
     String birthDate,
     String genderValue
 ) {
+    try {
         Email emailVO = new Email(email);
         BirthDate birthDateVO = new BirthDate(birthDate);
         Password passwordVO = Password.of(password, birthDateVO);
         Gender gender = Gender.from(genderValue);

         UserModel user = userService.signUp(userId, emailVO, birthDateVO, passwordVO, gender);
         return UserInfo.from(user);
+    } catch (IllegalArgumentException e) {
+        throw new CoreException(ErrorType.BAD_REQUEST, e.getMessage());
+    }
 }

Based on learnings: "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice".

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java`
around lines 22 - 25, When constructing VOs in UserFacade (Email, BirthDate,
Password.of, Gender.from) wrap the VO creation block in a try/catch that catches
IllegalArgumentException and rethrows a CoreException with a clear message and
the caught exception as the cause so all validation failures are routed through
CoreException (ensuring ApiControllerAdvice handles them). Keep the original
IllegalArgumentException as the cause when creating the CoreException to
preserve stack trace and context.

Comment on lines +31 to +33
public UserInfo getMyInfo(String userId) {
return userService.getMyInfo(userId);
}
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

getMyInfo에서 사용자를 찾지 못할 경우 null을 반환한다.

UserService.getMyInfo()가 null을 반환하면 이를 그대로 전달하는데, 컨트롤러에서 null 응답 처리가 누락되면 NullPointerException이 발생하거나 빈 응답이 반환될 수 있다. 사용자를 찾지 못한 경우 CoreException(NOT_FOUND)을 던지는 것이 에러 처리 일관성에 부합한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java`
around lines 31 - 33, getMyInfo currently returns null if
UserService.getMyInfo(userId) can't find a user; instead, change
UserFacade.getMyInfo to check the result of userService.getMyInfo(userId) and if
it's null throw a CoreException with the NOT_FOUND status; update the method to
call userService.getMyInfo(userId), assign to a local UserInfo (or similar)
variable, and throw new CoreException(NOT_FOUND, "User not found") (or the
project's standard NOT_FOUND message) so controllers never receive null.

Comment on lines +35 to +41
public PointsInfo getPoints(String userId) {
Long points = userService.getPoints(userId);
if (points == null) {
return null;
}
return new PointsInfo(userId, points);
}
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

getPoints에서도 null 반환 시 동일한 문제가 발생할 수 있다.

사용자가 존재하지 않아 userService.getPoints()가 null을 반환하면 PointsInfo를 생성하지 않고 null을 반환한다. 이 또한 NOT_FOUND 예외로 처리하는 것이 일관성 있다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java`
around lines 35 - 41, The getPoints method in UserFacade returns null when
userService.getPoints(userId) returns null; instead, throw the same
NOT_FOUND-style exception used elsewhere in this class. Locate getPoints in
UserFacade and replace the null return path with throwing a NOT_FOUND exception
(e.g., the existing UserNotFoundException or a ResponseStatusException with
HttpStatus.NOT_FOUND) including a clear message with the userId; keep the rest
of the method returning new PointsInfo(userId, points) when points != null.

Comment on lines 15 to 20
LocalDate date;
try {
date = LocalDate.parse(value, FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("생년월일 형식은 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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

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

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

Length of output: 157


🏁 Script executed:

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

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

Length of output: 1074


🌐 Web query:

Java IllegalArgumentException constructor Throwable cause exception chaining best practice

💡 Result:

Best practice: chaining a cause into IllegalArgumentException

1) Prefer the built-in “cause” constructors (Java 5+)

IllegalArgumentException has constructors that accept a Throwable cause since Java 1.5:

  • IllegalArgumentException(String message, Throwable cause)
  • IllegalArgumentException(Throwable cause) (auto-uses cause.toString() as the message) [1], [5]

Use them so you don’t lose the original stack trace and callers can inspect getCause().

Typical recommendation

catch (SomeCheckedException e) {
  throw new IllegalArgumentException("Invalid foo: " + foo, e);
}

Because the cause’s message is not automatically included in your new exception’s message, explicitly provide a clear contextual message when you have one [1].

2) Use initCause(...) only for “legacy” cases

If you’re dealing with an exception type (or code style) that didn’t set a cause via constructor, Java supports chaining via Throwable.initCause(cause) [2]. This is mainly for older/legacy throwables; you can only set the cause once, and not if the throwable was already created with a cause constructor [2].

3) Sanity check: should this be IllegalArgumentException?

IllegalArgumentException is intended for “illegal or inappropriate argument” passed to a method [1]. In many APIs, that’s a caller bug and there may be no deeper underlying exception to chain. If the failure is primarily an internal operation failure (I/O, parsing, reflection, etc.), consider whether a different exception type (often IllegalStateException or a domain-specific exception) better matches the semantics—then chain the cause.

Sources: Oracle Javadoc for IllegalArgumentException and Throwable exception chaining conventions [1], [2], plus Java 5-era API notes [5].


파싱 실패 예외의 원인이 누락돼 장애 분석이 어렵다

운영에서 잘못된 형식 입력이 들어올 때 원인 예외가 버려지면 로그에서 실패 원인을 파악하기 어려워 복구 시간이 길어진다. 원인 예외를 함께 전달해 예외 체이닝을 보존해야 한다.

수정안
-        } catch (DateTimeParseException e) {
-            throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다.");
+        } catch (DateTimeParseException e) {
+            throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다.", e);
         }

추가 테스트는 잘못된 형식 입력 시 예외의 cause가 유지되는지 검증하는 케이스를 BirthDateTest에 추가하는 것이 적절하다.

📝 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
LocalDate date;
try {
date = LocalDate.parse(value, FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다.");
}
LocalDate date;
try {
date = LocalDate.parse(value, FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다.", e);
}
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java`
around lines 15 - 20, BirthDate's parsing catch block drops the original
DateTimeParseException, losing root-cause information; update the catch in the
BirthDate constructor (where LocalDate.parse(value, FORMATTER) is called) to
rethrow an IllegalArgumentException with the original exception as the cause
(pass the caught DateTimeParseException into the IllegalArgumentException), and
add a unit test in BirthDateTest to assert that providing an invalid format
results in an IllegalArgumentException whose getCause() is a
DateTimeParseException.

Comment on lines +26 to +28
public ApiResponse<UserV1Dto.SignUpResponse> signUp(
@Valid @RequestBody UserV1Dto.SignUpRequest 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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the UserV1Controller.java file
find . -name "UserV1Controller.java" -type f | head -5

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

Length of output: 172


🏁 Script executed:

# Read the UserV1Controller.java file with line numbers to verify `@Valid` usage
if [ -f "apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java" ]; then
  cat -n "apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java"
else
  echo "File not found at expected location"
fi

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

Length of output: 3845


🏁 Script executed:

# Search for `@Valid` usage in the entire user API module
rg "@Valid" apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/ -A 2 -B 2

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

Length of output: 2512


🏁 Script executed:

# Check UserV1Dto for Bean Validation annotations
rg "jakarta.validation|javax.validation|@NotBlank|@Email|@Valid|@NotNull" apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java -n

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

Length of output: 516


🏁 Script executed:

# Examine domain layer User entity for validation
find . -path "*/domain/*" -name "User.java" -type f | head -5

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

Length of output: 67


🏁 Script executed:

# Search for CoreException and ApiControllerAdvice to verify unified error handling
rg "CoreException|ApiControllerAdvice" apps/commerce-api/src/main/java/com/loopers/ -l | head -10

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

Length of output: 560


🏁 Script executed:

# Read ApiControllerAdvice to see current error handling
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java | head -100

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

Length of output: 5697


🏁 Script executed:

# Search for PasswordPolicy implementation
find . -name "PasswordPolicy.java" -o -name "*PasswordPolicy*" | head -5

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

Length of output: 67


🏁 Script executed:

# Search for User entity in domain layer
find . -path "*/domain/user/*" -name "*.java" -type f

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

Length of output: 1227


🏁 Script executed:

# Search for UserFacade implementation
find . -name "UserFacade.java" -type f

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

Length of output: 163


🏁 Script executed:

# Read UserModel (domain layer User entity) to see validation
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java | head -100

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

Length of output: 3174


🏁 Script executed:

# Read Password value object for validation logic
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java

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

Length of output: 2677


🏁 Script executed:

# Read Email value object
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java

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

Length of output: 749


🏁 Script executed:

# Read UserFacade to see where validation is done
cat -n apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java | head -80

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

Length of output: 1712


🏁 Script executed:

# Check UserService for validation
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java | head -100

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

Length of output: 2615


🏁 Script executed:

# Verify that all signUp/updatePassword validation goes through domain
# Check if any other `@Valid` usage exists in controller that we missed
rg "@Valid" apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/ -c

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

Length of output: 257


🏁 Script executed:

# Confirm the complete flow - check how UserV1ApiSpec spec is used
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java

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

Length of output: 1885


@Valid 및 Bean Validation 어노테이션을 제거하여 통일된 예외 응답 포맷을 유지해야 한다

현재 구조에서 @Valid가 활성화되면 Spring의 Bean Validation이 먼저 동작하여 MethodArgumentNotValidException을 발생시키고, 이는 ApiControllerAdvice의 별도 핸들러(lines 52-56)로 처리되어 CoreException 기반 통일 포맷을 깬다. 도메인 계층(Email, Password, UserModel)에 이미 포괄적인 검증이 구현되어 있으므로, @Valid를 제거하고 모든 검증이 도메인 값 객체/정책을 통해 실행된 후 CoreException으로 변환되도록 정리해야 한다.

수정 대상:

  • UserV1Controller: signUp(line 27), updatePassword(line 81)에서 @Valid 제거
  • UserV1ApiSpec: signUp(line 19), updatePassword(line 48)에서 @Valid 제거
  • UserV1Dto: @NotBlank 및 모든 Bean Validation 어노테이션 제거

필수 필드 누락 시 ApiResponse FAIL 포맷과 BAD_REQUEST 상태 코드가 도메인 검증 경로를 통해 일관되게 반환되는지 E2E 테스트로 확인해야 한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java`
around lines 26 - 28, Remove Bean Validation from controller methods and DTOs so
validation flows through domain value objects and CoreException handlers: delete
the `@Valid` annotation on UserV1Controller.signUp and
UserV1Controller.updatePassword, remove `@Valid` on the corresponding methods in
UserV1ApiSpec (signUp and updatePassword), and strip all javax/hibernate
validation annotations (e.g., `@NotBlank`, `@Size`, `@Email`, etc.) from UserV1Dto
fields and request classes so missing/invalid fields are validated by domain
types (Email, Password, UserModel) and surface errors as CoreException-managed
ApiResponse FAIL with BAD_REQUEST; update any tests to assert E2E returns the
unified ApiResponse FAIL/BAD_REQUEST when required fields are missing.

Comment on lines +10 to +27
public record SignUpRequest(
@NotBlank(message = "사용자 ID는 필수입니다.")
@Pattern(regexp = "^[a-zA-Z0-9]{1,10}$", message = "사용자 ID는 영문자와 숫자로만 구성되며 최대 10자입니다.")
String userId,

@NotBlank(message = "비밀번호는 필수입니다.")
String password,

@NotBlank(message = "이메일은 필수입니다.")
String email,

@NotBlank(message = "생년월일은 필수입니다.")
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식은 yyyy-MM-dd 이어야 합니다.")
String birthDate,

@NotBlank(message = "성별은 필수입니다.")
String gender
) {
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

Bean Validation 어노테이션 사용이 프로젝트 가이드라인에 위배된다.

프로젝트 학습 기록에 따르면, DTO에 Bean Validation 어노테이션(@NotBlank, @Pattern 등)을 사용하지 않고 도메인 계층에서 검증을 수행해야 한다. 현재 SignUpRequest에 적용된 Bean Validation은 MethodArgumentNotValidException을 발생시켜 CoreException 기반 통합 에러 핸들링 패턴과 일관성이 깨진다.

도메인 계층(Email, BirthDate, Password VO)에서 이미 검증 로직이 구현되어 있으므로, DTO에서 Bean Validation 어노테이션을 제거하고 도메인 계층의 검증에 의존해야 한다.

Bean Validation 어노테이션 제거 제안
 public record SignUpRequest(
-    `@NotBlank`(message = "사용자 ID는 필수입니다.")
-    `@Pattern`(regexp = "^[a-zA-Z0-9]{1,10}$", message = "사용자 ID는 영문자와 숫자로만 구성되며 최대 10자입니다.")
     String userId,
-    
-    `@NotBlank`(message = "비밀번호는 필수입니다.")
     String password,
-    
-    `@NotBlank`(message = "이메일은 필수입니다.")
     String email,
-    
-    `@NotBlank`(message = "생년월일은 필수입니다.")
-    `@Pattern`(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식은 yyyy-MM-dd 이어야 합니다.")
     String birthDate,
-    
-    `@NotBlank`(message = "성별은 필수입니다.")
     String gender
 ) {
 }

Based on learnings: "Do not use Bean Validation annotations on DTOs in this project. Move validation logic into the domain layer" 및 "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice".

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java`
around lines 10 - 27, Remove Bean Validation annotations from the SignUpRequest
record (the `@NotBlank` and `@Pattern` annotations applied to fields userId,
password, email, birthDate, gender) and revert SignUpRequest to a plain DTO;
instead ensure callers (controller/service) construct and validate domain value
objects (e.g., Email, BirthDate, Password) from the DTO fields so validation
happens in the domain layer and any failures are translated into CoreException
handled by ApiControllerAdvice; update any tests or controller code that relied
on MethodArgumentNotValidException to construct the VOs and propagate domain
validation errors instead.

Comment on lines +76 to +83
public record UpdatePasswordRequest(
@NotBlank(message = "현재 비밀번호는 필수입니다.")
String currentPassword,

@NotBlank(message = "새 비밀번호는 필수입니다.")
String newPassword
) {
}
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

UpdatePasswordRequest에도 동일하게 Bean Validation 어노테이션이 적용되어 있다.

SignUpRequest와 마찬가지로 프로젝트 가이드라인에 위배된다. 비밀번호 검증은 도메인 계층에서 처리해야 한다.

Bean Validation 어노테이션 제거 제안
 public record UpdatePasswordRequest(
-    `@NotBlank`(message = "현재 비밀번호는 필수입니다.")
     String currentPassword,
-    
-    `@NotBlank`(message = "새 비밀번호는 필수입니다.")
     String newPassword
 ) {
 }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java`
around lines 76 - 83, The UpdatePasswordRequest record in UserV1Dto currently
carries Bean Validation annotations (`@NotBlank` on currentPassword and
newPassword); remove these annotations from the UpdatePasswordRequest
declaration so DTO remains a simple transport object, and move password
validation logic into the domain/service layer (e.g., the User domain model or
PasswordPolicy validator used by methods that handle password changes) ensuring
the controller accepts the raw DTO and domain layer enforces rules.

Comment on lines 14 to 67
@DisplayName("생년월일을 생성할 때, ")
@Nested
class Create {

@DisplayName("미래 날짜이면, IllegalArgumentException이 발생한다.")
@Test
void create_withFutureDate_shouldFail() {
// given
String futureDate = "2999-12-31";

// when & then
assertThrows(IllegalArgumentException.class, () -> {
new BirthDate(futureDate);
});
}

@DisplayName("형식이 yyyy-MM-dd가 아니면, IllegalArgumentException이 발생한다.")
@Test
void create_withInvalidFormat_shouldFail() {
// given
String invalidFormat = "1990/01/15";

// when & then
assertThrows(IllegalArgumentException.class, () -> {
new BirthDate(invalidFormat);
});
}

@DisplayName("올바른 형식(yyyy-MM-dd)의 과거 날짜이면, 정상적으로 생성된다.")
@Test
void create_withValidPastDate_shouldSuccess() {
// given
String validDate = "1990-01-15";

// when
BirthDate birthDate = new BirthDate(validDate);

// then
assertThat(birthDate.value()).isEqualTo(validDate);
}

@DisplayName("오늘 날짜이면, 정상적으로 생성된다.")
@Test
void create_withToday_shouldSuccess() {
// given
String today = LocalDate.now().toString();

// when
BirthDate birthDate = new BirthDate(today);

// then
assertThat(birthDate.value()).isEqualTo(today);
}
}
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/blank 입력 실패 케이스 테스트가 누락돼 있다
운영에서 빈 값이나 공백 값이 유입되면 도메인 규칙 위반이 회귀해도 테스트가 감지하지 못해 잘못된 데이터가 저장될 위험이 있다.
수정안으로 null, 빈 문자열, 공백 문자열 입력 시 예외가 발생하는 테스트 케이스를 추가해야 한다.
추가 테스트는 create_withNull_shouldFail 및 create_withBlank_shouldFail 형태로 실패 흐름을 명시적으로 검증하는 방향이 적절하다.
As per coding guidelines 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다를 근거로 했다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java`
around lines 14 - 67, Add two failing test cases in BirthDateTest under the
Create nested class to cover null/blank inputs: implement
create_withNull_shouldFail which asserts that new BirthDate(null) throws
IllegalArgumentException, and implement create_withBlank_shouldFail which
asserts that new BirthDate("") and new BirthDate("   ") (or separate assertions)
throw IllegalArgumentException; use assertThrows(IllegalArgumentException.class,
() -> new BirthDate(...)) and name the tests exactly create_withNull_shouldFail
and create_withBlank_shouldFail to match the review guidance.

Comment on lines +76 to +115
@DisplayName("동시에 같은 ID로 가입하면, 1개만 성공하고 나머지는 실패한다.")
@Test
void signUp_withConcurrentSameUserId_shouldOnlyOneSucceed() throws InterruptedException {
// given
String userId = "concurrent";
int threadCount = 5;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);

// when
for (int i = 0; i < threadCount; i++) {
int index = i;
executorService.submit(() -> {
try {
Email email = new Email("user" + index + "@example.com");
BirthDate birthDate = new BirthDate("1990-01-15");
Password password = Password.of("Pass1234!", birthDate);
Gender gender = Gender.MALE;

userService.signUp(userId, email, birthDate, password, gender);
successCount.incrementAndGet();
} catch (CoreException e) {
if (e.getErrorType() == ErrorType.CONFLICT) {
failCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}

latch.await();
executorService.shutdown();

// then
assertThat(successCount.get()).isEqualTo(1);
assertThat(failCount.get()).isEqualTo(threadCount - 1);
}
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 "UserServiceIntegrationTest.java" --type f

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

Length of output: 172


🏁 Script executed:

if [ -f "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java" ]; then
  wc -l "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java"
else
  echo "File not found"
fi

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

Length of output: 176


🏁 Script executed:

if [ -f "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java" ]; then
  sed -n '76,115p' "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java"
fi

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

Length of output: 1802


🏁 Script executed:

if [ -f "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java" ]; then
  grep -n "import.*TimeUnit\|import java.util.concurrent" "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java"
fi

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

Length of output: 274


🏁 Script executed:

if [ -f "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java" ]; then
  grep -n "@Timeout\|@Test\|awaitTermination" "apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java" | head -20
fi

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

Length of output: 334


동시성 테스트에 타임아웃이 없어 CI가 무한 대기할 수 있다

스레드가 교착되거나 완료되지 않으면 테스트가 영원히 대기하며 파이프라인이 멈추는 운영 위험이 있다. latch.await()에 타임아웃을 적용하고 완료 여부를 검증하며, executorService 종료도 awaitTermination()으로 명시적 대기를 보장해야 한다.

🛠️ 수정 제안
-            latch.await();
-            executorService.shutdown();
+            boolean completed = latch.await(5, TimeUnit.SECONDS);
+            assertThat(completed).isTrue();
+            executorService.shutdown();
+            executorService.awaitTermination(5, TimeUnit.SECONDS);

상단에 import java.util.concurrent.TimeUnit;를 추가한다.

📝 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("동시에 같은 ID로 가입하면, 1개만 성공하고 나머지는 실패한다.")
@Test
void signUp_withConcurrentSameUserId_shouldOnlyOneSucceed() throws InterruptedException {
// given
String userId = "concurrent";
int threadCount = 5;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
// when
for (int i = 0; i < threadCount; i++) {
int index = i;
executorService.submit(() -> {
try {
Email email = new Email("user" + index + "@example.com");
BirthDate birthDate = new BirthDate("1990-01-15");
Password password = Password.of("Pass1234!", birthDate);
Gender gender = Gender.MALE;
userService.signUp(userId, email, birthDate, password, gender);
successCount.incrementAndGet();
} catch (CoreException e) {
if (e.getErrorType() == ErrorType.CONFLICT) {
failCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
// then
assertThat(successCount.get()).isEqualTo(1);
assertThat(failCount.get()).isEqualTo(threadCount - 1);
}
`@DisplayName`("동시에 같은 ID로 가입하면, 1개만 성공하고 나머지는 실패한다.")
`@Test`
void signUp_withConcurrentSameUserId_shouldOnlyOneSucceed() throws InterruptedException {
// given
String userId = "concurrent";
int threadCount = 5;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
// when
for (int i = 0; i < threadCount; i++) {
int index = i;
executorService.submit(() -> {
try {
Email email = new Email("user" + index + "@example.com");
BirthDate birthDate = new BirthDate("1990-01-15");
Password password = Password.of("Pass1234!", birthDate);
Gender gender = Gender.MALE;
userService.signUp(userId, email, birthDate, password, gender);
successCount.incrementAndGet();
} catch (CoreException e) {
if (e.getErrorType() == ErrorType.CONFLICT) {
failCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
boolean completed = latch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
// then
assertThat(successCount.get()).isEqualTo(1);
assertThat(failCount.get()).isEqualTo(threadCount - 1);
}
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java`
around lines 76 - 115, Add a timeout to the concurrency test to avoid hanging
CI: in signUp_withConcurrentSameUserId_shouldOnlyOneSucceed, replace the
unconditional latch.await() with latch.await(<reasonable timeout>,
TimeUnit.SECONDS) (add import java.util.concurrent.TimeUnit), capture the
boolean result and assert it completed (fail the test if false), call
executorService.shutdown() and then call
executorService.awaitTermination(<reasonable timeout>, TimeUnit.SECONDS) and
assert termination succeeded (fail if not); keep the existing
successCount/failCount assertions after confirming the waits completed.

zeexzeex and others added 5 commits February 9, 2026 01:05
- Email: 로컬 파트(@앞) 빈 문자열 검증 추가 (parts[0].isBlank())
- Password: birthDate null 체크 추가, matches() null 방어 로직 추가
- BirthDate: DateTimeParseException 원인(cause) 보존
- Gender: IllegalArgumentException 원인(cause) 보존
- BirthDateTest: null/blank 및 예외 cause 검증 테스트 추가
- EmailTest: 로컬 파트 공백, 다중 @ 기호 테스트 추가
- PasswordTest: null/blank, birthDate null, matches null, 경계값(8자/16자) 테스트 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
- UserModel.create(): userId null/blank 검증 추가
- UserModel.updatePassword(): currentRawPassword, newRawPassword null/blank 검증 추가
- UserModelTest: create null/blank userId 실패 테스트 추가
- UserModelTest: updatePassword null/blank 입력 실패 테스트 4개 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- /customer/v1/users → /api/v1/users 경로 변경
- 회원가입: POST /sign-up → POST (base path)
- 비밀번호 변경: PATCH /me/password → PUT /password
- user-v1.http API 문서 신규 작성 (13개 시나리오)

Co-authored-by: Cursor <cursoragent@cursor.com>
- 엔드포인트 상수 /customer/v1 → /api/v1 경로 변경
- 비밀번호 변경 HttpMethod.PATCH → PUT 변경
- DisplayName 경로 표기 업데이트

Co-authored-by: Cursor <cursoragent@cursor.com>
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