-
Notifications
You must be signed in to change notification settings - Fork 44
feat: User API 구현 (회원가입, 내 정보 조회, 비밀번호 변경) #12
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: main
Are you sure you want to change the base?
Conversation
- 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>
|
Important Review skippedReview was skipped due to path filters ⛔ Files ignored due to path filters (4)
CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthrough사용자 등록, 인증, 비밀번호 변경 기능을 구현한 사용자 관리 시스템을 추가했다. Spring Security 암호화 라이브러리를 도입하고 도메인 레이어부터 인터페이스 레이어까지 계층별 아키텍처로 구성했다. 생년월일 패턴 검증을 포함한 비밀번호 정책이 적용되었다. Changes
Sequence DiagramssequenceDiagram
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)
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)
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 검토 주요사항보안 관점1. 생년월일 누수 방지 로직의 유효성 검증 필요
2. 헤더 기반 인증의 보안 위험
3. 비밀번호 변경 시 현재 비밀번호 재입력 재검증
운영 관점4. 유니크 제약 위반 시 예외 처리 명확화
5. 비밀번호 인코딩 강도 설정
6. 트랜잭션 격리 수준
성능/확장성 관점7. 로그인ID 조회 성능
8. 테스트 데이터베이스 정리
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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: 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를 별도로 인스턴스화하고 있다.애플리케이션 컨텍스트에서 관리하는
PasswordEncoderBean과 테스트에서 생성한 인스턴스가 동일한 설정(cost factor 등)을 사용하는지 보장되지 않는다. 현재는 둘 다 기본값을 사용하므로 문제없으나, 향후 설정이 변경될 경우 테스트가 실패할 수 있다.♻️ 개선 방안
- private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + `@Autowired` + private PasswordEncoder passwordEncoder;또는
UserService를 주입받아createTestUser()대신 서비스 레이어를 통해 사용자를 생성하는 방법도 고려할 수 있다.
153-198: 인증 헤더 누락 시나리오에 대한 테스트가 없다.
GET /api/v1/users/me와PUT /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 {
| 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, "비밀번호에 생년월일을 포함할 수 없습니다."); | ||
| } |
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 "User.java" --path "**/domain/user/**" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 300
🏁 Script executed:
find . -type f -name "User.java" | grep -i domainRepository: 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 -20Repository: 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.javaRepository: 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 -100Repository: 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.javaRepository: 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.javaRepository: 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.
| 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); |
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 . -type f -name "UserService.java" | head -20Repository: 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 -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 152
🏁 Script executed:
find . -type f -name "UserRepository.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 162
🏁 Script executed:
find . -type f -name "CoreException.java" | head -20Repository: 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.javaRepository: 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.javaRepository: 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.javaRepository: 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.javaRepository: 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 -25Repository: 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 2Repository: 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)로 변환하되, 다음 두 가지 중 선택해야 한다:
- 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;
}- 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.
| public ApiResponse<UserV1Dto.RegisterResponse> register( | ||
| @Valid @RequestBody UserV1Dto.RegisterRequest 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:
cd apps/commerce-api && find . -name "UserV1Controller.java" -type fRepository: 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 -80Repository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 220
🏁 Script executed:
find . -name "ApiControllerAdvice.java" -o -name "*ControllerAdvice.java" | head -5Repository: 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 -3Repository: 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 -60Repository: 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.javaRepository: 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 -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2538
🏁 Script executed:
find . -name "PasswordPolicy.java" -type fRepository: 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 등)를 추가해 흐름을 검증하세요.
| 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 | ||
| ) {} |
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:
cd apps/commerce-api && find . -name "UserV1Dto.java" -o -name "UserV1Controller.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 217
🏁 Script executed:
rg -t java "class UserV1Controller" -A 20Repository: 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 30Repository: 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 -50Repository: 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.javaRepository: 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>
📌 Summary
🧭 Context & Decision
구현 내용
📋 API Endpoints
🔒 검증 규칙
🧪 테스트
✅ Checklist
변경 목적
회원가입, 내 정보 조회, 비밀번호 변경 API를 육각형 아키텍처로 구현하고, BCrypt를 활용한 보안 강화 및 비밀번호 검증 규칙을 적용합니다.
핵심 변경점
new BCryptPasswordEncoder()직접 생성리스크/주의사항
테스트/검증 방법