-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현 #34
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: pable91
Are you sure you want to change the base?
Conversation
가# 웬만하면 작성
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the
✨ 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: 10
🤖 Fix all issues with AI agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java`:
- Around line 18-33: Add a DB-level unique constraint on the UserModel.email
column and handle unique-key violations in UserService.createUser: keep the
existsByEmail check but catch DataIntegrityViolationException (or the underlying
constraint violation) in createUser and translate it to throw new
CoreException(ErrorType.BAD_REQUEST, "...email already registered...") so
concurrent sign-ups return a controlled 400 instead of a 500; alternatively
document/implement a higher isolation/locking strategy if preferred and add a
concurrency test to assert only one of simultaneous signUp calls (via
UserFacade.signUp -> userService.createUser) succeeds while others receive the
mapped CoreException.
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java`:
- Around line 26-44: Add database-level unique constraints for both loginId and
email in the UserModel entity: modify the class-level mapping (UserModel) to
declare unique constraints for the login_id and email columns (e.g., via
`@Table`(uniqueConstraints=...)) or mark each field as unique via `@Column`(unique =
true) on the loginId and email fields, and ensure column names match "login_id"
and "email". After adding constraints, add/adjust an integration test to attempt
saving duplicate login_id and duplicate email and assert that a
DataIntegrityViolationException (or the JPA persistence exception your stack
throws) is raised when violating each constraint. Ensure UserFacade.signUp
behavior remains compatible (it can keep existsByEmail() but rely on DB
constraint for safety).
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java`:
- Around line 18-58: createUser currently skips password policy checks and may
pass a raw password into UserModel.create; call
PasswordPolicy.validate(rawPassword) and
validatePasswordNotContainsBirthDate(rawPassword, birthDate) before encoding,
then encode and pass only the BCrypt hash to UserModel.create (ensure
UserModel.create expects a hash, not plain text). Also add
PasswordPolicy.validate(newPassword) in changePassword before encoding (in
method changePassword), and add tests: in UserServiceTest assert createUser
rejects policy-violating or birthdate-containing passwords and that
passwordEncoder.encode is not called on invalid input, and in UserFacadeTest add
a signup policy-violation failure case.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java`:
- Around line 54-61: The handleBadRequest(MethodArgumentNotValidException)
handler in ApiControllerAdvice violates the project's error-handling pattern and
has runtime risks; remove the entire handleBadRequest method from
ApiControllerAdvice so validation errors are routed via the domain-layer
CoreException flow, or if you must keep it, make it defensive: check
e.getBindingResult().getFieldErrors() for emptiness before accessing get(0),
null-check fieldError.getDefaultMessage() and replace with a safe message, and
rethrow or wrap into the project's CoreException/ErrorType.BAD_REQUEST to
preserve consistent response formatting (reference:
ApiControllerAdvice.handleBadRequest, MethodArgumentNotValidException,
CoreException, ErrorType.BAD_REQUEST).
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java`:
- Around line 36-39: Replace the direct throw of IllegalStateException in
AuthUserArgumentResolver (where request =
webRequest.getNativeRequest(HttpServletRequest.class) is null) with throwing a
CoreException constructed with an appropriate ErrorType (e.g.,
INTERNAL_SERVER_ERROR) so the error flows through ApiControllerAdvice and
preserves the standard API error format and monitoring; update
AuthUserArgumentResolver's resolveArgument path to create and throw that
CoreException when request == null, and add a unit test or MVC-slice test to
verify that when request is null the controller advice returns the unified error
response format.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeaders.java`:
- Around line 3-27: Remove all Bean Validation annotations from the
CredentialsHeaders DTO (e.g., `@NotBlank`, `@Size`, `@Pattern` on loginId/loginPw) and
instead implement the same header format checks inside
CredentialsHeadersArgumentResolver: validate presence and patterns for loginId
and loginPw and throw CoreException(ErrorType.BAD_REQUEST) when validation
fails; update CredentialsHeadersArgumentResolverTest to assert CoreException is
thrown for missing/invalid headers and update UserControllerTest to assert the
API error response format remains the unified CoreException-based format. Ensure
references: class CredentialsHeaders, method in
CredentialsHeadersArgumentResolver that builds/validates the DTO, and tests
CredentialsHeadersArgumentResolverTest and UserControllerTest.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeadersArgumentResolver.java`:
- Around line 20-46: Remove all Bean Validation annotations from the
CredentialsHeaders DTO and stop calling the injected Validator from
CredentialsHeadersArgumentResolver.resolveArgument; instead construct the
CredentialsHeaders (or pass raw headers) and delegate validation to the
domain/application layer (e.g., User constructor or a new CredentialsValidator
in the domain package) which should throw CoreException on invalid input;
replace the current throws of ConstraintViolationException and
IllegalStateException in resolveArgument with propagation of CoreException (or
throw CoreException when request is null) so the error format is consistent with
ApiControllerAdvice; keep LoopersHeaders.X_LOOPERS_LOGIN_ID and
X_LOOPERS_LOGIN_PW usage for header extraction and ensure integration tests
assert ApiControllerAdvice handles missing/invalid headers and null request
cases.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/user/ChangePasswordRequest.java`:
- Around line 3-18: Remove the Bean Validation annotations from the
ChangePasswordRequest record (drop `@NotBlank`, `@Size`, `@Pattern`) and implement
equivalent format checks inside UserService.changePassword(): validate
currentPassword/newPassword length (8–16) and allowed characters
(^[A-Za-z0-9\p{P}\p{S}]+$) and on any violation throw the appropriate
CoreException so ApiControllerAdvice will produce the unified error
response/monitoring path; add unit tests in UserServiceTest for invalid-length
and disallowed-character cases and a UsersController slice test asserting the
same CoreException-backed error response format is returned through the
controller.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.java`:
- Around line 23-25: In UsersSignUpRequestDto update the `@Size` annotation on the
name field so the constraint and message match: either set max=30 to reflect the
existing message or change the message to "이름은 2자 이상 10자 이하여야 합니다." to reflect
max=10; locate the `@Size`(...) on the name property in UsersSignUpRequestDto and
make the chosen change so validation rule and error text are consistent.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserControllerTest.java`:
- Around line 71-407: Remove all Bean Validation annotations from
UsersSignUpRequestDto and remove `@Valid` from UsersController method signature so
DTOs are plain; move validation logic into the domain (User constructor or
SignUpCommand and PasswordPolicy) and ensure UserFacade.signUp throws
CoreException with ErrorType.BAD_REQUEST on validation failures; update tests in
UserControllerTest to mock UserFacade.signUp to throw
CoreException(ErrorType.BAD_REQUEST) for invalid inputs and assert
ApiControllerAdvice’s unified error response; finally remove the
MethodArgumentNotValidException handler from ApiControllerAdvice so only
CoreException handling remains.
🧹 Nitpick comments (3)
apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java (1)
3-13: BCrypt 강도 값을 설정으로 분리하는 편이 안전하다
- 운영 관점: 고정된 cost는 트래픽 증가 시 CPU 병목이나 보안 강도 저하를 초래할 수 있어 환경별 튜닝이 어렵다.
- 수정안: 설정값으로 strength를 주입해 BCryptPasswordEncoder(strength)를 생성하고 기본값을 둔다.
- 추가 테스트: 다른 strength 설정에서 회원가입/로그인 해시 검증이 정상인지 통합 테스트로 확인한다.
🔧 수정 예시
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; `@Configuration` public class PasswordEncoderConfig { `@Bean` - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + public PasswordEncoder passwordEncoder( + `@Value`("${security.password.bcrypt.strength:10}") int strength + ) { + return new BCryptPasswordEncoder(strength); } }apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java (1)
54-61: 비밀번호 검증 책임을 서비스로 고정하는 편이 낫다
- 운영 관점: 엔티티에서 null/blank만 확인하면 평문 비밀번호가 유입돼도 탐지하지 못하고, 검증 책임 분산으로 오류 응답 일관성이 깨질 수 있다.
- 수정안: UserModel.guard()의 password 체크를 제거하고, UserService에서 정책 검증 후 인코딩된 값만 create/changePassword로 전달하도록 계약을 문서화한다.
- 추가 테스트: UserService에서 정책 위반 시 예외가 발생하고 정상 입력은 인코딩 후 저장되는지 검증하는 테스트를 추가한다.
Based on learnings: 'User 엔티티 생성자의 password 파라미터는 BCrypt 해시를 받는다. 원본 비밀번호 검증은 UserService에서 PasswordPolicy.validate()로 수행한 후 암호화된다. 따라서 User 생성자 시점에는 항상 유효한 BCrypt 해시 문자열이 전달되며, 생성자에서 null/공백 검증은 불필요하다'를 근거로 했다.🔧 수정 예시
- if (password == null || password.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); - }apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java (1)
39-45: password null 검증은 서비스 레벨로 이동하는 편이 낫다
- 운영 관점: 엔티티에서 password null을 강제하면 서비스 정책 변경 시 테스트가 지속적으로 깨져 운영 배포 지연을 유발할 수 있다.
- 수정안: 이 테스트는 제거하거나 UserService의 정책 검증 테스트로 이동한다.
- 추가 테스트: UserService에서 평문 입력이 정책 위반으로 실패하는지와 인코딩 후 저장되는지를 검증한다.
Based on learnings: 'User 엔티티 생성자의 password 파라미터는 BCrypt 해시를 받는다. 원본 비밀번호 검증은 UserService에서 PasswordPolicy.validate()로 수행한 후 암호화된다. 따라서 User 생성자 시점에는 항상 유효한 BCrypt 해시 문자열이 전달되며, 생성자에서 null/공백 검증은 불필요하다'를 근거로 했다.🔧 수정 예시
- `@DisplayName`("password가 null이면 guard에서 예외가 발생한다.") - `@Test` - void guard_fail_when_password_is_null() { - UserModel user = UserModel.create("user123", null, LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); - - assertThatThrownBy(user::guard).isInstanceOf(CoreException.class); - }
| public UserInfo signUp(SignUpCommand command) { | ||
| if (userService.existsByEmail(command.getEmail())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입되어 있는 아이디 입니다."); | ||
| } | ||
|
|
||
| // TODO | ||
| // 만약 또다른 패스워드 검증 조건이 생기거나 다른 클래스에서도 같이 사용한다면 클래스로 분리해야함 | ||
| validatePasswordContent(command.getLoginPw(), command.getBirthDate()); | ||
|
|
||
| UserModel userModel = userService.createUser( | ||
| command.getLoginId(), | ||
| command.getLoginPw(), | ||
| command.getBirthDate(), | ||
| command.getName(), | ||
| command.getEmail() | ||
| ); |
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:
# 이메일 unique 제약 확인
find . -type f -name "*.java" | head -20 | xargs grep -l "User" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 470
🏁 Script executed:
# UserFacade가 있는 디렉토리 구조 파악
find . -type f -name "UserFacade.java" -o -name "UserService.java" -o -name "User.java" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 238
🏁 Script executed:
# unique 제약 및 마이그레이션 파일 검색
fd -e java -e sql -e xml | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2529
🏁 Script executed:
# 이메일 유니크 제약/인덱스 및 마이그레이션 확인
rg "UniqueConstraint|@Column.*unique|unique\s*=\s*true" --type java -A 2 -B 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# SQL 마이그레이션 파일에서 unique 확인
rg "create table|alter table|unique" --type sql -iRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 233
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3051
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 513
🏁 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: 3136
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2318
🏁 Script executed:
# DB 마이그레이션 파일 검색
find . -type f \( -name "*.sql" -o -name "V*.sql" -o -name "*.xml" \) | grep -i migratRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
이메일 중복 확인만으로는 레이스 컨디션을 막기 어렵다
동시 가입 요청이 existsByEmail을 통과한 후 createUser가 호출되면 중복 사용자가 생성되거나 DB 무결성 위반으로 500 에러가 발생한다. 현재 UserModel의 email 필드는 nullable 제약만 있고 unique 제약이 없으며(line 43), UserService.createUser에서 중복키 예외를 처리하지 않는다(line 19-22).
수정안: DB 레벨에 email 컬럼에 unique 제약을 추가하고, createUser에서 DataIntegrityViolationException을 CoreException(BAD_REQUEST)으로 변환하여 처리하거나 트랜잭션 격리 수준을 상향 조정하라. 추가로 동일 이메일 동시 요청 시 하나만 성공하고 나머지는 적절한 예외로 반환되는지 동시성 테스트로 검증하라.
참고로 password 검증 정책(생년월일 포함 여부)은 signUp과 changePassword에서 일관되게 적용되고 있다(UserService line 55, UserFacade line 43-53).
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java`
around lines 18 - 33, Add a DB-level unique constraint on the UserModel.email
column and handle unique-key violations in UserService.createUser: keep the
existsByEmail check but catch DataIntegrityViolationException (or the underlying
constraint violation) in createUser and translate it to throw new
CoreException(ErrorType.BAD_REQUEST, "...email already registered...") so
concurrent sign-ups return a controlled 400 instead of a 500; alternatively
document/implement a higher isolation/locking strategy if preferred and add a
concurrency test to assert only one of simultaneous signUp calls (via
UserFacade.signUp -> userService.createUser) succeeds while others receive the
mapped CoreException.
| @Comment("아이디") | ||
| @Column(name = "login_id", nullable = false) | ||
| private String loginId; | ||
|
|
||
| @Comment("비밀번호") | ||
| @Column(name = "password", nullable = false) | ||
| private String password; | ||
|
|
||
| @Comment("생년월일") | ||
| @Column(name = "birth", nullable = false) | ||
| private LocalDate birthDate; | ||
|
|
||
| @Comment("이름") | ||
| @Column(name = "name", nullable = false) | ||
| private String name; | ||
|
|
||
| @Comment("이메일") | ||
| @Column(name = "email", nullable = false) | ||
| private String email; |
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 apps/commerce-api -name "UserModel.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 155
🏁 Script executed:
fd "UserModel" --type f apps/commerce-apiRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 230
🏁 Script executed:
git ls-files "apps/commerce-api" | grep -i user | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1672
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3051
🏁 Script executed:
cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3097
🏁 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: 464
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1209
🏁 Script executed:
find apps/commerce-api -name "*.sql" -o -name "*schema*" -o -name "*migration*" | head -20Repository: 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/infrastructure/UserJpaRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 513
🏁 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: 3136
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2318
🏁 Script executed:
cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6343
🏁 Script executed:
find apps/commerce-api -path "*/resources/*" -name "*.sql" -o -path "*/resources/*" -name "*.xml" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
cat -n apps/commerce-api/src/test/java/com/loopers/application/UserFacadeTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3545
🏁 Script executed:
find apps/commerce-api/src/test -name "*Integration*" -o -name "*Repository*Test*"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 178
🏁 Script executed:
cat -n apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2006
🏁 Script executed:
rg "DataIntegrityViolation|ConstraintViolation|duplicate" apps/commerce-api/src/test --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5775
🏁 Script executed:
rg "existsByLoginId|findByLoginId" apps/commerce-api/src --type java -A 2 -B 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2362
login_id/email에 데이터베이스 유니크 제약이 필수다
- 현재 상태: UserFacade.signUp()에서 email만 existsByEmail()로 중복 확인하고, login_id는 중복 체크 없음. 두 필드 모두 데이터베이스 레벨 제약 부재.
- 운영 관점: 동시 회원가입 시 서비스 레벨 중복 체크만으로는 race condition에 의해 중복 레코드가 생성될 수 있으며, 특히 login_id는 현재 중복 방지 로직이 전혀 없어 인증 및 조회가 모호해질 수 있다.
- 수정안: users 테이블에
@UniqueConstraint를추가하여 login_id와 email 모두에 데이터베이스 레벨 유니크 제약을 적용한다. - 추가 테스트: 동일 login_id/email 저장 시 DataIntegrityViolationException이 발생하는지 저장소 통합 테스트로 검증한다.
🔧 수정 예시
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
@@
-@Table(name = "users")
+@Table(
+ name = "users",
+ uniqueConstraints = {
+ `@UniqueConstraint`(name = "uk_users_login_id", columnNames = "login_id"),
+ `@UniqueConstraint`(name = "uk_users_email", columnNames = "email")
+ }
+)🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java`
around lines 26 - 44, Add database-level unique constraints for both loginId and
email in the UserModel entity: modify the class-level mapping (UserModel) to
declare unique constraints for the login_id and email columns (e.g., via
`@Table`(uniqueConstraints=...)) or mark each field as unique via `@Column`(unique =
true) on the loginId and email fields, and ensure column names match "login_id"
and "email". After adding constraints, add/adjust an integration test to attempt
saving duplicate login_id and duplicate email and assert that a
DataIntegrityViolationException (or the JPA persistence exception your stack
throws) is raised when violating each constraint. Ensure UserFacade.signUp
behavior remains compatible (it can keep existsByEmail() but rely on DB
constraint for safety).
apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
Show resolved
Hide resolved
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) { | ||
| FieldError fieldError = e.getBindingResult().getFieldErrors().get(0); | ||
| String fieldName = fieldError.getField(); | ||
| String message = fieldError.getDefaultMessage(); | ||
| String errorMessage = String.format("필드 '%s': %s", fieldName, message); | ||
| return failureResponse(ErrorType.BAD_REQUEST, errorMessage); | ||
| } |
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.
프로젝트 에러 핸들링 패턴 위반 및 런타임 예외 위험이 있다.
이 프로젝트는 MethodArgumentNotValidException 핸들러나 Bean Validation 처리를 도입하지 않고, CoreException을 통해 에러를 라우팅하여 일관된 응답 포맷을 유지하는 것이 원칙이다. DTO에서 Bean Validation 대신 도메인 계층(예: User 엔티티 생성자, PasswordPolicy)에서 검증을 수행하도록 설계되어 있다.
추가로, 다음 런타임 오류 위험이 존재한다:
- Line 56:
getFieldErrors()가 빈 리스트를 반환할 경우get(0)호출 시IndexOutOfBoundsException발생 - Line 58:
getDefaultMessage()가 null을 반환할 수 있어 "필드 'fieldName': null" 형태의 부적절한 메시지 노출 가능
운영 환경에서 예상치 못한 500 에러로 이어질 수 있으며, 일관성 없는 에러 핸들링은 유지보수와 디버깅을 어렵게 한다.
수정안: 핸들러 제거 또는 방어적 코드 적용
권장안 1: 프로젝트 패턴에 맞게 핸들러 제거
Bean Validation 대신 도메인 계층에서 검증하고 CoreException을 throw하는 방식으로 전환한다.
권장안 2: 핸들러 유지 시 방어적 코드 적용
`@ExceptionHandler`
public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) {
- FieldError fieldError = e.getBindingResult().getFieldErrors().get(0);
- String fieldName = fieldError.getField();
- String message = fieldError.getDefaultMessage();
- String errorMessage = String.format("필드 '%s': %s", fieldName, message);
+ List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
+ if (fieldErrors.isEmpty()) {
+ return failureResponse(ErrorType.BAD_REQUEST, "요청 값이 올바르지 않습니다.");
+ }
+ FieldError fieldError = fieldErrors.get(0);
+ String fieldName = fieldError.getField();
+ String message = fieldError.getDefaultMessage();
+ String errorMessage = String.format("필드 '%s': %s", fieldName,
+ message != null ? message : "유효하지 않은 값입니다.");
return failureResponse(ErrorType.BAD_REQUEST, errorMessage);
}추가 테스트: FieldError가 없는 MethodArgumentNotValidException 케이스에 대한 단위 테스트 추가 필요하다.
Based on learnings: "Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns."
📝 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.
| @ExceptionHandler | |
| public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) { | |
| FieldError fieldError = e.getBindingResult().getFieldErrors().get(0); | |
| String fieldName = fieldError.getField(); | |
| String message = fieldError.getDefaultMessage(); | |
| String errorMessage = String.format("필드 '%s': %s", fieldName, message); | |
| return failureResponse(ErrorType.BAD_REQUEST, errorMessage); | |
| } | |
| `@ExceptionHandler` | |
| public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) { | |
| List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors(); | |
| if (fieldErrors.isEmpty()) { | |
| return failureResponse(ErrorType.BAD_REQUEST, "요청 값이 올바르지 않습니다."); | |
| } | |
| FieldError fieldError = fieldErrors.get(0); | |
| String fieldName = fieldError.getField(); | |
| String message = fieldError.getDefaultMessage(); | |
| String errorMessage = String.format("필드 '%s': %s", fieldName, | |
| message != null ? message : "유효하지 않은 값입니다."); | |
| return failureResponse(ErrorType.BAD_REQUEST, errorMessage); | |
| } |
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java`
around lines 54 - 61, The handleBadRequest(MethodArgumentNotValidException)
handler in ApiControllerAdvice violates the project's error-handling pattern and
has runtime risks; remove the entire handleBadRequest method from
ApiControllerAdvice so validation errors are routed via the domain-layer
CoreException flow, or if you must keep it, make it defensive: check
e.getBindingResult().getFieldErrors() for emptiness before accessing get(0),
null-check fieldError.getDefaultMessage() and replace with a safe message, and
rethrow or wrap into the project's CoreException/ErrorType.BAD_REQUEST to
preserve consistent response formatting (reference:
ApiControllerAdvice.handleBadRequest, MethodArgumentNotValidException,
CoreException, ErrorType.BAD_REQUEST).
| HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); | ||
| if (request == null) { | ||
| throw new IllegalStateException("HttpServletRequest not available"); | ||
| } |
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.
HttpServletRequest 누락 시 IllegalStateException은 응답 포맷을 깨뜨린다
운영 관점: CoreException 흐름을 건너뛰면 표준 에러 포맷과 모니터링 지표가 깨져 장애 분석이 어려워진다.
수정안: CoreException(적절한 ErrorType, 예: INTERNAL_SERVER_ERROR)으로 변환해 ApiControllerAdvice 흐름을 유지하라.
추가 테스트: Resolver 단위 테스트나 MVC 슬라이스 테스트로 request=null 상황에서 표준 에러 포맷이 내려오는지 검증하라.
Based on learnings: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice라는 지침을 따랐다.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java`
around lines 36 - 39, Replace the direct throw of IllegalStateException in
AuthUserArgumentResolver (where request =
webRequest.getNativeRequest(HttpServletRequest.class) is null) with throwing a
CoreException constructed with an appropriate ErrorType (e.g.,
INTERNAL_SERVER_ERROR) so the error flows through ApiControllerAdvice and
preserves the standard API error format and monitoring; update
AuthUserArgumentResolver's resolveArgument path to create and throw that
CoreException when request == null, and add a unit test or MVC-slice test to
verify that when request is null the controller advice returns the unified error
response format.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeaders.java
Show resolved
Hide resolved
| private final Validator validator; | ||
|
|
||
| @Override | ||
| public boolean supportsParameter(MethodParameter parameter) { | ||
| return parameter.getParameterType() == CredentialsHeaders.class; | ||
| } | ||
|
|
||
| @Override | ||
| public Object resolveArgument( | ||
| MethodParameter parameter, | ||
| ModelAndViewContainer mavContainer, | ||
| NativeWebRequest webRequest, | ||
| WebDataBinderFactory binderFactory | ||
| ) { | ||
| HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); | ||
| if (request == null) { | ||
| throw new IllegalStateException("HttpServletRequest not available"); | ||
| } | ||
|
|
||
| String loginId = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_ID); | ||
| String loginPw = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_PW); | ||
|
|
||
| CredentialsHeaders headers = new CredentialsHeaders(loginId, loginPw); | ||
| Set<ConstraintViolation<CredentialsHeaders>> violations = validator.validate(headers); | ||
| if (!violations.isEmpty()) { | ||
| throw new ConstraintViolationException(violations); | ||
| } |
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:
# CredentialsHeaders 클래스 확인
find apps/commerce-api -type f -name "CredentialsHeaders.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 167
🏁 Script executed:
# CredentialsHeadersArgumentResolver 전체 코드 확인
find apps/commerce-api -type f -name "CredentialsHeadersArgumentResolver.java" -exec wc -l {} \;Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 186
🏁 Script executed:
# ApiControllerAdvice의 예외 핸들러 확인
find apps/commerce-api -type f -name "ApiControllerAdvice.java" | head -3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 168
🏁 Script executed:
# Validator 사용 패턴 전역 검색
rg -l "Validator|@HandlerMethodArgumentResolver" apps/commerce-api/src/main/java --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 183
🏁 Script executed:
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeaders.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 863
🏁 Script executed:
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeadersArgumentResolver.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1981
🏁 Script executed:
wc -l apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 172
🏁 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: 7363
CredentialsHeaders DTO의 Bean Validation 주석과 ArgumentResolver의 Validator 사용은 프로젝트 아키텍처 규칙을 위반한다
프로젝트는 헥사고날 아키텍처 원칙에 따라 검증을 도메인 계층에서 수행하고 모든 오류를 CoreException으로 처리하도록 설계되었다. 현재 코드는 이 패턴을 위반한다.
구체적으로:
- CredentialsHeaders DTO에
@NotBlank,@Pattern,@Size주석이 있으므로 제거해야 한다. - ArgumentResolver에서 Validator를 직접 호출하여 ConstraintViolationException을 던지는 패턴은 도메인 계층 검증을 우회한다.
ApiControllerAdvice가 ConstraintViolationException 핸들러를 제공하므로 현재 코드가 500 오류로 승격되지는 않지만, 이는 아키텍처 위반을 정당화하지 않는다. 검증은 인증 흐름 확장 시 중복·누락이 발생할 수 있는 기술별 계층(ArgumentResolver)이 아닌 도메인 또는 애플리케이션 계층에서 수행되어야 한다.
수정안:
- CredentialsHeaders의 모든 Bean Validation 주석 제거
- 로그인 ID/비밀번호 검증 로직을 도메인 계층(User 엔티티 생성자 또는 별도 검증 클래스)으로 이동
- ArgumentResolver에서 검증 실패 시 CoreException 발생 (또는 도메인 계층 호출 후 발생한 CoreException 전파)
- request가 null인 경우도 IllegalStateException 대신 CoreException으로 처리하여 일관된 오류 포맷 유지
테스트:
- 헤더 누락, 형식 오류, null 요청에 대해 ApiControllerAdvice가 표준 오류 포맷과 적절한 오류 코드를 반환하는지 통합 테스트로 검증해야 한다.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeadersArgumentResolver.java`
around lines 20 - 46, Remove all Bean Validation annotations from the
CredentialsHeaders DTO and stop calling the injected Validator from
CredentialsHeadersArgumentResolver.resolveArgument; instead construct the
CredentialsHeaders (or pass raw headers) and delegate validation to the
domain/application layer (e.g., User constructor or a new CredentialsValidator
in the domain package) which should throw CoreException on invalid input;
replace the current throws of ConstraintViolationException and
IllegalStateException in resolveArgument with propagation of CoreException (or
throw CoreException when request is null) so the error format is consistent with
ApiControllerAdvice; keep LoopersHeaders.X_LOOPERS_LOGIN_ID and
X_LOOPERS_LOGIN_PW usage for header extraction and ensure integration tests
assert ApiControllerAdvice handles missing/invalid headers and null request
cases.
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.Pattern; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record ChangePasswordRequest( | ||
|
|
||
| @NotBlank(message = "기존 비밀번호는 필수입니다.") | ||
| String currentPassword, | ||
|
|
||
| @NotBlank(message = "새 비밀번호는 필수입니다.") | ||
| @Size(min = 8, max = 16, message = "비밀번호는 8~16자로 입력해주세요.") | ||
| @Pattern( | ||
| regexp = "^[A-Za-z0-9\\p{P}\\p{S}]+$", | ||
| 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:
# First, let's locate and examine the ChangePasswordRequest file
find . -name "ChangePasswordRequest.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 173
🏁 Script executed:
# Search for PasswordPolicy to understand validation rules
find . -name "*PasswordPolicy*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Look for CoreException and error handling
find . -name "CoreException.java" -type f
find . -name "*ControllerAdvice*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 249
🏁 Script executed:
# Check other DTOs in the interfaces directory to see the pattern
find . -path "*/interfaces/*" -name "*Request.java" -type f | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 173
🏁 Script executed:
# Read the ChangePasswordRequest file
cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/user/ChangePasswordRequest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 754
🏁 Script executed:
# Search for PasswordPolicy more broadly
find . -name "*.java" -type f | xargs grep -l "PasswordPolicy" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Read CoreException
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:
# Read ApiControllerAdvice
cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4463
🏁 Script executed:
# Search more broadly for validation logic
find . -name "*.java" -type f | xargs grep -l "PasswordPolicy\|password" | grep -E "(service|domain)" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 388
🏁 Script executed:
# Check if UserService exists and how it handles password validation
find . -name "UserService.java" -o -name "ChangePasswordService.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 159
🏁 Script executed:
# Look at other DTOs to see if they follow the no-validation pattern
find . -path "*/interfaces/*" -name "*.java" -type f | xargs grep -l "@NotBlank\|@Email\|@Valid" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 430
🏁 Script executed:
# Check the User domain entity to understand where validation should happen
find . -name "User.java" -type f | grep -E "domain|entity" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Read UserService to see password validation logic
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: 3136
🏁 Script executed:
# Read UserModel to see domain validation
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3051
🏁 Script executed:
# Check UsersSignUpRequestDto - another DTO with validation annotations
cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1336
🏁 Script executed:
# Check UsersController to see how validation is currently handled
cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersController.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2599
🏁 Script executed:
# Check UserServiceTest to see what validation is expected
cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java | head -150Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6343
🏁 Script executed:
# Search for any test files related to password validation
find . -name "*Test.java" -type f | xargs grep -l "changePassword\|password.*validation" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 248
DTO에서 Bean Validation 애노테이션을 제거하고 도메인 레이어에서 통일된 검증 처리가 필요하다
현재 ChangePasswordRequest는 Bean Validation 애노테이션을 사용하고 있어서, 요청 검증 실패 시 MethodArgumentNotValidException으로 분기되어 CoreException 기반의 통일된 응답 포맷과 모니터링 경로가 분리된다. 또한 HTTP 진입점이 아닌 다른 방식의 접근에서는 이 검증이 누락되는 문제가 발생한다.
수정안으로 다음을 수행하라:
- ChangePasswordRequest에서
@NotBlank,@Size,@Pattern애노테이션 제거 - UserService.changePassword()에서 형식 검증 추가 (길이 8~16자, 문자 규칙)
- 모든 검증 오류를 CoreException으로 처리하여 ApiControllerAdvice를 통해 통일된 응답 제공
추가 테스트로 UserServiceTest에서 형식 위반 케이스를 테스트하고 (길이 초과, 허용되지 않는 문자), UsersController 슬라이스 테스트에서 동일한 오류 응답 포맷이 유지되는지 검증하라.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/user/ChangePasswordRequest.java`
around lines 3 - 18, Remove the Bean Validation annotations from the
ChangePasswordRequest record (drop `@NotBlank`, `@Size`, `@Pattern`) and implement
equivalent format checks inside UserService.changePassword(): validate
currentPassword/newPassword length (8–16) and allowed characters
(^[A-Za-z0-9\p{P}\p{S}]+$) and on any violation throw the appropriate
CoreException so ApiControllerAdvice will produce the unified error
response/monitoring path; add unit tests in UserServiceTest for invalid-length
and disallowed-character cases and a UsersController slice test asserting the
same CoreException-backed error response format is returned through the
controller.
| @NotBlank(message = "이름은 필수입니다.") | ||
| @Size(min = 2, max = 10, message = "이름은 2자 이상 30자 이하여야 합니다.") | ||
| @Pattern( |
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:
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.java | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1336
🏁 Script executed:
# 전체 파일 구조 확인
wc -l apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 174
🏁 Script executed:
# 관련 테스트 파일 검색
find . -type f -name "*SignUp*Test*.java" -o -name "*UsersSignUp*Test*.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# 사용자 이름 길이 제약에 대한 다른 참조 검색
rg "이름.*자|name.*length|30" --type java -B 2 -A 2 apps/commerce-api/src/main/java/com/loopers/interfaces/user/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 676
🏁 Script executed:
# 테스트 파일 더 광범위하게 검색
find . -type f -name "*Test*.java" | grep -i user | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 491
🏁 Script executed:
# User 도메인 클래스 찾기
fd -e java "User" apps/commerce-api/src/main/java | grep -E "(domain|entity|model)" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 304
🏁 Script executed:
# 사용자 관련 테스트 폴더 전체 검색
find . -path "*test*" -name "*User*.java" -o -path "*test*" -name "*SignUp*.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 491
🏁 Script executed:
# application.yml이나 설정 파일에서 정책 확인
find . -name "*.yml" -o -name "*.yaml" -o -name "*.properties" | xargs grep -l "name\|user" 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 514
🏁 Script executed:
# UserDtoValidationTest 파일 확인
cat -n ./apps/commerce-api/src/test/java/com/loopers/interfaces/dto/UserDtoValidationTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 16205
🏁 Script executed:
# UserModel 도메인 클래스 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3051
🏁 Script executed:
# UserService 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java | head -60Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2780
🏁 Script executed:
# 데이터베이스 스키마 또는 entity 정의 확인
find . -name "User*.java" -path "*/entity/*" -o -name "User*.java" -path "*/persistence/*" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
이름 길이 제한값과 에러 메시지가 불일치한다
@Size 어노테이션에서 max=10으로 설정되어 있지만, 메시지는 "30자 이하"라고 명시되어 있다. 클라이언트는 메시지를 보고 최대 30자까지 입력 가능하다고 인식하여 불필요한 재시도와 문의가 발생할 수 있다.
메시지를 "이름은 2자 이상 10자 이하여야 합니다."로 수정하거나, 실제 정책이 30자라면 max 값을 30으로 변경한다.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.java`
around lines 23 - 25, In UsersSignUpRequestDto update the `@Size` annotation on
the name field so the constraint and message match: either set max=30 to reflect
the existing message or change the message to "이름은 2자 이상 10자 이하여야 합니다." to
reflect max=10; locate the `@Size`(...) on the name property in
UsersSignUpRequestDto and make the chosen change so validation rule and error
text are consistent.
| @DisplayName("회원가입 API 실패 테스트") | ||
| @Nested | ||
| class UserSignupFailureTest { | ||
|
|
||
| @Test | ||
| @DisplayName("이메일 형식이 잘못되면 400 Bad Request를 반환한다") | ||
| void fail_when_email_format_invalid() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "invalid-email" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이메일이 null이면 400 Bad Request를 반환한다") | ||
| void fail_when_email_null() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| null | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이름이 null이면 400 Bad Request를 반환한다") | ||
| void fail_when_name_null() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| null, | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이름이 빈 문자열이면 400 Bad Request를 반환한다") | ||
| void fail_when_name_empty() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이름이 1자이면 400 Bad Request를 반환한다") | ||
| void fail_when_name_too_short() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이름이 11자 이상이면 400 Bad Request를 반환한다") | ||
| void fail_when_name_too_long() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "가나다라마바사아자차카", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이름에 숫자가 포함되면 400 Bad Request를 반환한다") | ||
| void fail_when_name_contains_number() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권1", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("이름에 특수문자가 포함되면 400 Bad Request를 반환한다") | ||
| void fail_when_name_contains_special_character() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권!", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("생년월일이 null이면 400 Bad Request를 반환한다") | ||
| void fail_when_birthDate_null() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| null, | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("생년월일이 미래 날짜이면 400 Bad Request를 반환한다") | ||
| void fail_when_birthDate_future() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.now().plusDays(1), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("비밀번호가 7자 이하면 400 Bad Request를 반환한다") | ||
| void fail_when_password_too_short() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Abc12!") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("비밀번호가 17자 이상이면 400 Bad Request를 반환한다") | ||
| void fail_when_password_too_long() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Abcd123!@#efgh456") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("비밀번호에 한글이 포함되면 400 Bad Request를 반환한다") | ||
| void fail_when_password_contains_korean() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1가") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("비밀번호에 공백이 포함되면 400 Bad Request를 반환한다") | ||
| void fail_when_password_contains_space() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password 1!") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("X-Loopers-LoginId 헤더가 없으면 400 Bad Request를 반환한다") | ||
| void fail_when_loginId_header_missing() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("로그인 ID에 특수문자가 포함되면 400 Bad Request를 반환한다") | ||
| void fail_when_loginId_contains_special_character() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim!") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1!") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("X-Loopers-LoginPw 헤더가 없으면 400 Bad Request를 반환한다") | ||
| void fail_when_loginPw_header_missing() throws Exception { | ||
| UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( | ||
| LocalDate.of(1991, 12, 3), | ||
| "김용권", | ||
| "yk@google.com" | ||
| ); | ||
| String json = objectMapper.writeValueAsString(requestBody); | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .content(json)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("잘못된 JSON 형식이면 400 Bad Request를 반환한다") | ||
| void userSignupApiJsonInvalidTest() throws Exception { | ||
| String invalidJson = "{ invalid json }"; | ||
|
|
||
| mockMvc.perform(post("/users") | ||
| .contentType(APPLICATION_JSON) | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") | ||
| .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") | ||
| .content(invalidJson)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
| } | ||
|
|
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:
#!/bin/bash
# Check for Bean Validation annotations in DTO
find apps/commerce-api/src/main/java -name "*SignUp*Dto.java" -o -name "*UsersSignUp*Dto.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 171
🏁 Script executed:
# Check CoreException and ApiControllerAdvice existence
rg -l "class CoreException|class ApiControllerAdvice" apps/commerce-api/src/main/javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 245
🏁 Script executed:
# Check UserFacade signUp method
fd -e java "UserFacade" apps/commerce-api/src/main/java --exec grep -l "signUp" {}Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 161
🏁 Script executed:
# Search for signUp method signature
rg -A 5 "public.*signUp\(" apps/commerce-api/src/main/java/com/loopers/applicationRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 769
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1336
🏁 Script executed:
rg -B 5 -A 20 "@PostMapping.*users|public.*signUp" apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 208
🏁 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: 8455
🏁 Script executed:
find apps/commerce-api/src/main/java -name "*UserController*" -o -name "*UserV1Controller*"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
rg -B 5 -A 20 "@PostMapping.*users|public.*signUp" apps/commerce-api/src/main/java --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5899
DTO의 Bean Validation 어노테이션과 테스트가 아키텍처 원칙과 충돌한다
UsersSignUpRequestDto는 @NotNull, @NotBlank, @Email, @Pattern, @Size, @Past 어노테이션을 가지고 있으며, UsersController에서 @Valid로 검증한다. 그러나 프로젝트 아키텍처 원칙은 DTO에서 Bean Validation을 제거하고 도메인 레이어(User 엔티티 생성자, PasswordPolicy)에서 모든 검증을 수행하도록 규정한다.
현재 테스트는 MethodArgumentNotValidException이 400을 반환한다고 가정하므로, DTO 어노테이션이 제거되면 모두 실패한다. 또한 ApiControllerAdvice가 MethodArgumentNotValidException 핸들러를 가지고 있는데, 이는 아키텍처가 CoreException으로 통일된 에러 처리를 요구하는 것과 충돌한다.
수정 방향:
- UsersSignUpRequestDto에서 모든 Bean Validation 어노테이션 제거
- UsersController에서
@Valid제거 - 필드 검증은 User 엔티티 생성자나 SignUpCommand 생성 시점에서 수행하도록 이동
- UserFacade.signUp이 검증 실패 시 CoreException을 던지도록 보장
- ApiControllerAdvice에서 MethodArgumentNotValidException 핸들러 제거
테스트 재구성:
- 입력 검증 실패 시 UserFacade를 목킹하여 CoreException(ErrorType.BAD_REQUEST)을 던지도록 구성
- ApiControllerAdvice가 표준 에러 포맷을 반환하는지 검증하도록 변경
- 중복 이메일, 비밀번호 정책 위반 등 주요 오류 코드별로 상태 코드와 에러 바디 형식이 일관되는지 확인하는 케이스 추가
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserControllerTest.java`
around lines 71 - 407, Remove all Bean Validation annotations from
UsersSignUpRequestDto and remove `@Valid` from UsersController method signature so
DTOs are plain; move validation logic into the domain (User constructor or
SignUpCommand and PasswordPolicy) and ensure UserFacade.signUp throws
CoreException with ErrorType.BAD_REQUEST on validation failures; update tests in
UserControllerTest to mock UserFacade.signUp to throw
CoreException(ErrorType.BAD_REQUEST) for invalid inputs and assert
ApiControllerAdvice’s unified error response; finally remove the
MethodArgumentNotValidException handler from ApiControllerAdvice so only
CoreException handling remains.
📌 Summary
배경:
목표:
결과:
🧭 Context & Decision
문제 정의
현재 동작/제약:
문제(또는 리스크):
성공 기준(완료 정의):
선택지와 결정
고려한 대안:
A: Filter에서 인증 처리
B: ArgumentResolver에서 인증 처리
C: DTO Bean Validation vs Service 직접 검증
최종 결정: B (ArgumentResolver) + DTO Bean Validation
트레이드오프:
추후 개선 여지:
🏗️ Design Overview
영향 받는 모듈/도메인:
신규 추가:
interfaces/api/AuthUser- 인증 사용자 주입 어노테이션interfaces/api/AuthUserArgumentResolver- 인증 처리interfaces/api/CredentialsHeaders- 로그인 헤더 DTOinterfaces/api/CredentialsHeadersArgumentResolver- 헤더 바인딩application/user/AuthUserPrincipal- 인증된 사용자 식별 정보application/user/UserFacade- 유스케이스 조합application/user/UserInfo- 사용자 정보 응답 DTOapplication/user/SignUpCommand- 회원가입 커맨드domain/user/UserService- 도메인 서비스domain/user/UserRepository- 레포지토리 인터페이스domain/user/UserModel- JPA 엔티티제거/대체:
🔁 Flow Diagram
1. 회원가입 (POST /users)
sequenceDiagram participant Client participant Controller participant Facade participant Service participant Repository participant PasswordEncoder Client->>Controller: POST /users<br/>(loginId, loginPw, birthDate, name, email) Controller->>Controller: SignUpCommand 생성 Controller->>Facade: signUp(command) Facade->>Service: createUser(loginId, loginPw, birthDate, name, email) Service->>Repository: existsByLoginId(loginId) Repository-->>Service: boolean Service->>Repository: existsByEmail(email) Repository-->>Service: boolean Note over Service: validatePasswordNotContainsBirthDate() Service->>PasswordEncoder: encode(rawPassword) PasswordEncoder-->>Service: encodedPassword Service->>Repository: save(userModel) Repository-->>Service: UserModel Service-->>Facade: UserModel Facade-->>Controller: UserInfo Controller-->>Client: 200 OK {id}2. 내 정보 조회 (GET /users/me)
sequenceDiagram participant Client participant Controller participant AuthResolver participant Facade participant Service participant Repository Client->>Controller: GET /users/me<br/>(X-Loopers-LoginId, X-Loopers-LoginPw) Controller->>AuthResolver: @AuthUser 바인딩 AuthResolver->>Service: authenticate(loginId, loginPw) Service-->>AuthResolver: UserModel AuthResolver-->>Controller: AuthUserPrincipal Controller->>Facade: getMyInfo(userId) Facade->>Service: findById(userId) Service->>Repository: findById(userId) Repository-->>Service: UserModel Service-->>Facade: UserModel Facade-->>Controller: UserInfo Note over Controller: MaskingUtils.maskLastCharacter(name) Controller-->>Client: 200 OK {loginId, name(masked), birthDate, email}3. 비밀번호 변경 (PATCH /users/me/password)
sequenceDiagram participant Client participant Controller participant AuthResolver participant Facade participant Service participant Repository participant PasswordEncoder Client->>Controller: PATCH /users/me/password<br/>(currentPassword, newPassword) Controller->>AuthResolver: @AuthUser 바인딩 AuthResolver-->>Controller: AuthUserPrincipal Controller->>Facade: changePassword(userId, request) Facade->>Service: changePassword(userId, currentPw, newPw) Service->>Repository: findById(userId) Repository-->>Service: UserModel Service->>PasswordEncoder: matches(currentPw, user.password) PasswordEncoder-->>Service: true Service->>PasswordEncoder: matches(newPw, user.password) PasswordEncoder-->>Service: false (새 비밀번호 != 기존) Note over Service: validatePasswordNotContainsBirthDate() Service->>PasswordEncoder: encode(newPassword) PasswordEncoder-->>Service: newEncodedPassword Service->>Service: user.changePassword(newEncodedPassword) Note over Service: @Transactional - 변경 감지로 자동 저장 Service-->>Facade: void Facade-->>Controller: void Controller-->>Client: 200 OKAPI 요약
변경 목적
레이어드 아키텍처 기반으로 사용자 회원가입, 내 정보 조회, 비밀번호 변경 기능을 구현하며, 헤더 기반 인증 메커니즘을 ArgumentResolver로 분리하여 향후 인증 방식 변경에 대한 확장성을 확보합니다.
핵심 변경점
리스크/주의사항
테스트/검증
UsersApiE2ETest에서 회원가입, 내 정보 조회, 비밀번호 변경에 대한 E2E 테스트 실시
UserControllerTest에서 MockMvc 기반 컨트롤러 단위 테스트로 입력 검증 커버
UserDtoValidationTest에서 Bean Validation 어노테이션 단위 테스트
UserServiceTest에서 서비스 레이어 비즈니스 로직 단위 테스트
UserModelTest에서 도메인 엔티티 guard 검증