-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-1] 회원가입, 내 정보 조회, 비밀번호 수정 기능 구현 #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: zeexzeex
Are you sure you want to change the base?
Conversation
- 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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this 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 | 🟠 MajorMethodArgumentNotValidException 핸들러 추가는 에러 응답 통일성을 깨뜨린다
운영 관점에서 CoreException 경로와 별도 검증 경로가 공존하면 응답 포맷이 분기되어 클라이언트 호환성과 모니터링/알림 집계가 흔들린다. 수정안으로 본 핸들러와 관련 import를 제거하고, 검증 실패는 도메인 계층에서 CoreException으로 변환되어 기존 handle(CoreException) 경로를 타도록 정리하는 것이 필요하다. 추가 테스트로 잘못된 요청 입력에 대해 항상 CoreException 기반 ApiResponse 포맷이 유지되는지 통합 테스트를 추가하는 것이 안전하다.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/).🛠️ 수정안(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); - }
🤖 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 의존성이 직접 포함되어 있다.
PasswordVO가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 검증이 없다.
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(),
|
|
||
| // security | ||
| implementation("org.springframework.security:spring-security-crypto") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -path "**/apps/commerce-api/build.gradle.kts" -type f | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 121
🏁 Script executed:
cat -n apps/commerce-api/build.gradle.kts | head -30Repository: 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 returntruefor 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
DaoAuthenticationProviderconstant-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-cryptoto 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-cryptoin 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 -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 103
🏁 Script executed:
cat -n build.gradle.kts 2>/dev/null | head -50Repository: 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 -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 940
🏁 Script executed:
find . -name "gradle.properties" -type f | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 104
🏁 Script executed:
cat gradle.propertiesRepository: 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.
| Email emailVO = new Email(email); | ||
| BirthDate birthDateVO = new BirthDate(birthDate); | ||
| Password passwordVO = Password.of(password, birthDateVO); | ||
| Gender gender = Gender.from(genderValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
VO 생성 시 발생하는 IllegalArgumentException이 CoreException으로 변환되지 않는다.
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.
| public UserInfo getMyInfo(String userId) { | ||
| return userService.getMyInfo(userId); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| public PointsInfo getPoints(String userId) { | ||
| Long points = userService.getPoints(userId); | ||
| if (points == null) { | ||
| return null; | ||
| } | ||
| return new PointsInfo(userId, points); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| LocalDate date; | ||
| try { | ||
| date = LocalDate.parse(value, FORMATTER); | ||
| } catch (DateTimeParseException e) { | ||
| throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다."); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -name "BirthDate.java" -type fRepository: 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 -30Repository: 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-usescause.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.
| 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.
| public ApiResponse<UserV1Dto.SignUpResponse> signUp( | ||
| @Valid @RequestBody UserV1Dto.SignUpRequest request | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, locate and read the UserV1Controller.java file
find . -name "UserV1Controller.java" -type f | head -5Repository: 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"
fiRepository: 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 2Repository: 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 -nRepository: 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 -5Repository: 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 -10Repository: 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 -100Repository: 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 -5Repository: 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 fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1227
🏁 Script executed:
# Search for UserFacade implementation
find . -name "UserFacade.java" -type fRepository: 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 -100Repository: 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.javaRepository: 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.javaRepository: 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 -80Repository: 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 -100Repository: 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/ -cRepository: 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.javaRepository: 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.
| 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 | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| public record UpdatePasswordRequest( | ||
| @NotBlank(message = "현재 비밀번호는 필수입니다.") | ||
| String currentPassword, | ||
|
|
||
| @NotBlank(message = "새 비밀번호는 필수입니다.") | ||
| String newPassword | ||
| ) { | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| @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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| @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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
fd "UserServiceIntegrationTest.java" --type fRepository: 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"
fiRepository: 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"
fiRepository: 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"
fiRepository: 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
fiRepository: 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.
| @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.
- 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>
📌 Summary
배경: 회원가입 시 비밀번호 검증 로직이 없어 생년월일이 포함된 취약한 비밀번호 생성이 가능하며, 평문 비밀번호 저장은 보안상 심각한 위험이 존재함.
목표: Password Value Object를 도입하여 생성 시점에 길이 및 생년월일 포함 여부를 검증하고, BCrypt 기반 단방향 암호화를 적용하여 도메인 응집도와 보안성을 강화함.
결과: yyyy-MM-dd, yyyyMMdd, yyMMdd 3가지 형식을 모두 검증하여 우회 가능성을 차단하고, 암호화된 비밀번호만 Entity에 저장하도록 강제하여 평문 노출 리스크를 원천 차단함
🧭 Context & Decision
문제 정의
현재 동작/제약:
문제(또는 리스크):
성공 기준(완료 정의):
선택지와 결정
🏗️ Design Overview
변경 범위
주요 컴포넌트 책임
ComponentA:ComponentB:ComponentC:🔁 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변경 목적: 회원 관리 기능 구현 및 비밀번호 보안 강화. 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)도 테스트되어 사용자 정보 보호 적용 확인.