Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .codeguide/loopers-1-week.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,90 @@

- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.
- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.

---

## 📋 구현 기록

### 1. 회원가입 기능 (`feature/sign-up`)

**구현 파일:**
| 파일 | 역할 |
|------|------|
| `MemberModel.java` | 회원 엔티티 |
| `MemberRepository.java` | Repository 인터페이스 |
| `MemberService.java` | 비즈니스 로직 (중복 검증, 비밀번호 검증, 암호화) |
| `MemberJpaRepository.java` | Spring Data JPA 인터페이스 |
| `MemberRepositoryImpl.java` | Repository 구현체 |
| `MemberV1Controller.java` | REST API 컨트롤러 |
| `MemberV1Dto.java` | 요청/응답 DTO |
| `PasswordEncoderConfig.java` | BCrypt Bean 설정 |

**설계 근거:**
- `spring-security-crypto`만 사용: 전체 Spring Security는 과한 의존성
- Layered Architecture: Domain → Infrastructure → Interface 분리
- 비밀번호 검증을 Service에 위치: PasswordEncoder 의존성 필요

**TDD 테스트 목록:**
| 테스트 | 검증 내용 |
|--------|----------|
| `register_withValidInfo_savesMember` | 정상 회원가입 |
| `register_withDuplicateLoginId_throwsException` | 로그인 ID 중복 검증 |
| `register_withShortPassword_throwsException` | 비밀번호 8자 미만 검증 |
| `register_withBirthDateInPassword_throwsException` | 생년월일 포함 검증 |
| `signUp_withValidRequest_returnsCreated` | API 201 응답 |
| `signUp_withInvalidLoginIdFormat_returnsBadRequest` | API 400 응답 |

---

### 2. 내 정보 조회 기능 (`feature/my-info`)

**구현 파일:**
| 파일 | 역할 |
|------|------|
| `AuthMember.java` | 인증 어노테이션 |
| `AuthMemberResolver.java` | 헤더 기반 인증 처리 |
| `WebMvcConfig.java` | Resolver 등록 |
| `MemberV1Dto.MyInfoResponse` | 응답 DTO (마스킹 로직 포함) |
| `MemberV1Controller.getMyInfo()` | API 추가 |
| `ErrorType.UNAUTHORIZED` | 401 에러 타입 |

**설계 근거:**
- `HandlerMethodArgumentResolver` 사용: 컨트롤러 코드 깔끔, 인증 로직 집중
- Facade 생략: 단순 조회이므로 Controller에서 직접 DTO 변환
- 마스킹 로직을 DTO에 위치: 표현 계층 관심사

**TDD 테스트 목록:**
| 테스트 | 검증 내용 |
|--------|----------|
| `myInfoResponse_masksLastCharacterOfName` | 이름 마스킹 (홍길동 → 홍길*) |
| `myInfoResponse_doesNotMaskSingleCharacterName` | 1글자 이름 마스킹 안함 |
| `getMyInfo_withoutAuthHeaders_returnsUnauthorized` | 인증 헤더 없음 401 |
| `getMyInfo_withWrongPassword_returnsUnauthorized` | 잘못된 비밀번호 401 |
| `getMyInfo_withValidAuth_returnsOkWithMaskedName` | 정상 조회 200 |

---

### 3. 비밀번호 수정 기능 (`feature/change-password`)

**구현 파일:**
| 파일 | 역할 |
|------|------|
| `MemberModel.changePassword()` | 비밀번호 변경 메서드 |
| `MemberService.changePassword()` | 검증 로직 + 암호화 |
| `MemberV1Controller.changePassword()` | PATCH API |
| `MemberV1Dto.ChangePasswordRequest` | 요청 DTO |

**설계 근거:**
- 기존 비밀번호 검증 로직 재사용 (`PASSWORD_PATTERN`, `containsBirthDate`)
- Facade 생략: 단순 흐름 (Controller → Service → Entity)

**TDD 테스트 목록:**
| 테스트 | 검증 내용 | 상태 |
|--------|----------|------|
| `changePassword_withWrongCurrentPassword_throwsException` | 현재 비밀번호 불일치 | ✅ |
| `changePassword_withSamePassword_throwsException` | 동일 비밀번호 | ✅ |
| `changePassword_withInvalidNewPassword_throwsException` | 규칙 위반 | ✅ |
| `changePassword_withBirthDateInNewPassword_throwsException` | 생년월일 포함 | ✅ |
| `changePassword_withValidInput_updatesPassword` | 정상 변경 | ✅ |
| `changePassword_withValidAuth_returnsOk` | PATCH API 200 응답 | ✅ |
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ out/

### Kotlin ###
.kotlin

### Claude Code ###
CLAUDE.md
4 changes: 4 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ dependencies {

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")

// security (password encryption only)
implementation("org.springframework.security:spring-security-crypto")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")

Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.loopers.domain.member;

import com.loopers.domain.BaseEntity;
import com.loopers.domain.member.vo.BirthDate;
import com.loopers.domain.member.vo.Email;
import com.loopers.domain.member.vo.LoginId;
import com.loopers.domain.member.vo.Password;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

@Entity
@Table(name = "member")
public class Member extends BaseEntity {

@Embedded
private LoginId loginId;

@Embedded
private Password password;

@Column(nullable = false, length = 50)
private String name;

@Embedded
private BirthDate birthDate;

@Embedded
private Email email;

protected Member() {}

public Member(LoginId loginId, Password password, String name,
BirthDate birthDate, Email email) {
if (name == null || name.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다.");
}
this.loginId = loginId;
this.password = password;
this.name = name;
this.birthDate = birthDate;
this.email = email;
}

public LoginId getLoginId() { return loginId; }
public Password getPassword() { return password; }
public String getName() { return name; }
public BirthDate getBirthDate() { return birthDate; }
public Email getEmail() { return email; }

public void changePassword(Password newPassword) {
this.password = newPassword;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.loopers.domain.member;

import com.loopers.domain.member.vo.LoginId;

import java.util.Optional;

public interface MemberRepository {
Member save(Member member);
Optional<Member> findByLoginId(LoginId loginId);
boolean existsByLoginId(LoginId loginId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.loopers.domain.member;

import com.loopers.domain.member.vo.BirthDate;
import com.loopers.domain.member.vo.Email;
import com.loopers.domain.member.vo.LoginId;
import com.loopers.domain.member.vo.Password;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class MemberService {

private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;

@Transactional
public Member register(String loginId, String plainPassword, String name,
String birthDate, String email) {
LoginId loginIdVo = new LoginId(loginId);

if (memberRepository.existsByLoginId(loginIdVo)) {
throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다.");
}

BirthDate birthDateVo = BirthDate.from(birthDate);
Password password = Password.create(plainPassword, birthDateVo.value(), passwordEncoder);
Email emailVo = new Email(email);

Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo);
return memberRepository.save(member);
}
Comment on lines +24 to +39
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 -type f -name "*.java" | xargs rg -l "class LoginId" 2>/dev/null | head -5

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

Length of output: 236


🏁 Script executed:

find apps/commerce-api -type f -name "LoginId.java"

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

Length of output: 158


🏁 Script executed:

fd -e sql -o -e yaml | head -20

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

Length of output: 201


🏁 Script executed:

rg -n "class Member " apps/commerce-api/src/main/java/com/loopers/domain/member/ -A 30 | head -50

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

Length of output: 3077


🏁 Script executed:

rg -i "unique\|constraint" --type sql apps/commerce-api/ 2>/dev/null || echo "Checking migration files..."

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

Length of output: 112


🏁 Script executed:

find apps/commerce-api -type f \( -name "*migration*" -o -name "*schema*" -o -name "*.sql" \) | 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/domain/member/vo/LoginId.java

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

Length of output: 1458


🏁 Script executed:

find apps/commerce-api -type d -name "*migration*" -o -name "*flyway*" -o -name "*liquibase*"

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

Length of output: 67


🏁 Script executed:

find apps/commerce-api -type f -name "*.sql"

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

Length of output: 67


🏁 Script executed:

find apps/commerce-api -path "*/resources/db/*" -type f

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

Length of output: 67


🏁 Script executed:

rg -l "MemberRepository" apps/commerce-api/src/main/java/ | head -5

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

Length of output: 414


🏁 Script executed:

rg -A 20 "interface MemberRepository" apps/commerce-api/src/main/java/

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

Length of output: 653


🏁 Script executed:

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

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

Length of output: 1176


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java

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

Length of output: 2724


🏁 Script executed:

rg -B 5 -A 20 "public Member register" apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java

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

Length of output: 1037


🏁 Script executed:

find apps/commerce-api -type f -name "*MemberServiceTest*" -o -name "*MemberTest*"

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

Length of output: 158


🏁 Script executed:

rg -n "DataIntegrityViolationException" apps/commerce-api/src/main/java/

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

Length of output: 67


🏁 Script executed:

rg -n "ExceptionHandler\|ControllerAdvice" apps/commerce-api/src/main/java/ | head -10

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

Length of output: 67


🏁 Script executed:

find apps/commerce-api -path "*/exception/*" -o -path "*/error/*" -type f | head -10

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

Length of output: 315


🏁 Script executed:

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: 2716


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

rg -B 5 -A 10 "catch.*DataIntegrity\|catch.*Constraint" apps/commerce-api/src/ 2>/dev/null | head -30

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

Length of output: 67


🏁 Script executed:

find apps/commerce-api -type f -name "*Controller*" | xargs rg -l "register" | head -3

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

Length of output: 174


🏁 Script executed:

rg -B 3 -A 15 "RequestMapping.*register\|PostMapping.*register\|register.*Member" apps/commerce-api/src/main/java/ | head -40

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/interfaces/api/member/MemberV1Controller.java

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

Length of output: 2623


🏁 Script executed:

rg -r "register" apps/commerce-api/src/main/java/com/loopers/interfaces/ -A 10 -B 2 | head -50

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

Length of output: 67


🏁 Script executed:

find apps/commerce-api -type f -name "*ExceptionHandler*" -o -name "*GlobalException*" -o -name "*ErrorHandler*"

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

Length of output: 67


register 메서드의 동시성 이슈에 따른 예외 처리 개선이 필요하다.

existsByLoginId 체크와 save 사이의 race condition으로 인해 두 동시 요청이 동일한 loginId로 가입 시도 시 둘 다 체크를 통과한 후 하나의 요청은 DB 레벨의 유니크 제약에서 DataIntegrityViolationException을 발생시킨다. 현재 코드에서 이 예외가 처리되지 않아 사용자에게 일관되지 않은 오류 응답이 전달된다.

LoginId VO의 login_id 컬럼에 유니크 제약(@Column(name = "login_id", nullable = false, unique = true, length = 20))이 설정되어 있어 데이터 무결성은 보장되지만, memberRepository.save(member) 호출 시 발생하는 DataIntegrityViolationException을 catch하여 CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다.")로 변환해야 한다. 이를 통해 사용자에게 일관된 오류 메시지를 제공할 수 있다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 24 - 39, Wrap the call to memberRepository.save(member) inside a
try-catch to handle DB-level unique constraint failures: catch
DataIntegrityViolationException (and optionally its cause/constraint-violation
types) thrown by memberRepository.save in the register method and rethrow new
CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다."); keep the existing
LoginId/unique column as the source of truth, preserve the `@Transactional`
behavior, and ensure other exceptions continue to propagate normally.


public Optional<Member> findByLoginId(String loginId) {
return memberRepository.findByLoginId(new LoginId(loginId));
}

@Transactional
public void changePassword(Member member, String currentPlain, String newPlain) {
if (!member.getPassword().matches(currentPlain, passwordEncoder)) {
throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다.");
}

if (member.getPassword().matches(newPlain, passwordEncoder)) {
throw new CoreException(ErrorType.BAD_REQUEST,
"새 비밀번호는 현재 비밀번호와 달라야 합니다.");
}

Password newPassword = Password.create(
newPlain, member.getBirthDate().value(), passwordEncoder);
member.changePassword(newPassword);
}
Comment on lines +45 to +59
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:

# 1. AuthMemberResolver의 구현 및 트랜잭션 설정 확인
find . -name "*AuthMemberResolver*" -type f

# 2. MemberService의 전체 구조 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java

# 3. MemberRepository의 메서드 확인
find . -name "*MemberRepository*" -type f

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

Length of output: 2983


🏁 Script executed:

# AuthMemberResolver 찾기 (패턴 확대)
find . -name "*.java" -type f | xargs grep -l "AuthMemberResolver" | head -5

# MemberRepositoryImpl 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java

# changePassword를 호출하는 컨트롤러 찾기
rg -n "changePassword" --type java -A3 -B3

# Member 엔티티 확인 (detached 상태 판단을 위해)
find . -name "Member.java" -path "*/domain/member/*" -type f

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

Length of output: 6479


🏁 Script executed:

# AuthMemberResolver 파일 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java

# Member 엔티티 전체 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

# WebMvcConfig 확인 (AuthMemberResolver 등록)
cat -n apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java

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

Length of output: 5606


🏁 Script executed:

# 통합 테스트 또는 엔드투엔드 테스트 존재 여부 확인
find . -name "*MemberServiceTest*" -o -name "*MemberIntegrationTest*" | head -5

# MemberServiceTest 또는 유사 테스트 파일 검색
find . -path "*/test/*" -name "*Member*Service*.java" -type f

# 마이그레이션 또는 다른 서비스에서 detached entity 처리 패턴 확인
rg -n "findByLoginId" --type java -A3 -B1 | grep -A3 -B1 "Transactional"

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

Length of output: 610


🏁 Script executed:

# MemberServiceIntegrationTest 전체 내용 확인
cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java

# changePassword 테스트 케이스가 있는지 확인
rg -n "changePassword" ./apps/commerce-api/src/test/java/com/loopers/domain/member/ -A10

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

Length of output: 5119


🏁 Script executed:

# BaseEntity 확인 (ID 생성 방식, 버전 관리 등)
find . -name "BaseEntity.java" -type f | xargs cat -n

# MemberJpaRepository 확인 (Spring Data JPA 메서드 확인)
find . -name "MemberJpaRepository.java" -type f | xargs cat -n

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

Length of output: 2728


changePassword 메서드는 detached entity로 인해 비밀번호 변경이 DB에 반영되지 않을 수 있다.

AuthMemberResolver에서 조회된 Member 엔티티는 Spring Data JPA의 기본 트랜잭션(readOnly)이 종료되면서 detached 상태가 된다. changePassword 메서드의 새로운 @Transactional 경계에서 이 detached entity를 수신할 때, 해당 객체는 현재 persistence context에 attach되지 않는다. 따라서 member.changePassword(newPassword) 호출 후 flush 시점에도 dirty checking이 감지하지 못해 UPDATE 쿼리가 발생하지 않는다.

비밀번호 변경이 DB에 반영되지 않으면 사용자가 새 비밀번호로 로그인할 수 없는 심각한 장애로 이어진다.

해결 방안: 현재 트랜잭션 컨텍스트에서 엔티티를 재조회하여 영속 상태를 보장한다.

수정 코드
 `@Transactional`
 public void changePassword(Member member, String currentPlain, String newPlain) {
+    // 현재 트랜잭션 컨텍스트에서 엔티티를 다시 조회하여 영속 상태 보장
+    Member managedMember = memberRepository.findByLoginId(member.getLoginId())
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다."));
+
-    if (!member.getPassword().matches(currentPlain, passwordEncoder)) {
+    if (!managedMember.getPassword().matches(currentPlain, passwordEncoder)) {
         throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다.");
     }

-    if (member.getPassword().matches(newPlain, passwordEncoder)) {
+    if (managedMember.getPassword().matches(newPlain, passwordEncoder)) {
         throw new CoreException(ErrorType.BAD_REQUEST,
             "새 비밀번호는 현재 비밀번호와 달라야 합니다.");
     }

     Password newPassword = Password.create(
-        newPlain, member.getBirthDate().value(), passwordEncoder);
-    member.changePassword(newPassword);
+        newPlain, managedMember.getBirthDate().value(), passwordEncoder);
+    managedMember.changePassword(newPassword);
 }

추가로 MemberServiceIntegrationTestchangePassword 통합 테스트를 추가하여 비밀번호 변경이 실제 DB에 반영되는지 검증해야 한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 45 - 59, changePassword에서 전달받은 detached Member를 그대로 수정하면 영속성 컨텍스트에
속하지 않아 UPDATE가 발생하지 않으므로, 새 트랜잭션 경계(메서드의 `@Transactional`) 안에서 변경 전용으로 엔티티를 재조회하여
영속 상태로 만든 뒤 비밀번호를 변경하세요; 예를 들어 MemberService의 changePassword 내부에서
member.getId()로 memberRepository.findById(...) 또는 entityManager.find(...)로 영속
엔티티를 조회하고 해당 엔티티에 대해 member.changePassword(newPassword) (Password.create 사용) 를
호출하여 변경을 적용하고, 또한 MemberServiceIntegrationTest에 changePassword 통합 테스트를 추가해 실제
DB에 비밀번호 변경이 반영되는지를 검증하세요.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.loopers.domain.member.policy;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.regex.Pattern;

public class PasswordPolicy {

private static final Pattern FORMAT_PATTERN =
Pattern.compile("^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$");

public static void validate(String plain, LocalDate birthDate) {
validateFormat(plain);
validateNotContainsSubstrings(plain,
extractBirthDateStrings(birthDate),
"비밀번호에 생년월일을 포함할 수 없습니다.");
}

public static void validateFormat(String plain) {
if (plain == null || !FORMAT_PATTERN.matcher(plain).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST,
"비밀번호는 8~16자의 영문, 숫자, 특수문자만 허용됩니다.");
}
}

public static void validateNotContainsSubstrings(
String plain, List<String> forbidden, String errorMessage) {
for (String s : forbidden) {
if (plain.contains(s)) {
throw new CoreException(ErrorType.BAD_REQUEST, errorMessage);
}
}
}

public static List<String> extractBirthDateStrings(LocalDate birthDate) {
return List.of(
birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")),
birthDate.format(DateTimeFormatter.ofPattern("yyMMdd"))
);
Comment on lines +16 to +43
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

birthDate null 처리 누락으로 500이 발생할 수 있다다

운영 관점: birthDate가 null이면 format()에서 NPE가 발생해 500으로 떨어지며, 에러 응답 일관성이 깨져 운영 모니터링과 클라이언트 처리에 혼선을 준다다.
수정안: validate 또는 extractBirthDateStrings에서 null을 선제 검증해 CoreException(BAD_REQUEST)으로 반환해야 한다다.
추가 테스트: birthDate가 null인 경우 CoreException이 발생하는지 단위 테스트를 추가하는 것이 좋다다.

수정안 예시다
 public class PasswordPolicy {

     public static void validate(String plain, LocalDate birthDate) {
+        if (birthDate == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST,
+                "생년월일은 필수입니다.");
+        }
         validateFormat(plain);
         validateNotContainsSubstrings(plain,
             extractBirthDateStrings(birthDate),
             "비밀번호에 생년월일을 포함할 수 없습니다.");
     }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java`
around lines 16 - 43, validate() currently passes a potentially null birthDate
to extractBirthDateStrings() causing NPE; add a null-check and throw
CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다.") either at the start of
validate() or inside extractBirthDateStrings(LocalDate) to return a consistent
BAD_REQUEST error instead of 500, and update/ add a unit test to assert that
calling validate(null) produces the CoreException; reference methods: validate,
extractBirthDateStrings, and the CoreException(ErrorType.BAD_REQUEST) usage when
implementing the change.

}
}
Loading