Skip to content

Conversation

@hey-sion
Copy link

@hey-sion hey-sion commented Feb 6, 2026

📌 Summary

  • 배경: TDD 기반으로 AI를 활용하여 사용자 관리 기능(회원가입, 내 정보 조회, 비밀번호 변경) API 구현
  • 목표: Clean Architecture 기반으로 User 도메인 API 3개 구현 및 테스트 코드 작성
  • 결과: 회원가입(POST), 내 정보 조회(GET), 비밀번호 변경(PATCH) API 구현 완료, 단위/통합/E2E 테스트 작성

🧭 Context & Decision

1. PasswordEncoder 구현 방식

  • 고려한 대안:
    • A: Spring Security의 PasswordEncoder 직접 사용
    • B: 커스텀 인터페이스 생성 후 구현체에서 Spring Security 사용
  • 최종 결정: B - 커스텀 인터페이스 (domain/user/PasswordEncoder)
  • 이유: 추가 인터페이스 관리 필요하지만, 도메인 레이어가 Spring Security에 의존하지 않음 (Clean Architecture)

2. 비밀번호 검증 로직 위치

  • 고려한 대안:
    • A: User 엔티티 내부에서 모든 검증
    • B: SignUpValidator/PasswordPolicyValidator로 분리
  • 최종 결정: B - Validator 분리
  • 이유: User 엔티티는 불변식(null, 형식) 보호, Validator는 비즈니스 규칙(중복, 정책) 검증으로 역할 분리

3. DTO 구조

  • 고려한 대안:
    • A: 각 DTO를 별도 파일로 관리 (CreateUserRequestV1.java, UpdatePasswordRequestV1.java)
    • B: UserV1Dto 클래스 내 inner record로 통합
  • 최종 결정: B - UserV1Dto.CreateRequest, UserV1Dto.UpdatePasswordRequest, UserV1Dto.UserResponse
  • 이유: 파일 수 감소, User API 관련 DTO 응집도 향상

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api / User
  • 신규 추가:
    • Controller: SignUpV1Controller, UserV1Controller
    • Service: SignUpService, UserService
    • Domain: User, UserRepository, PasswordEncoder, SignUpValidator,
      PasswordPolicyValidator
    • DTO: UserV1Dto (CreateRequest, UpdatePasswordRequest, UserResponse)
    • Command: SignUpCommand, UpdatePasswordCommand
  • 제거/대체: -

주요 컴포넌트 책임

  • SignUpV1Controller: 회원가입 API 엔드포인트 (POST /api/v1/users)
  • UserV1Controller: 내 정보 조회/비밀번호 변경 API 엔드포인트
  • SignUpService: 회원가입 비즈니스 로직 (검증, 암호화, 저장)
  • UserService: 사용자 조회/비밀번호 변경 비즈니스 로직
  • SignUpValidator: 회원가입 검증 (loginId 중복, 생년월일 미래 체크)
  • PasswordPolicyValidator: 비밀번호 정책 검증 (8~16자, 공백 불가, 생년월일 미포함)
  • User: 사용자 엔티티 (검증 포함)

🔁 Flow Diagram

회원가입 Flow

sequenceDiagram
  autonumber
  participant Client
  participant SignUpV1Controller
  participant SignUpService
  participant SignUpValidator
  participant PasswordEncoder
  participant UserRepository

  Client->>SignUpV1Controller: POST /api/v1/users
  SignUpV1Controller->>SignUpService: signUp(command)
  SignUpService->>SignUpValidator: validate(command)
  SignUpValidator->>UserRepository: findByLoginId()
  UserRepository-->>SignUpValidator: Optional User
  SignUpValidator-->>SignUpService: 검증 통과
  SignUpService->>PasswordEncoder: encode(password)
  PasswordEncoder-->>SignUpService: encodedPassword
  SignUpService->>UserRepository: save(user)
  UserRepository-->>SignUpService: saved
  SignUpService-->>SignUpV1Controller: void
  SignUpV1Controller-->>Client: 201 Created
Loading

내 정보 조회 Flow

sequenceDiagram
  autonumber
  participant Client
  participant UserV1Controller
  participant UserService
  participant UserRepository

  Client->>UserV1Controller: GET /api/v1/users/me
  UserV1Controller->>UserService: getMyInfo(loginId)
  UserService->>UserRepository: findByLoginId()
  UserRepository-->>UserService: User
  UserService-->>UserV1Controller: UserInfo
  UserV1Controller-->>Client: 200 OK
Loading

비밀번호 변경 Flow

sequenceDiagram
  autonumber
  participant Client
  participant UserV1Controller
  participant UserService
  participant PasswordEncoder
  participant UserRepository

  Client->>UserV1Controller: PATCH /api/v1/users/me/password
  UserV1Controller->>UserService: updatePassword(command)
  UserService->>UserRepository: findByLoginId()
  UserRepository-->>UserService: User
  UserService->>PasswordEncoder: matches()
  PasswordEncoder-->>UserService: false
  UserService->>PasswordEncoder: encode()
  PasswordEncoder-->>UserService: encodedPassword
  UserService-->>UserV1Controller: void
  UserV1Controller-->>Client: 200 OK
Loading

📋 API 명세
┌────────┬───────────────────────────┬───────────────┬───────────────────┐
│ Method │ Endpoint │ 설명 │ 인증 │
├────────┼───────────────────────────┼───────────────┼───────────────────┤
│ POST │ /api/v1/users │ 회원가입 │ 불필요 │
├────────┼───────────────────────────┼───────────────┼───────────────────┤
│ GET │ /api/v1/users/me │ 내 정보 조회 │ X-Loopers-LoginId │
├────────┼───────────────────────────┼───────────────┼───────────────────┤
│ PATCH │ /api/v1/users/me/password │ 비밀번호 변경 │ X-Loopers-LoginId │
└────────┴───────────────────────────┴───────────────┴───────────────────┘
✅ 테스트

  • 단위 테스트: UserTest, SignUpServiceTest, SignUpValidatorTest, UserServiceTest
  • 통합 테스트: SignUpServiceIntegrationTest, UserServiceIntegrationTest
  • E2E 테스트: SignUpV1ApiE2ETest, UserV1ApiE2ETest

변경 목적: User 도메인 API 3가지(회원가입, 내 정보 조회, 비밀번호 변경)를 TDD와 Clean Architecture 원칙에 따라 구현하고, Spring Security 의존성 제거를 위해 도메인 레벨의 PasswordEncoder 인터페이스 도입.

핵심 변경점: ① Domain-level PasswordEncoder 인터페이스와 BCrypt 구현체 추가 ② SignUpValidator/PasswordPolicyValidator로 비즈니스 검증 분리(8~16자, 특수문자 허용, 생년월일 yyyyMMdd 형식 포함 금지) ③ SignUpCommand/UpdatePasswordCommand 커맨드 객체 도입 ④ 3개 REST API 엔드포인트 구현(POST /api/v1/users, GET /api/v1/users/me, PATCH /api/v1/users/me/password, X-Loopers-LoginId 헤더 사용) ⑤ 사용자 이름 마스킹 기능(마지막 글자 * 처리).

리스크/주의사항: ① 생년월일 포함 검증이 yyyyMMdd 형식만 대상이므로 다른 형식 입력 시 회피 가능성 ② 사용자 조회 시 X-Loopers-LoginId 헤더 필수인데, 헤더 누락 시 에러 처리 방식 확인 필요 ③ 이름 마스킹이 마지막 1글자만 치환하는데 한글명 보호에 충분한지 검토 필요 ④ spring-security-crypto 의존성만 추가되고 다른 Spring Security 모듈과의 상호작용 확인 필요?

테스트/검증: 단위 테스트(UserTest, SignUpValidatorTest, UserServiceTest, SignUpServiceTest), 통합 테스트(SignUpServiceIntegrationTest, UserServiceIntegrationTest), E2E 테스트(SignUpV1ApiE2ETest, UserV1ApiE2ETest) 포함되어 있으며, InMemoryUserRepository와 DatabaseCleanUp을 활용한 테스트 격리 구현.

hanyoung-kurly and others added 30 commits February 2, 2026 01:26
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- name: 빈 값/공백 포함 불가
- email: @와 . 포함 필수
- birthDate: YYYYMMDD 형식 (8자리 숫자)
- password: 8~16자 영문/숫자/특수문자, 생년월일 포함 불가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- UserService에서 원본 비밀번호 검증 (8~16자, 영문/숫자/특수문자, 생년월일 포함 불가)
- loginId 중복 체크 로직 추가 (CONFLICT 에러 사용)
- UserRepository에 findByLoginId 메서드 추가
- 통합테스트 6개 작성 (암호화 저장, 중복 체크, 비밀번호 검증)
- User 엔티티에서 password 검증 제거 (Service 레이어로 이동)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- local 프로필에서 TestContainers 로드 방지
- 로컬 인프라로 통합테스트 실행 가능
- spring-security-crypto 의존성 추가
- BcryptPasswordEncoder 구현
@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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 15

🤖 Fix all issues with AI agents
In `@apps/commerce-api/build.gradle.kts`:
- Line 11: The spring-security-crypto dependency is managed by the Spring Boot
3.4.4 BOM which pins it to 6.4.4 (CVE-2025-22234); fix it by explicitly
overriding the dependency version or bumping the BOM: either change the
dependency implementation("org.springframework.security:spring-security-crypto")
to include a safe version (e.g.,
implementation("org.springframework.security:spring-security-crypto:6.4.5")) or
update the Spring Boot BOM/platform coordinate to 3.4.5+ so the BOM supplies a
fixed spring-security-crypto, and add a CI step to validate the resolved
dependency tree and run your security scanner to confirm the CVE is no longer
present.

In
`@apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java`:
- Around line 7-12: SignUpCommand 레코드의 자동 생성된 toString()가 평문 password를 노출하므로
SignUpCommand에 toString()를 오버라이드해 password 값을 마스킹(예: "****" 또는 부분 마스킹)하도록 변경하고
나머지 필드만 출력하도록 구현하세요; 대상 식별자는 record SignUpCommand(...)와 필드 password 및 메서드
toString()입니다. 또한 SignUpCommandToStringMasking 같은 단위 테스트를 추가해 toString() 반환값에 실제
비밀번호가 포함되지 않는지(assertFalse(result.contains(actualPassword))) 검증하도록 작성하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java`:
- Around line 5-8: UpdatePasswordCommand 레코드의 기본 toString()가 평문 newPassword를
노출하므로 toString()을 오버라이드해서 비밀번호를 마스킹하거나 제외하도록 변경하세요 (예: 클래스
UpdatePasswordCommand의 toString()에서 loginId만 노출하거나 newPassword를 "****"로 대체). 변경할
위치는 UpdatePasswordCommand 레코드 선언부이며 toString() 오버라이드 구현과 함께 toString() 결과에 실제
newPassword가 포함되지 않음을 검증하는 단위 테스트를 추가하세요 (테스트는 new
UpdatePasswordCommand(...).toString()가 원본 newPassword 문구를 포함하지 않음을 assert).

In
`@apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java`:
- Around line 15-23: PasswordPolicyValidator.validate currently calls
password.matches(PASSWORD_PATTERN) and
birthDate.format(BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK) without null checks which
can throw NPEs; add explicit null validation at the start of validate(String
password, LocalDate birthDate) to check both password and birthDate and if
either is null throw new CoreException(ErrorType.BAD_REQUEST, "<appropriate
message>") (e.g., "비밀번호는 필수입니다." / "생년월일은 필수입니다.") before using PASSWORD_PATTERN
or BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK so invalid requests return 400 instead
of a 500.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java`:
- Around line 16-21: Add a UNIQUE constraint on the User.loginId (either add
`@Column`(unique = true) on the User entity or create a DB migration) to ensure
DB-level uniqueness, then catch DB constraint violations by adding a
DataIntegrityViolationException handler in ApiControllerAdvice that converts the
exception into a CoreException with CONFLICT; also update SignUpService.signUp
to remain as-is (keep signUpValidator.validate and userRepository.save) but rely
on the DB constraint for race conditions and add an integration test that
simulates concurrent signUp calls (use ExecutorService or CountDownLatch) to
assert only one user is created and others receive a conflict response.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java`:
- Around line 16-25: Add defensive null/blank checks at the start of
SignUpValidator.validate: verify command is non-null and its loginId and
password are non-null/non-blank and birthDate is non-null; if any check fails
throw new CoreException(ErrorType.BAD_REQUEST, "<field> is required") before
calling userRepository.findByLoginId, birthDate.isAfter or
PasswordPolicyValidator.validate so we avoid NPEs; update error messages to be
clear per field (loginId/password/birthDate) and add unit tests asserting
BAD_REQUEST is thrown for null/blank command.loginId(), command.password(),
command.birthDate() and for a null SignUpCommand.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/User.java`:
- Around line 27-30: 요약: User 생성자에서 validateEncodedPassword() 검증을 제거하세요; 수정:
User 클래스의 private User(String loginId, String encodedPassword, ...) 생성자에서
validateEncodedPassword() 호출과 관련 메서드(예: validateEncodedPassword())를 삭제하고
encodedPassword에 대한 null/공백 검증을 제거해 도메인이 인코딩 정책에 의존하지 않게 만드세요; 또한 SignUpService와
UserService.updatePassword() 흐름(PasswordPolicyValidator.validate() →
passwordEncoder.encode() → User.create(...))이 정책 검증 후 인코딩된 값만 User.create()로
전달하도록 테스트를 보강해 주세요; 참고 심볼: validateEncodedPassword(), User.create(),
SignUpService, UserService.updatePassword().
- Line 19: User.loginId에 DB 레벨 유니크 제약이 빠져 있어 동시 가입 레이스가 발생하므로 User 클래스의 loginId
필드에 `@Column`(unique = true) 또는 엔티티에 `@Table`(uniqueConstraints =
`@UniqueConstraint`(columnNames = "loginId")) 를 추가하고 DB 마이그레이션(또는 JPA 인덱스)으로 실제
유니크 인덱스를 생성하세요; 또한 ApiControllerAdvice에 DataIntegrityViolationException을 잡아
CoreException으로 변환해 ErrorType.CONFLICT를 반환하도록 핸들러를 추가(예: catch
DataIntegrityViolationException -> throw new CoreException(ErrorType.CONFLICT,
...))하고 SignUpValidator는 애플리케이션 레벨 체크로 유지하되 동시성 케이스를 검증하는 통합 테스트를 추가하여 동일한
loginId로 동시에 2건 요청시 하나만 저장되는지 확인하세요.
- Around line 14-15: The entity mapping for the User class uses a reserved table
name "user" which can break DDL on Postgres/Oracle; update the `@Table` annotation
on class User (replace `@Table`(name = "user") with a non-reserved name such as
`@Table`(name = "users") or `@Table`(name = "app_user")), then update any code, JPA
queries, repositories, and existing SQL migrations that reference "user" to the
new table name; after changing, add DDL verification tests against target DBs
(PostgreSQL/Oracle) and ensure your migration scripts or migration tool
configuration (Flyway/Liquibase) are updated/synced to reflect the new table
name.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java`:
- Line 11: 현재 ApiControllerAdvice에서 MethodArgumentNotValidException import 및 해당
예외 핸들러를 제거하고, 검증 실패 흐름을 CoreException으로 통일하도록 정리하세요: ApiControllerAdvice 클래스에서
"import org.springframework.web.bind.MethodArgumentNotValidException;"를 삭제하고
MethodArgumentNotValidException를 처리하는 핸들러 메서드를 제거하며, 도메인 쪽(SignUpValidator,
PasswordPolicyValidator 등)에 Bean Validation 로직을 이동하여 검증 실패 시 CoreException을
발생시키도록 구현(또는 기존 CoreException 발생 지점으로 흐르게)하고, 변경 후 E2E 테스트로 빈 필드/타입 불일치 요청에 대해
ApiResponse 표준 에러 포맷이 유지되는지 검증하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserV1Dto.java`:
- Around line 12-29: Remove the Bean Validation annotations from the DTO records
CreateRequest and UpdatePasswordRequest (drop `@NotBlank`, `@NotNull`, `@JsonFormat`
on birthDate) and instead implement null/blank/date-format checks inside the
domain validators (SignUpValidator, PasswordPolicyValidator, and User-related
validators) so validation logic is centralized; update SignUpValidator/User
validators to validate loginId, password, name, birthDate and email (including
date parse/format) and have them throw CoreException with BAD_REQUEST on invalid
input; finally add/extend unit tests (UserTest or SignUpValidatorTest) to assert
that null/blank/invalid inputs produce CoreException(BAD_REQUEST) for the
affected fields.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java`:
- Around line 23-27: Remove the controller-level Bean Validation by deleting the
`@Valid` on SignUpV1Controller.signUp so requests no longer short-circuit to
MethodArgumentNotValidException; instead, implement/ensure null/blank checks for
loginId and other fields inside the domain validation path (e.g.,
SignUpValidator and the User creation logic used by SignUpCommand.from) and have
signUpService.signUp throw the existing CoreException when validation fails;
finally update/add the SignUpV1ApiE2ETest.returnsBadRequest_whenLoginIdIsMissing
to assert the error flows through ApiControllerAdvice and returns the standard
error format (code/message).

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java`:
- Around line 30-35: Remove the `@Valid` annotation from the updatePassword
controller method so Bean Validation won't throw MethodArgumentNotValidException
and break the CoreException-based error format; instead, move/ensure validation
of UserV1Dto.UpdatePasswordRequest (fields like newPassword) into the
PasswordPolicyValidator or inside
UserService.updatePassword/UpdatePasswordCommand handling so all validation
errors are normalized into CoreException and flow through ApiControllerAdvice;
then add E2E coverage in UserV1ApiE2ETest to assert that requests with
missing/blank newPassword return the standard CoreException error response.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java`:
- Around line 60-142: Add positive/boundary unit tests in SignUpValidatorTest to
cover successful signup scenarios (password length exactly 8 and exactly 16 and
a password containing allowed special characters) so changes to the password
policy don’t cause silent regressions; create SignUpCommand instances with valid
passwords (e.g., 8-char and 16-char examples and one with permitted special
chars) and assert that signUpValidator.validate(command) does not throw (or
returns the expected success behavior) instead of using assertThrows; reference
the existing test class/methods (SignUpValidatorTest, SignUpCommand,
signUpValidator.validate) to add these passing cases alongside the existing
failing ones.

In
`@modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java`:
- Line 5: The import of org.springframework.context.annotation.Profile is
present but RedisTestContainersConfig lacks a `@Profile` annotation, causing the
static Redis container to start for all test contexts; either remove the unused
Profile import if you want the config to always load, or add `@Profile`("test")
(or another appropriate profile name) on the RedisTestContainersConfig class so
the Testcontainers-based Redis only starts when that profile is active; also
check MySqlTestContainersConfig for the same pattern and apply the same change
if needed.
🧹 Nitpick comments (8)
apps/commerce-api/src/test/java/com/loopers/domain/user/UserFixture.java (1)

7-7: VALID_ENCODED_PASSWORD 상수명이 실제 값과 불일치한다.

상수명에 "Encoded"가 포함되어 있으나 실제 값 "ValidEncodedPass1!"은 평문이다. 학습된 내용에 따르면 User 생성자는 BCrypt 해시를 받아야 한다. 테스트에서 혼란을 야기할 수 있으므로 상수명을 VALID_RAW_PASSWORD 또는 VALID_PASSWORD로 변경하거나, 실제 BCrypt 해시 값을 사용하는 것을 권장한다.

♻️ 수정 제안
-    public static final String VALID_ENCODED_PASSWORD = "ValidEncodedPass1!";
+    public static final String VALID_PASSWORD = "ValidPass1!";
apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java (1)

11-15: save() 메서드에서 기존 사용자 업데이트 시 데이터 불일치 가능성이 있다.

동일 loginId로 사용자 정보를 업데이트할 때, 이전 email 키에 대한 매핑이 storeByEmail에 남아 있어 고아 데이터가 발생할 수 있다. 운영 환경에서는 문제가 없으나, 테스트에서 사용자 정보 변경 후 이전 이메일로 조회 시 예상치 못한 결과가 발생할 수 있다.

♻️ 수정 제안
 `@Override`
 public void save(User user) {
+    // 기존 매핑 제거 (업데이트 케이스 대응)
+    User existingByLoginId = storeByLoginId.get(user.getLoginId());
+    if (existingByLoginId != null) {
+        storeByEmail.remove(existingByLoginId.getEmail());
+    }
     storeByEmail.put(user.getEmail(), user);
     storeByLoginId.put(user.getLoginId(), user);
 }
apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java (1)

103-189: 비밀번호 정책 위반 케이스 테스트가 누락되어 있다.

UpdatePassword 테스트에서 현재 비밀번호와 동일한 경우는 검증하지만, 비밀번호 정책 위반(8자 미만, 생년월일 포함 등) 시 400 에러를 반환하는지 검증하는 테스트가 없다. 운영 환경에서 정책 위반 시 올바른 에러 응답이 반환되는지 확인이 필요하다.

추가 테스트 예시:

  • 비밀번호가 8자 미만인 경우 → 400 Bad Request
  • 비밀번호에 생년월일이 포함된 경우 → 400 Bad Request
apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java (1)

67-82: name이 null인 경우의 테스트가 누락되어 있다.

빈 문자열 테스트는 있으나 null 입력에 대한 테스트가 없다. 도메인 검증에서 null과 빈 문자열을 동일하게 처리하는지 확인이 필요하다. null 입력 시 NPE가 발생하면 500 에러로 응답되므로 명시적인 테스트 추가를 권장한다.

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

42-45: 비밀번호 암호화 검증이 약하다.

isNotEqualTo("ValidPass1!")는 암호화 여부만 확인하고 실제 BCrypt 형식인지 검증하지 않는다. 암호화가 올바르게 수행되었는지 확인하려면 passwordEncoder.matches()를 사용하여 원본 비밀번호와 저장된 해시가 일치하는지 검증해야 한다.

♻️ 수정 제안
         // assert
         User savedUser = userRepository.findByLoginId("testUser123").orElse(null);
         assertThat(savedUser).isNotNull();
-        assertThat(savedUser.getPassword()).isNotEqualTo("ValidPass1!");
+        assertThat(passwordEncoder.matches("ValidPass1!", savedUser.getPassword())).isTrue();

27-46: 테스트 커버리지가 부족하다.

현재 암호화 성공 케이스만 테스트하고 있다. 다음 실패 시나리오에 대한 테스트 추가를 권장한다:

  • 중복 loginId로 회원가입 시 예외 발생
  • 비밀번호 정책 위반(8자 미만, 생년월일 포함) 시 예외 발생
  • 미래 birthDate로 회원가입 시 예외 발생

이러한 테스트가 없으면 SignUpValidator 연동 오류를 조기에 발견하기 어렵다.

apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java (1)

9-10: 애플리케이션 계층이 API DTO에 직접 의존한다
운영 관점에서 다른 입력 채널(배치/메시지)을 추가할 때 API DTO 변경이 애플리케이션 계층까지 전파되어 배포 영향 범위와 회귀 위험이 커진다.
수정안: DTO→Command 변환은 컨트롤러/매퍼 계층으로 이동하고, UpdatePasswordCommand는 순수 데이터만 보유하도록 분리한다.
추가 테스트: 컨트롤러 또는 매퍼 단위 테스트로 DTO 매핑 동작을 검증한다.
Based on learnings: This project (loop-pack-be-l2-vol3-java) intentionally avoids Bean Validation annotations (Valid, NotBlank, Email, etc.) in DTOs and keeps validation in the domain layer, so DTO 의존을 줄이는 원칙을 따른다.

apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java (1)

14-21: 애플리케이션 계층이 API DTO에 직접 의존한다
운영 관점에서 다른 입력 채널(배치/메시지)을 추가할 때 API DTO 변경이 애플리케이션 계층까지 전파되어 배포 영향 범위와 회귀 위험이 커진다.
수정안: DTO→Command 변환은 컨트롤러/매퍼 계층으로 이동하고, SignUpCommand는 순수 데이터만 보유하도록 분리한다.
추가 테스트: 컨트롤러 또는 매퍼 단위 테스트로 DTO 매핑 동작을 검증한다.
Based on learnings: This project (loop-pack-be-l2-vol3-java) intentionally avoids Bean Validation annotations (Valid, NotBlank, Email, etc.) in DTOs and keeps validation in the domain layer, so DTO 의존을 줄이는 원칙을 따른다.


// web
implementation("org.springframework.boot:spring-boot-starter-web")
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:

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

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

Length of output: 1238


🏁 Script executed:

cd apps/commerce-api && grep -i "spring\|bom\|version" build.gradle.kts | head -20

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

Length of output: 426


🌐 Web query:

spring-security-crypto latest version 2025 2026 CVE vulnerability

💡 Result:

Latest org.springframework.security:spring-security-crypto versions (2025–2026)

  • Latest stable: 7.0.2 (released to Maven Central 2025-12-15) [1][2]
  • Latest pre-release (milestone): 7.1.0-M1 (published 2026-01-19) [1]

Spring Security CVEs relevant to spring-security-crypto (2025)

  • CVE-2025-22228 (HIGH)BCryptPasswordEncoder.matches may incorrectly return true for passwords >72 chars when the first 72 chars match.

    • Affects Spring Security 6.4.0–6.4.3, 6.3.0–6.3.7, etc.
    • Fixed in 6.4.4 and 6.3.8 (OSS lines) [3]
  • CVE-2025-22234 (MEDIUM) – Fix for CVE-2025-22228 inadvertently broke DaoAuthenticationProvider timing-attack mitigation (can leak info via response-time differences).

    • Affects specific versions: 6.4.4 only, 6.3.8 only, etc.
    • Fixed in 6.4.5 and 6.3.9 (OSS lines) [4][5]

Related (not crypto-specific, but in Spring Security)

  • CVE-2025-41248 (MEDIUM) – method-security annotation resolution bug can cause authorization bypass in some generic type hierarchies; fixed in 6.4.11 / 6.5.5 [6][7]

If you’re on 7.0.2, you’re already beyond the vulnerable ranges for CVE-2025-22228 and the “bad” intermediate versions for CVE-2025-22234. [1][3][4]

Sources

[1] Maven Central directory listing for spring-security-crypto versions (shows 7.0.2 and 7.1.0-M1 dates)
[2] Spring blog: Spring Security 7.0.1 / 7.0.2 releases
[3] Spring advisory: CVE-2025-22228
[4] Spring advisory: CVE-2025-22234
[5] Snyk advisory for CVE-2025-22234 (fix versions)
[6] Spring advisory: CVE-2025-41248
[7] Spring blog: Fix releases for CVE-2025-41248 / CVE-2025-41249


🏁 Script executed:

cd apps/commerce-api && cat -n build.gradle.kts

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

Length of output: 1238


🏁 Script executed:

find . -maxdepth 3 -name "build.gradle.kts" -o -name "gradle.properties" -o -name "settings.gradle.kts" | head -10

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

Length of output: 396


🏁 Script executed:

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

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

Length of output: 1335


🏁 Script executed:

cat -n gradle.properties 2>/dev/null | head -30

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

Length of output: 747


🏁 Script executed:

rg "spring.*boot.*version|springBootVersion" --type kotlin --type properties -i | head -20

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

Length of output: 119


🏁 Script executed:

cat -n build.gradle.kts | head -80

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

Length of output: 3363


🏁 Script executed:

rg "spring-security|dependencyManagement" -A 5 -B 5 build.gradle.kts

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

Length of output: 436


🌐 Web query:

Spring Boot 3.4.4 spring-security version managed by BOM

💡 Result:

Spring Boot 3.4.4 (via the org.springframework.boot:spring-boot-dependencies:3.4.4 BOM) manages Spring Security 6.4.4 (i.e., spring-security-bom / spring-security-core, etc.). [1]

If you need to override it, set the BOM property spring-security.version. [2]

Sources:
[1] Spring Boot v3.4.4 release notes (“Upgrade to Spring Security 6.4.4”) (github.com)
[2] Spring Security reference docs (“override … with <spring-security.version>”) (docs.spring.io)

Citations:


🌐 Web query:

Spring Boot 3.4.4 release notes spring-security-crypto version

💡 Result:

Spring Boot 3.4.4 manages Spring Security via org.springframework.security:spring-security-bom version 6.4.4 [1]. Since spring-security-crypto is part of that BOM, the managed org.springframework.security:spring-security-crypto version is 6.4.4 [1]. (Spring Security 6.4.4 was released on 2025-03-19) [2].

Sources: [1] Spring Boot 3.4.4 BOM managed dependencies (MvnRepository), [2] Spring Security 6.4.4 release announcement.


Spring Security 6.4.4의 CVE-2025-22234 취약점으로 인한 명시적 버전 관리 필요

현재 Spring Boot 3.4.4 BOM은 spring-security-crypto 6.4.4를 관리하고 있으며, 이 버전은 타이밍 공격 취약점 CVE-2025-22234(MEDIUM)에 노출되어 있다. BOM 의존만으로는 이 취약점이 해결되지 않으므로, 다음 중 하나를 수행해야 한다:

  • spring-security-crypto를 6.4.5 이상으로 명시적으로 고정하거나
  • Spring Boot를 3.4.5 이상으로 업그레이드하여 BOM에서 관리하는 spring-security-crypto 버전을 상향한다.

추가적으로 CI에서 의존성 리졸브 결과를 검증하고 보안 스캔을 통해 CVE 해결 여부를 확인해야 한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/build.gradle.kts` at line 11, The spring-security-crypto
dependency is managed by the Spring Boot 3.4.4 BOM which pins it to 6.4.4
(CVE-2025-22234); fix it by explicitly overriding the dependency version or
bumping the BOM: either change the dependency
implementation("org.springframework.security:spring-security-crypto") to include
a safe version (e.g.,
implementation("org.springframework.security:spring-security-crypto:6.4.5")) or
update the Spring Boot BOM/platform coordinate to 3.4.5+ so the BOM supplies a
fixed spring-security-crypto, and add a CI step to validate the resolved
dependency tree and run your security scanner to confirm the CVE is no longer
present.

Comment on lines +7 to +12
public record SignUpCommand(
String loginId,
String password,
String name,
LocalDate birthDate,
String email
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

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

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

Length of output: 166


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java

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

Length of output: 818


Record의 toString() 메서드에서 평문 비밀번호가 노출될 수 있다

Java record는 자동으로 생성되는 toString() 메서드가 모든 필드를 포함하므로, 예외 발생 시 스택 트레이스나 로깅 시스템에 이 객체가 출력되면 password가 평문으로 남는다. 이는 보안 인사고(Credential Exposure), 규제 위반, 감시 로그 분석 시 민감정보 노출로 이어질 수 있다.

수정안: Record 내부에 toString() 메서드를 오버라이드하여 password를 마스킹한다.

추가 테스트: toString() 메서드의 결과에 실제 비밀번호가 포함되지 않음을 검증하는 단위 테스트를 작성한다.

수정 예시
 public record SignUpCommand(
         String loginId,
         String password,
         String name,
         LocalDate birthDate,
         String email
 ) {
+    `@Override`
+    public String toString() {
+        return "SignUpCommand[loginId=%s,password=***,name=%s,birthDate=%s,email=%s]"
+                .formatted(loginId, name, birthDate, email);
+    }
     public static SignUpCommand from(UserV1Dto.CreateRequest request) {
         return new SignUpCommand(
                 request.loginId(),
                 request.password(),
                 request.name(),
                 request.birthDate(),
                 request.email()
         );
     }
 }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java`
around lines 7 - 12, SignUpCommand 레코드의 자동 생성된 toString()가 평문 password를 노출하므로
SignUpCommand에 toString()를 오버라이드해 password 값을 마스킹(예: "****" 또는 부분 마스킹)하도록 변경하고
나머지 필드만 출력하도록 구현하세요; 대상 식별자는 record SignUpCommand(...)와 필드 password 및 메서드
toString()입니다. 또한 SignUpCommandToStringMasking 같은 단위 테스트를 추가해 toString() 반환값에 실제
비밀번호가 포함되지 않는지(assertFalse(result.contains(actualPassword))) 검증하도록 작성하세요.

Comment on lines 5 to 8
public record UpdatePasswordCommand(
String loginId,
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

🧩 Analysis chain

🏁 Script executed:

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

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

Length of output: 174


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java

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

Length of output: 532


🏁 Script executed:

# 이 record가 실제로 로그 또는 예외 처리에서 사용되는지 확인
rg "UpdatePasswordCommand" --type java -B 2 -A 2 | head -100

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

Length of output: 5978


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java | grep -A 20 "updatePassword"

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

Length of output: 1223


record의 기본 toString()이 평문 비밀번호를 노출한다

현재 UpdatePasswordCommand record는 toString() 메서드를 오버라이드하지 않아 Java 기본 구현이 사용된다. 이 경우 예외 발생 시 스택트레이스나 로깅 시스템에서 객체를 문자열로 변환할 때 newPassword가 평문으로 노출되어 보안/컬플라이언스 사고로 이어질 수 있다.

운영 관점에서는 Spring 프레임워크의 예외 처리, 디버그 로깅, 또는 모니터링 시스템이 자동으로 toString()을 호출하므로 실제 위험이 존재한다.

수정안: toString() 메서드를 오버라이드해 비밀번호를 마스킹한다.

수정 예시
 public record UpdatePasswordCommand(
     String loginId,
     String newPassword
 ) {
+    `@Override`
+    public String toString() {
+        return "UpdatePasswordCommand[loginId=%s,newPassword=***]".formatted(loginId);
+    }
     public static UpdatePasswordCommand from(String loginId, UserV1Dto.UpdatePasswordRequest request) {
         return new UpdatePasswordCommand(loginId, request.newPassword());
     }
 }

추가로 toString() 결과에 비밀번호가 포함되지 않는지 검증하는 단위 테스트를 작성한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java`
around lines 5 - 8, UpdatePasswordCommand 레코드의 기본 toString()가 평문 newPassword를
노출하므로 toString()을 오버라이드해서 비밀번호를 마스킹하거나 제외하도록 변경하세요 (예: 클래스
UpdatePasswordCommand의 toString()에서 loginId만 노출하거나 newPassword를 "****"로 대체). 변경할
위치는 UpdatePasswordCommand 레코드 선언부이며 toString() 오버라이드 구현과 함께 toString() 결과에 실제
newPassword가 포함되지 않음을 검증하는 단위 테스트를 추가하세요 (테스트는 new
UpdatePasswordCommand(...).toString()가 원본 newPassword 문구를 포함하지 않음을 assert).

Comment on lines +15 to +23
public static void validate(String password, LocalDate birthDate) {
if (!password.matches(PASSWORD_PATTERN)) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문/숫자/특수문자만 가능합니다.");
}

String birthDateString = birthDate.format(BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK);
if (password.contains(birthDateString)) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다.");
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

passwordbirthDate가 null일 경우 NPE가 발생한다.

password.matches() 또는 birthDate.format() 호출 시 null 파라미터가 전달되면 NullPointerException이 발생하여 500 에러로 응답된다. 운영 환경에서 잘못된 요청에 대해 명확한 400 에러를 반환하려면 null 체크가 필요하다.

🛡️ 수정 제안
 public static void validate(String password, LocalDate birthDate) {
+    if (password == null || password.isBlank()) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수 입력 항목입니다.");
+    }
+    if (birthDate == null) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수 입력 항목입니다.");
+    }
+
     if (!password.matches(PASSWORD_PATTERN)) {
         throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문/숫자/특수문자만 가능합니다.");
     }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java`
around lines 15 - 23, PasswordPolicyValidator.validate currently calls
password.matches(PASSWORD_PATTERN) and
birthDate.format(BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK) without null checks which
can throw NPEs; add explicit null validation at the start of validate(String
password, LocalDate birthDate) to check both password and birthDate and if
either is null throw new CoreException(ErrorType.BAD_REQUEST, "<appropriate
message>") (e.g., "비밀번호는 필수입니다." / "생년월일은 필수입니다.") before using PASSWORD_PATTERN
or BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK so invalid requests return 400 instead
of a 500.

Comment on lines +16 to +21
public void signUp(SignUpCommand command) {
signUpValidator.validate(command);
String encodedPassword = passwordEncoder.encode(command.password());
User user = User.create(command, encodedPassword);

userRepository.save(user);
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 . -name "SignUpService.java" -type f

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

Length of output: 161


🏁 Script executed:

find . -name "User.java" -type f | grep -E "domain|model"

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

Length of output: 152


🏁 Script executed:

find . -name "*Repository.java" | grep -i user

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

Length of output: 337


🏁 Script executed:

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

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

Length of output: 960


🏁 Script executed:

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

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

Length of output: 3782


🏁 Script executed:

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

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

Length of output: 314


🏁 Script executed:

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

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

Length of output: 163


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java

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

Length of output: 447


🏁 Script executed:

find . -name "*.sql" -o -name "*Migration*.java" | head -20

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

Length of output: 67


🏁 Script executed:

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

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

Length of output: 1143


🏁 Script executed:

find . -path "*/resources/db/migration*" -o -path "*/schema*" | head -10

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

Length of output: 67


🏁 Script executed:

find . -name "CoreException.java" -o -name "ErrorType.java"

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

Length of output: 238


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java

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

Length of output: 949


🏁 Script executed:

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

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

Length of output: 741


🏁 Script executed:

grep -r "@UniqueConstraint\|@Column.*unique" --include="*.java" | head -20

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

Length of output: 67


🏁 Script executed:

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

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

Length of output: 741


🏁 Script executed:

find . -name "*ExceptionHandler*" -o -name "*ControllerAdvice*" | head -10

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

Length of output: 170


🏁 Script executed:

rg "DataIntegrityViolationException|ConstraintViolationException" --type java

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

Length of output: 67


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

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

Length of output: 7333


🏁 Script executed:

find . -path "*/resources/db/migration*" -type f | head -20

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

Length of output: 67


🏁 Script executed:

find . -type f \( -name "*.sql" -o -name "*schema*" -o -name "*ddl*" \) | head -20

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

Length of output: 67


🏁 Script executed:

rg "loginId|login_id" --type java -A 2 -B 2 | grep -E "@Column|@UniqueConstraint|@Index" | head -20

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

Length of output: 67


🏁 Script executed:

find . -type d -name "migration" -o -type d -name "flyway" -o -type d -name "liquibase"

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

Length of output: 67


🏁 Script executed:

find . -type f \( -name "*.java" -path "*test*" \) | xargs grep -l "signUp\|SignUp" | head -10

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

Length of output: 425


🏁 Script executed:

rg "concurrent|simultaneous|race|duplicate" --type java | grep -i test | head -10

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

Length of output: 544


🏁 Script executed:

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

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

Length of output: 2074


🏁 Script executed:

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

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

Length of output: 5507


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java | head -150

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

Length of output: 5919


동시 요청 시 중복 loginId 저장을 방지하기 위해 데이터베이스 제약과 예외 처리가 필수다

현재 코드는 SignUpValidator에서 중복을 검증하지만, 검증과 저장 사이의 Race Condition으로 인해 동시 요청 시 동일 loginId가 중복 저장될 수 있다. 또한 데이터베이스 제약이 없으면 검증을 우회하는 다른 저장 경로에서도 중복이 발생할 수 있으며, 제약 위반 시 DataIntegrityViolationException이 발생하여 500 에러로 노출된다.

수정안:

  • User 엔티티의 loginId 필드에 @Column(unique = true) 추가하거나 데이터베이스 마이그레이션에서 UNIQUE 제약 설정
  • ApiControllerAdvice에 DataIntegrityViolationException 핸들러 추가하여 CoreException(CONFLICT)로 변환
  • 동일 loginId 동시 요청 시나리오를 테스트하는 통합 테스트 추가 (ExecutorService 또는 CountDownLatch 활용)
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java`
around lines 16 - 21, Add a UNIQUE constraint on the User.loginId (either add
`@Column`(unique = true) on the User entity or create a DB migration) to ensure
DB-level uniqueness, then catch DB constraint violations by adding a
DataIntegrityViolationException handler in ApiControllerAdvice that converts the
exception into a CoreException with CONFLICT; also update SignUpService.signUp
to remain as-is (keep signUpValidator.validate and userRepository.save) but rely
on the DB constraint for race conditions and add an integration test that
simulates concurrent signUp calls (use ExecutorService or CountDownLatch) to
assert only one user is created and others receive a conflict response.

Comment on lines +12 to +29
public record CreateRequest(
@NotBlank(message = "로그인 ID는 필수값입니다.")
String loginId,
@NotBlank(message = "비밀번호는 필수값입니다.")
String password,
@NotBlank(message = "이름은 필수값입니다.")
String name,
@NotNull(message = "생년월일은 필수값입니다.")
@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate birthDate,
@NotBlank(message = "이메일은 필수값입니다.")
String email
) {}

public record UpdatePasswordRequest(
@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

DTO Bean Validation 어노테이션 제거가 필요하다
DTO 단계 검증은 MethodArgumentNotValidException을 유발해 표준 에러 포맷이 깨지고, API 외 진입점(배치/메시지)에서는 검증이 누락되어 운영 품질이 흔들린다다.
수정안으로 @NotBlank/@NotNull을 제거하고, SignUpValidator/User/PasswordPolicyValidator에서 null·blank 검증을 수행하도록 일원화해 달라다.
추가 테스트로 UserTest 또는 SignUpValidatorTest에 null/blank 입력 시 CoreException(BAD_REQUEST)이 발생하는 케이스를 보강해 달라다.

🔧 수정 예시
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
@@
-            `@NotBlank`(message = "로그인 ID는 필수값입니다.")
             String loginId,
-            `@NotBlank`(message = "비밀번호는 필수값입니다.")
             String password,
-            `@NotBlank`(message = "이름은 필수값입니다.")
             String name,
-            `@NotNull`(message = "생년월일은 필수값입니다.")
             `@JsonFormat`(pattern = "yyyy-MM-dd")
             LocalDate birthDate,
-            `@NotBlank`(message = "이메일은 필수값입니다.")
             String email
     ) {}
@@
-            `@NotBlank`(message = "새 비밀번호는 필수값입니다.")
             String newPassword
     ) {}

Based on learnings: Do not use Bean Validation annotations on DTOs in this project and move validation into the domain layer to keep unified error handling.

📝 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
public record CreateRequest(
@NotBlank(message = "로그인 ID는 필수값입니다.")
String loginId,
@NotBlank(message = "비밀번호는 필수값입니다.")
String password,
@NotBlank(message = "이름은 필수값입니다.")
String name,
@NotNull(message = "생년월일은 필수값입니다.")
@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate birthDate,
@NotBlank(message = "이메일은 필수값입니다.")
String email
) {}
public record UpdatePasswordRequest(
@NotBlank(message = "새 비밀번호는 필수값입니다.")
String newPassword
) {}
public record CreateRequest(
String loginId,
String password,
String name,
`@JsonFormat`(pattern = "yyyy-MM-dd")
LocalDate birthDate,
String email
) {}
public record UpdatePasswordRequest(
String newPassword
) {}
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserV1Dto.java`
around lines 12 - 29, Remove the Bean Validation annotations from the DTO
records CreateRequest and UpdatePasswordRequest (drop `@NotBlank`, `@NotNull`,
`@JsonFormat` on birthDate) and instead implement null/blank/date-format checks
inside the domain validators (SignUpValidator, PasswordPolicyValidator, and
User-related validators) so validation logic is centralized; update
SignUpValidator/User validators to validate loginId, password, name, birthDate
and email (including date parse/format) and have them throw CoreException with
BAD_REQUEST on invalid input; finally add/extend unit tests (UserTest or
SignUpValidatorTest) to assert that null/blank/invalid inputs produce
CoreException(BAD_REQUEST) for the affected fields.

Comment on lines +23 to +27
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<Void> signUp(@Valid @RequestBody UserV1Dto.CreateRequest request) {
SignUpCommand command = SignUpCommand.from(request);
signUpService.signUp(command);
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

컨트롤러에서 @Valid 사용 제거 필요다
@Valid로 인한 MethodArgumentNotValidException은 CoreException → ApiControllerAdvice 경로를 우회해 에러 포맷이 엔드포인트마다 달라지고, 운영 중 모니터링/알림 규칙이 일관되지 않게 된다다.
수정안으로 컨트롤러의 @Valid를 제거하고, SignUpValidator/User 생성 규칙에서 null·blank 검증을 수행하도록 정리하는 것이 좋다다.
추가 테스트로 SignUpV1ApiE2ETest.returnsBadRequest_whenLoginIdIsMissing에서 표준 에러 포맷(code/message 등)이 ApiControllerAdvice 경로로 내려오는지 검증을 추가해 달라다.

🔧 수정 예시
-import jakarta.validation.Valid;
@@
-    public ApiResponse<Void> signUp(`@Valid` `@RequestBody` UserV1Dto.CreateRequest request) {
+    public ApiResponse<Void> signUp(`@RequestBody` UserV1Dto.CreateRequest request) {

Based on learnings: Do not use Bean Validation annotations (e.g., Valid, NotBlank, Email) on DTOs in this project. Move validation logic into the domain layer so validation is enforced regardless of entrypoint.

📝 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
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<Void> signUp(@Valid @RequestBody UserV1Dto.CreateRequest request) {
SignUpCommand command = SignUpCommand.from(request);
signUpService.signUp(command);
`@PostMapping`
`@ResponseStatus`(HttpStatus.CREATED)
public ApiResponse<Void> signUp(`@RequestBody` UserV1Dto.CreateRequest request) {
SignUpCommand command = SignUpCommand.from(request);
signUpService.signUp(command);
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java`
around lines 23 - 27, Remove the controller-level Bean Validation by deleting
the `@Valid` on SignUpV1Controller.signUp so requests no longer short-circuit to
MethodArgumentNotValidException; instead, implement/ensure null/blank checks for
loginId and other fields inside the domain validation path (e.g.,
SignUpValidator and the User creation logic used by SignUpCommand.from) and have
signUpService.signUp throw the existing CoreException when validation fails;
finally update/add the SignUpV1ApiE2ETest.returnsBadRequest_whenLoginIdIsMissing
to assert the error flows through ApiControllerAdvice and returns the standard
error format (code/message).

Comment on lines 30 to 35
@PatchMapping("/me/password")
public ApiResponse<Void> updatePassword(
@RequestHeader("X-Loopers-LoginId") String loginId,
@Valid @RequestBody UserV1Dto.UpdatePasswordRequest request
) {
userService.updatePassword(UpdatePasswordCommand.from(loginId, 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

PATCH 요청의 @Valid 제거가 필요하다
Bean Validation이 동작하면 MethodArgumentNotValidException이 발생해 CoreException 기반의 표준 에러 포맷이 깨지고, 운영 측면에서 클라이언트/로깅/모니터링 처리 일관성이 떨어진다다.
수정안으로 @Valid를 제거하고, PasswordPolicyValidator 또는 UserService에서 입력 검증을 수행하도록 일원화해 달라다.
추가 테스트로 UserV1ApiE2ETest에서 newPassword 누락/blank 요청 시 표준 에러 포맷이 반환되는지 검증을 추가해 달라다.

🔧 수정 예시
-import jakarta.validation.Valid;
@@
-            `@Valid` `@RequestBody` UserV1Dto.UpdatePasswordRequest request
+            `@RequestBody` UserV1Dto.UpdatePasswordRequest request

Based on learnings: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice and do not introduce Bean Validation handling.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java`
around lines 30 - 35, Remove the `@Valid` annotation from the updatePassword
controller method so Bean Validation won't throw MethodArgumentNotValidException
and break the CoreException-based error format; instead, move/ensure validation
of UserV1Dto.UpdatePasswordRequest (fields like newPassword) into the
PasswordPolicyValidator or inside
UserService.updatePassword/UpdatePasswordCommand handling so all validation
errors are normalized into CoreException and flow through ApiControllerAdvice;
then add E2E coverage in UserV1ApiE2ETest to assert that requests with
missing/blank newPassword return the standard CoreException error response.

Comment on lines +60 to +142
@Test
@DisplayName("회원가입시 password가 8자 미만이면 예외가 발생한다.")
void validate_throwsException_whenPasswordIsTooShort() {
// arrange
SignUpCommand command = new SignUpCommand(
"testUser",
"Short1!",
"박자바",
LocalDate.of(1990, 1, 15),
"test@example.com"
);

// act
CoreException result = assertThrows(CoreException.class, () -> {
signUpValidator.validate(command);
});

// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}

@Test
@DisplayName("회원가입시 password가 16자 초과면 예외가 발생한다.")
void validate_throwsException_whenPasswordIsTooLong() {
// arrange
SignUpCommand command = new SignUpCommand(
"testUser",
"VeryLongPass123!!",
"박자바",
LocalDate.of(1990, 1, 15),
"test@example.com"
);

// act
CoreException result = assertThrows(CoreException.class, () -> {
signUpValidator.validate(command);
});

// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}

@Test
@DisplayName("회원가입시 password에 공백이 포함되면 예외가 발생한다.")
void validate_throwsException_whenPasswordContainsWhitespace() {
// arrange
SignUpCommand command = new SignUpCommand(
"testUser",
"Pass 1234!",
"박자바",
LocalDate.of(1990, 1, 15),
"test@example.com"
);

// act
CoreException result = assertThrows(CoreException.class, () -> {
signUpValidator.validate(command);
});

// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}

@Test
@DisplayName("회원가입시 password에 생년월일이 포함되면 예외가 발생한다.")
void validate_throwsException_whenPasswordContainsBirthDate() {
// arrange
SignUpCommand command = new SignUpCommand(
"testUser",
"Pass19900115!",
"박자바",
LocalDate.of(1990, 1, 15),
"test@example.com"
);

// act
CoreException result = assertThrows(CoreException.class, () -> {
signUpValidator.validate(command);
});

// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

경계값/정상 케이스 부재로 정책 회귀 탐지가 약하다
운영 관점에서 비밀번호 정책이 변경될 때 성공 경계값 검증이 없어 정상 가입이 실패하는 회귀를 놓칠 수 있다.
수정안: 길이 8/16의 정상 비밀번호와 허용 특수문자 조합 성공 케이스를 추가한다.
추가 테스트: 위 정상/경계 케이스가 통과하는 단위 테스트를 추가한다.
As per coding guidelines: **/*Test*.java: Java 테스트 리뷰 기준이다. 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다. Mock 남용으로 의미가 약해지면 테스트 방향을 재정렬하도록 제안한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java`
around lines 60 - 142, Add positive/boundary unit tests in SignUpValidatorTest
to cover successful signup scenarios (password length exactly 8 and exactly 16
and a password containing allowed special characters) so changes to the password
policy don’t cause silent regressions; create SignUpCommand instances with valid
passwords (e.g., 8-char and 16-char examples and one with permitted special
chars) and assert that signUpValidator.validate(command) does not throw (or
returns the expected success behavior) instead of using assertThrows; reference
the existing test class/methods (SignUpValidatorTest, SignUpCommand,
signUpValidator.validate) to add these passing cases alongside the existing
failing ones.


import com.redis.testcontainers.RedisContainer;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
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:

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

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

Length of output: 1242


🏁 Script executed:

# Check for `@Profile` usage in testcontainers directories
rg -n '@Profile' --type=java modules/ 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

# Search for other TestContainers config classes
fd -e java -path '*testcontainers*' -path '*Config*' modules/

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

Length of output: 253


🏁 Script executed:

# Find all testcontainers config files correctly
find modules/ -type f -name "*TestContainersConfig.java" -o -name "*Testcontainers*.java" | grep -i config

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

Length of output: 270


🏁 Script executed:

# Check if any other configs use `@Profile` or `@Configuration`
rg -n '@Configuration|@Profile' --type java modules/ -A 2 | grep -B 2 -A 2 'testcontainer\|TestContainer' -i

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

Length of output: 1212


🏁 Script executed:

# Check test directory structure for how test containers are typically used
find modules/ -type d -name testcontainers -o -type d -name testFixtures

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

Length of output: 268


🏁 Script executed:

cat -n modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java

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

Length of output: 1701


Profile import을 제거하거나 @Profile 어노테이션을 추가하여 테스트 컨테이너 활성화를 제어해야 한다.

Line 5에서 Profile을 import했으나 클래스에는 @Profile 어노테이션이 없다. 현재 구현은 이 설정이 모든 테스트 컨텍스트에서 무조건 로드되고, static 블록에서 Redis 컨테이너를 시작한다.

운영 관점의 문제점:

  • Redis가 필요하지 않은 테스트에서도 컨테이너가 시작되어 테스트 실행 시간 증가
  • CI/CD 파이프라인에서 불필요한 리소스 소비
  • 병렬 테스트 실행 시 포트 충돌 가능성

권장 수정안:

  • Profile 기반 활성화가 필요하면 @Profile("test") 어노테이션을 추가한다. 이렇게 하면 특정 프로필이 활성화될 때만 이 설정이 로드된다.
  • Profile 제어가 불필요하면 Line 5의 import 문을 제거한다.

참고: MySqlTestContainersConfig도 동일한 패턴을 가지고 있으므로 함께 검토하는 것을 권장한다.

🤖 Prompt for AI Agents
In
`@modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java`
at line 5, The import of org.springframework.context.annotation.Profile is
present but RedisTestContainersConfig lacks a `@Profile` annotation, causing the
static Redis container to start for all test contexts; either remove the unused
Profile import if you want the config to always load, or add `@Profile`("test")
(or another appropriate profile name) on the RedisTestContainersConfig class so
the Testcontainers-based Redis only starts when that profile is active; also
check MySqlTestContainersConfig for the same pattern and apply the same change
if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants