Skip to content
Open
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 ###
.claude
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## 개발 규칙
### 진행 Workflow - 증강 코딩
- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행.
- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입.
- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행.

### 개발 Workflow - TDD (Red > Green > Refactor)
- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert)
#### 1. Red Phase : 실패하는 테스트 먼저 작성
- 요구사항을 만족하는 기능 테스트 케이스 작성
- 테스트 예시
#### 2. Green Phase : 테스트를 통과하는 코드 작성
- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성
- 오버엔지니어링 금지
#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선
- 불필요한 private 함수 지양, 객체지향적 코드 작성
- unused import 제거
- 성능 최적화
- 모든 테스트 케이스가 통과해야 함

## 주의사항
### 1. Never Do
- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것
- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것)
- println 코드 남기지 말 것
- 당장 필요하지 않은 코드는 만들지 말 것

### 2. Recommendation
- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성
- 재사용 가능한 객체 설계
- 성능 최적화에 대한 대안 및 제안
- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성

### 3. Priority
1. 실제 동작하는 해결책만 고려
2. null-safety, thread-safety 고려
3. 테스트 가능한 구조로 설계
4. 기존 코드 패턴 분석 후 일관성 유지

## 소통규칙
- 개발자의 의사를 확인해야 할 질문이 필요한 경우 사운드를 내서 알람을 준다
3 changes: 3 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ dependencies {
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

// security
implementation("org.springframework.security:spring-security-crypto")

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.loopers.domain.user;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.util.Objects;

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BirthDate {

private LocalDate value;

private BirthDate(LocalDate value) {
this.value = value;
}

public static BirthDate from(LocalDate value) {
if (value == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다.");
}

if (value.isAfter(LocalDate.now())) {
throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 오늘 이전 날짜만 가능합니다.");
}

return new BirthDate(value);
}

public LocalDate asLocalDate() {
return value;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
BirthDate birthDate = (BirthDate) o;
return Objects.equals(value, birthDate.value);
}

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}
51 changes: 51 additions & 0 deletions apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.loopers.domain.user;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.Objects;
import java.util.regex.Pattern;

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Email {

private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");

private String value;

private Email(String value) {
this.value = value;
}

public static Email from(String value) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다.");
}

if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다.");
}

return new Email(value);
}

public String asString() {
return value;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Email email = (Email) o;
return Objects.equals(value, email.value);
}

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.loopers.domain.user;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.Objects;
import java.util.regex.Pattern;

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LoginId {

private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{5,20}$");

private String value;

private LoginId(String value) {
this.value = value;
}

public static LoginId from(String value) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 비어있을 수 없습니다.");
}

if (!LOGIN_ID_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 5~20자의 영문과 숫자로만 이루어져야 합니다.");
}

return new LoginId(value);
}

public String asString() {
return value;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
LoginId loginId = (LoginId) o;
return Objects.equals(value, loginId.value);
}

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}
56 changes: 56 additions & 0 deletions apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.loopers.domain.user;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.Objects;
import java.util.regex.Pattern;

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Name {

private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣]{2,6}$");

private String value;

private Name(String value) {
this.value = value;
}


public static Name from(String value) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다.");
}

if (!NAME_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글 2~6자만 가능합니다.");
}

return new Name(value);
}

public String asString() {
return value;
}

public String masked() {
return value.substring(0, value.length() - 1) + "*";
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Name name = (Name) o;
return Objects.equals(value, name.value);
}

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.loopers.domain.user;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.regex.Pattern;

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Password {

private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9`~!@#$%^&*|'\";:\\\\₩?]{8,16}$");

private String value;

private Password(String value) {
this.value = value;
}

public static Password of(String value, PasswordEncoder encoder) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
}

if (!PASSWORD_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문대소문자, 숫자, 특수문자만 가능합니다.");
}

return new Password(encoder.encode(value));
}
Comment on lines +23 to +33
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

PasswordEncoder null 방어가 필요하다
운영 관점에서 DI 설정 누락이나 테스트 스텁 미주입 시 NPE로 500이 발생해 장애 원인 파악이 어려워진다. 수정안으로 encoder에 대한 fail-fast 검사를 추가해 명확한 오류를 내도록 하라. 추가 테스트로 encoder == null일 때 예외 타입/메시지를 검증하는 단위 테스트를 추가하라. As per coding guidelines: null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

🔧 제안 수정
+import java.util.Objects;
@@
     public static Password of(String value, PasswordEncoder encoder) {
+        Objects.requireNonNull(encoder, "PasswordEncoder must not be null");
         if (value == null || value.isBlank()) {
             throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
         }
📝 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
public static Password of(String value, PasswordEncoder encoder) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
}
if (!PASSWORD_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문대소문자, 숫자, 특수문자만 가능합니다.");
}
return new Password(encoder.encode(value));
}
public static Password of(String value, PasswordEncoder encoder) {
Objects.requireNonNull(encoder, "PasswordEncoder must not be null");
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
}
if (!PASSWORD_PATTERN.matcher(value).matches()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문대소문자, 숫자, 특수문자만 가능합니다.");
}
return new Password(encoder.encode(value));
}
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java` around
lines 23 - 33, The Password.of factory currently assumes PasswordEncoder is
non-null; add a fail-fast null check at the start of Password.of( String value,
PasswordEncoder encoder ) that throws a clear CoreException
(ErrorType.BAD_REQUEST or another appropriate ErrorType per project conventions)
with a descriptive message when encoder == null, then proceed with the existing
validations and encoding; also add a unit test that calls Password.of with
encoder == null and asserts the exact exception type and message to prevent NPEs
from DI/test misconfiguration.


public String asString() {
return value;
}

// Password는 저장 시 암호화 되므로 Password 간의 동일성/동등성은 PasswordEncoder 가 확인하는 것으로 한다.
public boolean matches(String raw, PasswordEncoder encoder) {
return encoder.matches(raw, value);
Comment on lines +39 to +41
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

matches 입력값 null/blank 처리 누락
운영 관점에서 로그인/비밀번호 변경 요청에 null/blank가 유입되면 내부 인코더가 예외를 던져 500으로 전파될 수 있다. 수정안으로 raw에 대한 사전 검증을 추가해 일관된 BAD_REQUEST 응답으로 처리하라. 추가 테스트로 raw가 null/blank일 때 예외와 메시지를 검증하는 테스트를 추가하라. As per coding guidelines: null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

🔧 제안 수정
     public boolean matches(String raw, PasswordEncoder encoder) {
+        if (raw == null || raw.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
+        }
         return encoder.matches(raw, value);
     }
📝 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
// Password는 저장 시 암호화 되므로 Password 간의 동일성/동등성은 PasswordEncoder 가 확인하는 것으로 한다.
public boolean matches(String raw, PasswordEncoder encoder) {
return encoder.matches(raw, value);
// Password는 저장 시 암호화 되므로 Password 간의 동일성/동등성은 PasswordEncoder 가 확인하는 것으로 한다.
public boolean matches(String raw, PasswordEncoder encoder) {
if (raw == null || raw.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
}
return encoder.matches(raw, value);
}
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java` around
lines 39 - 41, The Password.matches method lacks validation for the raw input
which can let null/blank reach the PasswordEncoder and cause 500s; update
Password.matches(String raw, PasswordEncoder encoder) to defensively validate
raw (null or blank) and throw a clear, documented exception (e.g.,
IllegalArgumentException with a consistent message) so controllers can translate
it to BAD_REQUEST, then add unit tests for Password.matches verifying that null
and blank raw values throw the expected exception with the expected message;
ensure this change follows the class's immutability/null-handling conventions
and does not modify Password.value or encoder usage.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.loopers.domain.user;

public interface PasswordEncoder {

String encode(String raw);

boolean matches(String raw, String encoded);
}
Loading