Skip to content

Conversation

@letsgojh0810
Copy link

@letsgojh0810 letsgojh0810 commented Feb 5, 2026

📌 Summary

  • 회원가입, 내 정보 조회, 비밀번호 변경 API 구현
  • TDD 테스트 코드 추가 (단위/통합/E2E)

🧭 Context & Decision

구현 내용

  • 헥사고날 아키텍처 적용 (interfaces → application → domain → infrastructure)
  • BCrypt 비밀번호 암호화
  • 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw)

📋 API Endpoints

Method Endpoint Description
POST /api/v1/users 회원가입
GET /api/v1/users/me 내 정보 조회 (헤더 인증, 이름 마스킹)
PUT /api/v1/users/password 비밀번호 변경 (헤더 인증)

🔒 검증 규칙

필드 규칙
loginId 영문/숫자만, 중복 불가
password 8-16자, 영문/숫자/특수문자, 생년월일 미포함
name 조회 시 마지막 글자 * 마스킹

🧪 테스트

테스트 파일 케이스
단위 UserTest.java 12
통합 UserServiceIntegrationTest.java 9
E2E UserV1ApiE2ETest.java 6

✅ Checklist

  • 회원가입 API
  • 내 정보 조회 API (이름 마스킹)
  • 비밀번호 변경 API
  • BCrypt 암호화
  • 비밀번호 검증 (8-16자, 생년월일 미포함)
  • 로그인 ID 검증 (영문/숫자만)
  • TDD 테스트 코드

변경 목적

회원가입, 내 정보 조회, 비밀번호 변경 API를 육각형 아키텍처로 구현하고, BCrypt를 활용한 보안 강화 및 비밀번호 검증 규칙을 적용합니다.

핵심 변경점

  • 3개 API 구현: POST /api/v1/users (회원가입), GET /api/v1/users/me (내 정보), PUT /api/v1/users/password (비밀번호 변경)
  • 헤더 기반 인증: X-Loopers-LoginId, X-Loopers-LoginPw를 통한 인증 (회원가입 제외)
  • 이름 마스킹: 내 정보 조회 시에만 마지막 문자를 * 처리하나, 회원가입 응답에서는 원본 name 반환
  • 비밀번호 검증: 8~16자, 영문/숫자/특수문자 포함 필수, 생년월일 미포함 검증
  • PasswordEncoder 구현: UserService 내에서 new BCryptPasswordEncoder() 직접 생성

리스크/주의사항

  • 보안: 비밀번호를 HTTP 헤더에 평문으로 전달하는 방식이 사용되고 있는데, HTTPS 강제 설정이 구현되어 있는지 확인 필요
  • PasswordEncoder 빈 관리: UserService에서 매번 새로운 BCryptPasswordEncoder 인스턴스를 생성하므로, @bean으로 싱글톤 빈 등록이 필요하지 않은지 재검토 필요
  • API 응답 일관성: RegisterResponse에서는 원본 name, UserResponse에서는 maskedName 사용으로, 클라이언트 입장에서 예기치 않은 동작일 수 있음

테스트/검증 방법

  • 690줄 TDD 테스트: 단위 테스트(234줄, 12개 케이스), 통합 테스트(208줄, 9개 케이스), E2E 테스트(248줄, 6개 케이스)로 회원가입/조회/비밀번호 변경, 검증 규칙, 에러 시나리오를 포괄적으로 검증 중

letsgojh0810 and others added 2 commits February 4, 2026 20:55
- POST /api/v1/users: 회원가입
- GET /api/v1/users/me: 내 정보 조회 (헤더 인증, 이름 마스킹)
- PUT /api/v1/users/password: 비밀번호 변경

검증 규칙:
- 로그인 ID: 영문/숫자만 허용
- 비밀번호: 8-16자, 영문/숫자/특수문자, 생년월일 미포함
- 비밀번호 BCrypt 암호화 저장

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- UserTest: 단위 테스트 (엔티티 생성, 비밀번호 검증, 이름 마스킹)
- UserServiceIntegrationTest: 통합 테스트 (회원가입, 조회, 인증, 비밀번호 변경)
- UserV1ApiE2ETest: E2E 테스트 (API 엔드포인트)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (4)
  • docs/design/01-requirements.md is excluded by !**/*.md and included by **
  • docs/design/02-sequence-diagrams.md is excluded by !**/*.md and included by **
  • docs/design/03-class-diagram.md is excluded by !**/*.md and included by **
  • docs/design/04-erd.md is excluded by !**/*.md and included by **

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

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
📝 Walkthrough

Walkthrough

사용자 등록, 인증, 비밀번호 변경 기능을 구현한 사용자 관리 시스템을 추가했다. Spring Security 암호화 라이브러리를 도입하고 도메인 레이어부터 인터페이스 레이어까지 계층별 아키텍처로 구성했다. 생년월일 패턴 검증을 포함한 비밀번호 정책이 적용되었다.

Changes

Cohort / File(s) Summary
의존성 추가
apps/commerce-api/build.gradle.kts
Spring Security 암호화 라이브러리 의존성 추가 (BCrypt 패스워드 인코딩용).
도메인 레이어
apps/commerce-api/src/main/java/com/loopers/domain/user/User.java, UserRepository.java, UserService.java
User JPA 엔티티, 생년월일 기반 비밀번호 검증, 로그인ID 중복 체크, 인증 및 비밀번호 변경 메서드 구현. UserRepository 인터페이스 정의.
인프라 레이어
apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java, UserRepositoryImpl.java
Spring Data JPA 저장소 및 도메인 저장소 구현체 추가.
애플리케이션 레이어
apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java, UserInfo.java
비즈니스 로직 파사드 및 DTO 정의. 도메인 User를 API 응답용 UserInfo로 변환.
API 인터페이스 레이어
apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java, UserV1Controller.java, UserV1Dto.java
REST 컨트롤러, 헤더 기반 인증, 요청/응답 DTO 및 API 사양 정의.
테스트
apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java, UserServiceIntegrationTest.java, UserV1ApiE2ETest.java
도메인 단위 테스트, 서비스 통합 테스트, 엔드-투-엔드 API 테스트 추가.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant UserV1Controller
    participant UserFacade
    participant UserService
    participant PasswordEncoder
    participant UserRepository
    participant Database

    Client->>UserV1Controller: POST /api/v1/users<br/>(RegisterRequest)
    UserV1Controller->>UserFacade: register(loginId, rawPassword, name, birthDate, email)
    UserFacade->>UserService: register(loginId, rawPassword, name, birthDate, email)
    UserService->>UserService: validateRawPassword(rawPassword, birthDate)
    UserService->>UserRepository: existsByLoginId(loginId)
    UserRepository->>Database: SELECT COUNT(*) FROM users WHERE login_id=?
    Database-->>UserRepository: 0 (not exists)
    UserRepository-->>UserService: false
    UserService->>PasswordEncoder: encode(rawPassword)
    PasswordEncoder-->>UserService: encodedPassword
    UserService->>UserRepository: save(User)
    UserRepository->>Database: INSERT INTO users
    Database-->>UserRepository: saved User
    UserRepository-->>UserService: User
    UserService-->>UserFacade: User
    UserFacade->>UserFacade: UserInfo.from(User)
    UserFacade-->>UserV1Controller: UserInfo
    UserV1Controller->>UserV1Controller: RegisterResponse.from(UserInfo)
    UserV1Controller-->>Client: 201 Created<br/>(RegisterResponse)
Loading
sequenceDiagram
    participant Client
    participant UserV1Controller
    participant UserFacade
    participant UserService
    participant PasswordEncoder
    participant UserRepository
    participant Database

    Client->>UserV1Controller: GET /api/v1/users/me<br/>Headers: X-Loopers-LoginId, X-Loopers-LoginPw
    UserV1Controller->>UserFacade: getMe(loginId, rawPassword)
    UserFacade->>UserService: authenticate(loginId, rawPassword)
    UserService->>UserRepository: findByLoginId(loginId)
    UserRepository->>Database: SELECT * FROM users WHERE login_id=?
    Database-->>UserRepository: User
    UserRepository-->>UserService: User
    UserService->>PasswordEncoder: matches(rawPassword, encodedPassword)
    PasswordEncoder-->>UserService: true
    UserService-->>UserFacade: User
    UserFacade->>UserFacade: UserInfo.from(User)
    UserFacade-->>UserV1Controller: UserInfo
    UserV1Controller->>UserV1Controller: UserResponse.from(UserInfo)<br/>(maskedName 적용)
    UserV1Controller-->>Client: 200 OK<br/>(UserResponse)
Loading
sequenceDiagram
    participant Client
    participant UserV1Controller
    participant UserFacade
    participant UserService
    participant PasswordEncoder
    participant UserRepository
    participant Database

    Client->>UserV1Controller: PUT /api/v1/users/password<br/>Headers: X-Loopers-LoginId, X-Loopers-LoginPw<br/>(ChangePasswordRequest)
    UserV1Controller->>UserFacade: changePassword(loginId, currentPassword, newPassword)
    UserFacade->>UserService: changePassword(userId, currentPassword, newPassword)
    UserService->>UserRepository: findById(userId)
    UserRepository->>Database: SELECT * FROM users WHERE id=?
    Database-->>UserRepository: User
    UserRepository-->>UserService: User
    UserService->>PasswordEncoder: matches(currentPassword, storedPassword)
    PasswordEncoder-->>UserService: true
    UserService->>UserService: validateRawPassword(newPassword, birthDate)
    UserService->>UserService: 신규 비밀번호 != 현재 비밀번호 검증
    UserService->>PasswordEncoder: encode(newPassword)
    PasswordEncoder-->>UserService: encodedNewPassword
    UserService->>UserService: user.changePassword(encodedNewPassword)
    UserService->>UserRepository: save(User)
    UserRepository->>Database: UPDATE users SET password=? WHERE id=?
    Database-->>UserRepository: updated User
    UserRepository-->>UserService: void
    UserService-->>UserFacade: void
    UserFacade-->>UserV1Controller: void
    UserV1Controller-->>Client: 200 OK
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes


검토 주요사항

보안 관점

1. 생년월일 누수 방지 로직의 유효성 검증 필요

  • User.containsBirthDate() 메서드가 yyyyMMdd, yyMMdd 패턴만 검사하는데, 다른 날짜 포맷(yyyy-MM-dd, yyyy/MM/dd 등)은 탐지하지 못한다.
  • 수정안: 정규식 기반 포괄적 패턴 매칭으로 강화하거나, 비밀번호 엔트로피 검사 로직을 추가로 도입한다.
  • 추가 테스트: yyyy-MM-dd, yyyy/MM/dd 등 다양한 형식의 생년월일이 포함된 비밀번호 테스트 케이스 추가.

2. 헤더 기반 인증의 보안 위험

  • UserV1Controller에서 X-Loopers-LoginIdX-Loopers-LoginPw 헤더로 인증하는 방식은 HTTPS 필수이며, 토큰 기반 인증(JWT) 도입 검토가 필요하다.
  • 수정안: 로그인 후 토큰 발급 엔드포인트 추가 및 이후 요청에서 Bearer 토큰 사용으로 전환.
  • 추가 테스트: HTTP 요청 시 자격증명 노출 시나리오 및 토큰 만료 테스트.

3. 비밀번호 변경 시 현재 비밀번호 재입력 재검증

  • UserService.changePassword()에서 현재 비밀번호를 인증하고 있으나, 새 비밀번호와의 동일성 검사만 수행한다.
  • 수정안: 새 비밀번호가 이전 3개 이상의 비밀번호와 다른지 확인하는 비밀번호 이력 관리 기능 검토.
  • 추가 테스트: 동일한 비밀번호로 여러 번 변경 시도 시 실패 케이스.

운영 관점

4. 유니크 제약 위반 시 예외 처리 명확화

  • UserService.register()에서 existsByLoginId() 체크 후 저장하는데, 동시 요청 시 race condition이 발생할 수 있다.
  • 수정안: 데이터베이스 UNIQUE 제약 위반 시 발생하는 DataIntegrityViolationException을 명시적으로 catch하여 ErrorType.CONFLICT로 변환하는 로직 추가.
  • 추가 테스트: 동시 다중 요청으로 동일 loginId 등록 시도 테스트.

5. 비밀번호 인코딩 강도 설정

  • build.gradle.kts에서 Spring Security Crypto를 의존성으로 추가했으나, BCrypt strength 설정(기본값 10 vs 권장 12+)이 명시되지 않았다.
  • 수정안: PasswordEncoder Bean 정의 시 BCryptPasswordEncoder(12) 이상으로 명시적 강도 설정.
  • 추가 테스트: 약한 강도 설정 시 성능 영향 측정 및 문서화.

6. 트랜잭션 격리 수준

  • UserService의 쓰기 메서드가 @Transactional만 있고, 격리 수준(isolation)이 기본값으로 설정되어 있다.
  • 수정안: @Transactional(isolation = Isolation.SERIALIZABLE) 또는 READ_COMMITTED + 명시적 Lock 검토 후 적용.
  • 추가 테스트: 높은 동시 부하 환경에서 데이터 일관성 테스트.

성능/확장성 관점

7. 로그인ID 조회 성능

  • UserService.getUserByLoginId() 및 인증 메서드가 매번 findByLoginId() 호출 시 데이터베이스 접근이 발생한다.
  • 수정안: 자주 조회되는 사용자 정보에 대해 Redis 캐싱 도입 검토 및 캐시 무효화 정책 정의.
  • 추가 테스트: 캐시 히트율 모니터링 및 캐시 일관성 테스트.

8. 테스트 데이터베이스 정리

  • 모든 통합/E2E 테스트가 databaseCleanUp.truncateAllTables()를 사용하는데, 테스트 병렬화 시 동시성 문제가 발생할 가능성이 있다.
  • 수정안: @Transactional 어노테이션으로 테스트 격리하거나, 테스트마다 고유한 데이터 생성 패턴 도입.
  • 추가 테스트: 테스트 병렬 실행 시나리오 검증.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 구현된 주요 기능(회원가입, 내 정보 조회, 비밀번호 변경 API)을 명확하고 간결하게 요약한다.
Description check ✅ Passed PR 설명이 Summary, Context & Decision, API Endpoints, 검증 규칙, 테스트, Checklist를 포함하여 구조화되어 있다.

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

✨ 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: 4

🤖 Fix all issues with AI agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/User.java`:
- Around line 50-62: validateRawPassword currently only checks allowed
characters via PASSWORD_PATTERN but does not enforce that a password contains at
least one letter, one digit, and one special character; update
validateRawPassword (in class User) to, after the PASSWORD_PATTERN check,
validate three additional regexes (letter: .* [A-Za-z].*, digit: .*\\d.*,
special: .*[!@#$%^&*()_+\\-=\\[\\]{};':"\\\\|,.<>/?].*) and throw new
CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문/숫자/특수문자를 각각 1자 이상 포함해야 합니다.") if
any of them fail; also add unit tests in UserTest.java covering passwords with
only letters, only digits, only special chars, and letters+digits (no special)
to assert that validateRawPassword throws the expected exception.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java`:
- Around line 21-30: The register method in UserService (UserService.register)
can hit a race where existsByLoginId passes but userRepository.save throws
DataIntegrityViolationException due to the unique loginId constraint; wrap the
save logic in a try/catch that catches DataIntegrityViolationException and
rethrow a CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.") preserving the
original exception as the cause (either via ce.initCause(ex) or by adding a new
CoreException constructor that accepts a cause), and add an integration test
that concurrently attempts to create the same loginId to assert a CONFLICT
response is always returned.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java`:
- Around line 31-33: register 메서드의 파라미터에서 `@Valid를` 제거하고 UserV1Dto에 선언된 Bean
Validation 애노테이션(`@NotBlank`, `@Email`, `@Pattern` 등)을 제거하여 요청 바디 검증이 컨트롤러에서 발생하지 않도록
하세요; 대신 User 도메인 클래스의 기존 검증 메서드(validateRawPassword, validateLoginId 등)가 호출되어
유효성 검사 실패 시 CoreException을 던지도록 보장하고 ApiControllerAdvice가 해당 CoreException을 처리해
400(BAD_REQUEST) 응답과 도메인 검증의 에러 코드/메시지를 반환하도록 수정 및 E2E 테스트(빈 loginId, 8자 미만
password, 잘못된 email 등)를 추가해 흐름을 검증하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java`:
- Around line 13-38: Remove all Bean Validation annotations and related imports
from UserV1Dto (specifically from the RegisterRequest and ChangePasswordRequest
records) and remove any use of `@Valid` on controller parameters in
UserV1Controller; rely on domain-layer validation (e.g., User constructor,
PasswordPolicy) to validate inputs and throw CoreException on violations. Update
controller method signatures to accept plain DTOs (no `@Valid`) and ensure
exceptions from domain validation propagate to the existing ApiControllerAdvice;
add E2E tests that submit invalid loginId, invalid email, and empty passwords to
verify responses follow the CoreException-based unified format.
🧹 Nitpick comments (8)
apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java (1)

15-24: from() 메서드에 null 방어 코드가 없다.

호출부에서 null User가 전달될 경우 NPE가 발생한다. 상위 레이어에서 null이 절대 전달되지 않는다면 문제없으나, 방어적 프로그래밍 관점에서 Objects.requireNonNull 또는 선제 조건 검사를 고려할 수 있다.

♻️ 선택적 방어 코드 예시
 public static UserInfo from(User user) {
+    Objects.requireNonNull(user, "User must not be null");
     return new UserInfo(
         user.getId(),
apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java (3)

3-3: 도메인 레이어 테스트에서 인프라 레이어를 직접 import하고 있다.

UserJpaRepository는 인프라 레이어 컴포넌트인데, 도메인 서비스 통합 테스트에서 직접 참조하고 있다. 현재 코드에서는 사용하지 않는 것으로 보이므로 제거를 권장한다. 만약 테스트 데이터 확인 용도라면 도메인 레이어의 UserRepository를 통해 접근하는 것이 아키텍처 일관성에 부합한다.

#!/bin/bash
# Description: UserJpaRepository가 실제로 사용되는지 확인

rg -n 'userJpaRepository' apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java

126-157: 존재하지 않는 로그인 ID로 인증 시도하는 테스트 케이스가 누락되었다.

authenticate 메서드는 getUserByLoginId를 호출하므로, 존재하지 않는 loginId로 인증 시 NOT_FOUND 예외가 발생해야 한다. 이 경계 케이스에 대한 테스트가 없으면 운영 환경에서 예상치 못한 에러 응답이 발생할 수 있다.

📝 추가 테스트 케이스 예시
`@DisplayName`("존재하지 않는 로그인 ID로 인증하면, NOT_FOUND 예외가 발생한다.")
`@Test`
void throwsNotFound_whenLoginIdDoesNotExist() {
    // act
    CoreException result = assertThrows(CoreException.class, () ->
        userService.authenticate("nonexistent", VALID_PASSWORD)
    );

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

159-207: 비밀번호 변경 시 새 비밀번호 유효성 검증 실패 케이스 테스트가 부족하다.

changePassword 메서드는 User.validateRawPassword(newRawPassword, birthDate)를 호출하여 새 비밀번호의 유효성을 검증한다. 다음 경계 케이스에 대한 테스트가 추가되면 회귀 방지에 유리하다:

  • 새 비밀번호가 8자 미만인 경우
  • 새 비밀번호에 생년월일이 포함된 경우

UserTest.java에 단위 테스트가 있으나, 통합 테스트에서도 서비스 레이어를 통한 검증 흐름을 확인하는 것이 권장된다.

apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java (3)

63-67: createTestUser()가 서비스 레이어를 우회하여 사용자를 생성한다.

E2E 테스트에서 userJpaRepository.save()를 직접 호출하면 UserService.register()의 유효성 검증(loginId 중복 체크, 비밀번호 정책 등)을 건너뛴다. 테스트 데이터 생성 시에도 실제 서비스 플로우와 동일한 경로를 사용하면 테스트 신뢰도가 높아진다.

단, 의도적으로 특정 상태의 데이터를 설정해야 하는 경우(예: 이미 존재하는 사용자 시뮬레이션)는 직접 저장이 필요할 수 있으므로, 그 의도를 주석으로 명시하는 것을 권장한다.


39-39: 테스트 클래스 내에서 PasswordEncoder를 별도로 인스턴스화하고 있다.

애플리케이션 컨텍스트에서 관리하는 PasswordEncoder Bean과 테스트에서 생성한 인스턴스가 동일한 설정(cost factor 등)을 사용하는지 보장되지 않는다. 현재는 둘 다 기본값을 사용하므로 문제없으나, 향후 설정이 변경될 경우 테스트가 실패할 수 있다.

♻️ 개선 방안
-    private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+    `@Autowired`
+    private PasswordEncoder passwordEncoder;

또는 UserService를 주입받아 createTestUser() 대신 서비스 레이어를 통해 사용자를 생성하는 방법도 고려할 수 있다.


153-198: 인증 헤더 누락 시나리오에 대한 테스트가 없다.

GET /api/v1/users/mePUT /api/v1/users/password 엔드포인트는 X-Loopers-LoginId, X-Loopers-LoginPw 헤더가 필수이다. 헤더가 누락되었을 때의 응답(예: 400 BAD_REQUEST 또는 401 UNAUTHORIZED)을 검증하는 테스트가 없으면 운영 환경에서 예상치 못한 에러 응답이 발생할 수 있다.

📝 추가 테스트 케이스 예시
`@DisplayName`("인증 헤더가 누락되면, 4xx 에러를 반환한다.")
`@Test`
void returnsError_whenAuthHeadersMissing() {
    // arrange
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);
    
    // act
    ResponseEntity<ApiResponse<UserV1Dto.UserResponse>> response = testRestTemplate.exchange(
        ENDPOINT_ME,
        HttpMethod.GET,
        new HttpEntity<>(headers),
        new ParameterizedTypeReference<>() {}
    );

    // assert
    assertThat(response.getStatusCode().is4xxClientError()).isTrue();
}
apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java (1)

10-12: @Component 대신 @Repository를 사용하는 것이 권장된다.

@Repository는 데이터 접근 계층임을 명시적으로 나타내며, Spring의 예외 변환 기능(PersistenceExceptionTranslationPostProcessor)을 자동으로 적용받는다. JPA 예외가 DataAccessException으로 변환되어 일관된 예외 처리가 가능해진다.

♻️ 수정 제안
-import org.springframework.stereotype.Component;
+import org.springframework.stereotype.Repository;

 `@RequiredArgsConstructor`
-@Component
+@Repository
 public class UserRepositoryImpl implements UserRepository {

Comment on lines +50 to +62
public static void validateRawPassword(String rawPassword, LocalDate birthDate) {
if (rawPassword == null || rawPassword.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
}
if (rawPassword.length() < 8 || rawPassword.length() > 16) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다.");
}
if (!rawPassword.matches(PASSWORD_PATTERN)) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용 가능합니다.");
}
if (birthDate != null && containsBirthDate(rawPassword, birthDate)) {
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "User.java" --path "**/domain/user/**" | head -5

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

Length of output: 300


🏁 Script executed:

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

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

Length of output: 152


🏁 Script executed:

git ls-files | grep -i "user.*\.java" | grep -E "(domain|user)" | head -20

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

Length of output: 1118


🏁 Script executed:

wc -l 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: 154


🏁 Script executed:

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

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

Length of output: 4493


🏁 Script executed:

wc -l apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java

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

Length of output: 158


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java | grep -A 20 -B 5 "validateRawPassword\|ValidateRawPassword"

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

Length of output: 3860


🏁 Script executed:

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

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

Length of output: 3255


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java | sed -n '105,120p'

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

Length of output: 576


비밀번호 정책이 영문/숫자/특수문자 포함 요구사항을 보장하지 못한다.

현재 PASSWORD_PATTERN은 허용되는 문자 집합만 검증하고, 영문과 숫자와 특수문자가 각각 최소 1자 이상 포함되었는지 확인하지 않는다. 따라서 "password12"(특수문자 없음) 또는 "Special!!!!"(영문/숫자 없음) 같은 약한 비밀번호가 통과하여 계정 탈취 위험이 증가한다.

validateRawPassword() 메서드의 PASSWORD_PATTERN 검증 후에 다음을 추가하여 각 카테고리 포함을 확인해야 한다:

  • 영문 포함 여부: .*[A-Za-z].*
  • 숫자 포함 여부: .*\\d.*
  • 특수문자 포함 여부: .*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*

세 패턴 모두 매칭되지 않으면 "비밀번호는 영문/숫자/특수문자를 각각 1자 이상 포함해야 합니다."로 오류를 발생시킨다.

UserTest.java의 ValidateRawPassword 테스트에 다음 케이스를 추가하여 예외 발생 여부를 확인해야 한다:

  • 영문만 포함된 비밀번호
  • 숫자만 포함된 비밀번호
  • 특수문자만 포함된 비밀번호
  • 영문+숫자만 포함된 비밀번호(특수문자 없음)
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/User.java` around
lines 50 - 62, validateRawPassword currently only checks allowed characters via
PASSWORD_PATTERN but does not enforce that a password contains at least one
letter, one digit, and one special character; update validateRawPassword (in
class User) to, after the PASSWORD_PATTERN check, validate three additional
regexes (letter: .* [A-Za-z].*, digit: .*\\d.*, special:
.*[!@#$%^&*()_+\\-=\\[\\]{};':"\\\\|,.<>/?].*) and throw new
CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문/숫자/특수문자를 각각 1자 이상 포함해야 합니다.") if
any of them fail; also add unit tests in UserTest.java covering passwords with
only letters, only digits, only special chars, and letters+digits (no special)
to assert that validateRawPassword throws the expected exception.

Comment on lines +21 to +30
public User register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) {
if (userRepository.existsByLoginId(loginId)) {
throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
}

User.validateRawPassword(rawPassword, birthDate);
String encodedPassword = passwordEncoder.encode(rawPassword);

User user = new User(loginId, encodedPassword, name, birthDate, email);
return 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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "UserService.java" | head -20

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

Length of output: 159


🏁 Script executed:

find . -type f -name "User.java" -path "*/domain/user/*" | head -20

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

Length of output: 152


🏁 Script executed:

find . -type f -name "UserRepository.java" | head -20

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

Length of output: 162


🏁 Script executed:

find . -type f -name "CoreException.java" | head -20

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/domain/user/UserService.java

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

Length of output: 3255


🏁 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: 5816


🏁 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: 411


🏁 Script executed:

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

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

Length of output: 741


🏁 Script executed:

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

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

Length of output: 741


🏁 Script executed:

rg "public CoreException" apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java -A 2

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

Length of output: 357


동시 가입 시 중복 ID가 500으로 노출되는 레이스 컨디션이 존재한다.

운영에서 동일 loginId 요청이 거의 동시에 들어오면 existsByLoginId 검사를 통과한 뒤 save 시점에 데이터베이스 유니크 제약 위반(DataIntegrityViolationException)이 발생해 500으로 노출될 수 있다. User.java의 loginId 컬럼에 unique = true 제약이 설정되어 있어 이 시나리오는 실제로 발생한다.

수정안은 save 구간을 try/catch로 감싸 DataIntegrityViolationException을 CoreException(CONFLICT)로 변환하되, 다음 두 가지 중 선택해야 한다:

  1. initCause() 사용 (현재 CoreException 구조에서 가능):
try {
    if (userRepository.existsByLoginId(loginId)) {
        throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
    }
    User.validateRawPassword(rawPassword, birthDate);
    String encodedPassword = passwordEncoder.encode(rawPassword);
    User user = new User(loginId, encodedPassword, name, birthDate, email);
    return userRepository.save(user);
} catch (DataIntegrityViolationException ex) {
    CoreException ce = new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
    ce.initCause(ex);
    throw ce;
}
  1. CoreException 확장 (권장): ErrorType, 사용자 메시지, cause를 모두 받는 생성자 추가.

추가 테스트로 동일 loginId를 동시 또는 연속 등록하는 통합 테스트에서 항상 CONFLICT 응답이 반환되는지 검증한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java`
around lines 21 - 30, The register method in UserService (UserService.register)
can hit a race where existsByLoginId passes but userRepository.save throws
DataIntegrityViolationException due to the unique loginId constraint; wrap the
save logic in a try/catch that catches DataIntegrityViolationException and
rethrow a CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.") preserving the
original exception as the cause (either via ce.initCause(ex) or by adding a new
CoreException constructor that accepts a cause), and add an integration test
that concurrently attempts to create the same loginId to assert a CONFLICT
response is always returned.

Comment on lines +31 to +33
public ApiResponse<UserV1Dto.RegisterResponse> register(
@Valid @RequestBody UserV1Dto.RegisterRequest request
) {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd apps/commerce-api && find . -name "UserV1Controller.java" -type f

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

Length of output: 154


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java | head -80

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

Length of output: 2884


🏁 Script executed:

grep -n "@Valid" apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java

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

Length of output: 220


🏁 Script executed:

find . -name "ApiControllerAdvice.java" -o -name "*ControllerAdvice.java" | head -5

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

Length of output: 170


🏁 Script executed:

grep -r "ApiControllerAdvice" apps/commerce-api/src/main/java --include="*.java" -l | head -3

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

Length of output: 168


🏁 Script executed:

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

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

Length of output: 2622


🏁 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: 6820


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java | head -100

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

Length of output: 2538


🏁 Script executed:

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

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

Length of output: 67


@Valid을 제거하여 도메인 검증을 통한 일관된 에러 응답을 보장한다.

요청 바디 검증 실패 시 @Valid는 MethodArgumentNotValidException을 발생시키는데, ApiControllerAdvice에 이에 대한 핸들러가 없어서 제네릭 Throwable 핸들러로 빠진다. 그 결과 INTERNAL_ERROR 상태코드와 오류 응답이 반환되어 도메인 검증을 통한 CoreException 흐름의 BAD_REQUEST 응답과 다르다.

UserV1Dto의 Bean Validation 애노테이션(@NotBlank, @Pattern, @Email 등)은 프로젝트 원칙(도메인 레이어에서만 검증)에 맞게 제거하고, User 도메인 클래스의 validateRawPassword, validateLoginId 등 기존 검증 메서드를 통해 모든 검증이 CoreException으로 흐르도록 한다.

제거 후 E2E 테스트에서 유효하지 않은 요청(빈 loginId, 8자 미만의 password, 잘못된 email 형식 등)을 보낼 때 응답 상태코드가 400(BAD_REQUEST)이고 에러 코드와 메시지가 도메인 검증 로직과 일치하는지 확인한다.

🛠️ 제안 수정안
-import jakarta.validation.Valid;
@@
     `@PostMapping`
     `@ResponseStatus`(HttpStatus.CREATED)
     `@Override`
     public ApiResponse<UserV1Dto.RegisterResponse> register(
-        `@Valid` `@RequestBody` UserV1Dto.RegisterRequest request
+        `@RequestBody` UserV1Dto.RegisterRequest request
     ) {
@@
     `@PutMapping`("/password")
     `@Override`
     public ApiResponse<Void> changePassword(
         `@RequestHeader`(HEADER_LOGIN_ID) String loginId,
         `@RequestHeader`(HEADER_LOGIN_PW) String password,
-        `@Valid` `@RequestBody` UserV1Dto.ChangePasswordRequest request
+        `@RequestBody` UserV1Dto.ChangePasswordRequest request
     ) {

프로젝트 원칙: 모든 에러는 CoreException → ApiControllerAdvice로 흐르며, 도메인 레이어에서만 검증을 수행한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java`
around lines 31 - 33, register 메서드의 파라미터에서 `@Valid를` 제거하고 UserV1Dto에 선언된 Bean
Validation 애노테이션(`@NotBlank`, `@Email`, `@Pattern` 등)을 제거하여 요청 바디 검증이 컨트롤러에서 발생하지 않도록
하세요; 대신 User 도메인 클래스의 기존 검증 메서드(validateRawPassword, validateLoginId 등)가 호출되어
유효성 검사 실패 시 CoreException을 던지도록 보장하고 ApiControllerAdvice가 해당 CoreException을 처리해
400(BAD_REQUEST) 응답과 도메인 검증의 에러 코드/메시지를 반환하도록 수정 및 E2E 테스트(빈 loginId, 8자 미만
password, 잘못된 email 등)를 추가해 흐름을 검증하세요.

Comment on lines +13 to +38
public record RegisterRequest(
@NotBlank(message = "로그인 ID는 필수입니다.")
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "로그인 ID는 영문과 숫자만 사용 가능합니다.")
String loginId,

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

@NotBlank(message = "이름은 필수입니다.")
String name,

@NotNull(message = "생년월일은 필수입니다.")
LocalDate birthDate,

@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email
) {}

public record ChangePasswordRequest(
@NotBlank(message = "현재 비밀번호는 필수입니다.")
String currentPassword,

@NotBlank(message = "새 비밀번호는 필수입니다.")
String newPassword
) {}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd apps/commerce-api && find . -name "UserV1Dto.java" -o -name "UserV1Controller.java" | head -20

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

Length of output: 217


🏁 Script executed:

rg -t java "class UserV1Controller" -A 20

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

Length of output: 2618


🏁 Script executed:

rg -t java "MethodArgumentNotValidException"

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

Length of output: 67


🏁 Script executed:

rg -t java "ApiControllerAdvice" -A 30

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

Length of output: 4227


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java | head -50

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

Length of output: 1791


🏁 Script executed:

cat 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: 5938


Bean Validation 애너테이션이 통합 에러 처리 아키텍처를 우회한다.

현재 코드는 @Valid로 Bean Validation을 활성화하고 있는데, MethodArgumentNotValidException이 발생 시 ApiControllerAdvice에 이를 처리하는 핸들러가 없어 일반 Throwable 핸들러에 걸린다. 이 경우 ERROR 로그로 기록되고 INTERNAL_ERROR 응답이 반환되어 CoreException 기반의 통합 응답 형식과 불일치한다. 도메인 레이어에서만 검증하는 프로젝트 정책에도 어긋난다.

수정안:

  • UserV1Dto의 모든 Bean Validation 애너테이션과 관련 import 제거
  • UserV1Controller의 @Valid 제거
  • 도메인 레이어(User 엔티티 생성자, PasswordPolicy 등)에서 검증하고 위반 시 CoreException 발생하도록 유지

추가 테스트:

  • loginId가 빈 문자열, 영문/숫자 외 문자 포함 시 CoreException 기반 응답 포맷인지 검증
  • 잘못된 email 형식 요청 시 동일하게 CoreException으로 처리되는지 E2E 테스트로 확인
  • changePassword에서 빈 비밀번호 요청 시에도 통합 응답 형식이 유지되는지 검증
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java`
around lines 13 - 38, Remove all Bean Validation annotations and related imports
from UserV1Dto (specifically from the RegisterRequest and ChangePasswordRequest
records) and remove any use of `@Valid` on controller parameters in
UserV1Controller; rely on domain-layer validation (e.g., User constructor,
PasswordPolicy) to validate inputs and throw CoreException on violations. Update
controller method signatures to accept plain DTOs (no `@Valid`) and ensure
exceptions from domain validation propagate to the existing ApiControllerAdvice;
add E2E tests that submit invalid loginId, invalid email, and empty passwords to
verify responses follow the CoreException-based unified format.

브랜드, 상품, 좋아요, 주문 도메인에 대한 설계 문서 4종을 작성한다.
- 01-requirements: 유저 시나리오 기반 기능 정의 및 요구사항 명세
- 02-sequence-diagrams: 주문 생성, 좋아요, 브랜드 삭제 시퀀스
- 03-class-diagram: JPA 엔티티 모델 + 서비스/애플리케이션 레이어 구조
- 04-erd: 테이블 구조, 인덱스, 데이터 정합성 전략

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant