⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

[volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현#30

Open
APapeIsName wants to merge 17 commits intoLoopers-dev-lab:APapeIsNamefrom
APapeIsName:volume-1
Open

[volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현#30
APapeIsName wants to merge 17 commits intoLoopers-dev-lab:APapeIsNamefrom
APapeIsName:volume-1

Conversation

@APapeIsName
Copy link

@APapeIsName APapeIsName commented Feb 5, 2026

📌 Summary

  • TDD를 기반으로 AI Agent 를 활용해, 회원가입 및 정보 조회, 비밀번호 변경의 플로우를 구현했습니다.

💬 리뷰 포인트

  • 코멘트 에 있는 내용입니다.
    • TDD 를 경험해보면서 엔티티의 객체지향적인 설계가 엔티티 기능의 단위 테스트의 품질을 높여준다는 점을 느꼈습니다. 만약, 엔티티 기능 테스트 코드 작성 중 getter와 같은 객체지향의 안티 패턴을 사용해야 하는 상황이 생긴다면 어떤 식으로 해결할 수 있을지 궁금합니다.
  • 코멘트 에 있는 내용입니다.
    • 이전에 주셨던 값 객체 활용이 JPA에서 어렵다는 점을 인지하고 다른 방법으로 엔티티 내부의 로직 분리를 시도했습니다. 이 방법이 좋은 방법인지 궁금합니다.
  • 추가적으로, 제가 PR을 통한 코드리뷰를 받아본 경험이 많이 없어서, PR이나 커밋을 이렇게 작성해도 괜찮은지, 어떤 점을 개선하면 좋을 지 궁금합니다.

✅ Checklist

  • 테스트 코드 포함
  • 불필요한 코드 제거
  • README or 주석 보강 (필요 시)

📎 기타 참고 사항

  • 관련 커밋, 이슈, 참고 링크 등

APapeIsName and others added 17 commits February 6, 2026 01:29
setting: gitignore 수

test: AI 를 활용한 TDD Red 코드 작성

feature: member 회원가입 도메인 기능 구현

test: 아이디 포맷 관련 테스트 작성

필요없는 mock 종속성 삭제

test: 아이디 test 작성 및 exception message 값 추가

fix: lombok @Getter 제거하고, message() 라는 메소드로 구현해 message 필드 캡슐화(추후 필드 형태가 바뀌더라도 그대로 message()로 불러올 수 있도록)

fix: 가독성 향상을 위해 내부 이넘으로 변경

fix: 비밀번호 암호화 관련 메시지 추가

test: 비밀번호 RED 테스트 작성

feature: PasswordEncryptor 유틸성 클래스 구현

delete: password -> membertest 안에 통합

test: 이름, 이메일, 생년월일 테스트 코드 작성 및 메시지 추가
feature: 정책 인터페이스화 -> 구체화 객체들 작성

refactor: 정책 인터페이스 제거 후, 하나의 Policy 안에 내부 정적 클래스로 변경

refactor: MemberPolicy 생성

feature: MemberPolicy 구현

fix: MemberPolicy 및 MemberExceptionMessage 잘못된 부분 수정
test: 회원가입 서비스 로직 Red Test 작성

feature: MemberRepository 구현

fix: @ExtendWith(MockitoExtension.class) 없어서 오류 발생 -> 추가
test: 회원가입 성공 시 test 추가 - red

feature: MemberService save 로직 구현 - green

refactor: 회원가입 시 아이디 중복 불가 코드 수정 - builder 사용해 필요한 값만 받도록 수정 - refactor
test: 회원가입 통합테스트 - red

fix: 회원가입 테스트 오류 해결 - Member 에 @table 추가 및 Service 에 @transactional 추가
test: 내 정보 조회 단위 Test 작성

test: 내 정보 조회 단위 Test 시 미존재 회원 조회 시 코드 수정

test: 내 정보 조회 단위 Test Member, MemberService 추가 작성

test: 내 정보 조회 단위 Test MemberService 누락된 부분 수정
feature: 내 정보 조회 기능 구현

test: 테스트 코드 내 비밀번호 암호화 추가

fix: 예외 메시지 수정
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • ✅ Full review completed - (🔄 Check again to review again)
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.


//then
verify(memberRepository).save(memberCaptor.capture());
assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId());
Copy link
Author

@APapeIsName APapeIsName Feb 5, 2026

Choose a reason for hiding this comment

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

현재는 위와 같이 getter 를 사용해서 request 의 Id 와 생성된 회원의 Id를 비교해서 테스트 성공 여부를 확인하고 있습니다. 이 코드는 캡슐화가 지켜지지 않아 변경 시에 테스트 코드를 수정해야 한다는 단점이 있다고 생각합니다.

위와 같이 테스트 코드 내부에서 getter를 사용하는 상황이 있다면 어떻게 테스트 코드를 작성하시는지 궁금합니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 16

🤖 Fix all issues with AI agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java`:
- Around line 54-60: The current login flow in MemberService differentiates
missing accounts (memberRepository.findByLoginId -> CANNOT_LOGIN) from wrong
passwords (member.isSamePassword -> PASSWORD_INCORRECT), which leaks account
existence; change the password-check branch to throw the same CANNOT_LOGIN error
as the not-found branch (keep detailed cause in an internal log using the member
id or login attempt and exception text), ensure the throw uses
MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message(), and add/adjust an
integration test to assert that both non-existent user and incorrect password
produce identical external responses while internal logs capture the distinct
reasons.
- Around line 31-40: Replace the direct IllegalArgumentException throws in
MemberService.getMyInfo with CoreException-based errors that use the appropriate
MemberExceptionMessage (e.g., ExistsMember.CANNOT_LOGIN) so the API error schema
is consistent with ApiControllerAdvice; specifically, throw new
CoreException(...) (or the project’s CoreException factory) and pass the
original cause when wrapping (preserve cause) rather than throwing raw
IllegalArgumentException, separate the error instances for "not found" vs
"authentication failed" using the correct MemberExceptionMessage constants, and
add/update controller/E2E tests to assert the unified error payload (code,
message, field) is returned for duplicate/auth failures.
- Around line 19-28: 현재 MemberService.register에서
memberRepository.existsByLoginId(...) 조회와 memberRepository.save(...) 사이에 경쟁 조건이
있어 동시 가입 시 중복이 발생합니다; DB 스키마에 login_id에 UNIQUE 제약을 추가하고 MemberService.register에서
save 호출을 감싸 DataIntegrityViolationException을 잡아
MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS로 변환해 던지도록 처리하며
existsByLoginId는 UX용 조기 피드백으로만 유지하세요; 또한 Member 엔티티의 매핑과 마이그레이션을 통해 login_id에
유니크 제약을 추가하고, 통합 테스트를 작성해 여러 스레드/프로세스로 병렬 등록을 시도할 때 하나만 성공하고 나머지는 중복 예외로 실패하는지
검증하십시오.

In
`@apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java`:
- Around line 21-50: MemberController's header parameters (getMyInfo and
updatePassword) currently rely on required=true which throws
MissingRequestHeaderException and bypasses ApiControllerAdvice's CoreException
handler; change the `@RequestHeader` annotations for "X-Loopers-LoginId" and
"X-Loopers-LoginPw" in MemberController.getMyInfo and
MemberController.updatePassword to required=false, then add explicit null/blank
checks that throw new CoreException(ErrorType.BAD_REQUEST, "<clear message>")
when missing; ensure these checks are applied to both loginId and password (and
currentPassword where applicable) so ApiControllerAdvice handles them and add
unit/E2E tests asserting BAD_REQUEST and the standard error body for missing
header and missing body cases.
- Around line 31-48: The controller currently reads raw passwords from the
custom header "X-Loopers-LoginPw" in MemberController.getMyInfo and
updatePassword; remove this practice by eliminating the
`@RequestHeader`("X-Loopers-LoginPw") parameters and instead accept credentials
via a standard mechanism (preferably Authorization header with Bearer/Basic
handled by your security layer or session/cookie authentication) and update
memberService.getMyInfo and memberService.updatePassword signatures to take the
authenticated principal or token-derived user identity and a request body for
new passwords (e.g., PasswordUpdateRequest); if you cannot migrate immediately,
add a Servlet filter/logging mask that strips or masks "X-Loopers-LoginPw" from
request/response logs and ensure all logging frameworks (Logback/SLF4J) are
configured to exclude or mask that header, then add security tests verifying no
sensitive header values appear in logs on success/failure.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java`:
- Around line 116-122: handleIllegalArgumentException in ApiControllerAdvice
currently maps all IllegalArgumentException to 401 which misrepresents
validation vs auth failures; change error handling so IllegalArgumentException
is not blanket-mapped to 401: introduce or use specific exception types (e.g.,
AuthenticationException / PasswordIncorrectException for auth failures,
ValidationException for input format errors, DuplicateResourceException for
duplicate-id) or wrap into a CoreException with an error code, then add/modify
handlers in ApiControllerAdvice (methods handling CoreException,
AuthenticationException, ValidationException, DuplicateResourceException) to
return appropriate status codes (401 for auth, 400 for validation, 409 for
conflicts) and consistent ApiResponse payloads; update any throw sites to throw
the specific exceptions or CoreException and extend E2E tests to assert correct
HTTP status for duplicate-id, invalid-format, and password-incorrect cases.

In
`@apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java`:
- Around line 101-109: 현재 테스트가 memberRepository.save(...)에
평문("correctPassword")을 저장해 해시 비교 로직을 검증하지 못하므로, MemberServiceIntegrationTest의
데이터 준비에서 저장 비밀번호를 PasswordEncryptor.encode("correctPassword")로 암호화해 실제 운영 저장 포맷과
일치시키세요; 또한 동일한 로그인Id로 해시 저장 조건에서 올바른 비밀번호로 인증이 성공하는 케이스(성공 테스트)와 잘못된 비밀번호로 인증이
실패하는 케이스(현재 실패 테스트)를 각각 분리해 검증하도록 테스트를 추가/정리하세요.

In
`@apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java`:
- Around line 100-122: The test 내_정보_조회_시_이름_마스킹 currently only asserts loginId;
update the assertions after calling memberService.getMyInfo(loginId, password)
to verify the name is masked by checking response.name() (for example:
assertThat(response.name()).startsWith("공") and
assertThat(response.name()).doesNotContain("명선") or
assertThat(response.name()).matches(expectedMaskRegex) so the test actually
validates the masking behavior for the Member built with name "공명선").
- Around line 124-142: The test uses a plain-text password in
Member.builder().password("correctPasswo") so
member.isSamePassword()/PasswordEncryptor.matches will fail; update the mock
setup so the stored Member has an encoded password (e.g., use the same
PasswordEncryptor.encode(...) used in production or mock
PasswordEncryptor.matches to return true for the expected pair) before stubbing
memberRepository.findByLoginId(loginId), and add a complementary success test
that verifies memberService.updatePassword(loginId, "correctPasswo",
"newPass123!") succeeds and that the repository/save method (or member state)
reflects the updated encoded password; reference Member.builder(),
memberRepository.findByLoginId(...), memberService.updatePassword(...),
Member.isSamePassword(), and PasswordEncryptor.matches().

In `@modules/jpa/build.gradle.kts`:
- Around line 21-27: Pin the test fixture dependency versions so they are
explicit and reproducible: update the
testFixturesImplementation/testFixturesRuntimeOnly entries for
"org.junit.jupiter:junit-jupiter-api", "org.junit.jupiter:junit-jupiter-params",
"org.junit.jupiter:junit-jupiter-engine" and "org.assertj:assertj-core" to
include explicit versions (or define those versions in gradle.properties and
reference them) so the current coordinates in modules/jpa/build.gradle.kts are
no longer versionless.

In `@modules/jpa/src/main/java/com/loopers/domain/member/Member.java`:
- Around line 25-26: Member 클래스의 loginId 필드에 DB 수준 유니크 제약이 빠져 TOCTOU로 동시 가입이
허용되므로 Member.loginId에 `@Column`(nullable = false, unique = true) 어노테이션을 추가하고 (클래스:
Member, 필드: loginId) 프로덕션 마이그레이션 스크립스키마에 실제 유니크 인덱스 생성 항목을 추가하여 DB가 중복을 차단하도록
하세요; 또한 서비스 레이어의 memberRepository.existsByLoginId() 체크만으로는 불충분하니 동시 등록을 시뮬레이트하는
통합 테스트를 추가해 동일한 loginId로 동시 저장 시 DataIntegrityViolationException이
발생하는지(assertThrows) 검증하도록 구현하세요.
- Around line 13-18: The Member entity currently exposes public constructors and
a public Lombok builder which bypass domain validation; change the Lombok
annotations on Member—@NoArgsConstructor, `@AllArgsConstructor`, and `@Builder`—to
use protected (or private where supported) access (e.g.,
`@NoArgsConstructor`(access = AccessLevel.PROTECTED), `@AllArgsConstructor`(access =
AccessLevel.PROTECTED), `@Builder`(access = AccessLevel.PRIVATE/PROTECTED) so
external code cannot call new Member(...) or Member.builder() directly, and
ensure the only public creation path is the existing register() factory method;
after that add an ArchUnit test that forbids direct constructor or builder calls
for com.loopers.domain.member.Member and asserts all Member instances are
created via Member.register().

In
`@modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java`:
- Around line 27-48: The Password.validate method can NPE when birthDate is
null; add an explicit null check at the start of Password.validate(String
password, LocalDate birthDate) and throw a clear IllegalArgumentException using
the appropriate MemberExceptionMessage (e.g., create/choose a message like
MemberExceptionMessage.Password.INVALID_BIRTHDATE or reuse an existing one)
before any use of birthDate, or call/ensure BirthDate.validate(birthDate) first;
update unit tests to assert that calling Password.validate with birthDate ==
null throws the expected IllegalArgumentException with the correct message.

In `@modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java`:
- Around line 10-36: The current PasswordEncryptor (methods encode and matches)
must be replaced to use a slow KDF with per-password random salt and iterations
(e.g., PBKDF2WithHmacSHA256, iterations=65536, salt length>=16) instead of raw
SHA-256: implement a new encode that generates a SecureRandom salt, derives the
key with SecretKeyFactory/PBEKeySpec, and returns a versioned encoded string
containing version, base64(salt), and base64(hash); update matches to parse that
format, re-derive the hash with the stored salt/iterations and compare using
MessageDigest.isEqual for constant-time equality; ensure null/invalid-format
inputs return false safely; and add unit tests that verify (1) same password
yields different hashes due to salt, (2) matches correctly accepts/rejects, and
(3) invalid/null formats safely fail—also keep
Member.register()/updatePassword() compatibility by using the new encode/matches
and include a version prefix to allow future migration.

In
`@modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java`:
- Around line 106-114: The test method 아이디는_중복_가입할_수_없음 in MemberTest is empty
and should either be removed or disabled/implemented; update the MemberTest
class by either deleting the empty 아이디는_중복_가입할_수_없음() method or annotate it with
`@Disabled`("통합 테스트로 이동 필요") if you intend to move it to an integration test
suite, or implement the actual duplicate-ID assertion logic against the
service/repository used in other tests so the method performs real verification.
- Around line 374-388: The test stores a raw password into Member.password
causing a false-positive because Member.isSamePassword uses
PasswordEncryptor.matches; update the isSamePassword_Fail setup to encode the
saved password the same way as isSamePassword_Success (use the same
PasswordEncryptor.encrypt/encode call or helper used in the other test) before
building the Member instance so the comparison path mirrors real usage in
Member.isSamePassword and the test reliably asserts a mismatch when passed
"wrongPassword".
🧹 Nitpick comments (6)
modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java (2)

424-518: 중복된 테스트 코드 제거 필요

UpdatePasswordPolicy.PasswordFormatValidation 내부 클래스의 테스트들이 외부 PasswordFormatValidation 클래스(Lines 164-259)의 테스트와 거의 동일하다. 차이점은 Member.register() 대신 member.updatePassword()를 호출한다는 점뿐이다.

중복 테스트는 유지보수 비용 증가 및 정책 변경 시 동기화 누락 위험을 초래한다.

수정안:

  1. 비밀번호 형식 검증이 동일 정책(MemberPolicy.Password)을 사용한다면, 하나의 테스트 세트로 통합하거나
  2. 파라미터화 테스트(@ParameterizedTest)로 리팩토링하여 registerupdatePassword 양쪽을 검증한다.

471-481: 미사용 변수 userBirthDate 제거 필요

Lines 473, 487에서 userBirthDate 변수를 선언했으나 실제로는 사용하지 않는다. throwIfWrongPasswordInput 헬퍼 메서드(Lines 509-516) 내부에서 LocalDate.of(2001, 2, 9)로 하드코딩되어 있다.

이는 테스트 의도 파악을 어렵게 하고, 향후 생년월일을 변경해야 할 때 버그를 유발할 수 있다.

수정 제안
            // 2-1
            `@Test`
            public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception {
                //given
-               LocalDate userBirthDate = LocalDate.of(2001, 2, 9);
                String wrongPassword = "pwd20010209!";

                //when

                //then
                throwIfWrongPasswordInput(wrongPassword)
                        .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message());
            }

또는 헬퍼 메서드에서 birthDate를 파라미터로 받도록 수정한다.

Also applies to: 483-495

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java (1)

119-119: 로그에 스택 트레이스 누락

다른 핸들러(handle(CoreException e), handle(Throwable e))와 달리 스택 트레이스를 로깅하지 않는다. 운영 환경에서 예외 발생 원인 추적이 어려워질 수 있다.

수정 제안
-        log.warn("IllegalArgumentException : {}", e.getMessage());
+        log.warn("IllegalArgumentException : {}", e.getMessage(), e);
apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java (1)

5-10: 필드명 일관성 검토: birthdate vs birthDate

도메인 모델(Member.birthDate)과 DTO(birthdate)의 필드명 케이스가 다르다. JSON 응답에서 birthdate로 직렬화되며, 이후 다른 DTO에서 birthDate를 사용할 경우 API 응답 규격 불일치가 발생할 수 있다.

프로젝트 전반의 네이밍 컨벤션을 확인하고 통일하는 것을 권장한다.

apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java (2)

74-78: 예상 상태 코드 불확실성 해소 필요

주석에 "또는 400 에러 (설정한 예외 처리에 따름)"라고 명시되어 있어 테스트 의도가 불명확하다. ApiControllerAdviceIllegalArgumentException 핸들러가 401을 반환하므로 현재는 테스트가 통과하지만, 핸들러 수정 시 테스트가 실패할 수 있다.

E2E 테스트는 API 규약을 검증하는 계약 역할을 하므로, 예상 상태 코드를 명확히 정의하고 주석을 제거해야 한다.

수정 제안
        // [5] 기존 비밀번호로 조회 시도 (Should Fail)
        mockMvc.perform(get("/api/members/me")
                        .header("X-Loopers-LoginId", loginId)
                        .header("X-Loopers-LoginPw", initialPw)) // Old Password
-               .andExpect(status().isUnauthorized()); // 또는 400 에러 (설정한 예외 처리에 따름)
+               .andExpect(status().isUnauthorized())
+               .andExpect(jsonPath("$.code").value("UNAUTHORIZED"));

31-79: 추가 테스트 케이스 권장

현재 테스트는 정상 흐름(happy path)과 단일 실패 케이스만 포함한다. 운영 안정성을 위해 다음 시나리오 추가를 권장한다:

  1. 중복 회원가입 시도: 동일 loginId로 재등록 시 적절한 에러 응답 확인
  2. 잘못된 비밀번호 형식으로 회원가입: 정책 위반 시 400 응답 확인
  3. 존재하지 않는 회원 조회: 404 또는 401 응답 확인
  4. 동일 비밀번호로 변경 시도: 정책 위반 에러 확인

각 시나리오별로 별도 테스트 메서드로 분리하면 실패 시 원인 파악이 용이하다.

Comment on lines +19 to +28
@Transactional
public void register(MemberRegisterRequest request) {
boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId());

if (isLoginIdAlreadyExists) {
throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message());
}

memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email()));
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

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

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

Length of output: 169


🏁 Script executed:

find . -type f -name "*.sql" -o -name "*.ddl" | head -20

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

Length of output: 67


🏁 Script executed:

find . -type f -path "**/*/Service.java" -o -path "**/*Service.java" | head -10

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

Length of output: 250


🏁 Script executed:

fd -t f "MemberService" --extension java

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

Length of output: 336


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java

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

Length of output: 3628


🏁 Script executed:

find . -type f -name "Member.java" -o -name "*Member*.java" | grep -E "(entity|domain)" | head -10

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

Length of output: 404


🏁 Script executed:

find . -path "**/resources/*" -name "*.sql" -o -name "*.ddl" -o -name "schema*" | head -20

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

Length of output: 67


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java

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

Length of output: 2535


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java

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

Length of output: 4192


🏁 Script executed:

find . -path "**/resources/*" -type f \( -name "*.sql" -o -name "*.xml" \) | xargs grep -l "login_id\|loginId" 2>/dev/null

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

Length of output: 69


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java

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

Length of output: 8912


🏁 Script executed:

find . -path "**/resources/*" -type f | grep -E "\.(sql|xml|properties|yaml|yml)$" | head -20

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

Length of output: 967


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/support/error/CoreException.java 2>/dev/null || echo "File not found"

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

Length of output: 99


🏁 Script executed:

grep -r "unique.*login" --include="*.java" --include="*.sql" . 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

find . -type f -name "*.sql" | xargs cat 2>/dev/null | head -100

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

Length of output: 67


🏁 Script executed:

find . -type f -name "CoreException.java" -o -name "*Exception.java" | grep -E "support|error|exception" | head -20

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

Length of output: 163


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java | grep -A 5 -B 5 "@Column"

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

Length of output: 67


🏁 Script executed:

rg "DataIntegrityViolationException" --type java

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

Length of output: 67


🏁 Script executed:

rg "@Column.*unique" --type java

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

Length of output: 67


🏁 Script executed:

find . -path "*/schema*" -o -path "*/migration*" -o -path "*/flyway*" | head -20

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

Length of output: 67


🏁 Script executed:

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

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

Length of output: 741


🏁 Script executed:

find . -type d -name "db" -o -name "migration" -o -name "flyway" -o -name "liquibase" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

find . -type f \( -name "*.sql" -o -name "*.ddl" \) 2>/dev/null

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

Length of output: 67


🏁 Script executed:

rg "class.*Repository" --type java | grep Member | head -10

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

Length of output: 67


🏁 Script executed:

cat -n ./modules/jpa/src/main/resources/jpa.yml

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

Length of output: 2903


🏁 Script executed:

find . -type f -name "application*.yml" -o -name "application*.yaml" | xargs grep -l "jpa\|hibernate" | head -5

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

Length of output: 256


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/resources/application.yml | head -50

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

Length of output: 1297


🏁 Script executed:

rg "hibernate\.ddl-auto|spring\.jpa\.hibernate" --type yaml

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

Length of output: 67


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepository.java 2>/dev/null || find . -name "MemberRepository.java"

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

Length of output: 174


🏁 Script executed:

rg "catch.*DataIntegrityViolationException" --type java -A 3

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

Length of output: 67


🏁 Script executed:

rg "class.*Service" --type java | head -10

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

Length of output: 683


🏁 Script executed:

find . -type f -name "*Repository.java" -path "*/infrastructure/*" | head -5 | xargs cat | head -50

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

Length of output: 672


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java

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

Length of output: 524


🏁 Script executed:

find . -path "*/db/migration*" -o -path "*/flyway*" -o -path "*/changelog*" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

rg "^CREATE TABLE.*member" -A 20 --type sql

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

Length of output: 233


🏁 Script executed:

rg "@Transactional.*Propagation" --type java | head -10

Repository: 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/MemberServiceTest.java

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

Length of output: 5710


🏁 Script executed:

rg "@Column.*unique\|@UniqueConstraint" --type java

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

Length of output: 67


🏁 Script executed:

rg "Propagation\|Isolation" --type java | head -20

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

Length of output: 67


🏁 Script executed:

grep -n "loginId" ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java

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

Length of output: 234


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java | grep -A 2 -B 2 "loginId"

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

Length of output: 773


🏁 Script executed:

rg "IllegalArgumentException" ./apps/commerce-api/src/main/java/com/loopers/application/service/ --type java

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

Length of output: 1107


🏁 Script executed:

rg "UNIQUE.*login_id\|login_id.*UNIQUE" --type sql --type java

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

Length of output: 67


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java | head -35

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

Length of output: 1002


🏁 Script executed:

find . -type f -name "*.java" -path "*/test/*" | xargs grep -l "concurrent\|parallel\|ExecutorService\|CountDownLatch" | head -5

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

Length of output: 67


🏁 Script executed:

rg "class.*Service.*{" ./apps/commerce-api/src/main/java/com/loopers/application/service/ --type java -A 5 | head -30

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

Length of output: 182


🏁 Script executed:

rg "throw new.*Exception" ./apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java

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

Length of output: 430


🏁 Script executed:

find . -path "*/infrastructure/*" -name "*Repository*.java" | xargs grep -l "save\|persist" | head -3

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

Length of output: 67


🏁 Script executed:

rg "@Column.*loginId" ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java

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

Length of output: 67


🏁 Script executed:

rg "class.*Repository" ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ --type java -A 10 | head -50

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

Length of output: 1102


🏁 Script executed:

rg "catch.*DataIntegrityViolationException\|catch.*ConstraintViolation" --type java

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

Length of output: 67


🏁 Script executed:

find . -name "*.java" -path "*/service/*" | xargs grep -l "@Transactional" | head -5 | xargs grep -A 20 "DataIntegrityViolation\|ConstraintViolation"

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

Length of output: 69


🏁 Script executed:

rg "synchronized\|Lock\|ReentrantLock" --type java

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

Length of output: 67


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java

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

Length of output: 2535


🏁 Script executed:

rg "CoreException" ./apps/commerce-api/src/main/java/com/loopers/application/service/ --type java -B 2 -A 2

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

Length of output: 759


🏁 Script executed:

find . -name "ErrorType.java" -o -name "*ErrorCode*.java" | head -5

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

Length of output: 159


🏁 Script executed:

grep -r "DataIntegrityViolation" . --include="*.java" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

rg "exists.*then.*save\|check.*then.*insert" --type java | head -10

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

Length of output: 67


🏁 Script executed:

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

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

Length of output: 949


🏁 Script executed:

rg "CoreException" ./apps/commerce-api/src/main/java/com/loopers/ --type java | grep "throw\|new" | head -20

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

Length of output: 750


🏁 Script executed:

grep -n "import.*CoreException\|import.*ErrorType" ./apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java

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

Length of output: 134


🏁 Script executed:

rg "findByLoginId\|existsByLoginId" ./apps/commerce-api/src/main/java/com/loopers/ --type java

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

Length of output: 67


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java | head -30

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

Length of output: 1457


동시 가입 요청 시 중복 로그인 ID 검증이 원자적이지 않아 경쟁 조건이 발생한다

existsByLoginId() 조회 후 save() 사이에 다른 요청이 동일 ID로 가입하면 중복 레코드가 생성되거나 DataIntegrityViolationException으로 500 오류가 발생해 운영에서 간헐 장애로 보일 수 있다.

현재 Member 엔티티에는 login_id 컬럼에 유니크 제약이 없으므로 다음을 함께 수행해야 한다:

  1. DB 스키마에 login_id에 UNIQUE 제약을 추가한다
  2. save() 호출 시 DataIntegrityViolationException을 캐치해 중복 오류로 변환한다
  3. existsByLoginId는 UX용 조기 피드백으로만 유지한다

동시 가입 요청을 병렬로 실행하고 하나만 성공, 나머지는 중복 오류로 떨어지는지 검증하는 통합 테스트를 추가한다.

🔧 수정 제안
+import org.springframework.dao.DataIntegrityViolationException;
+
 `@Transactional`
 public void register(MemberRegisterRequest request) {
     boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId());
@@ -26,7 +27,11 @@ public class MemberService {
         if (isLoginIdAlreadyExists) {
             throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message());
         }
-
-        memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email()));
+        try {
+            memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email()));
+        } catch (DataIntegrityViolationException e) {
+            throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message(), e);
+        }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public void register(MemberRegisterRequest request) {
boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId());
if (isLoginIdAlreadyExists) {
throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message());
}
memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email()));
}
`@Transactional`
public void register(MemberRegisterRequest request) {
boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId());
if (isLoginIdAlreadyExists) {
throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message());
}
try {
memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email()));
} catch (DataIntegrityViolationException e) {
throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message(), e);
}
}
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java`
around lines 19 - 28, 현재 MemberService.register에서
memberRepository.existsByLoginId(...) 조회와 memberRepository.save(...) 사이에 경쟁 조건이
있어 동시 가입 시 중복이 발생합니다; DB 스키마에 login_id에 UNIQUE 제약을 추가하고 MemberService.register에서
save 호출을 감싸 DataIntegrityViolationException을 잡아
MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS로 변환해 던지도록 처리하며
existsByLoginId는 UX용 조기 피드백으로만 유지하세요; 또한 Member 엔티티의 매핑과 마이그레이션을 통해 login_id에
유니크 제약을 추가하고, 통합 테스트를 작성해 여러 스레드/프로세스로 병렬 등록을 시도할 때 하나만 성공하고 나머지는 중복 예외로 실패하는지
검증하십시오.

Comment on lines +31 to +40
public MyMemberInfoResponse getMyInfo(String userId, String password) {
// 1. 회원 조회 (없으면 예외 발생 - MemberExceptionMessage.Common.NOT_FOUND 사용)
Member member = memberRepository.findByLoginId(userId)
.orElseThrow(() -> new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()));

// 2. 비밀번호 일치 여부 확인 (도메인 모델의 isSamePassword 활용)
if (!member.isSamePassword(password)) {
// 비밀번호 불일치 시 예외 발생 (인증 관련 메시지 사용)
throw new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message());
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

IllegalArgumentException 사용으로 오류 포맷과 원인 보존이 일관되지 않다
현재 IllegalArgumentException을 직접 던지면 에러 코드/포맷이 CoreException 경로와 달라져 운영에서 클라이언트 파싱과 장애 분석이 흔들릴 위험이 있다.
중복/인증 실패 등은 CoreException으로 통일하고, 하위 예외가 있는 경우 cause를 보존해 래핑하는 것이 안전하다.
중복 ID, 인증 실패 각각에 대해 동일한 에러 스키마(코드·메시지·필드)가 내려오는지 컨트롤러/E2E 테스트를 추가해야 한다.
Based on learnings: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/)라는 지침을 따랐다.
As per coding guidelines: 예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다.

Also applies to: 55-60

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java`
around lines 31 - 40, Replace the direct IllegalArgumentException throws in
MemberService.getMyInfo with CoreException-based errors that use the appropriate
MemberExceptionMessage (e.g., ExistsMember.CANNOT_LOGIN) so the API error schema
is consistent with ApiControllerAdvice; specifically, throw new
CoreException(...) (or the project’s CoreException factory) and pass the
original cause when wrapping (preserve cause) rather than throwing raw
IllegalArgumentException, separate the error instances for "not found" vs
"authentication failed" using the correct MemberExceptionMessage constants, and
add/update controller/E2E tests to assert the unified error payload (code,
message, field) is returned for duplicate/auth failures.

Comment on lines +54 to +60
Member member = memberRepository.findByLoginId(userId)
.orElseThrow(() -> new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()));

// 2. 본인 확인 (기존 비밀번호 일치 여부)
if (!member.isSamePassword(currentPassword)) {
throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); // 적절한 메시지로 변경 가능
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

비밀번호 오류 메시지 차이로 계정 존재 여부가 노출될 수 있다
존재하지 않는 아이디는 CANNOT_LOGIN, 비밀번호 오류는 PASSWORD_INCORRECT로 구분되어 응답이 달라져 계정 열거 공격에 악용될 수 있다.
외부로는 동일한 메시지/코드(CANNOT_LOGIN)로 통일하고, 상세 원인은 내부 로그로만 남기는 방식이 안전하다.
잘못된 아이디와 잘못된 비밀번호 두 케이스 모두 동일한 에러 응답이 내려오는지 통합 테스트를 추가해야 한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java`
around lines 54 - 60, The current login flow in MemberService differentiates
missing accounts (memberRepository.findByLoginId -> CANNOT_LOGIN) from wrong
passwords (member.isSamePassword -> PASSWORD_INCORRECT), which leaks account
existence; change the password-check branch to throw the same CANNOT_LOGIN error
as the not-found branch (keep detailed cause in an internal log using the member
id or login attempt and exception text), ensure the throw uses
MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message(), and add/adjust an
integration test to assert that both non-existent user and incorrect password
produce identical external responses while internal logs capture the distinct
reasons.

Comment on lines +21 to +50
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public void register(@RequestBody MemberRegisterRequest request) {
memberService.register(request);
}

/**
* 내 정보 조회
* 헤더 인증 (ID, PW) 기반
*/
@GetMapping("/me")
public MyMemberInfoResponse getMyInfo(
@RequestHeader("X-Loopers-LoginId") String loginId,
@RequestHeader("X-Loopers-LoginPw") String password
) {
return memberService.getMyInfo(loginId, password);
}

/**
* 비밀번호 수정
* 헤더 인증 (ID, 기존 PW) + 바디 (새 PW)
*/
@PatchMapping("/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updatePassword(
@RequestHeader("X-Loopers-LoginId") String loginId,
@RequestHeader("X-Loopers-LoginPw") String currentPassword,
@RequestBody PasswordUpdateRequest request
) {
memberService.updatePassword(loginId, currentPassword, request.newPassword());
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find apps/commerce-api/src/main -name "*Controller.java" -type f | head -5

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

Length of output: 253


🏁 Script executed:

find apps/commerce-api/src/main -name "*ControllerAdvice.java" -o -name "*ExceptionHandler.java" | head -5

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

Length of output: 168


🏁 Script executed:

find apps -name "CoreException.java" -o -name "*Exception.java" | grep -i core | head -5

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

Length of output: 161


🏁 Script executed:

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

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

Length of output: 7255


🏁 Script executed:

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

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

Length of output: 741


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java

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

Length of output: 2029


필수 헤더 누락 시 표준 에러 포맷이 깨질 수 있다

@RequestHeader의 기본값은 required=true이므로, 헤더가 누락되면 MissingRequestHeaderException이 발생한다. 그런데 ApiControllerAdvice에는 이 예외에 대한 명시적 핸들러가 없어서, 제네릭 Throwable 핸들러로 빠진다. 결과적으로 INTERNAL_ERROR 상태코드와 일반 메시지를 반환하게 되어, 다른 검증 오류들(MissingServletRequestParameterException, HttpMessageNotReadableException)의 BAD_REQUEST 포맷과 일치하지 않는다.

LoginId, LoginPw 헤더가 필수인 경우, required=false로 설정한 후 null/blank를 수동으로 검증하고 CoreException으로 변환해야 한다. 이렇게 하면 ApiControllerAdvice의 CoreException 핸들러를 거쳐 일관된 BAD_REQUEST 응답이 반환된다.

<details>
<summary>예시</summary>

```java
`@GetMapping`("/me")
public MyMemberInfoResponse getMyInfo(
        `@RequestHeader`(value = "X-Loopers-LoginId", required = false) String loginId,
        `@RequestHeader`(value = "X-Loopers-LoginPw", required = false) String password
) {
    if (loginId == null || loginId.isBlank()) {
        throw new CoreException(ErrorType.BAD_REQUEST, "필수 헤더 'X-Loopers-LoginId'가 누락되었습니다.");
    }
    if (password == null || password.isBlank()) {
        throw new CoreException(ErrorType.BAD_REQUEST, "필수 헤더 'X-Loopers-LoginPw'가 누락되었습니다.");
    }
    return memberService.getMyInfo(loginId, password);
}
```

헤더/바디 누락 케이스에서 BAD_REQUEST 상태코드와 표준 에러 포맷이 반환되는지 단위 테스트와 E2E 테스트를 추가한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java`
around lines 21 - 50, MemberController's header parameters (getMyInfo and
updatePassword) currently rely on required=true which throws
MissingRequestHeaderException and bypasses ApiControllerAdvice's CoreException
handler; change the `@RequestHeader` annotations for "X-Loopers-LoginId" and
"X-Loopers-LoginPw" in MemberController.getMyInfo and
MemberController.updatePassword to required=false, then add explicit null/blank
checks that throw new CoreException(ErrorType.BAD_REQUEST, "<clear message>")
when missing; ensure these checks are applied to both loginId and password (and
currentPassword where applicable) so ApiControllerAdvice handles them and add
unit/E2E tests asserting BAD_REQUEST and the standard error body for missing
header and missing body cases.

Comment on lines +31 to +48
@GetMapping("/me")
public MyMemberInfoResponse getMyInfo(
@RequestHeader("X-Loopers-LoginId") String loginId,
@RequestHeader("X-Loopers-LoginPw") String password
) {
return memberService.getMyInfo(loginId, password);
}

/**
* 비밀번호 수정
* 헤더 인증 (ID, 기존 PW) + 바디 (새 PW)
*/
@PatchMapping("/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updatePassword(
@RequestHeader("X-Loopers-LoginId") String loginId,
@RequestHeader("X-Loopers-LoginPw") String currentPassword,
@RequestBody PasswordUpdateRequest request
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# 대상 파일의 전체 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java

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

Length of output: 2029


🏁 Script executed:

# 로그 필터 또는 마스킹 설정 파일 검색
find . -type f \( -name "*Log*" -o -name "*Filter*" \) -path "*/commerce-api/*" | head -20

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

Length of output: 67


🏁 Script executed:

# 보안/인증 관련 설정 파일 검색
find . -type f \( -name "*Security*" -o -name "*Auth*" -o -name "*Config*" \) -path "*/commerce-api/*" | head -20

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

Length of output: 67


🏁 Script executed:

# 헤더 마스킹이나 로깅 관련 설정 검색
rg -i "mask|header|password|sensitive" --type java -A 2 apps/commerce-api/src/main/java/

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

Length of output: 6587


🏁 Script executed:

# MemberService.java 전체 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java

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

Length of output: 3628


🏁 Script executed:

# 테스트 코드 확인
find . -type f -name "*MemberControllerTest*" -o -name "*MemberServiceTest*" | head -10

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

Length of output: 165


🏁 Script executed:

# 로깅 설정 파일 검색
find . -type f \( -name "logback.xml" -o -name "logback-spring.xml" -o -name "log4j*.xml" -o -name "application*.yml" -o -name "application*.yaml" \) -path "*/commerce-api/*"

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

Length of output: 139


🏁 Script executed:

# 헤더 로깅 관련 필터 구현 여부 확인
rg -i "filter|interceptor|logging" --type java -l apps/commerce-api/src/main/java/ | head -20

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

Length of output: 67


🏁 Script executed:

# application.yml 로깅 설정 확인
cat -n apps/commerce-api/src/main/resources/application.yml

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

Length of output: 1450


🏁 Script executed:

# MemberServiceTest.java 확인
cat -n apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java

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

Length of output: 5710


🏁 Script executed:

# 전체 프로젝트의 Filter/Interceptor 구현 검색
find . -type f -name "*Filter*.java" -o -name "*Interceptor*.java" | grep -v node_modules | head -20

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

Length of output: 67


🏁 Script executed:

# 헤더 로그 마스킹 관련 구현 확인
rg "X-Loopers" --type java -B 2 -A 2

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

Length of output: 4666


비밀번호를 커스텀 헤더로 전달하면 운영 환경에서 심각한 정보 유출 위험이 있다

비밀번호를 X-Loopers-LoginPw 헤더로 전달하면 접근 로그, APM, 프록시, 네트워크 모니터링 도구에서 헤더가 그대로 수집되어 보안 사고로 이어질 수 있다. 현재 구조에서는 비밀번호 마스킹을 위한 로그 필터가 구현되지 않았다.

표준 Authorization 헤더(예: Bearer 토큰, Basic 인증) 또는 세션/쿠키 기반 인증으로 전환해야 한다. 불가피하게 헤더 기반 인증을 유지하려면 다음을 필수로 적용해야 한다:

  • 로그 필터에서 X-Loopers-LoginPw 헤더를 마스킹 처리
  • 모든 로깅 라이브러리(Logback, SLF4J 등)에서 민감 헤더를 제외하거나 마스킹

인증 실패/성공 시나리오와 민감 헤더가 로그에 기록되지 않음을 검증하는 보안 테스트를 추가해야 한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java`
around lines 31 - 48, The controller currently reads raw passwords from the
custom header "X-Loopers-LoginPw" in MemberController.getMyInfo and
updatePassword; remove this practice by eliminating the
`@RequestHeader`("X-Loopers-LoginPw") parameters and instead accept credentials
via a standard mechanism (preferably Authorization header with Bearer/Basic
handled by your security layer or session/cookie authentication) and update
memberService.getMyInfo and memberService.updatePassword signatures to take the
authenticated principal or token-derived user identity and a request body for
new passwords (e.g., PasswordUpdateRequest); if you cannot migrate immediately,
add a Servlet filter/logging mask that strips or masks "X-Loopers-LoginPw" from
request/response logs and ensure all logging frameworks (Logback/SLF4J) are
configured to exclude or mask that header, then add security tests verifying no
sensitive header values appear in logs on success/failure.

Comment on lines +25 to +26
private String loginId;

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n modules/jpa/src/main/java/com/loopers/domain/member/Member.java | head -50

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

Length of output: 1622


🏁 Script executed:

# DB 마이그레이션 및 스키마 파일 검색
find . -type f \( -name "*.sql" -o -name "*Migration*" -o -name "*.yml" -o -name "*.yaml" -o -name "*.xml" \) | grep -E "(schema|migration|member|flyway|liquibase)" | head -20

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

Length of output: 67


🏁 Script executed:

# member 테이블 정의 확인
rg -i "member" -A 10 -B 2 -g "*.sql" | head -100

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

Length of output: 233


🏁 Script executed:

# 전체 파일 목록 확인
git ls-files | grep -E "\.(sql|xml|yml|yaml)$" | head -30

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

Length of output: 1118


🏁 Script executed:

# 마이그레이션 디렉토리 확인
find . -type d -name "*migration*" -o -type d -name "*schema*" -o -type d -name "*db*" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# SQL 파일 검색
find . -type f -name "*.sql" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# JPA 관련 설정 파일 검색
find . -type f \( -name "application*.yml" -o -name "application*.yaml" \) 2>/dev/null | xargs cat

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

Length of output: 2710


🏁 Script executed:

# jpa.yml 내용 확인
cat modules/jpa/src/main/resources/jpa.yml

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

Length of output: 2231


🏁 Script executed:

# 리소스 디렉토리 구조 확인
find modules/jpa/src/main/resources -type f | head -20

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

Length of output: 123


🏁 Script executed:

# Flyway/Liquibase 마이그레이션 폴더 확인
find . -type d -name "db" -o -type d -name "migration" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# 모든 SQL 파일 검색
find . -type f -name "*.sql" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# Member 클래스 전체 확인 (특히 `@Table`, `@Column` 등)
cat -n modules/jpa/src/main/java/com/loopers/domain/member/Member.java

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

Length of output: 2535


🏁 Script executed:

# MemberPolicy 검증 로직 확인
find . -type f -name "*MemberPolicy*" -o -name "*member*policy*" 2>/dev/null | head -10

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

Length of output: 163


🏁 Script executed:

# 회원 등록 서비스 중복 체크 로직
find . -type f -name "*MemberService*" -o -name "*MemberRepository*" 2>/dev/null | head -10

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

Length of output: 432


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java

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

Length of output: 4669


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java

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

Length of output: 3628


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java

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

Length of output: 524


loginId에 DB 유니크 제약이 없어 동시 가입 시 중복 회원이 저장될 수 있다

현재 코드는 서비스 레이어에서 memberRepository.existsByLoginId()로 중복을 체크하지만, 체크와 저장 사이의 TOCTOU 레이스 조건으로 인해 동시 요청 시 중복 회원이 저장될 수 있다. 더욱이 프로덕션 환경은 ddl-auto: none으로 설정되어 있으므로, 수동 마이그레이션 없이는 DB 제약이 전혀 생성되지 않는다.

loginId 컬럼에 @Column(nullable = false, unique = true) 어노테이션을 추가하고, 프로덕션 스키마 마이그레이션에서 실제 유니크 인덱스를 생성해야 한다. 동일 loginId로 동시 등록 시 DataIntegrityViolationException이 발생하는지 통합 테스트로 확인하도록 한다.

수정 제안 diff
-    private String loginId;
+    `@Column`(nullable = false, unique = true)
+    private String loginId;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private String loginId;
`@Column`(nullable = false, unique = true)
private String loginId;
🤖 Prompt for AI Agents
In `@modules/jpa/src/main/java/com/loopers/domain/member/Member.java` around lines
25 - 26, Member 클래스의 loginId 필드에 DB 수준 유니크 제약이 빠져 TOCTOU로 동시 가입이 허용되므로
Member.loginId에 `@Column`(nullable = false, unique = true) 어노테이션을 추가하고 (클래스:
Member, 필드: loginId) 프로덕션 마이그레이션 스크립스키마에 실제 유니크 인덱스 생성 항목을 추가하여 DB가 중복을 차단하도록
하세요; 또한 서비스 레이어의 memberRepository.existsByLoginId() 체크만으로는 불충분하니 동시 등록을 시뮬레이트하는
통합 테스트를 추가해 동일한 loginId로 동시 저장 시 DataIntegrityViolationException이
발생하는지(assertThrows) 검증하도록 구현하세요.

Comment on lines +27 to +48
public static class Password {
public static void validate(String password, LocalDate birthDate) {
// 1-1. 길이 제한 (8~16자)
if (password == null || password.length() < 8 || password.length() > 16) {
throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message());
}

// 1-2. 조합 규칙 수정: "한글 등 허용되지 않은 문자"가 포함되었는지만 체크
// 영문, 숫자, 특수문자(@$!%*?&)만 허용하는 정규식으로 변경 (필수 포함 조건 삭제)
String allowedCharsRegex = "^[A-Za-z\\d@$!%*?&]*$";
if (!password.matches(allowedCharsRegex)) {
throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message());
}

// 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy)
// 이 로직에 도달하기 전에 위 정규식에서 튕기지 않도록 테스트 데이터가 수정되거나 정규식이 유연해야 합니다.
String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String yyMMdd = yyyyMMdd.substring(2);
if (password.contains(yyyyMMdd) || password.contains(yyMMdd)) {
throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message());
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Password.validate에서 birthDate가 null이면 NPE가 발생한다

  • 운영 관점: 검증 단계에서 NPE가 터지면 500 응답으로 전환되어 오류 응답 형식이 깨지고 장애 분석이 어려워진다.
  • 수정안: birthDate null을 먼저 검사해 명시적 IllegalArgumentException을 던지거나 BirthDate.validate를 선행 호출해야 한다.
  • 추가 테스트: birthDate=null 입력 시 기대 메시지로 예외가 발생하는지 단위 테스트를 추가해야 한다.
수정 제안 diff다
     public static void validate(String password, LocalDate birthDate) {
+        if (birthDate == null) {
+            throw new IllegalArgumentException("생년월일은 필수입니다.");
+        }
         // 1-1. 길이 제한 (8~16자)
         if (password == null || password.length() < 8 || password.length() > 16) {
             throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message());
         }

As per coding guidelines **/*.java: null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

🤖 Prompt for AI Agents
In `@modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java`
around lines 27 - 48, The Password.validate method can NPE when birthDate is
null; add an explicit null check at the start of Password.validate(String
password, LocalDate birthDate) and throw a clear IllegalArgumentException using
the appropriate MemberExceptionMessage (e.g., create/choose a message like
MemberExceptionMessage.Password.INVALID_BIRTHDATE or reuse an existing one)
before any use of birthDate, or call/ensure BirthDate.validate(birthDate) first;
update unit tests to assert that calling Password.validate with birthDate ==
null throws the expected IllegalArgumentException with the correct message.

Comment on lines +10 to +36
private static final String ALGORITHM = "SHA-256";

/**
* 비밀번호를 SHA-256으로 해싱함.
* (주의: 이 예제는 순수 해싱만 수행합니다. 실무에선 Salt를 추가해야 안전합니다.)
*/
public static String encode(String rawPassword) {
try {
MessageDigest digest = MessageDigest.getInstance(ALGORITHM);
byte[] encodedHash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8));

// 바이트 배열을 읽기 쉬운 문자열(Base64)로 변환
return Base64.getEncoder().encodeToString(encodedHash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("암호화 알고리즘을 찾을 수 없습니다.", e);
}
}

/**
* 일치 여부 확인
*/
public static boolean matches(String rawPassword, String encodedPassword) {
if (rawPassword == null || encodedPassword == null) return false;

// 입력받은 원문을 똑같이 해싱해서 결과가 같은지 비교
String newHash = encode(rawPassword);
return newHash.equals(encodedPassword);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# 파일 구조 확인
find . -type f -name "PasswordEncryptor.java" 2>/dev/null

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

Length of output: 153


🏁 Script executed:

# PasswordEncryptor 사용처 검색
rg "PasswordEncryptor" --type java -A 2 -B 2

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

Length of output: 11135


🏁 Script executed:

# User.java 확인 (learnings에서 언급)
find . -type f -name "User.java" -path "*/domain/user/*" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# BCrypt 관련 클래스/import 검색
rg "BCrypt|PasswordEncoder" --type java | head -30

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

Length of output: 67


🏁 Script executed:

# User 엔티티 찾기
find . -type f -name "User.java" 2>/dev/null | head -10

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

Length of output: 67


🏁 Script executed:

# Member 엔티티 확인
cat -n ./modules/jpa/src/main/java/com/loopers/domain/member/Member.java | head -80

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

Length of output: 2535


🏁 Script executed:

# PasswordEncryptor 전체 코드 확인
cat -n ./modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java

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

Length of output: 1528


🏁 Script executed:

# Member 디렉토리 구조 확인
find ./modules/jpa/src/main/java/com/loopers/domain/member -type f -name "*.java" | sort

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

Length of output: 311


🏁 Script executed:

# User.java 경로 재확인
find . -type f -name "*.java" -path "*domain/user*" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# commerce-api의 User 관련 클래스 검색
find ./apps/commerce-api -type f -name "*.java" -path "*user*" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

# BCrypt 또는 Spring Security PasswordEncoder 사용 여부 확인
rg "PasswordEncoder|BCryptPasswordEncoder" --type java | head -20

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

Length of output: 67


🏁 Script executed:

# pom.xml이나 build.gradle에서 보안 라이브러리 확인
find . -name "pom.xml" -o -name "build.gradle" | xargs grep -l "spring-security\|bcrypt" 2>/dev/null | head -5

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

Length of output: 67


🏁 Script executed:

# 프로젝트 구조 전체 확인 - User 엔티티 위치 찾기
find . -type f -name "*.java" | xargs grep -l "class User" | grep -E "(domain|entity)" | head -10

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

Length of output: 67


🏁 Script executed:

# gradle/maven 파일 직접 확인
find . \( -name "build.gradle*" -o -name "pom.xml" \) | head -5

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

Length of output: 252


🏁 Script executed:

# PasswordEncryptor encode/matches 동작 확인 - 같은 비밀번호로 여러 번 해싱 시 결과 일관성
python3 << 'PYEOF'
import hashlib
import base64

# SHA-256 동작 확인
password = "test123"
for i in range(3):
    h = hashlib.sha256(password.encode('utf-8')).digest()
    encoded = base64.b64encode(h).decode('utf-8')
    print(f"Iteration {i+1}: {encoded}")
    
print("\n결론: 같은 비밀번호는 항상 같은 해시를 생성 → salt 없음 확인")
PYEOF

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

Length of output: 298


🏁 Script executed:

# 보안 정책/가이드 확인
find . -type f \( -name "*.md" -o -name "*.txt" \) | xargs grep -i "password\|hash\|encrypt" 2>/dev/null | head -10

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

Length of output: 1080


🏁 Script executed:

# gradle.kts 확인 - 의존성 및 보안 라이브러리 확인
cat ./modules/jpa/build.gradle.kts | grep -A 20 "dependencies"

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

Length of output: 909


비밀번호 해시가 단순 SHA‑256이라 계정 유출 위험이 크다

현재 PasswordEncryptor는 salt와 반복 없는 SHA-256을 사용하고 있으며, 동일한 비밀번호는 항상 동일한 해시를 생성한다. 이는 Member 엔티티의 register()와 updatePassword() 메서드에서 실제로 사용되는 프로덕션 코드로, 대규모 계정 탈취 및 컴플라이언스 위반의 직접적인 위험요소다.

레인보우 테이블 공격과 GPU 기반 브루트포스에 취약하므로, PBKDF2(예: PBKDF2WithHmacSHA256)나 BCrypt로 즉시 교체해야 한다. PBKDF2는 충분한 반복 횟수(예: 65,536회)와 무작위 salt(16바이트 이상)를 사용하고, 해시 문자열에 버전·salt를 함께 저장해 기존 데이터의 점진적 마이그레이션이 가능하도록 구현해야 한다.

추가로 다음 검증 테스트를 필수로 추가하라:

  • 동일 비밀번호가 매번 다른 해시를 생성하는가
  • matches() 메서드가 정상/오류 비밀번호를 정확히 구분하는가
  • 잘못된 포맷이나 null 입력이 안전하게 실패하는가(타이밍 공격 방지를 위해 MessageDigest.isEqual() 사용)
수정 제안 diff
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Base64;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Base64;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.security.MessageDigest;

 public class PasswordEncryptor {

-    private static final String ALGORITHM = "SHA-256";
+    private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
+    private static final int ITERATIONS = 65_536;
+    private static final int KEY_LENGTH = 256;
+    private static final int SALT_LENGTH = 16;
+    private static final SecureRandom RANDOM = new SecureRandom();

     /**
-     * 비밀번호를 SHA-256으로 해싱함.
-     * (주의: 이 예제는 순수 해싱만 수행합니다. 실무에선 Salt를 추가해야 안전합니다.)
+     * 비밀번호를 PBKDF2로 해싱함.
      */
     public static String encode(String rawPassword) {
+        if (rawPassword == null) {
+            throw new IllegalArgumentException("비밀번호는 필수입니다.");
+        }
         try {
-            MessageDigest digest = MessageDigest.getInstance(ALGORITHM);
-            byte[] encodedHash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
-
-            // 바이트 배열을 읽기 쉬운 문자열(Base64)로 변환
-            return Base64.getEncoder().encodeToString(encodedHash);
-        } catch (NoSuchAlgorithmException e) {
+            byte[] salt = new byte[SALT_LENGTH];
+            RANDOM.nextBytes(salt);
+            byte[] hash = pbkdf2(rawPassword.toCharArray(), salt);
+            return Base64.getEncoder().encodeToString(salt) + ":" +
+                    Base64.getEncoder().encodeToString(hash);
+        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
             throw new RuntimeException("암호화 알고리즘을 찾을 수 없습니다.", e);
         }
     }

     /**
      * 일치 여부 확인
      */
     public static boolean matches(String rawPassword, String encodedPassword) {
         if (rawPassword == null || encodedPassword == null) return false;
-
-        // 입력받은 원문을 똑같이 해싱해서 결과가 같은지 비교
-        String newHash = encode(rawPassword);
-        return newHash.equals(encodedPassword);
+        String[] parts = encodedPassword.split(":");
+        if (parts.length != 2) return false;
+        try {
+            byte[] salt = Base64.getDecoder().decode(parts[0]);
+            byte[] expected = Base64.getDecoder().decode(parts[1]);
+            byte[] actual = pbkdf2(rawPassword.toCharArray(), salt);
+            return MessageDigest.isEqual(actual, expected);
+        } catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) {
+            return false;
+        }
+    }
+
+    private static byte[] pbkdf2(char[] password, byte[] salt)
+            throws NoSuchAlgorithmException, InvalidKeySpecException {
+        PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_LENGTH);
+        return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).getEncoded();
     }
 }
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 17-17: Triple DES (3DES or DESede) is considered deprecated. AES is the recommended cipher. Upgrade to use AES.
Context: MessageDigest.getInstance(ALGORITHM)
Note: [CWE-326]: Inadequate Encryption Strength [OWASP A03:2017]: Sensitive Data Exposure [OWASP A02:2021]: Cryptographic Failures [REFERENCES]
- https://find-sec-bugs.github.io/bugs.htm#TDES_USAGE
- https://csrc.nist.gov/News/2017/Update-to-Current-Use-and-Deprecation-of-TDEA

(desede-is-deprecated-java)


[warning] 17-17: Use of AES with ECB mode detected. ECB doesn't provide message confidentiality and is not semantically secure so should not be used. Instead, use a strong, secure cipher: Cipher.getInstance("AES/CBC/PKCS7PADDING"). See https://owasp.org/www-community/Using_the_Java_Cryptographic_Extensions for more information.
Context: MessageDigest.getInstance(ALGORITHM)
Note: [CWE-327]: Use of a Broken or Risky Cryptographic Algorithm [OWASP A03:2017]: Sensitive Data Exposure [OWASP A02:2021]: Cryptographic Failures [REFERENCES]
- https://owasp.org/Top10/A02_2021-Cryptographic_Failures
- https://googleprojectzero.blogspot.com/2022/10/rc4-is-still-considered-harmful.html

(use-of-aes-ecb-java)

🤖 Prompt for AI Agents
In `@modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java` around
lines 10 - 36, The current PasswordEncryptor (methods encode and matches) must
be replaced to use a slow KDF with per-password random salt and iterations
(e.g., PBKDF2WithHmacSHA256, iterations=65536, salt length>=16) instead of raw
SHA-256: implement a new encode that generates a SecureRandom salt, derives the
key with SecretKeyFactory/PBEKeySpec, and returns a versioned encoded string
containing version, base64(salt), and base64(hash); update matches to parse that
format, re-derive the hash with the stored salt/iterations and compare using
MessageDigest.isEqual for constant-time equality; ensure null/invalid-format
inputs return false safely; and add unit tests that verify (1) same password
yields different hashes due to salt, (2) matches correctly accepts/rejects, and
(3) invalid/null formats safely fail—also keep
Member.register()/updatePassword() compatibility by using the new encode/matches
and include a version prefix to allow future migration.

Comment on lines +106 to +114
@Test
public void 아이디는_중복_가입할_수_없음() throws Exception {
//given

//when

//then

}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

빈 테스트 메서드 제거 또는 구현 필요

테스트 본문이 비어 있어 실제로 어떤 검증도 수행하지 않는다. 주석에 "서비스의 통합 테스트"라고 명시되어 있으나, 빈 테스트 메서드는 테스트 커버리지 착시를 유발하고 CI 파이프라인에서 실패 없이 통과되어 운영 배포 시 검증 누락을 야기할 수 있다.

수정안:

  1. 해당 테스트를 @Disabled("통합 테스트로 이동 필요") 처리하거나
  2. 테스트 메서드를 완전히 삭제하고, 통합 테스트 파일에서 구현한다.
수정 제안
-        // 1-3 -> 레포지토리 가져오니까 서비스의 통합 테스트
-        `@Test`
-        public void 아이디는_중복_가입할_수_없음() throws Exception {
-            //given
-
-            //when
-
-            //then
-
-        }
+        // 1-3: 아이디 중복 검증은 MemberServiceIntegrationTest에서 수행
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Test
public void 아이디는_중복_가입할_수_없음() throws Exception {
//given
//when
//then
}
// 1-3: 아이디 중복 검증은 MemberServiceIntegrationTest에서 수행
🤖 Prompt for AI Agents
In
`@modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java`
around lines 106 - 114, The test method 아이디는_중복_가입할_수_없음 in MemberTest is empty
and should either be removed or disabled/implemented; update the MemberTest
class by either deleting the empty 아이디는_중복_가입할_수_없음() method or annotate it with
`@Disabled`("통합 테스트로 이동 필요") if you intend to move it to an integration test
suite, or implement the actual duplicate-ID assertion logic against the
service/repository used in other tests so the method performs real verification.

Comment on lines +374 to +388
@Test
@DisplayName("입력받은 비밀번호가 저장된 비밀번호와 다르면 false를 반환한다")
void isSamePassword_Fail() {
// given
String savedPassword = "password123!";
Member member = Member.builder()
.password(savedPassword)
.build();

// when
boolean result = member.isSamePassword("wrongPassword");

// then
assertThat(result).isFalse();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

테스트 데이터 설정 불일치로 인한 테스트 신뢰성 문제

isSamePassword_Fail 테스트에서 savedPassword를 인코딩하지 않고 저장하고 있다. isSamePassword 메서드는 PasswordEncryptor.matches()를 사용하여 비교할 가능성이 높으므로, 인코딩되지 않은 비밀번호와 비교 시 항상 false를 반환하여 테스트가 의미 없이 통과할 수 있다.

Line 360-372의 isSamePassword_Success 테스트와 비교했을 때, 인코딩 처리가 일관되지 않다.

수정 제안
        `@Test`
        `@DisplayName`("입력받은 비밀번호가 저장된 비밀번호와 다르면 false를 반환한다")
        void isSamePassword_Fail() {
            // given
            String savedPassword = "password123!";
            Member member = Member.builder()
-                    .password(savedPassword)
+                    .password(PasswordEncryptor.encode(savedPassword))
                    .build();

            // when
            boolean result = member.isSamePassword("wrongPassword");

            // then
            assertThat(result).isFalse();
        }
🤖 Prompt for AI Agents
In
`@modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java`
around lines 374 - 388, The test stores a raw password into Member.password
causing a false-positive because Member.isSamePassword uses
PasswordEncryptor.matches; update the isSamePassword_Fail setup to encode the
saved password the same way as isSamePassword_Success (use the same
PasswordEncryptor.encrypt/encode call or helper used in the other test) before
building the Member instance so the comparison path mirrors real usage in
Member.isSamePassword and the test reliably asserts a mismatch when passed
"wrongPassword".

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class MemberPolicy {
Copy link
Author

Choose a reason for hiding this comment

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

이전 멘토링을 통해 도메인 객체에서 값 객체를 사용하면 값 객체 고유 로직(ex. 정책 검증 등)을 풀어내는 데 유지보수성이 좋다는 피드백, 다만 JPA에서는 사용하기 까다롭다는 단점도 있다는 피드백을 해주셨습니다.

이에 이 코드와 같이 정책 관련된 사항을 검증하는 객체를 따로 분리해 적용해보았습니다. 이렇게 따로 분리하는 방법이 값 객체를 대체할 만한지, 아니면 오버 엔지니어링일지에 대한 멘토님의 의견이 궁금합니다.

@APapeIsName APapeIsName changed the title Volume 1 : TDD + AI Agent 활용해, 사용자 회원가입 플로우 구현하기 [Volume 1] 회원가입, 사용자 정보 조회, 비밀번호 변경 Feb 5, 2026
@APapeIsName APapeIsName changed the title [Volume 1] 회원가입, 사용자 정보 조회, 비밀번호 변경 [volume-1] 회원가입, 사용자 정보 조회, 비밀번호 변경 Feb 5, 2026
@APapeIsName APapeIsName changed the title [volume-1] 회원가입, 사용자 정보 조회, 비밀번호 변경 [volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현 Feb 5, 2026
@loopers-len loopers-len changed the base branch from APapeIsName to main February 7, 2026 01:08
@loopers-len loopers-len changed the base branch from main to APapeIsName February 7, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants