-
Notifications
You must be signed in to change notification settings - Fork 44
[VOLUME-1] 회원가입, 내 정보조회, 비밀번호 수정 구현 #15
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: letter333
Are you sure you want to change the base?
Conversation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- member-erd.md: 회원 테이블 ERD 설계 - member-signup-design.md: 시퀀스/클래스 다이어그램, 패키지 구조 - CLAUDE.md: 개발 규칙 및 문서 작성 가이드 반영 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Member 도메인 객체 구현 (순수 Java, JPA 어노테이션 없음) - 필드 검증: loginId, password, name, birthday, email - 비밀번호 규칙: 8~16자, 영문+숫자+특수문자, 생년월일 포함 불가 - encryptPassword()로 암호화된 비밀번호 교체 지원 test: add MemberTest with 14 unit test cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ure/member-signup
… entity Member 도메인의 Infrastructure 레이어 구현: - MemberEntity (JPA 영속성 엔티티, Domain↔Entity 변환) - MemberRepository 인터페이스 (도메인 레이어) - MemberJpaRepository (Spring Data JPA) - MemberRepositoryImpl (Repository 구현체) - MemberEntityTest, MemberRepositoryImplIntegrationTest - spring-security-crypto 의존성 추가 - docker-java.properties (Docker Engine 29 TestContainers 호환) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberService.signUp(): 중복 검사 → 도메인 생성 → 비밀번호 암호화 → 저장 - PasswordEncoderConfig: BCryptPasswordEncoder Bean 등록 - MemberServiceTest: 정상 가입, loginId 중복, email 중복 통합 테스트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberInfo: Domain → 응답 변환 record (password, birthday 제외) - MemberFacade: MemberService 위임 및 MemberInfo 변환 - MemberInfoTest, MemberFacadeTest 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- POST /api/v1/members → 201 Created - MemberV1Dto: SignUpRequest/SignUpResponse record - MemberV1ApiSpec: Swagger 스펙 인터페이스 - MemberV1Controller: Facade 위임 및 응답 변환 - MemberV1ApiE2ETest: 정상 가입(201), 검증 실패(400), 중복(409) E2E 테스트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Controller에서 birthday null/빈 문자열/잘못된 형식 시 400 BAD_REQUEST 반환 - birthday 관련 E2E 테스트 2건 추가 - 회원가입 API .http 파일 생성 - CLAUDE.md 프로젝트 규칙 보강 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 내 정보 조회 기능 시퀀스/클래스 다이어그램 작성 - 회원가입 시퀀스 다이어그램 Entity 반환 화살표 누락 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberRepository 인터페이스에 findByLoginId 추가 - MemberJpaRepository, MemberRepositoryImpl 구현 - 통합 테스트 2건 추가 (존재/미존재 케이스) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberService.authenticate() 메서드 추가 (loginId 조회 + 비밀번호 검증) - ErrorType에 UNAUTHORIZED(401) 추가 - 통합 테스트 3건 추가 (성공, 회원 미존재, 비밀번호 불일치) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- GET /api/v1/members/me 엔드포인트 추가 (X-Loopers-LoginId, X-Loopers-LoginPw 헤더 인증) - MemberInfo에 birthday 필드 추가, MyInfoResponse DTO 추가 - MemberFacade.getMyInfo(), MemberV1ApiSpec, MemberV1Controller 구현 - ApiControllerAdvice에 MissingRequestHeaderException 핸들러 추가 - E2E 테스트 4건 추가 (200, 401×2, 400) - 통합 테스트 생성자 주입으로 리팩터링 (필드 주입 → 생성자 주입) - member-v1.http에 내 정보 조회 요청 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- password MIN(8자), MAX(16자) 성공 테스트 추가 - name MIN(한글 2자), MAX(한글 20자) 성공 테스트 추가 - birthday 오늘 날짜(경계값) 성공 테스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feature/member-profile-lookup
Member.changePassword() 메서드를 통해 현재 비밀번호 검증, 동일 비밀번호 방지, 새 비밀번호 룰 검증(길이/패턴/생년월일), 암호화까지 도메인 엔티티에서 캡슐화 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
MemberService.updatePassword() 추가, MemberRepository에 updatePassword 메서드 정의, MemberRepositoryImpl에서 JPA dirty checking 기반 UPDATE 구현, 통합 테스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
PATCH /api/v1/members/me/password 엔드포인트 추가, 헤더 PW와 Body currentPassword 일치 검증, E2E 테스트 8건 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
API 응답 규칙, 의존성 방향, 인증 헤더 규칙, TDD 단계별 진행 규칙, 테스트 경계값 케이스 가이드 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: 비밀번호 변경 API 구현
도메인 계층의 PasswordEncoder 의존성을 제거하고, 유즈케이스 검증(현재 비밀번호 확인, 동일 비밀번호 확인)을 MemberService로 이동하여 의존성 방향 규칙 준수 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
refactor: 비밀번호 변경 검증 책임을 서비스 레이어로 분리
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…loop-pack-be-l2-vol3-java into feature/member-signup
feat: 회원 도메인 기능 구현 (가입, 조회, 비밀번호 변경)
📝 WalkthroughWalkthrough회원 관리 기능을 위한 도메인 엔티티, 서비스, 레포지토리, API 컨트롤러, DTO 및 관련 테스트를 새로 추가한다. 회원가입, 인증, 비밀번호 변경 기능을 포함한다. Changes
Sequence DiagramsequenceDiagram
participant Client
participant Controller as MemberV1Controller
participant Facade as MemberFacade
participant Service as MemberService
participant Repository as MemberRepository
participant DB as Database
participant Encoder as PasswordEncoder
Client->>Controller: POST /api/v1/members (SignUpRequest)
Controller->>Facade: signUp(loginId, password, name, birthday, email)
Facade->>Service: signUp(loginId, password, name, birthday, email)
Service->>Repository: existsByLoginId(loginId)
Repository->>DB: SELECT * FROM member WHERE login_id=?
DB-->>Repository: 0 rows
Repository-->>Service: false
Service->>Repository: existsByEmail(email)
Repository->>DB: SELECT * FROM member WHERE email=?
DB-->>Repository: 0 rows
Repository-->>Service: false
Service->>Encoder: encode(password)
Encoder-->>Service: encoded_password
Service->>Repository: save(Member)
Repository->>DB: INSERT INTO member VALUES(...)
DB-->>Repository: saved_entity
Repository-->>Service: Member
Service-->>Facade: Member
Facade-->>Controller: MemberInfo
Controller-->>Client: ApiResponse(SignUpResponse)
Client->>Controller: GET /api/v1/members/me (headers: loginId, password)
Controller->>Facade: getMyInfo(loginId, password)
Facade->>Service: authenticate(loginId, password)
Service->>Repository: findByLoginId(loginId)
Repository->>DB: SELECT * FROM member WHERE login_id=?
DB-->>Repository: member_entity
Repository-->>Service: Member
Service->>Encoder: matches(password, encoded_password)
Encoder-->>Service: true
Service-->>Facade: Member
Facade->>Facade: withMaskedName()
Facade-->>Controller: MemberInfo
Controller-->>Client: ApiResponse(MyInfoResponse)
Client->>Controller: PATCH /api/v1/members/me/password (headers: loginId, password)
Controller->>Facade: updatePassword(loginId, currentPassword, newPassword)
Facade->>Service: updatePassword(loginId, currentPassword, newPassword)
Service->>Repository: findByLoginId(loginId)
Repository->>DB: SELECT * FROM member WHERE login_id=?
DB-->>Repository: member_entity
Repository-->>Service: Member
Service->>Encoder: matches(currentPassword, encoded_password)
Encoder-->>Service: true
Service->>Encoder: encode(newPassword)
Encoder-->>Service: encoded_new_password
Service->>Repository: updatePassword(loginId, encoded_new_password)
Repository->>DB: UPDATE member SET password=? WHERE login_id=?
DB-->>Repository: success
Repository-->>Service: void
Service-->>Facade: void
Facade-->>Controller: void
Controller-->>Client: ApiResponse(success)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 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 |
MemberInfo에 withMaskedName() 메서드 추가하여 이름의 마지막 글자를 *로 마스킹하는 기능 구현 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: 내 정보 조회 시 이름 마스킹 기능 추가
LOGIN_ID_PATTERN을 추가하여 영문 대소문자와 숫자만 허용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: 로그인 ID 영문/숫자만 허용하는 검증 규칙 추가
- DTO: @Valid + Bean Validation으로 형식 검증 (null, 패턴, 길이) - Domain: 비즈니스 규칙만 유지 (생년월일 미래 불가, 비밀번호에 생년월일 포함 불가) - ApiControllerAdvice에 MethodArgumentNotValidException 핸들러 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- main 브랜치의 비밀번호 변경 기능과 Bean Validation 리팩토링 통합 - UpdatePasswordRequest에 Bean Validation 추가 - changePassword()에서 생년월일 포함 여부만 검증 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
refactor: DTO에 Bean Validation 적용 및 도메인 검증 책임 분리
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
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: 13
🤖 Fix all issues with AI agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java`:
- Around line 18-20: withMaskedName currently calls name.substring(...) without
defensive checks causing NPE or StringIndexOutOfBounds; update
MemberInfo.withMaskedName to: if name is null or blank return this (or a new
MemberInfo with same fields) unchanged; if name.length() == 1 return new
MemberInfo(..., "*", ...); if name.length() >= 2 build maskedName by replacing
only the last character with "*" and return new MemberInfo(..., maskedName,
...). Add unit tests in MemberInfoTest covering null name, empty string name,
and single-character name to assert correct returned value and no exceptions.
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java`:
- Around line 31-38: The public all-args constructor on Member can be misused
and bypass domain validation; make that constructor non-public (package-private
or private) and introduce a clearly-named static factory for DB rehydration such
as Member.reconstructForPersistence(Long id, String loginId, String password,
String name, LocalDate birthday, String email) (or Member.fromDatabase(...)) so
intent is explicit; keep the normal public factory/constructor that enforces
validations separate, document/annotate the reconstructForPersistence method as
internal/for-persistence-only, restrict its visibility to the persistence
package if possible, and add a unit test that ensures normal creation runs
validations while reconstructForPersistence is only used by the persistence
layer.
In
`@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`:
- Around line 57-59: MemberService currently encodes the new password, mutates
the Member, then calls memberRepository.updatePassword(loginId,
member.getPassword()), which causes MemberRepositoryImpl.updatePassword to
re-query by loginId and creates duplicate queries and a TOCTOU risk; change the
repository API to accept the domain object (e.g. add/replace with
MemberRepository.updatePassword(Member member) and implement
MemberRepositoryImpl.updatePassword(Member member) to persist the passed member
(using the ORM save/flush or entityManager.merge) so MemberService can call
memberRepository.updatePassword(member) directly after
member.changePassword(...), eliminating the extra findByLoginId; update tests to
cover concurrent password-change scenarios.
- Around line 40-42: The MemberService is instantiating Member with the raw
password (Member constructor) then calling
member.encryptPassword(passwordEncoder.encode(password)), which temporarily
stores sensitive plaintext and risks exposure; instead, perform
validatePasswordNotContainsBirthday against the raw password inside
MemberService, then immediately encrypt with passwordEncoder.encode and create
the Member without passing the raw password (or with a constructor that accepts
only the encoded password), remove or relocate the
validatePasswordNotContainsBirthday logic out of the Member constructor, and
apply the same pattern to updatePassword/changePassword so password validation
happens in the service and only the encrypted password is stored on the domain
object.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java`:
- Around line 8-10: The repository currently allows a race between
existsByLoginId/existsByEmail and save(), so update MemberRepositoryImpl.save()
to catch org.springframework.dao.DataIntegrityViolationException and translate
it into an HTTP 409 conflict (e.g., throw a custom ConflictException or
ResponseStatusException(HttpStatus.CONFLICT)), or alternatively add a handler
method in ApiControllerAdvice that maps DataIntegrityViolationException -> 409;
then add a concurrency integration test in MemberRepositoryImplIntegrationTest
that runs concurrent save attempts with the same loginId/email (using
ExecutorService/CountDownLatch or similar) and asserts one succeeds while the
other results in a 409/conflict condition.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java`:
- Line 35: Wrap the LocalDate.parse(request.birthday()) call in
MemberV1Controller with a try-catch that catches
java.time.format.DateTimeParseException and converts it into the project's
standard error response (e.g., throw a CoreException or return a BAD_REQUEST
error via existing error handling) so invalid-but-format-matching dates like
"2024-02-30" yield a consistent client-facing error; alternatively add a
ControllerAdvice handler for DateTimeParseException to map it to BAD_REQUEST;
add unit tests for MemberV1Controller signup using invalid dates ("2024-02-30",
"2024-13-01") to assert a 400 response.
- Around line 65-67: Move the password-equality business check out of
MemberV1Controller and into the service/facade layer: remove the headerPassword
vs request.currentPassword() comparison from MemberV1Controller and add a
validation in MemberFacade (e.g., add/update a method updatePassword(String
loginId, String headerPassword, String currentPassword, String newPassword))
that throws CoreException(ErrorType.BAD_REQUEST, "인증 정보가 일치하지 않습니다.") when
headerPassword and currentPassword differ, then delegate to
memberService.updatePassword(loginId, currentPassword, newPassword); also add a
unit test for MemberFacade to assert the exception is thrown on mismatch.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java`:
- Around line 3-55: Remove all Jakarta Bean Validation annotations from
MemberV1Dto.SignUpRequest and MemberV1Dto.UpdatePasswordRequest and move their
validation into the domain layer (e.g., Member constructor or new Value Objects
like LoginId, Password, Name, Birthday, Email) so that format/length/pattern
checks (loginId pattern, password length & allowed chars, name Korean chars,
birthday yyyy-MM-dd, email format) throw CoreException with
ErrorType.BAD_REQUEST; update Member creation flow to call these
validators/value-object constructors and keep existing MemberInfo/SignUpResponse
mapping unchanged; also ensure duplicate loginId/email checks remain in the
service layer and add/adjust integration tests to assert
CoreException/ErrorType.BAD_REQUEST and the standardized API error response for
each invalid field and duplicate cases.
In `@apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java`:
- Around line 68-89: Tests and Member.validateBirthday() both call
LocalDate.now(), causing flakiness at midnight; modify Member to accept a Clock
(e.g., add a constructor overload or field used by validateBirthday()) so
birthday validation uses LocalDate.now(clock), and update the tests in
MemberTest (createsSuccessfully_whenBirthdayIsToday and
throwsBadRequest_whenBirthdayIsFuture) to construct Members with a fixed Clock
(or use a test factory that supplies a fixed Clock) so the "today" and "future"
dates are deterministic; ensure validateBirthday() and Member constructors
consistently use the injected Clock.
In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java`:
- Around line 39-158: Add integration tests in
MemberRepositoryImplIntegrationTest to cover updatePassword success and failure:
create two `@Test` methods—one that saves a Member (use createMember), calls
memberRepository.updatePassword("testuser1", "newEncodedHash"), then reloads
with memberRepository.findByLoginId("testuser1") and asserts the
saved.getPassword() equals the new hash and does not equal the old one (and
optionally assert old-password-based auth fails if an auth method exists); and
another that calls memberRepository.updatePassword("nonexistent", "hash") and
asserts it results in an UNAUTHORIZED flow (expect the specific exception the
repository uses, e.g., UnauthorizedException, or the repository's defined error
behavior). Ensure tests reference updatePassword, memberRepository.save, and
memberRepository.findByLoginId so they exercise persistence and error handling.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java`:
- Around line 56-154: The SignUp nested tests in MemberV1ApiE2ETest cover some
negative flows but miss validation failure cases for
loginId/password/name/email; add tests in the SignUp class that post requests
with (1) non-alphanumeric loginId, (2) password violating allowed characters and
length, (3) name outside the expected Korean character range, and (4) malformed
email; for each new test (e.g., returnsBadRequest_whenLoginIdInvalid,
returnsBadRequest_whenPasswordInvalid, returnsBadRequest_whenNameInvalid,
returnsBadRequest_whenEmailInvalid) send the request via
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request),
new ParameterizedTypeReference<ApiResponse<Object>>() {}) and assert HTTP 400
plus that response.getBody() contains the standardized error code/message (use
the ApiResponse error fields returned by your controller) to ensure consistent
validation error responses.
In `@http/commerce-api/member-v1.http`:
- Around line 14-22: MemberV1Controller is currently reading plain passwords
from the X-Loopers-LoginPw header in getMyInfo() and updatePassword(), which is
insecure; change authentication to JWT Bearer tokens by removing reliance on
X-Loopers-LoginPw and instead validate Authorization: Bearer <token> in those
endpoints (update authentication middleware/guards used by MemberV1Controller),
ensure updatePassword() accepts the new password only in the request body over
HTTPS and requires a valid JWT or re-auth flow, enforce HTTPS in server config,
and update E2E tests to send a valid JWT for protected calls and to verify no
plaintext password appears in logs or headers.
🧹 Nitpick comments (4)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java (1)
58-65: MethodArgumentNotValidException 핸들러가 기존 프로젝트 학습 내용과 충돌한다.이전 PR
#6에서"Bean Validation 핸들링을 도입하지 않는다"는 학습이 기록되어 있다. 그러나 현재 PR 목표에서는 "DTOs use Bean Validation for format checks; domain enforces business rules only"를 명시적으로 선택했다.운영 관점 문제:
findFirst()로 첫 번째 에러만 반환하면, 클라이언트가 여러 필드 오류를 한 번에 확인할 수 없어 반복적인 요청이 발생할 수 있다.♻️ 모든 검증 오류를 반환하는 수정안
`@ExceptionHandler` public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException e) { - String message = e.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(error -> error.getDefaultMessage()) - .orElse("입력값이 올바르지 않습니다."); + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> String.format("[%s] %s", error.getField(), error.getDefaultMessage())) + .collect(Collectors.joining(", ")); + if (message.isEmpty()) { + message = "입력값이 올바르지 않습니다."; + } return failureResponse(ErrorType.BAD_REQUEST, message); },
기존 학습 내용과의 충돌이 의도된 설계 변경인지 확인이 필요하다. Based on learnings: "Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns."
apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java (2)
49-53: LocalDate.now() 직접 호출은 테스트 격리를 어렵게 한다.운영 관점: 경계 조건(오늘 날짜가 생년월일인 경우) 테스트 시 시스템 시간에 의존하게 된다.
♻️ Clock 주입으로 테스트 용이성 개선
+ private static Clock clock = Clock.systemDefaultZone(); + + // 테스트용 Clock 설정 메서드 (package-private) + static void setClock(Clock testClock) { + clock = testClock; + } + private void validateBirthday(LocalDate birthday) { - if (birthday != null && birthday.isAfter(LocalDate.now())) { + if (birthday != null && birthday.isAfter(LocalDate.now(clock))) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다."); } }
9-87: equals/hashCode 미구현으로 컬렉션/캐시 사용 시 문제가 발생할 수 있다.운영 관점: Member 객체를 Set, Map 키, 또는 캐시에 저장할 경우 Object의 기본 참조 비교로 인해 예기치 않은 동작이 발생할 수 있다.
수정안: 식별자(id) 기반 equals/hashCode를 구현한다.
♻️ id 기반 equals/hashCode 추가
`@Override` public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Member member = (Member) o; return id != null && id.equals(member.id); } `@Override` public int hashCode() { return getClass().hashCode(); }추가 테스트: 동일 id를 가진 Member 객체의 equals 동작을 검증하는 테스트를 추가한다. As per coding guidelines: "equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다."
apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java (1)
12-14:@Component대신@Service사용을 권장한다.기능적으로 동일하나,
@Service는 서비스 레이어임을 명시적으로 표현하여 계층 구분이 명확해진다. Spring의 stereotype annotation 관례를 따르는 것이 코드 가독성과 일관성에 유리하다.♻️ 수정 제안
`@RequiredArgsConstructor` -@Component +@Service public class MemberService {
| public MemberInfo withMaskedName() { | ||
| String maskedName = name.substring(0, name.length() - 1) + "*"; | ||
| return new MemberInfo(id, loginId, maskedName, birthday, 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:
fd -a "MemberInfo.java" --type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 182
🏁 Script executed:
fd -a "Member.java" --type f | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 173
🏁 Script executed:
fd -a "MemberV1Dto.java" --type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 186
🏁 Script executed:
cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 898
🏁 Script executed:
cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3138
🏁 Script executed:
cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2897
🏁 Script executed:
fd -a "*Test.java" -o "*Tests.java" | xargs rg -l "withMaskedName|MemberInfo" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 810
🏁 Script executed:
rg -n "withMaskedName" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 630
🏁 Script executed:
cat -n /home/jailuser/git/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2928
🏁 Script executed:
cat -n /home/jailuser/git/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1274
withMaskedName()에서 이름 필드의 null/빈 문자열 방어 로직이 필요하다
현재 코드는 name.substring(0, name.length() - 1)을 검증 없이 호출한다. Member 엔티티의 생성자는 name 필드를 검증하지 않으므로, 데이터베이스에서 로드된 데이터나 다른 코드 경로에서 null 또는 빈 문자열을 가질 수 있다. 이 경우 NullPointerException 또는 StringIndexOutOfBoundsException이 발생하여 /members/me 조회가 500으로 실패한다.
다음과 같은 방어 로직을 추가해야 한다:
- name이 null 또는 빈 문자열이면 그대로 반환
- name이 1자이면 "*"로 마스킹
- 2자 이상이면 마지막 글자만 마스킹
추가로 null, 빈 문자열, 1자 이름에 대한 테스트 케이스를 MemberInfoTest에 보강해야 한다.
수정안 예시
public MemberInfo withMaskedName() {
+ if (name == null || name.isEmpty()) {
+ return this;
+ }
+ if (name.length() == 1) {
+ return new MemberInfo(id, loginId, "*", birthday, email);
+ }
String maskedName = name.substring(0, name.length() - 1) + "*";
return new MemberInfo(id, loginId, maskedName, birthday, email);
}🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java`
around lines 18 - 20, withMaskedName currently calls name.substring(...) without
defensive checks causing NPE or StringIndexOutOfBounds; update
MemberInfo.withMaskedName to: if name is null or blank return this (or a new
MemberInfo with same fields) unchanged; if name.length() == 1 return new
MemberInfo(..., "*", ...); if name.length() >= 2 build maskedName by replacing
only the last character with "*" and return new MemberInfo(..., maskedName,
...). Add unit tests in MemberInfoTest covering null name, empty string name,
and single-character name to assert correct returned value and no exceptions.
| public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) { | ||
| this.id = id; | ||
| this.loginId = loginId; | ||
| this.password = password; | ||
| this.name = name; | ||
| this.birthday = birthday; | ||
| this.email = 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.
DB 재구성용 생성자가 검증을 건너뛰어 도메인 무결성이 깨질 수 있다.
운영 관점 문제: 이 생성자가 DB 재구성 외 다른 용도로 사용되면 생년월일 미래 날짜, 비밀번호 내 생년월일 포함 등 무효한 상태의 Member 객체가 생성될 수 있다.
수정안: 재구성용 생성자임을 명확히 하고, 의도치 않은 사용을 방지한다.
♻️ 정적 팩토리 메서드로 명확화
- public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) {
- this.id = id;
- this.loginId = loginId;
- this.password = password;
- this.name = name;
- this.birthday = birthday;
- this.email = email;
- }
+ /**
+ * 영속성 계층에서 기존 데이터 재구성 시에만 사용한다.
+ * 새 Member 생성 시에는 5개 인자 생성자를 사용한다.
+ */
+ public static Member reconstitute(Long id, String loginId, String password, String name, LocalDate birthday, String email) {
+ Member member = new Member(loginId, password, name, birthday, email);
+ member.id = id;
+ return member;
+ }추가 테스트: 재구성 메서드 사용 시에도 검증이 적용되는지 테스트를 추가한다.
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java` around
lines 31 - 38, The public all-args constructor on Member can be misused and
bypass domain validation; make that constructor non-public (package-private or
private) and introduce a clearly-named static factory for DB rehydration such as
Member.reconstructForPersistence(Long id, String loginId, String password,
String name, LocalDate birthday, String email) (or Member.fromDatabase(...)) so
intent is explicit; keep the normal public factory/constructor that enforces
validations separate, document/annotate the reconstructForPersistence method as
internal/for-persistence-only, restrict its visibility to the persistence
package if possible, and add a unit test that ensures normal creation runs
validations while reconstructForPersistence is only used by the persistence
layer.
| Member member = new Member(loginId, password, name, birthday, email); | ||
| member.encryptPassword(passwordEncoder.encode(password)); | ||
| return memberRepository.save(member); |
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 "Member.java" | grep -E "(domain|member)" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 156
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/Member.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3138
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java | head -60Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2859
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java | sed -n '45,60p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1010
Member 생성자에 raw password가 전달되어 도메인 객체에 일시적으로 저장되는 구조는 보안 위험이 있다.
40행에서 raw password로 Member 객체를 생성한 후 41행에서 암호화된 비밀번호로 덮어쓰는 방식이다. 이 사이에 예외 발생, 메모리 덤프, 디버깅 시 raw password가 노출될 수 있으며, changePassword도 같은 방식으로 동일한 위험을 가진다.
도메인 모델에서는 Member 생성자의 validatePasswordNotContainsBirthday 검증이 raw password 기반이므로, 단순히 암호화 후 전달하는 방식은 작동하지 않는다. 대신 서비스에서 검증을 먼저 수행한 후 암호화하고, Member 생성 시 raw password를 전달하지 않는 구조로 개선해야 한다.
🔒 수정 제안: 서비스 계층에서 검증 후 암호화
`@Transactional`
public Member signUp(String loginId, String password, String name, LocalDate birthday, String email) {
if (memberRepository.existsByLoginId(loginId)) {
throw new CoreException(ErrorType.CONFLICT);
}
if (memberRepository.existsByEmail(email)) {
throw new CoreException(ErrorType.CONFLICT);
}
+ // 서비스 계층에서 raw password 검증 수행
+ validatePasswordNotContainsBirthday(password, birthday);
+ String encodedPassword = passwordEncoder.encode(password);
+
- Member member = new Member(loginId, password, name, birthday, email);
- member.encryptPassword(passwordEncoder.encode(password));
+ Member member = new Member(loginId, encodedPassword, name, birthday, email);
return memberRepository.save(member);
}
+private void validatePasswordNotContainsBirthday(String password, LocalDate birthday) {
+ if (password != null && birthday != null) {
+ String birthdayStr = birthday.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+ if (password.contains(birthdayStr)) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다.");
+ }
+ }
+}그리고 Member 생성자에서 검증 로직을 제거하여 도메인 규칙과 인프라 관심사를 명확히 분리한다. updatePassword의 changePassword 호출도 동일한 방식으로 개선한다.
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 40 - 42, The MemberService is instantiating Member with the raw
password (Member constructor) then calling
member.encryptPassword(passwordEncoder.encode(password)), which temporarily
stores sensitive plaintext and risks exposure; instead, perform
validatePasswordNotContainsBirthday against the raw password inside
MemberService, then immediately encrypt with passwordEncoder.encode and create
the Member without passing the raw password (or with a constructor that accepts
only the encoded password), remove or relocate the
validatePasswordNotContainsBirthday logic out of the Member constructor, and
apply the same pattern to updatePassword/changePassword so password validation
happens in the service and only the encrypted password is stored on the domain
object.
| String encodedNewPassword = passwordEncoder.encode(newPassword); | ||
| member.changePassword(newPassword, encodedNewPassword); | ||
| memberRepository.updatePassword(loginId, member.getPassword()); |
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.
동일 트랜잭션 내에서 중복 조회가 발생하며, TOCTOU 위험이 있다.
MemberRepositoryImpl.updatePassword가 내부적으로 findByLoginId를 다시 호출한다(relevant snippet 참조). 이로 인해:
- 동일 트랜잭션 내 불필요한 중복 쿼리 발생
- 첫 번째 조회와 두 번째 조회 사이에 데이터 변경 시 일관성 문제(TOCTOU)
운영 환경에서 불필요한 쿼리는 DB 부하를 증가시키고, 동시성 상황에서 예측하기 어려운 에러가 발생할 수 있다.
♻️ 수정 제안: Repository에 Member 엔티티 기반 업데이트 메서드 추가
Repository 계층에서 Member 도메인 객체를 받아 업데이트하도록 변경하면 중복 조회를 제거할 수 있다:
- member.changePassword(newPassword, encodedNewPassword);
- memberRepository.updatePassword(loginId, member.getPassword());
+ member.changePassword(newPassword, encodedNewPassword);
+ memberRepository.save(member);또는 기존 updatePassword 메서드가 Member 객체를 받도록 수정:
// MemberRepository
void updatePassword(Member member);테스트 시 동시 요청 상황에서 비밀번호 변경이 정상 처리되는지 검증 필요하다.
📝 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.
| String encodedNewPassword = passwordEncoder.encode(newPassword); | |
| member.changePassword(newPassword, encodedNewPassword); | |
| memberRepository.updatePassword(loginId, member.getPassword()); | |
| String encodedNewPassword = passwordEncoder.encode(newPassword); | |
| member.changePassword(newPassword, encodedNewPassword); | |
| memberRepository.save(member); |
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 57 - 59, MemberService currently encodes the new password, mutates
the Member, then calls memberRepository.updatePassword(loginId,
member.getPassword()), which causes MemberRepositoryImpl.updatePassword to
re-query by loginId and creates duplicate queries and a TOCTOU risk; change the
repository API to accept the domain object (e.g. add/replace with
MemberRepository.updatePassword(Member member) and implement
MemberRepositoryImpl.updatePassword(Member member) to persist the passed member
(using the ORM save/flush or entityManager.merge) so MemberService can call
memberRepository.updatePassword(member) directly after
member.changePassword(...), eliminating the extra findByLoginId; update tests to
cover concurrent password-change scenarios.
| boolean existsByLoginId(String loginId); | ||
| boolean existsByEmail(String email); | ||
| Optional<MemberEntity> findByLoginId(String loginId); |
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:
# MemberEntity 및 관련 파일 찾기
fd -type f -name "MemberEntity.java" -o -name "MemberService.java" -o -name "MemberRepository.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 255
🏁 Script executed:
# Repository 파일 패턴 확인
fd -type f -name "*Member*" | grep -E "(java|sql)$" | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 255
🏁 Script executed:
# Find Member-related Java files
fd "Member.*\.java$" --extension java | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1606
🏁 Script executed:
# Search in apps directory for repository files
find apps -name "*Member*" -type f | grep -E "\.java$"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1606
🏁 Script executed:
# Check MemberEntity for unique constraints
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2272
🏁 Script executed:
# Check MemberService for business logic
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2868
🏁 Script executed:
# Check MemberRepositoryImpl for exception handling
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1965
🏁 Script executed:
# Search for exception handling in related files
rg -n "DataIntegrityViolationException|ExceptionHandler|ControllerAdvice" apps/commerce-api/src/main/java/com/loopers/infrastructure/member/ apps/commerce-api/src/main/java/com/loopers/application/member/ apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check integration tests for concurrent scenarios
cat -n apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6202
🏁 Script executed:
# Search for global exception handlers
find apps/commerce-api -name "*Exception*" -o -name "*Advice*" -o -name "*Handler*" | grep -E "\.java$"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 326
🏁 Script executed:
# Check exception handling in ApiControllerAdvice
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: 7765
🏁 Script executed:
# Check 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
DataIntegrityViolationException을 CONFLICT로 매핑하고 동시성 테스트를 추가해야 한다
DB 유니크 제약은 이미 설정되어 있으나, 예외 처리와 테스트가 부족하다. 현재 코드는 existsByLoginId/Email 체크와 저장 사이의 경쟁 조건에서 동시 가입이 발생할 경우 DataIntegrityViolationException이 발생하지만, 이를 처리하는 핸들러가 없어 사용자가 500 오류를 받게 된다.
MemberRepositoryImpl의 save() 메서드에서 DataIntegrityViolationException을 catch하여 CONFLICT 예외로 변환하거나, ApiControllerAdvice에 DataIntegrityViolationException 핸들러를 추가해 409 응답을 반환해야 한다. 또한 MemberRepositoryImplIntegrationTest에 같은 loginId/email로 동시에 가입 요청하는 시나리오에 대한 통합 테스트를 추가해야 한다.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java`
around lines 8 - 10, The repository currently allows a race between
existsByLoginId/existsByEmail and save(), so update MemberRepositoryImpl.save()
to catch org.springframework.dao.DataIntegrityViolationException and translate
it into an HTTP 409 conflict (e.g., throw a custom ConflictException or
ResponseStatusException(HttpStatus.CONFLICT)), or alternatively add a handler
method in ApiControllerAdvice that maps DataIntegrityViolationException -> 409;
then add a concurrency integration test in MemberRepositoryImplIntegrationTest
that runs concurrent save attempts with the same loginId/email (using
ExecutorService/CountDownLatch or similar) and asserts one succeeds while the
other results in a 409/conflict condition.
| @DisplayName("오늘 날짜(경계값)이면, 정상적으로 생성된다.") | ||
| @Test | ||
| void createsSuccessfully_whenBirthdayIsToday() { | ||
| // arrange | ||
| LocalDate today = LocalDate.now(); | ||
|
|
||
| // act | ||
| Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, today, VALID_EMAIL); | ||
|
|
||
| // assert | ||
| assertThat(member.getBirthday()).isEqualTo(today); | ||
| } | ||
|
|
||
| @DisplayName("미래 날짜이면, BAD_REQUEST 예외가 발생한다.") | ||
| @Test | ||
| void throwsBadRequest_whenBirthdayIsFuture() { | ||
| // arrange | ||
| LocalDate futureDate = LocalDate.now().plusDays(1); | ||
|
|
||
| // act | ||
| CoreException result = assertThrows(CoreException.class, () -> | ||
| new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, futureDate, VALID_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:
#!/bin/bash
# 1. Member.java 파일 위치 및 내용 확인
fd "Member.java" -type f | grep -v Test
# 2. Member.java에서 LocalDate.now() 및 Clock 사용 확인
fd "Member.java" -type f | grep -v Test | xargs -I {} sh -c 'echo "=== {} ===" && rg -n "LocalDate\.now|Clock|java\.time\." {}'
# 3. Member 생성자 시그니처 확인
fd "Member.java" -type f | grep -v Test | xargs -I {} sh -c 'echo "=== Constructor in {} ===" && ast-grep --pattern "class Member { $$$ }" {}'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 597
🏁 Script executed:
#!/bin/bash
# MemberTest.java 파일 전체 구조 및 테스트 유형 확인
fd "MemberTest.java" -type f && wc -l $(fd "MemberTest.java" -type f)Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 255
🏁 Script executed:
#!/bin/bash
# 테스트 클래스의 Spring annotation 및 통합 테스트 여부 확인
fd "MemberTest.java" -type f | xargs -I {} sh -c 'echo "=== First 50 lines of {} ===" && head -50 {}'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 255
🏁 Script executed:
#!/bin/bash
# Member.java 파일 위치 찾기
fd "Member.java" | grep -v TestRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 154
🏁 Script executed:
#!/bin/bash
# MemberTest.java 파일 위치 찾기
fd "MemberTest.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 158
🏁 Script executed:
#!/bin/bash
# 도메인 폴더 구조 확인
fd "Member" --extension java | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1606
🏁 Script executed:
#!/bin/bash
# Member.java 전체 내용 확인 (도메인 모델의 시간 처리 방식)
cat -n "apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3138
🏁 Script executed:
#!/bin/bash
# MemberTest.java 전체 내용 확인 (테스트 구조 및 테스트 유형)
cat -n "apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6106
단위 테스트의 자정 경계 플래키 위험을 제거하려면 시간을 고정해야 한다
Member 클래스가 validateBirthday() 메서드 내부에서 LocalDate.now()를 호출하고, 테스트도 LocalDate.now()에 의존하면 자정 전후에 두 호출의 결과가 달라져 테스트가 실패할 수 있다. CI 파이프라인이 24시간 운영되면 언젠가는 이 시나리오가 발생한다.
Member 생성자에 Clock을 주입하는 방식이나, 테스트 내에서 고정된 기준 시간을 설정하는 팩토리 메서드를 도입해 검증 시간을 명시적으로 통제해야 한다. 추가로 고정 Clock을 사용해 자정 전후의 경계값 테스트를 보강하면 회귀를 방지할 수 있다.
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java`
around lines 68 - 89, Tests and Member.validateBirthday() both call
LocalDate.now(), causing flakiness at midnight; modify Member to accept a Clock
(e.g., add a constructor overload or field used by validateBirthday()) so
birthday validation uses LocalDate.now(clock), and update the tests in
MemberTest (createsSuccessfully_whenBirthdayIsToday and
throwsBadRequest_whenBirthdayIsFuture) to construct Members with a fixed Clock
(or use a test factory that supplies a fixed Clock) so the "today" and "future"
dates are deterministic; ensure validateBirthday() and Member constructors
consistently use the injected Clock.
| private Member createMember(String loginId, String email) { | ||
| Member member = new Member(loginId, "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), email); | ||
| member.encryptPassword("$2a$10$encodedHash"); | ||
| return member; | ||
| } | ||
|
|
||
| @DisplayName("회원을 저장할 때,") | ||
| @Nested | ||
| class Save { | ||
|
|
||
| @DisplayName("정상적으로 저장되고, ID가 부여된다.") | ||
| @Test | ||
| void savesMember_andAssignsId() { | ||
| // arrange | ||
| Member member = createMember("testuser1", "test@example.com"); | ||
|
|
||
| // act | ||
| Member saved = memberRepository.save(member); | ||
|
|
||
| // assert | ||
| assertAll( | ||
| () -> assertThat(saved.getId()).isNotNull(), | ||
| () -> assertThat(saved.getLoginId()).isEqualTo("testuser1"), | ||
| () -> assertThat(saved.getPassword()).isEqualTo("$2a$10$encodedHash"), | ||
| () -> assertThat(saved.getName()).isEqualTo("홍길동"), | ||
| () -> assertThat(saved.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), | ||
| () -> assertThat(saved.getEmail()).isEqualTo("test@example.com") | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| @DisplayName("로그인 ID 중복을 확인할 때,") | ||
| @Nested | ||
| class ExistsByLoginId { | ||
|
|
||
| @DisplayName("존재하는 loginId이면, true를 반환한다.") | ||
| @Test | ||
| void returnsTrue_whenLoginIdExists() { | ||
| // arrange | ||
| memberRepository.save(createMember("testuser1", "test@example.com")); | ||
|
|
||
| // act | ||
| boolean result = memberRepository.existsByLoginId("testuser1"); | ||
|
|
||
| // assert | ||
| assertThat(result).isTrue(); | ||
| } | ||
|
|
||
| @DisplayName("존재하지 않는 loginId이면, false를 반환한다.") | ||
| @Test | ||
| void returnsFalse_whenLoginIdDoesNotExist() { | ||
| // act | ||
| boolean result = memberRepository.existsByLoginId("nonexistent"); | ||
|
|
||
| // assert | ||
| assertThat(result).isFalse(); | ||
| } | ||
| } | ||
|
|
||
| @DisplayName("로그인 ID로 회원을 조회할 때,") | ||
| @Nested | ||
| class FindByLoginId { | ||
|
|
||
| @DisplayName("존재하는 loginId이면, 회원을 반환한다.") | ||
| @Test | ||
| void returnsMember_whenLoginIdExists() { | ||
| // arrange | ||
| memberRepository.save(createMember("testuser1", "test@example.com")); | ||
|
|
||
| // act | ||
| Optional<Member> result = memberRepository.findByLoginId("testuser1"); | ||
|
|
||
| // assert | ||
| assertAll( | ||
| () -> assertThat(result).isPresent(), | ||
| () -> assertThat(result.get().getLoginId()).isEqualTo("testuser1"), | ||
| () -> assertThat(result.get().getName()).isEqualTo("홍길동"), | ||
| () -> assertThat(result.get().getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), | ||
| () -> assertThat(result.get().getEmail()).isEqualTo("test@example.com") | ||
| ); | ||
| } | ||
|
|
||
| @DisplayName("존재하지 않는 loginId이면, 빈 Optional을 반환한다.") | ||
| @Test | ||
| void returnsEmpty_whenLoginIdDoesNotExist() { | ||
| // act | ||
| Optional<Member> result = memberRepository.findByLoginId("nonexistent"); | ||
|
|
||
| // assert | ||
| assertThat(result).isEmpty(); | ||
| } | ||
| } | ||
|
|
||
| @DisplayName("이메일 중복을 확인할 때,") | ||
| @Nested | ||
| class ExistsByEmail { | ||
|
|
||
| @DisplayName("존재하는 email이면, true를 반환한다.") | ||
| @Test | ||
| void returnsTrue_whenEmailExists() { | ||
| // arrange | ||
| memberRepository.save(createMember("testuser1", "test@example.com")); | ||
|
|
||
| // act | ||
| boolean result = memberRepository.existsByEmail("test@example.com"); | ||
|
|
||
| // assert | ||
| assertThat(result).isTrue(); | ||
| } | ||
|
|
||
| @DisplayName("존재하지 않는 email이면, false를 반환한다.") | ||
| @Test | ||
| void returnsFalse_whenEmailDoesNotExist() { | ||
| // act | ||
| boolean result = memberRepository.existsByEmail("nonexistent@example.com"); | ||
|
|
||
| // assert | ||
| assertThat(result).isFalse(); | ||
| } | ||
| } |
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.
updatePassword 통합 테스트가 누락되었다
운영에서 비밀번호 변경이 반영되지 않거나 예외 처리 흐름이 깨져도 현재 통합 테스트로는 회귀를 잡기 어렵다.
수정안: updatePassword 성공 케이스(비밀번호 변경 후 조회 시 반영)와 loginId 미존재 시 UNAUTHORIZED 예외 케이스를 추가하는 것이 좋다.
추가 테스트: 변경된 비밀번호가 실제로 저장되었는지, 기존 비밀번호로는 인증되지 않는지도 함께 검증하는 것이 좋다.
As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다다.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java`
around lines 39 - 158, Add integration tests in
MemberRepositoryImplIntegrationTest to cover updatePassword success and failure:
create two `@Test` methods—one that saves a Member (use createMember), calls
memberRepository.updatePassword("testuser1", "newEncodedHash"), then reloads
with memberRepository.findByLoginId("testuser1") and asserts the
saved.getPassword() equals the new hash and does not equal the old one (and
optionally assert old-password-based auth fails if an auth method exists); and
another that calls memberRepository.updatePassword("nonexistent", "hash") and
asserts it results in an UNAUTHORIZED flow (expect the specific exception the
repository uses, e.g., UnauthorizedException, or the repository's defined error
behavior). Ensure tests reference updatePassword, memberRepository.save, and
memberRepository.findByLoginId so they exercise persistence and error handling.
| @DisplayName("POST /api/v1/members") | ||
| @Nested | ||
| class SignUp { | ||
|
|
||
| @DisplayName("정상적인 정보로 가입하면, 201 Created와 회원 정보를 반환한다.") | ||
| @Test | ||
| void returnsCreated_whenValidRequest() { | ||
| // arrange | ||
| MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( | ||
| "testuser1", "Test1234!", "홍길동", "1995-03-15", "test@example.com" | ||
| ); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<MemberV1Dto.SignUpResponse>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<MemberV1Dto.SignUpResponse>> response = | ||
| testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); | ||
|
|
||
| // assert | ||
| assertAll( | ||
| () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), | ||
| () -> assertThat(response.getBody().data().id()).isNotNull(), | ||
| () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), | ||
| () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), | ||
| () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") | ||
| ); | ||
| } | ||
|
|
||
| @DisplayName("필수 필드가 누락되면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenFieldMissing() { | ||
| // arrange | ||
| MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( | ||
| "testuser1", "Test1234!", "홍길동", "1995-03-15", null | ||
| ); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = | ||
| testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @DisplayName("생년월일이 누락되면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenBirthdayMissing() { | ||
| // arrange | ||
| MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( | ||
| "testuser1", "Test1234!", "홍길동", null, "test@example.com" | ||
| ); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = | ||
| testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @DisplayName("생년월일 형식이 잘못되면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenBirthdayFormatInvalid() { | ||
| // arrange | ||
| MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( | ||
| "testuser1", "Test1234!", "홍길동", "19950315", "test@example.com" | ||
| ); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = | ||
| testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @DisplayName("이미 존재하는 loginId로 가입하면, 409 Conflict를 반환한다.") | ||
| @Test | ||
| void returnsConflict_whenDuplicateLoginId() { | ||
| // arrange | ||
| Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); | ||
| existing.encryptPassword(passwordEncoder.encode("Test1234!")); | ||
| memberRepository.save(existing); | ||
|
|
||
| MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( | ||
| "testuser1", "Other1234!", "김철수", "1990-01-01", "other@example.com" | ||
| ); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = | ||
| testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); | ||
| } | ||
| } |
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.
회원가입 입력 검증 실패 케이스가 일부 부족하다
운영에서 형식이 잘못된 로그인 ID/비밀번호/이름/이메일이 통과하면 DB 제약 오류나 잘못된 계정 생성으로 이어져 장애 원인이 된다.
수정안: 로그인 ID 비영문/숫자, 비밀번호 허용 문자 위반·길이 위반, 이름 한글 범위 위반, 이메일 형식 오류 케이스를 추가하는 것이 좋다.
추가 테스트: 각 케이스에서 400과 표준 ApiResponse 오류 코드/메시지가 반환되는지 검증하는 테스트를 추가하는 것이 좋다.
As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다다.
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java`
around lines 56 - 154, The SignUp nested tests in MemberV1ApiE2ETest cover some
negative flows but miss validation failure cases for
loginId/password/name/email; add tests in the SignUp class that post requests
with (1) non-alphanumeric loginId, (2) password violating allowed characters and
length, (3) name outside the expected Korean character range, and (4) malformed
email; for each new test (e.g., returnsBadRequest_whenLoginIdInvalid,
returnsBadRequest_whenPasswordInvalid, returnsBadRequest_whenNameInvalid,
returnsBadRequest_whenEmailInvalid) send the request via
testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request),
new ParameterizedTypeReference<ApiResponse<Object>>() {}) and assert HTTP 400
plus that response.getBody() contains the standardized error code/message (use
the ApiResponse error fields returned by your controller) to ensure consistent
validation error responses.
| @DisplayName("PATCH /api/v1/members/me/password") | ||
| @Nested | ||
| class UpdatePassword { | ||
|
|
||
| private Member saveMember(String loginId, String rawPassword) { | ||
| Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); | ||
| member.encryptPassword(passwordEncoder.encode(rawPassword)); | ||
| return memberRepository.save(member); | ||
| } | ||
|
|
||
| private HttpEntity<MemberV1Dto.UpdatePasswordRequest> createRequest( | ||
| String loginId, String headerPassword, String currentPassword, String newPassword | ||
| ) { | ||
| HttpHeaders headers = new HttpHeaders(); | ||
| headers.set("X-Loopers-LoginId", loginId); | ||
| headers.set("X-Loopers-LoginPw", headerPassword); | ||
| headers.setContentType(MediaType.APPLICATION_JSON); | ||
| return new HttpEntity<>(new MemberV1Dto.UpdatePasswordRequest(currentPassword, newPassword), headers); | ||
| } | ||
|
|
||
| @DisplayName("올바른 인증 정보와 유효한 새 비밀번호로 변경하면, 200 OK를 반환한다.") | ||
| @Test | ||
| void returnsOk_whenValidRequest() { | ||
| // arrange | ||
| saveMember("testuser1", "Test1234!"); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), | ||
| responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); | ||
| } | ||
|
|
||
| @DisplayName("비밀번호 변경 후, 새 비밀번호로 내 정보 조회가 가능하다.") | ||
| @Test | ||
| void canLoginWithNewPassword_afterPasswordUpdate() { | ||
| // arrange | ||
| saveMember("testuser1", "Test1234!"); | ||
| testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), | ||
| new ParameterizedTypeReference<ApiResponse<Object>>() {} | ||
| ); | ||
|
|
||
| // act | ||
| HttpHeaders headers = new HttpHeaders(); | ||
| headers.set("X-Loopers-LoginId", "testuser1"); | ||
| headers.set("X-Loopers-LoginPw", "NewPass123!"); | ||
| ParameterizedTypeReference<ApiResponse<MemberV1Dto.MyInfoResponse>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<MemberV1Dto.MyInfoResponse>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(headers), responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertAll( | ||
| () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), | ||
| () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1") | ||
| ); | ||
| } | ||
|
|
||
| @DisplayName("존재하지 않는 loginId로 변경하면, 401 Unauthorized를 반환한다.") | ||
| @Test | ||
| void returnsUnauthorized_whenLoginIdNotFound() { | ||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("nonexistent", "Test1234!", "Test1234!", "NewPass123!"), | ||
| responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); | ||
| } | ||
|
|
||
| @DisplayName("현재 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") | ||
| @Test | ||
| void returnsUnauthorized_whenCurrentPasswordWrong() { | ||
| // arrange | ||
| saveMember("testuser1", "Test1234!"); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("testuser1", "Wrong1234!", "Wrong1234!", "NewPass123!"), | ||
| responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); | ||
| } | ||
|
|
||
| @DisplayName("헤더 비밀번호와 Body currentPassword가 다르면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenHeaderPasswordMismatchesBody() { | ||
| // arrange | ||
| saveMember("testuser1", "Test1234!"); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("testuser1", "Test1234!", "Different1!", "NewPass123!"), | ||
| responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenNewPasswordSameAsCurrent() { | ||
| // arrange | ||
| saveMember("testuser1", "Test1234!"); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("testuser1", "Test1234!", "Test1234!", "Test1234!"), | ||
| responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @DisplayName("새 비밀번호가 8자 미만이면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenNewPasswordTooShort() { | ||
| // arrange | ||
| saveMember("testuser1", "Test1234!"); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, | ||
| createRequest("testuser1", "Test1234!", "Test1234!", "New12!"), | ||
| responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") | ||
| @Test | ||
| void returnsBadRequest_whenHeaderMissing() { | ||
| // arrange | ||
| HttpHeaders headers = new HttpHeaders(); | ||
| headers.setContentType(MediaType.APPLICATION_JSON); | ||
| HttpEntity<MemberV1Dto.UpdatePasswordRequest> request = new HttpEntity<>( | ||
| new MemberV1Dto.UpdatePasswordRequest("Test1234!", "NewPass123!"), headers | ||
| ); | ||
|
|
||
| // act | ||
| ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {}; | ||
| ResponseEntity<ApiResponse<Object>> response = testRestTemplate.exchange( | ||
| ENDPOINT + "/me/password", HttpMethod.PATCH, request, responseType | ||
| ); | ||
|
|
||
| // assert | ||
| assertThat(response.getStatusCode()).isEqualTo(HttpStatus.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.
생년월일 포함 금지 규칙을 E2E에서 검증하지 않는다
운영에서 해당 규칙이 회귀되면 취약한 비밀번호가 허용되어 보안 사고로 이어질 수 있다.
수정안: 새 비밀번호에 생년월일(yyyyMMdd)이 포함된 케이스를 추가하고 400을 기대하는 테스트를 추가하는 것이 좋다.
추가 테스트: 오류 응답의 표준 ApiResponse 코드/메시지도 함께 검증하는 테스트를 추가하는 것이 좋다.
As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다다.
| GET {{commerce-api}}/api/v1/members/me | ||
| X-Loopers-LoginId: testuser1 | ||
| X-Loopers-LoginPw: Test1234! | ||
|
|
||
| ### 비밀번호 변경 | ||
| PATCH {{commerce-api}}/api/v1/members/me/password | ||
| X-Loopers-LoginId: testuser1 | ||
| X-Loopers-LoginPw: Test1234! | ||
| Content-Type: application/json |
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:
# Step 1: Search for X-Loopers-LoginPw usage in backend code
rg -n "X-Loopers-LoginPw" --type java -C 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5249
🏁 Script executed:
# Step 2: Search for X-Loopers-LoginId usage in backend code
rg -n "X-Loopers-LoginId" --type java -C 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5157
🏁 Script executed:
# Step 3: Explore the http directory structure to understand its purpose
find . -path "*/http/*" -type f | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 183
🏁 Script executed:
# Step 4: Search for authentication-related patterns in controllers
rg -n "@PostMapping|@GetMapping|@PatchMapping" --type java -C 1 | grep -i -E "auth|login|member|password" -A 2 -B 2 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1184
🏁 Script executed:
# Step 5: Look for JWT or session-based auth implementation
rg -n "JWT|Bearer|Authorization|session" --type java | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 430
X-Loopers-LoginPw 헤더 기반 인증 방식은 운영 환경에서 보안 위험을 초래한다.
MemberV1Controller의 getMyInfo(), updatePassword() 메서드에서 평문 비밀번호를 HTTP 헤더로 전송하도록 구현되어 있다. 이는 다음 문제가 있다:
- 웹서버/프록시 로그 노출: X-Loopers-LoginPw 헤더가 접근 로그에 기록되어 평문 비밀번호가 노출된다
- 중간자 공격: HTTPS 미적용 또는 인증서 문제 발생 시 탈취 가능하다
- 보안 표준 미준수: OAuth 2.0, JWT 등 업계 표준 인증 방식을 사용하지 않는다
수정 방안:
- JWT 기반 인증으로 전환하여 Authorization 헤더의 Bearer 토큰 방식을 사용한다
- 민감한 정보(비밀번호)는 요청 바디가 아닌 다른 채널로 전달하거나 초기 로그인 시에만 사용한다
- 모든 운영 환경에 HTTPS를 강제한다
추가 테스트:
- E2E 테스트에서 암호화된 토큰 검증 케이스를 추가한다
- 헤더 노출 시 비밀번호가 평문으로 기록되지 않는지 로그 수준에서 검증한다
🤖 Prompt for AI Agents
In `@http/commerce-api/member-v1.http` around lines 14 - 22, MemberV1Controller is
currently reading plain passwords from the X-Loopers-LoginPw header in
getMyInfo() and updatePassword(), which is insecure; change authentication to
JWT Bearer tokens by removing reliance on X-Loopers-LoginPw and instead validate
Authorization: Bearer <token> in those endpoints (update authentication
middleware/guards used by MemberV1Controller), ensure updatePassword() accepts
the new password only in the request body over HTTPS and requires a valid JWT or
re-auth flow, enforce HTTPS in server config, and update E2E tests to send a
valid JWT for protected calls and to verify no plaintext password appears in
logs or headers.
📌 Summary
🧭 Context & Decision
문제 정의
선택지와 결정
🏗️ Design Overview
변경 범위
주요 컴포넌트 책임
🔁 Flow Diagram
회원가입 Flow
sequenceDiagram autonumber participant Client participant Controller participant Facade participant Service participant Repository participant DB Client->>Controller: POST /api/v1/members Note over Controller: @Valid로 DTO 검증 Controller->>Facade: signUp(loginId, password, ...) Facade->>Service: signUp(...) Service->>Service: Member 생성 (도메인 검증) Service->>Service: 비밀번호 BCrypt 암호화 Service->>Repository: save(member) Repository->>DB: INSERT DB-->>Repository: member with ID Repository-->>Service: Member Service-->>Facade: Member Facade-->>Controller: MemberInfo Controller-->>Client: 201 Created내 정보 조회 Flow
sequenceDiagram autonumber participant Client participant Controller participant Facade participant Service participant Repository Client->>Controller: GET /api/v1/members/me Note over Controller: X-Loopers-LoginId/LoginPw 헤더 Controller->>Facade: getMyInfo(loginId, password) Facade->>Service: authenticate(loginId, password) Service->>Repository: findByLoginId(loginId) Repository-->>Service: Member Service->>Service: BCrypt 비밀번호 검증 Service-->>Facade: Member Facade->>Facade: 이름 마스킹 (홍길동 → 홍길*) Facade-->>Controller: MemberInfo Controller-->>Client: 200 OK비밀번호 변경 Flow
sequenceDiagram autonumber participant Client participant Controller participant Facade participant Service participant Member participant Repository Client->>Controller: PATCH /api/v1/members/me/password Note over Controller: 헤더 PW == Body currentPassword 검증 Controller->>Facade: updatePassword(loginId, current, new) Facade->>Service: updatePassword(...) Service->>Repository: findByLoginId(loginId) Repository-->>Service: Member Service->>Service: 현재 비밀번호 검증 (BCrypt) Service->>Service: 새 비밀번호 != 현재 비밀번호 검증 Service->>Member: changePassword(newRaw, newEncoded) Note over Member: 생년월일 포함 여부 검증 Service->>Repository: updatePassword(loginId, encoded) Repository-->>Service: void Service-->>Facade: void Facade-->>Controller: void Controller-->>Client: 200 OK✅ Checklist
변경 목적: 회원 관리 API 3개(회원가입, 내 정보 조회, 비밀번호 수정)를 TDD 기반으로 구현하고, 도메인 계층에서 비즈니스 규칙, DTO에서 형식 검증을 분리하여 책임을 명확히 함.
핵심 변경점: 도메인 Member 엔티티(생년월일 미래 불가, 비밀번호에 생년월일 미포함 검증), MemberService(회원가입·인증·비밀번호 변경), MemberFacade(서비스 오케스트레이션), 3개 REST 엔드포인트(가입/조회/수정), BCryptPasswordEncoder 설정, DTO 빈 검증 추가, 예외 핸들러 확장(헤더/검증 에러 처리).
리스크/주의사항: 비밀번호 변경 시 헤더(X-Loopers-LoginPw)와 request body(currentPassword)의 값이 문자열 equals로 비교되고 있는데, 양쪽이 일치해야만 진행되는 로직 설계가 의도한 것인지 확인 필요. 프로덕션 환경에서는 헤더 기반 비밀번호 전송에 HTTPS/TLS 필수. 도메인 검증(생년월일, 비밀번호 규칙) vs DTO 검증(형식, 길이, 패턴) 책임 분리가 일관성 있게 적용되었는지 확인 권장.
테스트/검증: 도메인 유닛 테스트, 서비스 통합 테스트, 리포지토리 통합 테스트, E2E 테스트 포함하여 9개 테스트 파일 구성. E2E 테스트에서 비밀번호 변경 후 신규 비밀번호로의 로그인 성공 시나리오 검증 완료. 모든 테스트 통과 상태로 PR 설명 기반.