diff --git a/.claude/commands/prd-writer-cmd.md b/.claude/commands/prd-writer-cmd.md new file mode 100644 index 00000000..a30ecc17 --- /dev/null +++ b/.claude/commands/prd-writer-cmd.md @@ -0,0 +1,39 @@ +# PRD + AC 작성 + +$ARGUMENTS 경로의 요구사항 파일을 분석하여 PRD + Acceptance Criteria를 작성합니다. + +## 사용법 +``` +/prd-writer mission/round2.md +``` + +## 실행 절차 + +### 1단계: 스킬 로드 +``` +Skill tool 호출: skill="prd-writer" +``` +- PRD 작성 가이드라인과 템플릿을 로드합니다. + +### 2단계: 요구사항 파일 읽기 +- 지정된 파일($ARGUMENTS)을 읽습니다. +- 파일이 없으면 사용자에게 확인합니다. + +### 3단계: 브레인스토밍 진행 +스킬의 워크플로우를 따릅니다: + +1. **CAPTURE**: Feature 목록 추출 +2. **CLARIFY**: 모호한 부분 AskUserQuestion으로 확인 +3. **STRUCTURE**: PRD + AC 형식으로 구조화 +4. **VALIDATE**: 완전성 체크 + +### 4단계: 결과물 저장 +- `.claude/PLAN.md`에 저장 +- 사용자에게 요약 제시 + +## 출력물 +- `.claude/PLAN.md`: 완성된 PRD + AC 문서 + +## 참조 +- 템플릿: `.claude/skills/prd-writer/templates/prd-template.md` +- 예시: `.claude/skills/prd-writer/examples/user-feature.md` diff --git a/.claude/rules/core/design-priciples.md b/.claude/rules/core/design-priciples.md new file mode 100644 index 00000000..407b107c --- /dev/null +++ b/.claude/rules/core/design-priciples.md @@ -0,0 +1,38 @@ +# Design Principles + +## 핵심 원칙 +- SRP (Single Responsibility Principle) 무조건 준수 +- OCP (Open/Closed Principle) 준수 +- 현재 요구사항에 집중하되, 구조는 유연하게 +- 파라미터가 6개 이하면 그대로 유지, 7개 이상이면 Command 객체로 그루핑 + +## 입력값 검증 전략 + +### 2단계 검증 (관심사 분리) +| 단계 | 계층 | 검증 대상 | 예시 | +|------|------|----------|------| +| 1단계 | Request DTO | 입력값 형식 | 필수값, 타입, 범위 | +| 2단계 | Domain Entity | 도메인 불변식 | 비즈니스 규칙 | + +### Request DTO 검증 (입력값 형식) +- Bean Validation 사용 허용: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Min`, `@Max`, `@Positive`, `@Size` 등 +- 목적: "값이 존재하는가? 기본 형식에 맞는가?" +- Controller에서 `@Valid` 사용 + +### Domain Entity 검증 (도메인 불변식) +- 도메인 객체가 자기 불변식을 스스로 검증 +- 목적: "비즈니스 규칙에 맞는가?" +- 정적 팩토리 메서드(`create`)에서 검증 +- cross-field 검증(필드 간 교차 검증)은 도메인에서 처리 + +## 중복 체크 전략 +- 비즈니스 중복 체크는 Service에서 exists 쿼리로 명시적 수행 +- DB unique constraint는 스키마 레벨에서 반드시 설정 (최종 방어선) +- DataIntegrityViolationException은 글로벌 예외 핸들러에서 공통 처리 +- Service에서 try-catch로 DataIntegrityViolationException을 잡지 않는다 + +## 계층 간 데이터 전달 +- Controller → Service: 원시값 파라미터로 전달 (dto.toEntity 방식 사용하지 않음) +- Service → Controller: Result/Info 객체 사용 +- DTO가 Domain Entity를 직접 알지 않는다 +- 암호화 등 외부 의존성이 필요한 변환은 Service에서 수행 \ No newline at end of file diff --git a/.claude/rules/core/dto-patterns.md b/.claude/rules/core/dto-patterns.md new file mode 100644 index 00000000..4939e0c4 --- /dev/null +++ b/.claude/rules/core/dto-patterns.md @@ -0,0 +1,50 @@ +# DTO Patterns + +## Record 사용 원칙 +- 모든 DTO는 Java record로 작성 (불변성 보장) +- Request/Response는 버전별 DTO 컨테이너 내부에 정의 +- Info는 application 계층에 독립 record로 정의 + +## DTO 컨테이너 구조 +```java +public class UserV1Dto { + public record SignUpRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record UserResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } +} +``` + +## 데이터 흐름 +``` +Request → Controller (원시값 추출) → Service → Domain → Info → Response +``` + +## Request DTO 검증 +- Bean Validation 사용 허용: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Min`, `@Max`, `@Positive`, `@Size` 등 +- 입력값 형식 검증 담당 (도메인 불변식 검증과 관심사 분리) +- Controller에서 `@Valid` 사용 + +## 금지 사항 +- DTO가 Domain Entity를 직접 import 금지 +- dto.toEntity() 패턴 금지 (Controller에서 원시값 전달) +- 비즈니스 규칙 검증을 DTO에서 수행 금지 (도메인 역할) diff --git a/.claude/rules/core/exception-patterns.md b/.claude/rules/core/exception-patterns.md new file mode 100644 index 00000000..8358134c --- /dev/null +++ b/.claude/rules/core/exception-patterns.md @@ -0,0 +1,48 @@ +# Exception Patterns + +## 예외 종류별 사용 + +### IllegalArgumentException +- 도메인 불변식 검증 실패 시 사용 +- User.create(), validate{Field}() 등에서 throw +- 글로벌 핸들러가 400 BAD_REQUEST로 변환 + +```java +private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new IllegalArgumentException("로그인 ID는 필수입니다"); + } +} +``` + +### CoreException +- 비즈니스 로직 오류 시 사용 +- Service 계층에서 throw +- ErrorType에 따라 HTTP 상태코드 결정 + +```java +// 중복 체크 +if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); +} + +// 조회 실패 +User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + +// 인증 실패 +throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"); +``` + +## ErrorType 선택 기준 +| ErrorType | HTTP Status | 사용 상황 | +|-----------|-------------|-----------| +| BAD_REQUEST | 400 | 입력값 검증 실패 | +| UNAUTHORIZED | 401 | 인증 실패, 헤더 누락 | +| NOT_FOUND | 404 | 리소스 없음 | +| CONFLICT | 409 | 중복 리소스 | +| INTERNAL_ERROR | 500 | 예상치 못한 오류 | + +## 금지 사항 +- Controller/Service에서 try-catch로 예외 잡기 금지 +- DataIntegrityViolationException 직접 처리 금지 (글로벌 핸들러가 처리) diff --git a/.claude/rules/core/layer-patterns.md b/.claude/rules/core/layer-patterns.md new file mode 100644 index 00000000..113e738a --- /dev/null +++ b/.claude/rules/core/layer-patterns.md @@ -0,0 +1,39 @@ +# Layer Patterns + +## 패키지 구조 +``` +com.loopers/ +├── interfaces/api/{domain}/ # REST API +├── application/{domain}/ # Facade, Info +├── domain/{domain}/ # Entity, Service, Repository 인터페이스 +└── infrastructure/{domain}/ # Repository 구현, 외부 어댑터 +``` + +## 계층별 역할 + +### Controller (interfaces) +- HTTP 요청/응답 변환만 담당 +- Request에서 원시값 추출하여 Facade에 전달 +- try-catch 금지 (글로벌 핸들러가 처리) +- `@Valid`로 Request DTO 입력값 형식 검증 + +### Facade (application) +- Service 호출 오케스트레이션 +- Domain Entity → Info 변환 +- 트랜잭션 경계 아님 (Service에서 관리) + +### Service (domain) +- 비즈니스 로직 담당 +- 중복 체크는 exists 쿼리로 명시적 수행 +- `@Transactional(readOnly = true)` 기본, 쓰기만 `@Transactional` + +### Repository (domain → infrastructure) +- 인터페이스는 domain 패키지에 정의 +- 구현체는 infrastructure 패키지에 배치 + +## 의존성 방향 +``` +interfaces → application → domain ← infrastructure +``` +- Domain은 Infrastructure를 알지 않음 +- 외부 의존성은 인터페이스로 추상화 (예: PasswordEncoder) diff --git a/.claude/rules/core/naming-conventions.md b/.claude/rules/core/naming-conventions.md new file mode 100644 index 00000000..f4f97955 --- /dev/null +++ b/.claude/rules/core/naming-conventions.md @@ -0,0 +1,43 @@ +# Naming Conventions + +## 클래스 네이밍 + +### API 계층 (interfaces/api/) +- Controller: `{Domain}V{version}Controller` (예: `UserV1Controller`) +- API Spec: `{Domain}ApiV{version}Spec` (예: `UserApiV1Spec`) +- DTO Container: `{Domain}V{version}Dto` (예: `UserV1Dto`) +- Request DTO: `{Action}Request` (내부 record, 예: `SignUpRequest`) +- Response DTO: `{Domain}Response` (내부 record, 예: `UserResponse`) + +### Application 계층 (application/) +- Facade: `{Domain}Facade` (예: `UserFacade`) +- Info: `{Domain}Info` (예: `UserInfo`) + +### Domain 계층 (domain/) +- Entity: `{Domain}` (예: `User`) +- Service: `{Domain}Service` (예: `UserService`) +- Repository: `{Domain}Repository` (예: `UserRepository`) +- 의존성 인터페이스: `{Concept}Encoder`, `{Concept}Validator` (예: `PasswordEncoder`) + +### Infrastructure 계층 (infrastructure/) +- Repository 구현: `{Domain}RepositoryImpl` (예: `UserRepositoryImpl`) +- JPA Repository: `{Domain}JpaRepository` (예: `UserJpaRepository`) +- 인터페이스 구현: `{Prefix}{Concept}` (예: `BcryptPasswordEncoder`) + +## 메서드 네이밍 + +### Repository 메서드 +- 저장: `save(entity)` +- 단일 조회: `findBy{Field}(value)` → `Optional` 반환 +- 존재 여부: `existsBy{Field}(value)` → `boolean` 반환 + +### DTO 변환 메서드 +- 팩토리 메서드: `from(source)` (예: `UserResponse.from(UserInfo)`) + +### 도메인 엔티티 메서드 +- 정적 팩토리: `create(...)` (생성자 대신 사용) +- 비즈니스 메서드: 동사로 시작 (예: `changePassword`, `delete`, `restore`) + +### 검증 메서드 +- private 검증: `validate{Field}(value)` (예: `validateLoginId`) +- 정적 검증 유틸: `{Validator}.validate(...)` (예: `PasswordValidator.validate`) diff --git a/.claude/rules/core/never-do.md b/.claude/rules/core/never-do.md new file mode 100644 index 00000000..3b42c766 --- /dev/null +++ b/.claude/rules/core/never-do.md @@ -0,0 +1,9 @@ +# Never Do + +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현 금지 +- null-safety 하지 않은 코드 작성 금지 (Optional 활용) +- System.out.println 코드 남기지 않기 +- 테스트를 임의로 삭제하거나 @Disabled 처리 금지 +- 요청하지 않은 기능을 선제적으로 구현하지 않기 +- 과도한 미래 예측 기반 설계 금지 (YAGNI 준수) +- Service 간 직접 호출 금지 (순환 참조 방지) \ No newline at end of file diff --git a/.claude/rules/core/project-conventions.md b/.claude/rules/core/project-conventions.md new file mode 100644 index 00000000..7a5edbe1 --- /dev/null +++ b/.claude/rules/core/project-conventions.md @@ -0,0 +1,26 @@ +# Project Conventions (기존 코드 패턴) + +## API Response +- 성공: ApiResponse.success(data) +- 실패: ApiResponse.fail(errorType) + +## Exception +- throw new CoreException(ErrorType.NOT_FOUND) +- throw new CoreException(ErrorType.BAD_REQUEST, "Custom message") + +## ErrorType 종류 +- INTERNAL_ERROR (500), BAD_REQUEST (400), NOT_FOUND (404), CONFLICT (409) + +## Feature 생성 순서 +1. Controller (interfaces/api/) — *ApiSpec 인터페이스 + *Controller 구현 +2. Facade (application/) — orchestration + *Info records +3. Service (domain/) — 비즈니스 로직 +4. Repository (domain/) — 인터페이스 정의 +5. Repository Impl (infrastructure/) — JPA 구현 + +## 주의사항 +- open-in-view disabled (Controller에서 lazy loading 금지) +- Timezone: Asia/Seoul +- Soft Delete: deletedAt 필드 사용, hard delete 금지 +- Hibernate DDL은 production에서 none (스키마 변경은 migration으로만) +- QueryDSL Q-class 경로: build/generated/sources/annotationProcessor \ No newline at end of file diff --git a/.claude/rules/core/recommendation.md b/.claude/rules/core/recommendation.md new file mode 100644 index 00000000..b982f4ef --- /dev/null +++ b/.claude/rules/core/recommendation.md @@ -0,0 +1,21 @@ +# Recommendation + +## 코드 품질 +- 재사용 가능한 객체 설계 +- 기존 코드 패턴 분석 후 일관성 유지 +- Java 21 모던 문법 적극 활용 (Records, Pattern Matching, Text Blocks, Sealed Class) + +## 테스트 +- 실제 API를 호출해 확인하는 E2E 테스트 코드 작성 +- 도메인 검증은 단위 테스트로 작성 (HTTP 컨텍스트 불필요) +- Testcontainers 활용하여 실제 DB 환경에서 통합 테스트 + +## 문서화 +- 개발 완료된 API는 `http/*.http` 파일에 분류해 작성 +- 성능 최적화에 대한 대안 및 제안 제시 + +## 우선순위 +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file diff --git a/.claude/rules/core/workflow.md b/.claude/rules/core/workflow.md new file mode 100644 index 00000000..fd0354aa --- /dev/null +++ b/.claude/rules/core/workflow.md @@ -0,0 +1,39 @@ +# Workflow + +## 대원칙 - 증강 코딩 +- 방향성 및 주요 의사 결정은 개발자에게 제안만 하고, 최종 승인된 사항을 기반으로 작업 수행 +- AI가 임의판단하지 않고, 제안 후 개발자 승인을 받은 뒤 수행 +- 요청하지 않은 기능 구현, 테스트 삭제, 반복적 동작 시 즉시 중단하고 보고 + +## 구현 워크플로우 (하향식) + +PRD + AC가 명세 역할을 하므로, **AC ↔ 테스트 1:1 매핑 검증**이 핵심 + +### 구현 순서 (하향식) + +``` +Controller → Facade → Service → Repository → Entity +``` + +1. **Controller + DTO** - API 인터페이스 정의 +2. **Facade + Info** - 오케스트레이션 +3. **Service** - 비즈니스 로직 +4. **Repository** - 인터페이스 + 구현체 +5. **Entity** - 도메인 모델 + +### 테스트 작성 + +- 구현과 테스트를 **동시에** 작성 +- 모든 테스트는 3A 원칙: Arrange - Act - Assert +- AC와 테스트 1:1 매핑 필수 + +| 계층 | 테스트 유형 | +|------|-----------| +| Entity | 단위 테스트 | +| Service | 통합 테스트 | +| Controller | E2E 테스트 | + +### Refactor Phase +- 불필요한 코드 제거, 객체지향적 구조 개선 +- unused import 제거 +- 모든 테스트 케이스가 통과해야 함 \ No newline at end of file diff --git a/.claude/rules/layer/api.md b/.claude/rules/layer/api.md new file mode 100644 index 00000000..c026eaa2 --- /dev/null +++ b/.claude/rules/layer/api.md @@ -0,0 +1,89 @@ +--- +paths: + - "**/interfaces/api/**/*.java" +--- +# API Layer Rules + +## URL 경로 규칙 +- 리소스명은 **복수형** 사용: `/api/v1/users`, `/api/v1/orders` +- 케이스: **kebab-case** (단어 구분 시) +- 네스팅 깊이: **최대 4단계** (`/api/v1/users/me/password`) +- 버전 관리: 클래스명에 `V1`, `V2` 포함 (URL과 동일) +- 행위성 엔드포인트: 리소스로 표현 불가능한 경우만 허용 + +## HTTP 메서드 규칙 +| 메서드 | 용도 | 멱등성 | +|--------|------|--------| +| GET | 조회 | O | +| POST | 생성 | X | +| PATCH | 부분 수정 | O | +| DELETE | 삭제 (soft delete 포함) | O | + +- PUT은 사용하지 않음 (PATCH로 통일) +- soft delete도 DELETE 메서드 사용 + +## Controller 구조 +``` +{Domain}ApiV{version}Spec.java - 인터페이스 (Swagger 문서화) +{Domain}V{version}Controller.java - 구현체 +{Domain}V{version}Dto.java - Request/Response DTO 그룹 +``` + +## Request 규칙 +- **record** 사용 (불변 보장) +- 네이밍: `{Domain}V{version}Dto.{Action}Request` + ```java + UserV1Dto.SignUpRequest + UserV1Dto.ChangePasswordRequest + ``` +- **Bean Validation 허용**: 입력값 형식 검증 목적 + - 허용 어노테이션: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Min`, `@Max`, `@Positive`, `@Size` 등 + - Controller에서 `@Valid` 사용 + - 비즈니스 규칙 검증은 도메인에서 수행 (관심사 분리) + +## Response 규칙 +- **ApiResponse** 래핑 필수 +- 네이밍: `{Domain}V{version}Dto.{Domain}Response` + ```java + UserV1Dto.UserResponse + ``` +- 변환 메서드: `from(Info)` 정적 팩토리 메서드 사용 + ```java + public static UserResponse from(UserInfo info) { ... } + ``` + +## Controller → Service 전달 +- **원시값 파라미터로 전달** (Request DTO 그대로 넘기지 않음) + ```java + // Good + userFacade.signUp(request.loginId(), request.password(), request.name()); + + // Bad + userFacade.signUp(request); + ``` +- DTO는 Domain Entity를 직접 알지 않음 + +## Controller 책임 범위 + +### 허용 +- HTTP 요청/응답 변환 +- 인증 헤더 필수값 검증 (존재 여부만) +- Facade/Service 호출 +- Response 변환 + +### 금지 +- 비즈니스 로직 +- Repository/DB 직접 접근 +- 조건 분기로 서비스 다르게 호출 +- @Transactional 사용 +- try-catch로 예외 처리 + +## 예외 처리 +- `@RestControllerAdvice`에서 글로벌 처리 +- Controller에서 try-catch 금지 +- 비즈니스 예외: `CoreException(ErrorType.XXX)` throw +- ErrorType: `BAD_REQUEST(400)`, `UNAUTHORIZED(401)`, `NOT_FOUND(404)`, `CONFLICT(409)`, `INTERNAL_ERROR(500)` + +## HTTP Status Code +- 성공: **200** 통일 (201, 204 사용하지 않음) +- 실패: ErrorType의 status 사용 diff --git a/.claude/rules/layer/domain.md b/.claude/rules/layer/domain.md new file mode 100644 index 00000000..3adb245b --- /dev/null +++ b/.claude/rules/layer/domain.md @@ -0,0 +1,23 @@ +--- +paths: + - "**/domain/**/*.java" +--- +# Domain Layer Rules + +## Entity 설계 +- 정적 팩토리 메서드로 생성 (public 생성자 금지) +- JPA용 protected 기본 생성자 필수 +- 생성 시점에 모든 불변식 검증 수행 +- 검증 실패 시 IllegalArgumentException 던지기 +- 검증 정규식은 상수(private static final Pattern)로 선언 + +## 검증 범위 +- null/blank 체크 +- 형식 검증 (이메일, 로그인ID 패턴 등) +- 비즈니스 규칙 (cross-field 검증 포함) +- 미래 날짜 불가 등 논리적 제약 + +## 의존성 +- 도메인 객체는 Spring Bean을 필드 주입받지 않는다 +- 도메인 불변식에 필요한 외부 기능은 도메인 패키지에 인터페이스를 정의하고, + 메서드 파라미터로 전달받는다 (예: PasswordEncoder) \ No newline at end of file diff --git a/.claude/rules/layer/service.md b/.claude/rules/layer/service.md new file mode 100644 index 00000000..5cd7ee48 --- /dev/null +++ b/.claude/rules/layer/service.md @@ -0,0 +1,20 @@ +--- +paths: + - "**/application/**/*.java" + - "**/service/**/*.java" +--- +# Service Layer Rules + +## 메서드 흐름 +1. 비즈니스 중복 체크 등 IO 수행 +2. 암호화 등 외부 의존성 처리 +3. Repository 저장/조회 + +## 의존성 규칙 +- Service 간 직접 호출 금지 (순환 참조 방지) +- 여러 Service 조합이 필요하면 Facade 패턴 사용 +- Repository, PasswordEncoder 등 인프라 의존성 주입 가능 + +## 반환 +- Result/Info 객체(record)로 반환 +- Entity를 Controller에 직접 노출하지 않음 \ No newline at end of file diff --git a/.claude/skills/controller-patterns/SKILL.md b/.claude/skills/controller-patterns/SKILL.md new file mode 100644 index 00000000..936bbd16 --- /dev/null +++ b/.claude/skills/controller-patterns/SKILL.md @@ -0,0 +1,508 @@ +--- +name: controller-patterns +description: Spring Controller + DTO 구현 패턴. "Controller 구현해줘", "API 만들어줘", "DTO 작성해줘" 요청 시 사용. ApiSpec 인터페이스, Controller 구현, Bean Validation 패턴 제공. +--- + +# Controller Patterns + +Controller 계층 구현 가이드입니다. + +## 필수 규칙 참조 + +- `.claude/rules/layer/api.md` - API Layer 상세 규칙 ⭐ +- `.claude/rules/core/layer-patterns.md` - Controller 역할 +- `.claude/rules/core/naming-conventions.md` - 네이밍 규칙 +- `.claude/rules/core/dto-patterns.md` - DTO 패턴 + +--- + +## 핵심 규칙 요약 + +| 항목 | 규칙 | +|------|------| +| HTTP Method | PUT 사용 안 함, **PATCH로 통일** | +| HTTP Status | 성공은 **200 통일** (201, 204 사용 안 함) | +| Controller 역할 | HTTP 변환만, 비즈니스 로직 금지 | +| 인증 헤더 검증 | **존재 여부만** 검증 허용 | +| 예외 처리 | try-catch 금지, 글로벌 핸들러가 처리 | + +> 상세 규칙은 `.claude/rules/layer/api.md` 참조 + +--- + +## 패키지 구조 + +``` +com.loopers.interfaces.api.{domain}/ +├── {Domain}ApiV{version}Spec.java # API 스펙 인터페이스 +├── {Domain}V{version}Controller.java # Controller 구현 +└── {Domain}V{version}Dto.java # DTO 컨테이너 +``` + +--- + +## 1. ApiSpec 인터페이스 + +**목적:** API 명세를 인터페이스로 분리 (Swagger 문서화 + 구현 분리) + +### 템플릿 + +```java +@Tag(name = "{Domain} API", description = "{도메인} 관련 API") +public interface {Domain}ApiV1Spec { + + @Operation(summary = "{요약}", description = "{상세 설명}") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패"), + @ApiResponse(responseCode = "404", description = "리소스 없음"), + @ApiResponse(responseCode = "409", description = "중복 리소스") + }) + @PostMapping("/api/v1/{domain}s") + ApiResponse<{Domain}V1Dto.{Domain}Response> create( + @Valid @RequestBody {Domain}V1Dto.CreateRequest request + ); +} +``` + +### 규칙 + +| 항목 | 규칙 | +|------|------| +| 네이밍 | `{Domain}ApiV{version}Spec` | +| 어노테이션 | `@Tag`, `@Operation`, `@ApiResponses` | +| 파라미터 | `@Valid`, `@RequestBody`, `@PathVariable` | +| 반환 타입 | `ApiResponse` | + +--- + +## 2. Controller 구현 + +**목적:** HTTP 요청/응답 변환만 담당 (비즈니스 로직 없음) + +### 템플릿 + +```java +@RestController +@RequiredArgsConstructor +public class {Domain}V1Controller implements {Domain}ApiV1Spec { + + private final {Domain}Facade {domain}Facade; + + @Override + public ApiResponse<{Domain}V1Dto.{Domain}Response> create({Domain}V1Dto.CreateRequest request) { + {Domain}Info info = {domain}Facade.create( + request.field1(), + request.field2(), + request.field3() + ); + return ApiResponse.success({Domain}V1Dto.{Domain}Response.from(info)); + } +} +``` + +### 규칙 + +| 항목 | 규칙 | +|------|------| +| 네이밍 | `{Domain}V{version}Controller` | +| 어노테이션 | `@RestController`, `@RequiredArgsConstructor` | +| 의존성 | Facade만 주입 (Service 직접 호출 금지) | +| 데이터 전달 | Request에서 **원시값 추출**하여 Facade 전달 | +| 반환 | `ApiResponse.success(Response.from(Info))` | + +### 금지 사항 + +```java +// ❌ 금지: request 객체를 그대로 전달 +{domain}Facade.create(request); + +// ❌ 금지: try-catch 사용 +try { + ... +} catch (Exception e) { + return ApiResponse.fail(...); +} + +// ❌ 금지: 비즈니스 로직 수행 +if (request.amount() > 10000) { + throw new CoreException(...); +} +``` + +### 허용 사항 + +```java +// ✅ 허용: 인증 헤더 존재 여부만 검증 +private void validateAuthHeaders(String loginId, String password) { + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다"); + } +} +``` + +> 인증 헤더 **존재 여부**만 검증. 비밀번호 일치 등 비즈니스 검증은 Service에서. + +### 올바른 패턴 + +```java +// ✅ 원시값 추출하여 전달 +{Domain}Info info = {domain}Facade.create( + request.loginId(), + request.password(), + request.name(), + request.email(), + request.birthDate() +); + +// ✅ 단순 변환 후 반환 +return ApiResponse.success({Domain}V1Dto.{Domain}Response.from(info)); +``` + +--- + +## 3. DTO 컨테이너 + +**목적:** 버전별 Request/Response를 하나의 클래스에서 관리 + +### 템플릿 + +```java +public class {Domain}V1Dto { + + // === Request DTOs === + + public record CreateRequest( + @NotBlank(message = "필드1은 필수입니다") + @Size(min = 4, max = 20, message = "필드1은 4-20자여야 합니다") + String field1, + + @NotBlank(message = "필드2는 필수입니다") + String field2, + + @NotBlank(message = "이메일은 필수입니다") + @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다") + String email, // 형식 검증은 Entity에서 + + LocalDate birthDate // 선택 필드, 날짜 규칙은 Entity에서 + ) {} + + public record UpdateRequest( + @NotBlank(message = "필드1은 필수입니다") + String field1 + ) {} + + // === Response DTOs === + + public record {Domain}Response( + Long id, + String field1, + String field2, + LocalDateTime createdAt + ) { + public static {Domain}Response from({Domain}Info info) { + return new {Domain}Response( + info.id(), + info.field1(), + info.field2(), + info.createdAt() + ); + } + } + + public record {Domain}ListResponse( + List<{Domain}Response> items, + int totalCount + ) { + public static {Domain}ListResponse from(List<{Domain}Info> infos) { + return new {Domain}ListResponse( + infos.stream() + .map({Domain}Response::from) + .toList(), + infos.size() + ); + } + } +} +``` + +### 규칙 + +| 항목 | 규칙 | +|------|------| +| 컨테이너 네이밍 | `{Domain}V{version}Dto` | +| Request 네이밍 | `{Action}Request` (예: `CreateRequest`, `UpdateRequest`) | +| Response 네이밍 | `{Domain}Response`, `{Domain}ListResponse` | +| 타입 | Java `record` 사용 (불변성) | +| 변환 메서드 | `from(Info)` 정적 팩토리 | + +--- + +## 4. Bean Validation + +**목적:** 입력값 검증 (존재 여부 + 기본 범위) + +> **중요:** 형식/규칙 검증(`@Pattern`, `@Email` 등)은 **도메인 불변식**으로, Entity에서 검증 + +### 허용 어노테이션 (존재 + 범위만) + +| 어노테이션 | 용도 | 예시 | +|-----------|------|------| +| `@NotNull` | null 불가 | 필수 객체 | +| `@NotBlank` | null, 빈문자열, 공백만 불가 | 필수 문자열 | +| `@NotEmpty` | null, 빈 컬렉션 불가 | 필수 리스트 | +| `@Size` | 길이/크기 범위 | `@Size(min=4, max=20)` | +| `@Min`, `@Max` | 숫자 범위 | `@Min(0)`, `@Max(100)` | +| `@Positive` | 양수만 | 수량, 가격 | + +### 사용 금지 (도메인 불변식으로 이동) + +| 어노테이션 | 이유 | 대신 | +|-----------|------|------| +| `@Pattern` | 형식 규칙은 도메인 불변식 | Entity에서 정규식 검증 | +| `@Email` | 이메일 형식은 도메인 불변식 | Entity에서 검증 | +| `@Past`, `@Future` | 날짜 규칙은 도메인 불변식 | Entity에서 검증 | + +### 검증 예시 + +```java +public record SignUpRequest( + // 존재 + 범위만 + @NotBlank(message = "로그인 ID는 필수입니다") + @Size(min = 4, max = 20, message = "로그인 ID는 4-20자여야 합니다") + String loginId, + + // 존재 + 범위만 + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8-20자여야 합니다") + String password, + + // 존재 + 범위만 (이메일 형식은 Entity에서 검증) + @NotBlank(message = "이메일은 필수입니다") + @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다") + String email, + + // 선택 필드 (날짜 규칙은 Entity에서 검증) + LocalDate birthDate +) {} +``` + +### 검증 책임 분리 + +| 계층 | 담당 | 예시 | +|------|------|------| +| **Request DTO** | 존재 여부 + 기본 범위 | `@NotBlank`, `@Size`, `@Min`, `@Max` | +| **Domain Entity** | 형식 + 비즈니스 규칙 | 정규식, 이메일 형식, 날짜 규칙, cross-field 검증 | +| **Service** | 비즈니스 검증 | 중복 체크, 권한 검증 | + +### Entity에서 형식 검증 예시 + +```java +// Entity 내부 +private static void validateLoginId(String loginId) { + if (!loginId.matches("^[a-z][a-z0-9]*$")) { + throw new IllegalArgumentException("로그인 ID는 영문 소문자로 시작하고, 영문 소문자와 숫자만 허용됩니다"); + } +} + +private static void validateEmail(String email) { + if (!email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) { + throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다"); + } +} + +private static void validateBirthDate(LocalDate birthDate) { + if (birthDate != null && !birthDate.isBefore(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 과거 날짜만 허용됩니다"); + } +} +``` + +--- + +## 5. HTTP Method별 패턴 + +### POST (생성) + +```java +// ApiSpec +@PostMapping("/api/v1/users") +ApiResponse create(@Valid @RequestBody CreateRequest request); + +// Controller +@Override +public ApiResponse create(CreateRequest request) { + UserInfo info = userFacade.create( + request.loginId(), + request.password(), + request.name() + ); + return ApiResponse.success(UserResponse.from(info)); +} +``` + +### GET (단건 조회) + +```java +// ApiSpec +@GetMapping("/api/v1/users/{id}") +ApiResponse getById(@PathVariable Long id); + +// Controller +@Override +public ApiResponse getById(Long id) { + UserInfo info = userFacade.getById(id); + return ApiResponse.success(UserResponse.from(info)); +} +``` + +### GET (목록 조회) + +```java +// ApiSpec +@GetMapping("/api/v1/users") +ApiResponse getList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size +); + +// Controller +@Override +public ApiResponse getList(int page, int size) { + List infos = userFacade.getList(page, size); + return ApiResponse.success(UserListResponse.from(infos)); +} +``` + +### PATCH (수정) + +> PUT 사용 안 함, **PATCH로 통일** + +```java +// ApiSpec +@PatchMapping("/api/v1/users/{id}") +ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody UpdateRequest request +); + +// Controller +@Override +public ApiResponse update(Long id, UpdateRequest request) { + UserInfo info = userFacade.update(id, request.name(), request.email()); + return ApiResponse.success(UserResponse.from(info)); +} +``` + +### DELETE (삭제) + +```java +// ApiSpec +@DeleteMapping("/api/v1/users/{id}") +ApiResponse delete(@PathVariable Long id); + +// Controller +@Override +public ApiResponse delete(Long id) { + userFacade.delete(id); + return ApiResponse.success(null); +} +``` + +--- + +## 체크리스트 + +### ApiSpec +- [ ] `@Tag`로 API 그룹 정의 +- [ ] `@Operation`으로 요약/설명 작성 +- [ ] `@ApiResponses`로 응답 코드 문서화 +- [ ] `@Valid` 적용 + +### Controller +- [ ] `@RestController`, `@RequiredArgsConstructor` 적용 +- [ ] Facade만 의존 (Service 직접 호출 금지) +- [ ] Request에서 원시값 추출하여 전달 +- [ ] try-catch 사용 안 함 +- [ ] 비즈니스 로직 없음 + +### DTO +- [ ] record로 작성 (불변성) +- [ ] 버전별 컨테이너 클래스 사용 +- [ ] Request: Bean Validation 적용 +- [ ] Response: `from(Info)` 정적 팩토리 +- [ ] Domain Entity import 안 함 + +--- + +## 트러블슈팅 + +### 1. @Valid가 동작하지 않음 + +**원인:** `@RequestBody` 없이 `@Valid`만 사용 +```java +// ❌ 잘못됨 +ApiResponse create(@Valid CreateRequest request); + +// ✅ 올바름 +ApiResponse create(@Valid @RequestBody CreateRequest request); +``` + +### 2. Validation 에러 메시지가 출력되지 않음 + +**원인:** `message` 속성 누락 +```java +// ❌ 메시지 없음 +@NotBlank +String loginId; + +// ✅ 메시지 있음 +@NotBlank(message = "로그인 ID는 필수입니다") +String loginId; +``` + +### 3. Controller에서 비즈니스 검증 로직 작성 + +**문제:** Controller는 HTTP 변환만 담당 +```java +// ❌ Controller에서 비즈니스 검증 +if (request.amount() > 10000) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액 초과"); +} + +// ✅ Service에서 비즈니스 검증 (Controller는 전달만) +userFacade.create(request.amount()); // 검증은 Service에서 +``` + +### 4. @Pattern, @Email 사용하면 안 됨 + +**이유:** 형식 검증은 도메인 불변식으로, Entity에서 검증 +```java +// ❌ DTO에서 형식 검증 +@Email +String email; + +// ✅ DTO는 존재+범위만, Entity에서 형식 검증 +@NotBlank +@Size(max = 100) +String email; +``` + +### 5. Response에서 Entity 직접 사용 + +**문제:** Response는 Info를 통해 변환 +```java +// ❌ Entity 직접 import +public static UserResponse from(User user) { ... } + +// ✅ Info를 통해 변환 +public static UserResponse from(UserInfo info) { ... } +``` + +--- + +## 참조 문서 + +| 문서 | 설명 | +|------|------| +| [user-controller.md](./examples/user-controller.md) | 회원 Controller 예시 | diff --git a/.claude/skills/controller-patterns/examples/user-controller.md b/.claude/skills/controller-patterns/examples/user-controller.md new file mode 100644 index 00000000..65a1e77e --- /dev/null +++ b/.claude/skills/controller-patterns/examples/user-controller.md @@ -0,0 +1,297 @@ +# 회원 Controller 예시 + +User 도메인의 Controller 계층 구현 예시입니다. + +--- + +## 파일 구조 + +``` +com.loopers.interfaces.api.user/ +├── UserApiV1Spec.java # API 스펙 인터페이스 +├── UserV1Controller.java # Controller 구현 +└── UserV1Dto.java # DTO 컨테이너 +``` + +--- + +## 1. UserApiV1Spec.java + +```java +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "User API", description = "회원 관련 API") +public interface UserApiV1Spec { + + @Operation(summary = "회원가입", description = "신규 회원을 등록합니다") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "회원가입 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "중복된 로그인 ID 또는 이메일") + }) + @PostMapping("/api/v1/users") + ApiResponse signUp( + @Valid @RequestBody UserV1Dto.SignUpRequest request + ); + + @Operation(summary = "회원 조회", description = "회원 정보를 조회합니다") + @GetMapping("/api/v1/users/{id}") + ApiResponse getById(@PathVariable Long id); + + @Operation(summary = "회원 정보 수정", description = "회원 정보를 수정합니다 (PATCH 사용)") + @PatchMapping("/api/v1/users/{id}") + ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody UserV1Dto.UpdateRequest request + ); + + @Operation(summary = "회원 탈퇴", description = "회원을 탈퇴 처리합니다 (Soft Delete)") + @DeleteMapping("/api/v1/users/{id}") + ApiResponse delete(@PathVariable Long id); +} +``` + +--- + +## 2. UserV1Controller.java + +```java +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.user.dto.UserV1Dto; +import com.loopers.support.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class UserV1Controller implements UserApiV1Spec { + + private final UserFacade userFacade; + + @Override + public ApiResponse signUp(UserV1Dto.SignUpRequest request) { + UserInfo info = userFacade.signUp( + request.loginId(), + request.password(), + request.name(), + request.email(), + request.birthDate() + ); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @Override + public ApiResponse getById(Long id) { + UserInfo info = userFacade.getById(id); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @Override + public ApiResponse update(Long id, UserV1Dto.UpdateRequest request) { + UserInfo info = userFacade.update( + id, + request.name(), + request.email() + ); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @Override + public ApiResponse delete(Long id) { + userFacade.delete(id); + return ApiResponse.success(null); + } +} +``` + +--- + +## 3. UserV1Dto.java + +```java +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class UserV1Dto { + + // ======================================== + // Request DTOs + // ======================================== + + /** + * Bean Validation: 존재 여부 + 기본 범위만 검증 + * 형식 검증(@Pattern, @Email, @Past)은 Entity 도메인 불변식에서 처리 + */ + public record SignUpRequest( + @NotBlank(message = "로그인 ID는 필수입니다") + @Size(min = 4, max = 20, message = "로그인 ID는 4-20자여야 합니다") + String loginId, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8-20자여야 합니다") + String password, + + @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 20, message = "이름은 2-20자여야 합니다") + String name, + + @NotBlank(message = "이메일은 필수입니다") + @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다") + String email, + + LocalDate birthDate // 선택 필드, 날짜 규칙은 Entity에서 검증 + ) {} + + public record UpdateRequest( + @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 20, message = "이름은 2-20자여야 합니다") + String name, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다") + String email + ) {} + + // ======================================== + // Response DTOs + // ======================================== + + public record UserResponse( + Long id, + String loginId, + String name, + String email, + LocalDate birthDate, + LocalDateTime createdAt + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.id(), + info.loginId(), + info.maskedName(), // 마스킹 처리된 이름 + info.email(), + info.birthDate(), + info.createdAt() + ); + } + } + + public record UserListResponse( + List users, + int totalCount + ) { + public static UserListResponse from(List infos) { + return new UserListResponse( + infos.stream() + .map(UserResponse::from) + .toList(), + infos.size() + ); + } + } +} +``` + +--- + +## 핵심 포인트 + +### 1. Controller는 변환만 담당 + +```java +// Request → 원시값 추출 → Facade 호출 +UserInfo info = userFacade.signUp( + request.loginId(), // 원시값 추출 + request.password(), + request.name(), + request.email(), + request.birthDate() +); + +// Info → Response 변환 +return ApiResponse.success(UserV1Dto.UserResponse.from(info)); +``` + +### 2. 비즈니스 로직 없음 + +```java +// ❌ 잘못된 예시 - Controller에서 비즈니스 로직 +if (userFacade.existsByLoginId(request.loginId())) { + throw new CoreException(ErrorType.CONFLICT); +} + +// ✅ 올바른 예시 - 단순히 Facade 호출만 +UserInfo info = userFacade.signUp(...); +``` + +### 3. try-catch 사용 안 함 + +```java +// ❌ 잘못된 예시 +try { + UserInfo info = userFacade.signUp(...); + return ApiResponse.success(...); +} catch (CoreException e) { + return ApiResponse.fail(e.getErrorType()); +} + +// ✅ 올바른 예시 - 글로벌 핸들러가 처리 +UserInfo info = userFacade.signUp(...); +return ApiResponse.success(...); +``` + +### 4. Response는 Info에서 변환 + +```java +// Response가 Info를 알고 있음 (의존 방향: interfaces → application) +public static UserResponse from(UserInfo info) { + return new UserResponse( + info.id(), + info.loginId(), + info.maskedName(), // Info의 가공 메서드 활용 + ... + ); +} +``` + +--- + +## 데이터 흐름 + +``` +[HTTP Request] + ↓ +[Controller] - @Valid로 형식 검증 + ↓ request.field() (원시값 추출) +[Facade] - 오케스트레이션 + ↓ +[Service] - 비즈니스 로직 + ↓ +[Entity] - 도메인 불변식 + ↓ +[Repository] - 영속화 + ↑ +[Entity] - 저장된 엔티티 + ↓ +[Info] - Entity → Info 변환 (Facade에서) + ↓ +[Response] - Info → Response 변환 + ↓ +[HTTP Response] +``` diff --git a/.claude/skills/entity-patterns/SKILL.md b/.claude/skills/entity-patterns/SKILL.md new file mode 100644 index 00000000..82e05603 --- /dev/null +++ b/.claude/skills/entity-patterns/SKILL.md @@ -0,0 +1,480 @@ +--- +name: entity-patterns +description: Domain Entity 구현 패턴. "Entity 만들어줘", "도메인 모델 작성해줘", "JPA Entity 구현해줘" 요청 시 사용. BaseEntity 상속, 정적 팩토리, 도메인 불변식 검증, Soft Delete 패턴 제공. +--- + +# Entity Patterns + +Domain Entity 구현 가이드입니다. + +## 필수 규칙 참조 + +- `.claude/rules/layer/domain.md` - Domain Layer 상세 규칙 ⭐ +- `.claude/rules/core/design-principles.md` - 2단계 검증 전략 +- `.claude/rules/core/exception-patterns.md` - IllegalArgumentException 사용 +- `.claude/rules/core/naming-conventions.md` - Entity 네이밍 + +--- + +## 핵심 규칙 요약 + +| 항목 | 규칙 | +|------|------| +| 상속 | `extends BaseEntity` 필수 | +| 생성자 | **Lombok 금지**, 직접 작성 | +| 기본 생성자 | `protected {Domain}() {}` JPA용 | +| private 생성자 | 모든 필드 초기화 + 검증 호출 | +| 팩토리 메서드 | `public static create()` | +| 정규식 | `private static final Pattern` 상수 | +| 예외 | `IllegalArgumentException` | + +### Lombok 사용 규칙 + +| 허용 | 금지 | +|------|------| +| `@Getter` | `@NoArgsConstructor` | +| | `@AllArgsConstructor` | +| | `@Builder` | +| | `@Setter` | + +> 생성자는 **반드시 직접 작성**. Lombok 생성자 어노테이션 사용 금지. + +> 상세 규칙은 `.claude/rules/layer/domain.md` 참조 + +--- + +## 패키지 구조 + +``` +com.loopers.domain.{domain}/ +├── {Domain}.java # Entity +├── {Domain}Repository.java # Repository 인터페이스 +└── {Domain}Service.java # Domain Service +``` + +--- + +## 1. BaseEntity 상속 + +모든 Entity는 `BaseEntity`를 상속받습니다. + +### BaseEntity 제공 기능 + +| 필드/메서드 | 설명 | +|------------|------| +| `id` | Auto-generated Long ID | +| `createdAt` | 생성 시각 (자동) | +| `updatedAt` | 수정 시각 (자동) | +| `deletedAt` | 삭제 시각 (Soft Delete) | +| `delete()` | Soft Delete 수행 | +| `restore()` | 삭제 복원 | +| `guard()` | PrePersist/PreUpdate 시 호출되는 검증 훅 | + +### 기본 구조 + +```java +@Entity +@Table(name = "{domain}s") +@Getter // Getter만 허용 +public class {Domain} extends BaseEntity { + + // 정규식 상수 + private static final Pattern XXX_PATTERN = Pattern.compile("..."); + + // 필드 정의 + @Column(nullable = false) + private String field1; + + // JPA용 기본 생성자 (직접 작성) + protected {Domain}() {} + + // private 생성자 (직접 작성) + private {Domain}(String field1, ...) { + this.field1 = field1; + ... + } + + // 정적 팩토리 메서드 + public static {Domain} create(...) { ... } + + // 도메인 불변식 검증 메서드 + private static void validateXxx(...) { ... } + + // 비즈니스 메서드 + public void changeXxx(...) { ... } +} +``` + +> **주의:** `@NoArgsConstructor`, `@AllArgsConstructor` 사용 금지. 생성자 직접 작성. + +--- + +## 2. 생성자 패턴 + +**목적:** 생성 시점에 모든 불변식 검증 수행 + +### JPA용 기본 생성자 + +```java +// 반드시 직접 작성 (@NoArgsConstructor 금지) +protected {Domain}() {} +``` + +### private 생성자 + +```java +// 반드시 직접 작성 (@AllArgsConstructor 금지) +private {Domain}(String field1, String field2, LocalDate field3) { + this.field1 = field1; + this.field2 = field2; + this.field3 = field3; +} +``` + +### 정적 팩토리 메서드 + +```java +public static {Domain} create(String field1, String field2, LocalDate field3) { + validateField1(field1); + validateField2(field2); + validateField3(field3); + return new {Domain}(field1, field2, field3); +} +``` + +### 외부 의존성이 필요한 경우 + +```java +// ❌ 금지: 필드 주입 +@Autowired +private PasswordEncoder passwordEncoder; + +// ✅ 올바름: create() 파라미터로 전달 +public static User create(String loginId, String rawPassword, + String name, LocalDate birthDate, String email, + PasswordEncoder encoder) { + validateLoginId(loginId); + validateBirthDate(birthDate); + validateName(name); + validateEmail(email); + PasswordValidator.validate(rawPassword, birthDate); + + String encodedPassword = encoder.encode(rawPassword); + return new User(loginId, encodedPassword, name, birthDate, email); +} +``` + +> 외부 의존성(PasswordEncoder 등)은 **create() 파라미터**로 전달받아 Entity 내에서 처리 + +--- + +## 3. 도메인 불변식 검증 + +**목적:** Entity가 항상 유효한 상태를 보장 + +### 검증 순서 + +1. **null/blank 체크** - 필수값 존재 여부 +2. **형식 검증** - 정규식 패턴 (이메일, 아이디 등) +3. **비즈니스 규칙** - 논리적 제약 (미래 날짜 불가 등) +4. **cross-field 검증** - 필드 간 교차 검증 + +### 검증 메서드 패턴 + +```java +// 정규식은 상수로 선언 +private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-z][a-z0-9]*$"); +private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); + +private static void validateLoginId(String loginId) { + // 1. null/blank 체크 + if (loginId == null || loginId.isBlank()) { + throw new IllegalArgumentException("로그인 ID는 필수입니다"); + } + // 2. 형식 검증 + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new IllegalArgumentException("로그인 ID는 영문 소문자로 시작하고, 영문 소문자와 숫자만 허용됩니다"); + } +} + +private static void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("이메일은 필수입니다"); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다"); + } +} + +private static void validateBirthDate(LocalDate birthDate) { + // 선택 필드는 null 허용 + if (birthDate != null && !birthDate.isBefore(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 과거 날짜만 허용됩니다"); + } +} +``` + +### 예외 타입 + +| 상황 | 예외 | 처리 | +|------|------|------| +| 도메인 불변식 위반 | `IllegalArgumentException` | 글로벌 핸들러 → 400 BAD_REQUEST | +| 비즈니스 로직 오류 | `CoreException` | Service에서 throw | + +--- + +## 4. Soft Delete + +**목적:** 데이터 삭제 시 실제 삭제 대신 `deletedAt` 필드 사용 + +### BaseEntity 제공 메서드 + +```java +// Soft Delete (멱등) +public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } +} + +// 복원 (멱등) +public void restore() { + if (this.deletedAt != null) { + this.deletedAt = null; + } +} +``` + +### Repository 조회 시 주의 + +```java +// ❌ 삭제된 데이터도 조회됨 +Optional findByLoginId(String loginId); + +// ✅ 삭제되지 않은 데이터만 조회 +Optional findByLoginIdAndDeletedAtIsNull(String loginId); +``` + +--- + +## 5. 비즈니스 메서드 + +**목적:** Entity 상태 변경 로직 캡슐화 + +### 패턴 + +```java +// 상태 변경 메서드 +public void changePassword(String newEncodedPassword) { + validatePassword(newEncodedPassword); + this.password = newEncodedPassword; +} + +public void updateProfile(String name, String email) { + validateName(name); + validateEmail(email); + this.name = name; + this.email = email; +} + +// 조회용 메서드 +public boolean isDeleted() { + return this.deletedAt != null; +} + +public String getMaskedName() { + if (name == null || name.length() < 2) return name; + if (name.length() == 2) return name.charAt(0) + "*"; + return name.charAt(0) + "*".repeat(name.length() - 2) + name.charAt(name.length() - 1); +} +``` + +--- + +## 6. JPA 어노테이션 + +### 필수 어노테이션 + +```java +@Entity // JPA Entity +@Table(name = "users") // 테이블명 +@Getter // Lombok Getter만 사용 +public class User extends BaseEntity { + // JPA용 기본 생성자 (직접 작성) + protected User() {} +``` + +### 필드 어노테이션 + +```java +@Column(nullable = false, unique = true, length = 20) +private String loginId; + +@Column(nullable = false) +private String password; + +@Column(nullable = false, length = 20) +private String name; + +@Column(nullable = false, unique = true, length = 100) +private String email; + +private LocalDate birthDate; // nullable = true (기본값) +``` + +### 연관관계 + +```java +// ManyToOne +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "user_id", nullable = false) +private User user; + +// OneToMany +@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) +private List orders = new ArrayList<>(); +``` + +--- + +## 7. guard() 메서드 (선택) + +**목적:** PrePersist/PreUpdate 시점에 추가 검증 + +```java +@Override +protected void guard() { + // 저장/수정 직전에 호출됨 + if (this.amount != null && this.amount < 0) { + throw new IllegalStateException("금액은 0 이상이어야 합니다"); + } +} +``` + +> 주의: `guard()`는 영속화 시점 검증용. 생성 시점 검증은 `create()`에서 수행. + +--- + +## 체크리스트 + +### 기본 구조 +- [ ] `extends BaseEntity` 상속 +- [ ] `@Entity`, `@Table` 적용 +- [ ] `@Getter`만 사용 +- [ ] `@NoArgsConstructor`, `@AllArgsConstructor` **사용 안 함** +- [ ] `protected {Domain}() {}` 직접 작성 +- [ ] `private {Domain}(...)` 직접 작성 +- [ ] `public static create()` 정적 팩토리 존재 + +### 불변식 검증 +- [ ] 모든 필수 필드 null/blank 체크 +- [ ] 형식 검증 (정규식 → `Pattern.compile()` 상수) +- [ ] 비즈니스 규칙 검증 +- [ ] `IllegalArgumentException` 사용 + +### 외부 의존성 +- [ ] 필드 주입 금지 +- [ ] `create()` 파라미터로 전달 +- [ ] Entity 내에서 처리 (예: 암호화) + +### 기타 +- [ ] Soft Delete 사용 (`delete()`, `restore()`) +- [ ] 상태 변경 메서드에서도 검증 수행 + +--- + +## 트러블슈팅 + +### 1. `@NoArgsConstructor` 사용함 + +**문제:** Lombok 생성자 어노테이션 사용 금지 +```java +// ❌ 잘못됨 +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { ... } + +// ✅ 올바름 - 직접 작성 +public class User extends BaseEntity { + protected User() {} +} +``` + +### 2. `@Builder` 사용함 + +**문제:** Builder는 불변식 검증을 우회함 +```java +// ❌ 검증 우회 가능 +@Builder +public class User { ... } + +User.builder().loginId(null).build(); // 검증 안 됨 + +// ✅ 정적 팩토리에서 검증 강제 +public static User create(...) { + validateLoginId(loginId); // 검증 수행 + return new User(...); +} +``` + +### 3. 검증에서 `CoreException` 사용 + +**문제:** Entity 검증은 `IllegalArgumentException` 사용 +```java +// ❌ Entity에서 CoreException 사용 +private static void validateLoginId(String loginId) { + if (loginId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "..."); + } +} + +// ✅ IllegalArgumentException 사용 +private static void validateLoginId(String loginId) { + if (loginId == null) { + throw new IllegalArgumentException("로그인 ID는 필수입니다"); + } +} +``` + +### 4. 정규식을 매번 컴파일 + +**문제:** 성능 저하 +```java +// ❌ 매번 컴파일 +if (!loginId.matches("^[a-z][a-z0-9]*$")) { ... } + +// ✅ 상수로 한 번만 컴파일 +private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-z][a-z0-9]*$"); +if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { ... } +``` + +### 5. Service에서 암호화 후 Entity 생성 + +**문제:** 외부 의존성은 Entity.create() 파라미터로 전달 +```java +// ❌ Service에서 암호화 +String encoded = passwordEncoder.encode(rawPassword); +User user = User.create(loginId, encoded, ...); + +// ✅ Entity.create()에 PasswordEncoder 전달 +User user = User.create(loginId, rawPassword, ..., passwordEncoder); +// Entity 내부에서 암호화 수행 +``` + +### 6. Soft Delete 조회 시 삭제된 데이터 포함 + +**문제:** `deletedAt` 조건 누락 +```java +// ❌ 삭제된 데이터도 조회됨 +Optional findByLoginId(String loginId); + +// ✅ 삭제되지 않은 데이터만 조회 +Optional findByLoginIdAndDeletedAtIsNull(String loginId); +``` + +--- + +## 참조 문서 + +| 문서 | 설명 | +|------|------| +| [user-entity.md](./examples/user-entity.md) | 회원 Entity 예시 | diff --git a/.claude/skills/entity-patterns/examples/user-entity.md b/.claude/skills/entity-patterns/examples/user-entity.md new file mode 100644 index 00000000..718271f8 --- /dev/null +++ b/.claude/skills/entity-patterns/examples/user-entity.md @@ -0,0 +1,393 @@ +# 회원 Entity 예시 + +User 도메인의 Entity 구현 예시입니다. + +--- + +## 파일 위치 + +``` +com.loopers.domain.user/ +├── User.java # Entity +├── UserRepository.java # Repository 인터페이스 +├── UserService.java # Domain Service +└── PasswordEncoder.java # 외부 라이브러리 인터페이스 +``` + +--- + +## User.java + +```java +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.regex.Pattern; + +@Entity +@Table(name = "users") +@Getter // Getter만 사용, @NoArgsConstructor/@AllArgsConstructor 금지 +public class User extends BaseEntity { + + // ======================================== + // 검증용 정규식 상수 + // ======================================== + + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + // ======================================== + // 필드 + // ======================================== + + @Column(nullable = false, unique = true) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Column(nullable = false) + private String email; + + // ======================================== + // JPA용 기본 생성자 (직접 작성) + // ======================================== + + protected User() {} + + // ======================================== + // private 생성자 (직접 작성) + // ======================================== + + private User(String loginId, String password, String name, LocalDate birthDate, String email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + // ======================================== + // 정적 팩토리 메서드 + // ======================================== + + /** + * 회원 생성 + * @param loginId 로그인 ID (영문/숫자) + * @param rawPassword 원문 비밀번호 (Entity 내에서 암호화) + * @param name 이름 + * @param birthDate 생년월일 + * @param email 이메일 + * @param encoder 비밀번호 인코더 (외부 의존성, 파라미터로 전달) + */ + public static User create(String loginId, String rawPassword, String name, + LocalDate birthDate, String email, PasswordEncoder encoder) { + // 검증 + validateLoginId(loginId); + validateBirthDate(birthDate); + PasswordValidator.validate(rawPassword, birthDate); // 비밀번호 복잡도 검증 + validateName(name); + validateEmail(email); + + // 암호화 (Entity 내에서 수행) + String encodedPassword = encoder.encode(rawPassword); + return new User(loginId, encodedPassword, name, birthDate, email); + } + + // ======================================== + // 도메인 불변식 검증 + // ======================================== + + private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new IllegalArgumentException("로그인 ID는 필수입니다"); + } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new IllegalArgumentException("로그인 ID는 영문/숫자만 가능합니다"); + } + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("이름은 필수입니다"); + } + } + + private static void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new IllegalArgumentException("생년월일은 필수입니다"); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 미래일 수 없습니다"); + } + } + + private static void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("이메일은 필수입니다"); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new IllegalArgumentException("올바른 이메일 형식이 아닙니다"); + } + } + + // ======================================== + // 비즈니스 메서드 + // ======================================== + + /** + * 비밀번호 변경 (외부 의존성 파라미터로 전달) + */ + public void changePassword(String newRawPassword, PasswordEncoder encoder) { + // 현재 비밀번호와 동일한지 검증 + if (encoder.matches(newRawPassword, password)) { + throw new IllegalArgumentException("현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다"); + } + // 비밀번호 복잡도 검증 + PasswordValidator.validate(newRawPassword, birthDate); + // 암호화 및 저장 + this.password = encoder.encode(newRawPassword); + } + + /** + * 마스킹된 이름 반환 (예: 홍*동) + */ + public String getMaskedName() { + if (name == null || name.length() < 2) return name; + if (name.length() == 2) return name.charAt(0) + "*"; + return name.charAt(0) + "*".repeat(name.length() - 2) + name.charAt(name.length() - 1); + } + + /** + * 삭제 여부 확인 + */ + public boolean isDeleted() { + return getDeletedAt() != null; + } +} +``` + +--- + +## 핵심 포인트 + +### 1. 정규식은 Pattern 상수로 + +```java +// ✅ 컴파일 비용 절약 +private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-z][a-z0-9]*$"); + +// ❌ 매번 컴파일 +if (!loginId.matches("^[a-z][a-z0-9]*$")) { ... } +``` + +### 2. 생성자는 private, 팩토리는 public + +```java +// ✅ 생성은 반드시 팩토리를 통해 +private User(...) { ... } +public static User create(...) { return new User(...); } + +// ❌ public 생성자 금지 +public User(...) { ... } +``` + +### 3. 검증은 static 메서드로 + +```java +// ✅ static → 생성자에서 호출 가능, 재사용 가능 +private static void validateLoginId(String loginId) { ... } + +// ❌ instance 메서드 → this 참조 전에 호출 불가 +private void validateLoginId() { ... } +``` + +### 4. 외부 의존성은 파라미터로 + +```java +// ✅ 올바름: create() 파라미터로 전달받아 Entity 내에서 처리 +public static User create(String loginId, String rawPassword, ..., PasswordEncoder encoder) { + // 검증 + validateLoginId(loginId); + PasswordValidator.validate(rawPassword, birthDate); + // 암호화 (Entity 내에서 수행) + String encodedPassword = encoder.encode(rawPassword); + return new User(loginId, encodedPassword, ...); +} + +// ❌ 금지: 필드 주입 +@Autowired +private PasswordEncoder passwordEncoder; +``` + +### 5. 상태 변경 시에도 검증 + +```java +public void changePassword(String newEncodedPassword) { + validatePassword(newEncodedPassword); // 검증 후 변경 + this.password = newEncodedPassword; +} +``` + +--- + +## 관련 테스트 + +```java +class UserTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_회원이_생성된다() { + // given + String loginId = "john123"; + String password = "encodedPassword"; + String name = "홍길동"; + String email = "john@test.com"; + LocalDate birthDate = LocalDate.of(1995, 3, 15); + + // when + User user = User.create(loginId, password, name, email, birthDate); + + // then + assertThat(user.getLoginId()).isEqualTo(loginId); + assertThat(user.getName()).isEqualTo(name); + } + + @Test + void 로그인_ID가_null이면_예외발생() { + assertThatThrownBy(() -> + User.create(null, "password", "홍길동", "test@test.com", null) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("로그인 ID"); + } + + @Test + void 로그인_ID가_숫자로_시작하면_예외발생() { + assertThatThrownBy(() -> + User.create("1john", "password", "홍길동", "test@test.com", null) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("영문 소문자로 시작"); + } + + @Test + void 이메일_형식이_잘못되면_예외발생() { + assertThatThrownBy(() -> + User.create("john123", "password", "홍길동", "invalid-email", null) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이메일 형식"); + } + + @Test + void 생년월일이_미래면_예외발생() { + LocalDate future = LocalDate.now().plusDays(1); + + assertThatThrownBy(() -> + User.create("john123", "password", "홍길동", "test@test.com", future) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("과거 날짜"); + } + + @Test + void 생년월일이_null이면_허용된다() { + // when + User user = User.create("john123", "password", "홍길동", "test@test.com", null); + + // then + assertThat(user.getBirthDate()).isNull(); + } + } + + @Nested + class 비즈니스_메서드 { + + @Test + void 마스킹된_이름_반환() { + User user = User.create("john123", "password", "홍길동", "test@test.com", null); + + assertThat(user.getMaskedName()).isEqualTo("홍*동"); + } + + @Test + void 비밀번호_변경() { + User user = User.create("john123", "password", "홍길동", "test@test.com", null); + + user.changePassword("newPassword"); + + assertThat(user.getPassword()).isEqualTo("newPassword"); + } + } +} +``` + +--- + +## 연관관계 Entity 예시 + +```java +@Entity +@Table(name = "orders") +@Getter // Getter만 사용, @NoArgsConstructor/@AllArgsConstructor 금지 +public class Order extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private Integer amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + // JPA용 기본 생성자 (직접 작성) + protected Order() {} + + // private 생성자 (직접 작성) + private Order(User user, Integer amount) { + validateUser(user); + validateAmount(amount); + this.user = user; + this.amount = amount; + this.status = OrderStatus.PENDING; + } + + public static Order create(User user, Integer amount) { + return new Order(user, amount); + } + + private static void validateUser(User user) { + if (user == null) { + throw new IllegalArgumentException("주문자는 필수입니다"); + } + } + + private static void validateAmount(Integer amount) { + if (amount == null || amount <= 0) { + throw new IllegalArgumentException("주문 금액은 0보다 커야 합니다"); + } + } + + public void complete() { + if (this.status != OrderStatus.PENDING) { + throw new IllegalStateException("대기 중인 주문만 완료할 수 있습니다"); + } + this.status = OrderStatus.COMPLETED; + } +} +``` diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 00000000..818fcd38 --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,236 @@ +--- +name: implement +description: PRD 기반 AI 최적화 구현. 기존 코드 패턴을 참조하여 일관된 코드 작성. "구현해줘", "개발해줘", "만들어줘", "코드 작성" 요청 시 사용. +--- + +# Implement Skill + +PRD/AC 기반 **AI 최적화 구현** 가이드입니다. + +## 핵심 철학 + +``` +PRD (What) + 기존 패턴 (How) → 일관된 구현 +``` + +- PRD/AC는 **약속된 인터페이스** (계약) +- 내부 구현은 **블랙박스** - 인터페이스만 지키면 됨 +- 기존 코드 패턴을 참조하여 **일관성** 유지 +- **AC ↔ 테스트 1:1 매핑**이 TDD의 본질 + +--- + +## 워크플로우 + +``` +Phase 0: CONTEXT (컨텍스트) + ↓ +Phase 1: INTERFACE (인터페이스 정의) + ↓ +Phase 2: IMPLEMENT + TEST (구현과 테스트 동시) + ↓ +Phase 3: VERIFY (AC 매핑 검증) + ↓ +Phase 4: REFACTOR (리팩터링) +``` + +--- + +## Phase 0: CONTEXT (컨텍스트 파악) + +**목적:** 기존 코드 패턴과 프로젝트 규칙 파악 + +### 필수 읽기 파일 + +1. `.claude/rules/core/layer-patterns.md` - 계층 구조 +2. `.claude/rules/core/naming-conventions.md` - 네이밍 규칙 +3. `.claude/rules/core/exception-patterns.md` - 예외 처리 +4. `.claude/rules/core/dto-patterns.md` - DTO 패턴 + +### 기존 코드 참조 (유사 도메인 있는 경우) + +- `domain/{유사도메인}/` - Entity, Service, Repository +- `interfaces/api/{유사도메인}/` - Controller, DTO +- `infrastructure/{유사도메인}/` - Repository 구현 + +### 산출물 + +- 참조할 기존 코드 패턴 목록 +- 준수해야 할 규칙 체크리스트 + +--- + +## Phase 1: INTERFACE (인터페이스 정의) + +**목적:** PRD/AC에서 공개 인터페이스 추출 + +### 입력 + +PRD 문서 (mission/*.md) + +### 추출 항목 + +| 계층 | 추출 내용 | +|------|----------| +| Controller | API 스펙 (Method, Endpoint, Request/Response) | +| Facade | 공개 메서드 시그니처 | +| Service | 비즈니스 메서드 시그니처 | +| Repository | 필요한 쿼리 메서드 | +| Entity | 필드, 정적 팩토리, 비즈니스 메서드 | + +### 산출물 + +- 각 계층의 인터페이스 정의 (구현 없이 시그니처만) + +--- + +## Phase 2: IMPLEMENT + TEST (구현과 테스트 동시) + +**목적:** 구현과 테스트를 동시에 작성 + +### 구현 순서 (상향식) + +1. **Entity + 단위 테스트** (도메인 모델 + 불변식 검증) + - BaseEntity 상속 + - 정적 팩토리 `create()` + - 도메인 불변식 검증 + - 단위 테스트로 불변식 검증 + +2. **Repository** (인터페이스 + 구현) + - 인터페이스: `domain/{도메인}/` 패키지 + - 구현체: `infrastructure/{도메인}/` 패키지 + +3. **Service + 통합 테스트** (비즈니스 로직 + DB 연동) + - 중복 체크 (exists 쿼리) + - 트랜잭션 관리 + - CoreException 사용 + - 통합 테스트 (TestContainers) + +4. **Facade** (오케스트레이션) + - Entity → Info 변환 + - Service 호출 조합 + +5. **Controller + DTO** + - ApiSpec 인터페이스 + Controller 구현 + - DTO 컨테이너 (`{Domain}V{version}Dto`) + - Bean Validation + +### 테스트 패턴 + +- **3A 원칙**: Arrange - Act - Assert +- **AC와 1:1 매핑** (중요!) +- 도메인 테스트: 단위 테스트 +- Service 테스트: 통합 테스트 (TestContainers) + +### 참조 + +- `.claude/skills/test-patterns/SKILL.md` - 테스트 전략 +- `.claude/skills/test-patterns/examples/` - 테스트 예시 +- `references/layer-checklist.md` - 계층별 체크리스트 + +### 산출물 + +- 구현 코드 + 테스트 코드 (함께) +- 테스트 실행 결과 (모두 초록불) + +--- + +## Phase 3: VERIFY (AC 매핑 검증) + +**목적:** AC 커버리지 검증 + +### AC 매핑 테이블 작성 + +```markdown +| AC# | 조건 | 테스트 메서드 | 결과 | +|-----|------|-------------|------| +| AC-1 | 모든 필수값 유효 | 성공() | ✅ | +| AC-2 | loginId 누락 | 로그인_ID_누락이면_예외발생() | ✅ | +| AC-14 | loginId 중복 | 로그인_ID_중복이면_예외발생() | ✅ | +``` + +### 검증 체크리스트 + +- [ ] 모든 AC에 대응하는 테스트가 있는가? +- [ ] AC에 없는 테스트가 있는가? (과잉 구현 신호) +- [ ] 전체 테스트 실행 통과? + +### 과잉/누락 판단 + +- **AC에 없는 테스트** → 과잉 구현이거나 PRD 부족 +- **테스트 없는 AC** → 구현 누락 + +### 산출물 + +- AC 매핑 테이블 +- 전체 테스트 실행 결과 + +--- + +## Phase 4: REFACTOR (리팩터링) + +**목적:** 코드 품질 개선 (테스트 유지) + +### 체크리스트 + +- [ ] 중복 코드 제거 +- [ ] 불필요한 코드 제거 +- [ ] unused import 제거 +- [ ] 모든 테스트 통과 확인 + +### 산출물 + +- 리팩터링된 코드 +- 테스트 실행 결과 (모두 초록불 유지) + +--- + +## 진행 가이드 + +### 시작하기 + +``` +PRD 파일 경로를 알려주세요. +예: mission/round1.md + +또는 구현할 Feature를 지정해주세요. +예: Feature 1: 회원가입 +``` + +### Phase 전환 시 보고 + +``` +[Phase X 완료] +- 완료 항목: ... +- 생성 파일: ... + +[Phase X+1 시작] +- 작업 대상: ... +``` + +### 마무리 + +``` +[구현 완료] + +생성 파일: +- src/main/.../User.java +- src/main/.../UserService.java +- ... + +테스트 결과: +- 단위 테스트: X개 통과 +- 통합 테스트: Y개 통과 +- 전체: Z개 통과 + +다음 Feature로 진행할까요? +``` + +--- + +## 참조 문서 + +| 문서 | 설명 | +|------|------| +| [layer-checklist.md](./references/layer-checklist.md) | 계층별 구현 체크리스트 | +| [user-signup.md](./examples/user-signup.md) | 회원가입 구현 예시 | diff --git a/.claude/skills/implement/examples/user-signup.md b/.claude/skills/implement/examples/user-signup.md new file mode 100644 index 00000000..3bc3c9ef --- /dev/null +++ b/.claude/skills/implement/examples/user-signup.md @@ -0,0 +1,699 @@ +# 회원가입 구현 예시 + +PRD → 구현까지의 전체 흐름을 보여주는 예시입니다. (AI 최적화 워크플로우) + +--- + +## PRD 참조 + +> `mission/round1.md` - Feature 1: 회원가입 + +### API 명세 요약 + +| 항목 | 내용 | +|------|------| +| Method | POST | +| Endpoint | /api/v1/users | +| Auth | 불필요 | + +### Request Body + +| 필드 | 타입 | 필수 | 검증 규칙 | +|------|------|------|----------| +| loginId | String | Y | 4-20자, 영문소문자+숫자, 영문으로 시작 | +| password | String | Y | 8-20자, 영문+숫자+특수문자 각 1개 이상 | +| name | String | Y | 2-20자, 한글 또는 영문 | +| email | String | Y | 이메일 형식, 최대 100자 | +| birthDate | LocalDate | N | 과거 날짜만 허용 | + +### 핵심 AC + +| AC# | 조건 | 기대 결과 | +|-----|------|----------| +| AC-1 | 모든 필수값 유효 | 200, 회원 생성 | +| AC-14 | loginId 중복 | 409 CONFLICT | +| AC-3~13 | 입력값 검증 실패 | 400 BAD_REQUEST | + +--- + +## Phase 0: CONTEXT + +### 읽은 파일 + +- `.claude/rules/core/layer-patterns.md` +- `.claude/rules/core/naming-conventions.md` +- `.claude/rules/core/exception-patterns.md` +- `.claude/rules/core/dto-patterns.md` + +### 확인된 규칙 + +- BaseEntity 상속 +- 정적 팩토리 `create()` 사용 +- 도메인 불변식은 IllegalArgumentException +- 비즈니스 오류는 CoreException +- DTO는 record로 작성 +- Controller에서 원시값 추출하여 Facade 전달 + +--- + +## Phase 1: INTERFACE + +### Entity 인터페이스 + +```java +public class User extends BaseEntity { + // Fields + private String loginId; + private String password; + private String name; + private String email; + private LocalDate birthDate; + + // Factory + public static User create(String loginId, String encodedPassword, + String name, String email, LocalDate birthDate); +} +``` + +### Repository 인터페이스 + +```java +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} +``` + +### Service 인터페이스 + +```java +public class UserService { + public User signUp(String loginId, String password, + String name, String email, LocalDate birthDate); +} +``` + +### Facade 인터페이스 + +```java +public class UserFacade { + public UserInfo signUp(String loginId, String password, + String name, String email, LocalDate birthDate); +} +``` + +### Controller 인터페이스 + +```java +public interface UserApiV1Spec { + @PostMapping("/api/v1/users") + ApiResponse signUp( + @Valid @RequestBody UserV1Dto.SignUpRequest request + ); +} +``` + +--- + +## Phase 2: IMPLEMENT + TEST (구현과 테스트 동시) + +### 1. Entity + 단위 테스트 + +**Entity 구현** + +```java +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Column(nullable = false, unique = true, length = 20) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 20) + private String name; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + private LocalDate birthDate; + + private User(String loginId, String password, String name, String email, LocalDate birthDate) { + validateLoginId(loginId); + validatePassword(password); + validateName(name); + validateEmail(email); + validateBirthDate(birthDate); + this.loginId = loginId; + this.password = password; + this.name = name; + this.email = email; + this.birthDate = birthDate; + } + + public static User create(String loginId, String encodedPassword, + String name, String email, LocalDate birthDate) { + return new User(loginId, encodedPassword, name, email, birthDate); + } + + // === 도메인 불변식 검증 (형식 + 비즈니스 규칙) === + + private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new IllegalArgumentException("로그인 ID는 필수입니다"); + } + if (!loginId.matches("^[a-z][a-z0-9]*$")) { + throw new IllegalArgumentException("로그인 ID는 영문 소문자로 시작하고, 영문 소문자와 숫자만 허용됩니다"); + } + } + + private static void validatePassword(String password) { + if (password == null || password.isBlank()) { + throw new IllegalArgumentException("비밀번호는 필수입니다"); + } + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("이름은 필수입니다"); + } + } + + private static void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("이메일은 필수입니다"); + } + if (!email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) { + throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다"); + } + } + + private static void validateBirthDate(LocalDate birthDate) { + if (birthDate != null && !birthDate.isBefore(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 과거 날짜만 허용됩니다"); + } + } +} +``` + +**Entity 단위 테스트** + +```java +class UserTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_회원이_생성된다() { + // given + String loginId = "john123"; + String password = "encodedPassword"; + String name = "홍길동"; + String email = "john@test.com"; + LocalDate birthDate = LocalDate.of(1995, 3, 15); + + // when + User user = User.create(loginId, password, name, email, birthDate); + + // then + assertThat(user.getLoginId()).isEqualTo(loginId); + assertThat(user.getPassword()).isEqualTo(password); + assertThat(user.getName()).isEqualTo(name); + assertThat(user.getEmail()).isEqualTo(email); + assertThat(user.getBirthDate()).isEqualTo(birthDate); + } + + @Test + void 로그인_ID가_null이면_예외발생() { + assertThatThrownBy(() -> + User.create(null, "password", "홍길동", "test@test.com", null) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("로그인 ID"); + } + + @Test + void 이름이_빈_문자열이면_예외발생() { + assertThatThrownBy(() -> + User.create("john123", "password", "", "test@test.com", null) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이름"); + } + + @Test + void 이메일이_null이면_예외발생() { + assertThatThrownBy(() -> + User.create("john123", "password", "홍길동", null, null) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이메일"); + } + + @Test + void 생년월일이_null이면_허용된다() { + // when + User user = User.create("john123", "password", "홍길동", "test@test.com", null); + + // then + assertThat(user.getBirthDate()).isNull(); + } + } +} +``` + +### 2. Repository (인터페이스 + 구현) + +**Repository 인터페이스** + +```java +// domain/user/UserRepository.java +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} +``` + +**Repository 구현** + +```java +// infrastructure/user/UserRepositoryImpl.java +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository jpaRepository; + + @Override + public User save(User user) { + return jpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return jpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return jpaRepository.existsByLoginId(loginId); + } + + @Override + public boolean existsByEmail(String email) { + return jpaRepository.existsByEmail(email); + } +} +``` + +**JPA Repository** + +```java +// infrastructure/user/UserJpaRepository.java +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginIdAndDeletedAtIsNull(String loginId); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} +``` + +**PasswordEncoder 인터페이스** + +```java +// domain/user/PasswordEncoder.java +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} +``` + +**PasswordEncoder 구현** + +```java +// infrastructure/user/BcryptPasswordEncoder.java +@Component +public class BcryptPasswordEncoder implements PasswordEncoder { + + private final BCryptPasswordEncoder delegate = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return delegate.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return delegate.matches(rawPassword, encodedPassword); + } +} +``` + +### 3. Service + 통합 테스트 + +**Service 구현** + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public User signUp(String loginId, String password, String name, + String email, LocalDate birthDate) { + // 중복 체크 + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } + if (userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 이메일입니다"); + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(password); + + // 엔티티 생성 및 저장 + User user = User.create(loginId, encodedPassword, name, email, birthDate); + return userRepository.save(user); + } +} +``` + +**Service 통합 테스트** + +```java +class UserServiceIT extends IntegrationTestSupport { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Nested + class 회원가입 { + + @Test + void 성공() { + // given + String loginId = "john123"; + String password = "Pass1234!"; + String name = "홍길동"; + String email = "john@test.com"; + LocalDate birthDate = LocalDate.of(1995, 3, 15); + + // when + User user = userService.signUp(loginId, password, name, email, birthDate); + + // then + assertThat(user.getId()).isNotNull(); + assertThat(user.getLoginId()).isEqualTo(loginId); + + // DB 확인 + var saved = userRepository.findByLoginId(loginId); + assertThat(saved).isPresent(); + } + + @Test + void 로그인_ID_중복이면_예외발생() { + // given + String loginId = "john123"; + userService.signUp(loginId, "Pass1234!", "홍길동", "john1@test.com", null); + + // when & then + assertThatThrownBy(() -> + userService.signUp(loginId, "Pass1234!", "김철수", "john2@test.com", null) + ).isInstanceOf(CoreException.class) + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.CONFLICT); + }); + } + + @Test + void 이메일_중복이면_예외발생() { + // given + String email = "john@test.com"; + userService.signUp("john123", "Pass1234!", "홍길동", email, null); + + // when & then + assertThatThrownBy(() -> + userService.signUp("john456", "Pass1234!", "김철수", email, null) + ).isInstanceOf(CoreException.class) + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.CONFLICT); + }); + } + + @Test + void 비밀번호가_암호화되어_저장된다() { + // given + String rawPassword = "Pass1234!"; + + // when + User user = userService.signUp("john123", rawPassword, "홍길동", "john@test.com", null); + + // then + assertThat(user.getPassword()).isNotEqualTo(rawPassword); + assertThat(user.getPassword()).startsWith("$2"); // BCrypt prefix + } + } +} +``` + +### 4. Facade + Info + +**Info** + +```java +// application/user/UserInfo.java +public record UserInfo( + String loginId, + String name, + String email, + LocalDate birthDate +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getLoginId(), + user.getName(), + user.getEmail(), + user.getBirthDate() + ); + } + + public String maskedName() { + if (name == null || name.length() < 2) return name; + if (name.length() == 2) return name.charAt(0) + "*"; + return name.charAt(0) + "*".repeat(name.length() - 2) + name.charAt(name.length() - 1); + } +} +``` + +**Facade** + +```java +@Component +@RequiredArgsConstructor +public class UserFacade { + + private final UserService userService; + + public UserInfo signUp(String loginId, String password, String name, + String email, LocalDate birthDate) { + User user = userService.signUp(loginId, password, name, email, birthDate); + return UserInfo.from(user); + } +} +``` + +### 5. Controller + DTO + +**DTO** + +```java +public class UserV1Dto { + + /** + * Bean Validation: 입력값 검증 (존재 여부 + 기본 범위) + * 형식 검증(@Pattern, @Email, @Past)은 Entity 도메인 불변식에서 처리 + */ + public record SignUpRequest( + @NotBlank(message = "로그인 ID는 필수입니다") + @Size(min = 4, max = 20, message = "로그인 ID는 4-20자여야 합니다") + String loginId, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8-20자여야 합니다") + String password, + + @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 20, message = "이름은 2-20자여야 합니다") + String name, + + @NotBlank(message = "이메일은 필수입니다") + @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다") + String email, + + LocalDate birthDate // 선택 필드, 날짜 규칙은 Entity에서 검증 + ) {} + + public record UserResponse( + String loginId, + String name, + String email, + LocalDate birthDate + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + info.maskedName(), + info.email(), + info.birthDate() + ); + } + } +} +``` + +**ApiSpec** + +```java +@Tag(name = "User API", description = "회원 관련 API") +public interface UserApiV1Spec { + + @Operation(summary = "회원가입", description = "신규 회원을 등록합니다") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원가입 성공"), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패"), + @ApiResponse(responseCode = "409", description = "중복된 로그인 ID 또는 이메일") + }) + @PostMapping("/api/v1/users") + ApiResponse signUp( + @Valid @RequestBody UserV1Dto.SignUpRequest request + ); +} +``` + +**Controller** + +```java +@RestController +@RequiredArgsConstructor +public class UserV1Controller implements UserApiV1Spec { + + private final UserFacade userFacade; + + @Override + public ApiResponse signUp(UserV1Dto.SignUpRequest request) { + UserInfo info = userFacade.signUp( + request.loginId(), + request.password(), + request.name(), + request.email(), + request.birthDate() + ); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } +} +``` + +--- + +## Phase 3: VERIFY (AC 매핑 검증) + +### AC 매핑 테이블 + +| AC# | 조건 | 테스트 메서드 | 결과 | +|-----|------|-------------|------| +| AC-1 | 모든 필수값 유효 | `UserServiceIT.성공()` | ✅ | +| AC-3 | loginId null | `UserTest.로그인_ID가_null이면_예외발생()` | ✅ | +| AC-4 | loginId 빈문자열 | (AC-3과 동일 검증) | ✅ | +| AC-7 | name null | (Bean Validation) | ✅ | +| AC-8 | name 빈문자열 | `UserTest.이름이_빈_문자열이면_예외발생()` | ✅ | +| AC-10 | email null | `UserTest.이메일이_null이면_예외발생()` | ✅ | +| AC-12 | birthDate null | `UserTest.생년월일이_null이면_허용된다()` | ✅ | +| AC-14 | loginId 중복 | `UserServiceIT.로그인_ID_중복이면_예외발생()` | ✅ | +| AC-15 | email 중복 | `UserServiceIT.이메일_중복이면_예외발생()` | ✅ | +| - | 비밀번호 암호화 | `UserServiceIT.비밀번호가_암호화되어_저장된다()` | ✅ | + +### 검증 체크리스트 + +- [x] 모든 AC에 대응하는 테스트가 있는가? → Yes +- [x] AC에 없는 테스트가 있는가? → 비밀번호 암호화 테스트 (보안 요구사항으로 추가됨) +- [x] 전체 테스트 실행 통과? → Yes + +### 전체 테스트 실행 결과 + +``` +UserTest + 생성 + ✓ 유효한_값이면_회원이_생성된다 + ✓ 로그인_ID가_null이면_예외발생 + ✓ 이름이_빈_문자열이면_예외발생 + ✓ 이메일이_null이면_예외발생 + ✓ 생년월일이_null이면_허용된다 + +UserServiceIT + 회원가입 + ✓ 성공 + ✓ 로그인_ID_중복이면_예외발생 + ✓ 이메일_중복이면_예외발생 + ✓ 비밀번호가_암호화되어_저장된다 + +9 tests passed +``` + +--- + +## Phase 4: REFACTOR + +### 완료 체크리스트 + +- [x] 중복 코드 없음 +- [x] 불필요한 코드 없음 +- [x] unused import 없음 +- [x] 모든 테스트 통과 + +### 테스트 실행 결과 + +``` +UserTest + 생성 + ✓ 유효한_값이면_회원이_생성된다 + ✓ 로그인_ID가_null이면_예외발생 + ✓ 이름이_빈_문자열이면_예외발생 + ✓ 이메일이_null이면_예외발생 + ✓ 생년월일이_null이면_허용된다 + +UserServiceIT + 회원가입 + ✓ 성공 + ✓ 로그인_ID_중복이면_예외발생 + ✓ 이메일_중복이면_예외발생 + ✓ 비밀번호가_암호화되어_저장된다 + +9 tests passed +``` + +--- + +## 생성 파일 목록 + +| 계층 | 파일 | +|------|------| +| Domain | `User.java`, `UserRepository.java`, `PasswordEncoder.java` | +| Infrastructure | `UserRepositoryImpl.java`, `UserJpaRepository.java`, `BcryptPasswordEncoder.java` | +| Application | `UserFacade.java`, `UserInfo.java` | +| Interfaces | `UserApiV1Spec.java`, `UserV1Controller.java`, `UserV1Dto.java` | +| Test | `UserTest.java`, `UserServiceIT.java` | diff --git a/.claude/skills/implement/references/layer-checklist.md b/.claude/skills/implement/references/layer-checklist.md new file mode 100644 index 00000000..1f0fd76e --- /dev/null +++ b/.claude/skills/implement/references/layer-checklist.md @@ -0,0 +1,376 @@ +# 계층별 구현 체크리스트 + +각 계층 구현 시 확인해야 할 필수 항목들입니다. + +--- + +## Entity 체크리스트 + +### 필수 항목 + +- [ ] `BaseEntity` 상속 +- [ ] 정적 팩토리 메서드 `create(...)` 사용 +- [ ] private 생성자 (JPA용 `protected` no-args 생성자 별도) +- [ ] 도메인 불변식 검증 (`validate{Field}()` private 메서드) +- [ ] `IllegalArgumentException` 사용 (도메인 검증 실패 시) + +### 비즈니스 메서드 + +- [ ] 상태 변경 메서드 (동사로 시작: `changePassword`, `delete`, `restore`) +- [ ] 조회 메서드 필요 시 추가 + +### 예시 + +```java +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Column(nullable = false, unique = true) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + private User(String loginId, String password, String name) { + validateLoginId(loginId); + validatePassword(password); + validateName(name); + this.loginId = loginId; + this.password = password; + this.name = name; + } + + public static User create(String loginId, String password, String name) { + return new User(loginId, password, name); + } + + private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new IllegalArgumentException("로그인 ID는 필수입니다"); + } + } + + // ... 다른 검증 메서드 +} +``` + +--- + +## Repository 체크리스트 + +### 인터페이스 (domain 패키지) + +- [ ] `domain/{도메인}/` 패키지에 위치 +- [ ] 필요한 쿼리 메서드만 정의 +- [ ] 반환 타입 규칙 준수 + +### 반환 타입 규칙 + +| 메서드 패턴 | 반환 타입 | +|-------------|----------| +| `findBy{Field}(value)` | `Optional` | +| `existsBy{Field}(value)` | `boolean` | +| `findAllBy{Field}(value)` | `List` | +| `save(entity)` | `T` | + +### 예시 (인터페이스) + +```java +// domain/user/UserRepository.java +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} +``` + +### 구현체 (infrastructure 패키지) + +- [ ] `infrastructure/{도메인}/` 패키지에 위치 +- [ ] `@Repository` 어노테이션 +- [ ] JPA Repository 위임 +- [ ] Soft Delete 조건 포함 (`deletedAt IS NULL`) + +### 예시 (구현체) + +```java +// infrastructure/user/UserRepositoryImpl.java +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository jpaRepository; + + @Override + public User save(User user) { + return jpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return jpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return jpaRepository.existsByLoginId(loginId); + } +} +``` + +### JPA Repository + +```java +// infrastructure/user/UserJpaRepository.java +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginIdAndDeletedAtIsNull(String loginId); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} +``` + +--- + +## Service 체크리스트 + +### 트랜잭션 관리 + +- [ ] 클래스 레벨: `@Transactional(readOnly = true)` (기본) +- [ ] 쓰기 메서드만: `@Transactional` (개별 지정) + +### 중복 체크 + +- [ ] `existsBy{Field}()` 메서드로 명시적 수행 +- [ ] 중복 시 `CoreException(ErrorType.CONFLICT, "메시지")` throw +- [ ] **try-catch로 DataIntegrityViolationException 잡지 않음** + +### 조회 실패 + +- [ ] `findBy{Field}().orElseThrow()` 패턴 +- [ ] `CoreException(ErrorType.NOT_FOUND, "메시지")` throw + +### 예시 + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public User signUp(String loginId, String password, String name, String email) { + // 중복 체크 + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } + if (userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 이메일입니다"); + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(password); + + // 엔티티 생성 및 저장 + User user = User.create(loginId, encodedPassword, name, email); + return userRepository.save(user); + } + + public User getUser(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + } +} +``` + +--- + +## Facade 체크리스트 + +### 역할 + +- [ ] Service 호출 오케스트레이션 +- [ ] Entity → Info 변환 +- [ ] **트랜잭션 경계 아님** (Service에서 관리) + +### 예시 + +```java +@Component +@RequiredArgsConstructor +public class UserFacade { + + private final UserService userService; + + public UserInfo signUp(String loginId, String password, String name, String email) { + User user = userService.signUp(loginId, password, name, email); + return UserInfo.from(user); + } + + public UserInfo getUser(String loginId) { + User user = userService.getUser(loginId); + return UserInfo.from(user); + } +} +``` + +--- + +## Controller 체크리스트 + +### 구조 + +- [ ] ApiSpec 인터페이스 + Controller 구현 분리 +- [ ] `@Valid`로 Request DTO 검증 +- [ ] `ApiResponse.success(data)` 반환 +- [ ] **try-catch 금지** (글로벌 핸들러가 처리) + +### ApiSpec 인터페이스 + +```java +@Tag(name = "User API") +public interface UserApiV1Spec { + + @Operation(summary = "회원가입") + @PostMapping("/api/v1/users") + ApiResponse signUp( + @Valid @RequestBody UserV1Dto.SignUpRequest request + ); +} +``` + +### Controller 구현 + +```java +@RestController +@RequiredArgsConstructor +public class UserV1Controller implements UserApiV1Spec { + + private final UserFacade userFacade; + + @Override + public ApiResponse signUp(UserV1Dto.SignUpRequest request) { + UserInfo info = userFacade.signUp( + request.loginId(), + request.password(), + request.name(), + request.email() + ); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } +} +``` + +--- + +## DTO 체크리스트 + +### 구조 + +- [ ] Java record 사용 (불변성) +- [ ] DTO 컨테이너 클래스 내부에 정의 (`{Domain}V{version}Dto`) +- [ ] Request: Bean Validation 어노테이션 +- [ ] Response: `from(Info)` 정적 팩토리 메서드 + +### Request DTO 검증 + +| 어노테이션 | 용도 | +|-----------|------| +| `@NotNull` | null 불가 | +| `@NotBlank` | null, 빈 문자열, 공백만 불가 | +| `@NotEmpty` | null, 빈 컬렉션 불가 | +| `@Size(min, max)` | 길이 제한 | +| `@Pattern(regexp)` | 정규식 패턴 | +| `@Email` | 이메일 형식 | +| `@Positive` | 양수만 | +| `@Past` | 과거 날짜만 | + +### 예시 + +```java +public class UserV1Dto { + + public record SignUpRequest( + @NotBlank @Size(min = 4, max = 20) + @Pattern(regexp = "^[a-z][a-z0-9]{3,19}$") + String loginId, + + @NotBlank @Size(min = 8, max = 20) + String password, + + @NotBlank @Size(min = 2, max = 20) + String name, + + @NotBlank @Email @Size(max = 100) + String email, + + @Past + LocalDate birthDate + ) {} + + public record UserResponse( + String loginId, + String name, + String email, + LocalDate birthDate + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + info.maskedName(), + info.email(), + info.birthDate() + ); + } + } +} +``` + +--- + +## Info 체크리스트 + +### 위치 + +- [ ] `application/{도메인}/` 패키지 + +### 역할 + +- [ ] Entity → 외부 전달용 데이터 +- [ ] 필요 시 변환 로직 포함 (마스킹 등) + +### 예시 + +```java +// application/user/UserInfo.java +public record UserInfo( + String loginId, + String name, + String email, + LocalDate birthDate +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getLoginId(), + user.getName(), + user.getEmail(), + user.getBirthDate() + ); + } + + public String maskedName() { + if (name == null || name.length() < 2) return name; + if (name.length() == 2) return name.charAt(0) + "*"; + return name.charAt(0) + "*".repeat(name.length() - 2) + name.charAt(name.length() - 1); + } +} +``` diff --git a/.claude/skills/prd-writer/SKILL.md b/.claude/skills/prd-writer/SKILL.md new file mode 100644 index 00000000..fad743f8 --- /dev/null +++ b/.claude/skills/prd-writer/SKILL.md @@ -0,0 +1,263 @@ +--- +name: prd-writer +description: PRD + Acceptance Criteria 작성 가이드. 요구사항을 구조화된 명세서로 변환. "PRD 작성", "요구사항 분석", "AC 작성", "기능 명세" 요청 시 사용. +--- + +# PRD + AC Writer + +원시 요구사항을 **구현 가능한 명세서**로 변환하는 브레인스토밍 가이드입니다. + +## 워크플로우 + +``` +Phase 0: CONTEXT (컨텍스트) + ↓ +Phase 1: CAPTURE (수집) + ↓ +Phase 2: CLARIFY (명확화) ←──┐ + ↓ │ + └── 모호함 발견? ─────────┘ + ↓ +Phase 3: STRUCTURE (구조화) + ↓ +Phase 4: VALIDATE (검증) +``` + +--- + +## Phase 0: CONTEXT (컨텍스트 파악) + +PRD 작성 전 프로젝트의 기존 규칙과 구현 패턴을 파악합니다. + +### 필수 읽기 파일 +1. `.claude/rules/core/design-principles.md` - 설계 원칙 +2. `.claude/rules/core/exception-patterns.md` - 예외 처리 +3. `.claude/rules/core/project-conventions.md` - 프로젝트 컨벤션 + +### 확인 사항 체크리스트 +- [ ] HTTP 상태코드 패턴 (200 vs 201 vs 204) +- [ ] 에러 응답 형식 (ErrorType 종류, 메시지 형식) +- [ ] 검증 위치 (Request DTO vs Domain Entity) +- [ ] 필드별 검증 규칙 표준 + +### 이 단계의 산출물 +- 프로젝트 컨텍스트 요약 +- PRD 작성 시 준수해야 할 규칙 목록 + +--- + +## Phase 1: CAPTURE (수집) + +원시 요구사항에서 핵심 요소를 추출합니다. + +### 추출할 정보 +| 요소 | 질문 | 예시 | +|------|------|------| +| WHO | 누가 이 기능을 사용하는가? | 비회원, 회원, 관리자 | +| WHAT | 무엇을 할 수 있어야 하는가? | 회원가입, 로그인, 주문 | +| WHY | 왜 이 기능이 필요한가? | 서비스 이용을 위해 | +| WHEN | 언제 이 기능을 사용하는가? | 최초 방문 시 | +| CONSTRAINT | 제약 조건은 무엇인가? | 중복 ID 불가 | + +### 이 단계의 산출물 +- Feature 목록 +- 각 Feature의 간단한 설명 +- 우선순위 (P0/P1/P2) + +--- + +## Phase 2: CLARIFY (명확화) + +모호한 부분을 질문을 통해 명확하게 만듭니다. + +### 모호함 탐지 체크리스트 + +#### 1. 데이터 관점 +- [ ] 어떤 필드가 필요한가? +- [ ] 각 필드의 타입은? +- [ ] 필수/선택 구분은? +- [ ] 각 필드의 검증 규칙은? +- [ ] 최소/최대 길이는? +- [ ] 허용 문자는? + +#### 2. 행위 관점 +- [ ] 정상 동작은 어떻게 되는가? +- [ ] 실패하면 어떻게 되는가? +- [ ] 부분 성공은 가능한가? +- [ ] 롤백이 필요한가? + +#### 3. 상태 관점 +- [ ] 이 작업 전에 필요한 조건은? +- [ ] 이 작업 후 상태는 어떻게 변하는가? +- [ ] 다른 기능에 영향을 주는가? + +#### 4. 보안 관점 +- [ ] 인증이 필요한가? +- [ ] 권한 검사가 필요한가? +- [ ] 민감 정보가 있는가? +- [ ] 로깅이 필요한가? + +#### 5. 비기능 관점 +- [ ] 성능 요구사항은? +- [ ] 동시성 처리는? +- [ ] 캐싱이 필요한가? + +### 질문 템플릿 + +``` +[필드명]에 대해 확인이 필요합니다: +1. 타입: String / Integer / LocalDate / ...? +2. 필수 여부: Y / N? +3. 검증 규칙: (예: 4-20자, 영문소문자+숫자) +4. 기본값: (선택 필드인 경우) +``` + +``` +[기능명]의 실패 케이스 확인: +1. [조건] 일 때 → 어떤 HTTP 상태코드? 어떤 메시지? +``` + +--- + +## Phase 3: STRUCTURE (구조화) + +명확해진 요구사항을 템플릿에 맞게 정리합니다. + +### Feature 구조 + +```markdown +## Feature N: [기능명] + +### 개요 +### API 명세 +### Acceptance Criteria +### 결정 사항 +### 미결 사항 +``` + +### 좋은 Acceptance Criteria의 조건 + +#### INVEST 원칙 +| 원칙 | 의미 | 체크 | +|------|------|------| +| **I**ndependent | 다른 AC와 독립적 | 단독 테스트 가능? | +| **N**egotiable | 협의 가능 | 구현 방법 유연? | +| **V**aluable | 가치 제공 | 사용자에게 의미? | +| **E**stimable | 추정 가능 | 구현 규모 파악? | +| **S**mall | 작은 단위 | 하나의 시나리오? | +| **T**estable | 테스트 가능 | 명확한 기대 결과? | + +#### AC 작성 형식: 조건-행위-기대결과 + +```markdown +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 모든 필수값 유효 | POST /api/v1/users | 201, 회원 생성 | +``` + +#### 나쁜 AC vs 좋은 AC + +❌ 나쁜 예: +- "비밀번호는 안전해야 한다" → 테스트 불가 +- "빠르게 응답해야 한다" → 기준 없음 +- "적절한 에러 처리" → 모호함 + +✅ 좋은 예: +- "비밀번호는 8자 이상, 영문+숫자+특수문자 포함" +- "응답 시간 200ms 이내" +- "loginId 중복 시 409 CONFLICT, 메시지: '이미 사용 중인 로그인 ID입니다'" + +### 비즈니스 규칙 작성 + +```markdown +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 비밀번호는 BCrypt로 암호화 | UserService | +``` + +구현 위치 가이드: +- **Entity**: 도메인 불변식 (생성 시 검증) +- **Service**: 비즈니스 로직 (중복 체크 등) +- **Controller**: 입력값 형식 검증 +- **전체**: 시스템 전반 규칙 + +--- + +## Phase 4: VALIDATE (검증) + +작성된 PRD가 완전한지 검증합니다. + +### 완전성 체크리스트 + +#### API 명세 +- [ ] 모든 필드에 타입이 명시되었는가? +- [ ] 모든 필드에 필수 여부가 명시되었는가? +- [ ] 모든 필드에 검증 규칙이 있는가? +- [ ] Response 필드가 정의되었는가? + +#### Acceptance Criteria +- [ ] Happy Path가 정의되었는가? +- [ ] 모든 필수 필드 누락 케이스가 있는가? +- [ ] 모든 검증 실패 케이스가 있는가? +- [ ] 비즈니스 규칙 위반 케이스가 있는가? +- [ ] 모든 AC가 테스트 가능한가? + +#### 의존성 +- [ ] 선행 조건이 명시되었는가? +- [ ] 다른 Feature와의 관계가 명시되었는가? + +### 누락 탐지 질문 + +각 Feature에 대해: +1. "이 API를 E2E 테스트한다면 어떤 시나리오가 필요한가?" +2. "이 기능이 실패할 수 있는 모든 경우는?" +3. "다른 기능에 영향을 주는 부분은?" + +--- + +## 브레인스토밍 진행 가이드 + +### 시작하기 + +``` +요구사항을 공유해주세요. 다음 형식 중 어떤 것이든 괜찮습니다: +- 원시 텍스트 +- 기획 문서 +- 화면 설계서 +- 구두 설명 + +함께 PRD + AC로 정리해보겠습니다. +``` + +### 진행 중 + +``` +[기능명]에 대해 다음이 명확하지 않습니다: +1. [질문1] +2. [질문2] + +어떻게 처리할까요? +A) [옵션1] +B) [옵션2] +C) 다른 방식 +``` + +### 마무리 + +``` +[기능명] PRD가 완성되었습니다. + +요약: +- API: [엔드포인트] +- AC: [N]개 (정상 [X]개, 실패 [Y]개) +- 비즈니스 규칙: [M]개 + +다음 기능으로 넘어갈까요, 아니면 수정할 부분이 있나요? +``` + +--- + +## 템플릿 & 예시 + +- 템플릿: [templates/prd-template.md](./templates/prd-template.md) +- 예시: [examples/user-feature.md](./examples/user-feature.md) diff --git a/.claude/skills/prd-writer/examples/user-feature.md b/.claude/skills/prd-writer/examples/user-feature.md new file mode 100644 index 00000000..55fc5301 --- /dev/null +++ b/.claude/skills/prd-writer/examples/user-feature.md @@ -0,0 +1,258 @@ +# PLAN: 회원 서비스 + +> 작성일: 2024-01-15 +> 버전: v1.0 + +--- + +## 프로젝트 컨텍스트 + +> `.claude/rules/core/` 참조 결과 + +| 항목 | 프로젝트 표준 | +|------|--------------| +| HTTP 상태코드 | 성공: 200, 실패: 400/401/404/409 | +| 에러 응답 | `ApiResponse.fail(ErrorType.XXX)` | +| 검증 위치 | 형식→Request DTO, 불변식→Domain Entity | +| 중복 체크 | Service에서 exists 쿼리로 명시적 수행 | + +--- + +## Feature 1: 회원가입 + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | 신규 사용자가 서비스에 가입한다 | +| Actor | 비회원 | +| 우선순위 | P0 | +| 선행 조건 | 없음 | +| 후행 영향 | Feature 2(로그인), Feature 3(내 정보 조회)에서 회원 정보 참조 | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | POST | +| Endpoint | /api/v1/users | +| Auth | 불필요 | + +#### Request Body +| 필드 | 타입 | 필수 | 검증 규칙 | 예시 | +|------|------|------|----------|------| +| loginId | String | Y | 4-20자, 영문소문자+숫자, 영문으로 시작 | "john123" | +| password | String | Y | 8-20자, 영문+숫자+특수문자 각 1개 이상 | "Pass1234!" | +| name | String | Y | 2-20자, 한글 또는 영문 | "홍길동" | +| email | String | Y | 이메일 형식, 최대 100자 | "john@test.com" | +| birthDate | LocalDate | N | 과거 날짜만 허용 | "1995-03-15" | + +#### Response Body (200 OK) +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 (마스킹: 홍*동) | +| email | String | 이메일 | +| birthDate | LocalDate | 생년월일 (nullable) | + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 모든 필수값 유효 | POST /api/v1/users | 200, 회원 생성, 비밀번호 BCrypt 암호화 저장 | +| AC-2 | birthDate 미입력 | POST /api/v1/users | 200, birthDate null로 저장 | + +#### 실패 케이스 - 입력값 검증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-3 | loginId 3자 (최소 미달) | POST /api/v1/users | 400 | +| AC-4 | loginId 21자 (최대 초과) | POST /api/v1/users | 400 | +| AC-5 | loginId 숫자로 시작 | POST /api/v1/users | 400 | +| AC-6 | loginId 대문자 포함 | POST /api/v1/users | 400 | +| AC-7 | password 7자 (최소 미달) | POST /api/v1/users | 400 | +| AC-8 | password 영문만 | POST /api/v1/users | 400 | +| AC-9 | password 특수문자 없음 | POST /api/v1/users | 400 | +| AC-10 | name 1자 (최소 미달) | POST /api/v1/users | 400 | +| AC-11 | name 숫자 포함 | POST /api/v1/users | 400 | +| AC-12 | email 형식 오류 | POST /api/v1/users | 400 | +| AC-13 | birthDate 미래 날짜 | POST /api/v1/users | 400 | + +#### 실패 케이스 - 비즈니스 규칙 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-14 | loginId 중복 | POST /api/v1/users | 409, "이미 사용 중인 로그인 ID입니다" | +| AC-15 | email 중복 | POST /api/v1/users | 409, "이미 사용 중인 이메일입니다" | +| AC-16 | 탈퇴한 회원의 loginId | POST /api/v1/users | 409, "이미 사용 중인 로그인 ID입니다" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 비밀번호는 BCrypt로 암호화 저장 | Service | +| BR-2 | loginId는 생성 후 변경 불가 | 전체 (수정 API 미제공) | +| BR-3 | 탈퇴한 회원의 loginId도 재사용 불가 | Service (soft delete 조건 포함 조회) | +| BR-4 | 이름은 응답 시 마스킹 처리 (가운데 글자 *) | Info 변환 시 | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| 이메일 인증 필요? | No | MVP 범위 외, Sprint 2에서 검토 | +| 비밀번호 정책 수준? | 중간 (8자+영문+숫자+특수문자) | 보안과 사용성 균형 | +| 중복 체크 시 탈퇴 회원 포함? | Yes | 재가입 시 혼란 방지 | +| 응답에 id(PK) 포함? | No | 보안상 내부 ID 노출 지양 | + +### 미결 사항 +| 항목 | 영향 범위 | 결정 기한 | 설계 대응 | +|------|----------|----------|----------| +| 소셜 로그인 | 회원가입 흐름 변경 | Sprint 2 전 | 현재는 일반 가입만, 추후 AuthProvider 분리 | +| 본인인증 | 회원가입 필수 조건 | Sprint 3 전 | 현재는 미적용, 추후 인터페이스 추가 | + +--- + +## Feature 2: 내 정보 조회 + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | 회원이 자신의 정보를 조회한다 | +| Actor | 회원 | +| 우선순위 | P0 | +| 선행 조건 | Feature 1(회원가입) 완료 | +| 후행 영향 | 없음 | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | GET | +| Endpoint | /api/v1/users/me | +| Auth | 회원 (헤더 인증) | + +#### Request Header +| 헤더 | 필수 | 설명 | +|------|------|------| +| X-Login-Id | Y | 로그인 ID | +| X-Login-Pw | Y | 비밀번호 (평문) | + +#### Response Body (200 OK) +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 (마스킹) | +| email | String | 이메일 | +| birthDate | LocalDate | 생년월일 (nullable) | + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 유효한 인증 정보 | GET /api/v1/users/me | 200, 본인 정보 반환 | + +#### 실패 케이스 - 인증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-2 | X-Login-Id 헤더 누락 | GET /api/v1/users/me | 401, "인증 헤더가 필요합니다" | +| AC-3 | X-Login-Pw 헤더 누락 | GET /api/v1/users/me | 401, "인증 헤더가 필요합니다" | +| AC-4 | 존재하지 않는 loginId | GET /api/v1/users/me | 404, "회원을 찾을 수 없습니다" | +| AC-5 | 비밀번호 불일치 | GET /api/v1/users/me | 401, "비밀번호가 일치하지 않습니다" | +| AC-6 | 탈퇴한 회원 | GET /api/v1/users/me | 404, "회원을 찾을 수 없습니다" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 본인 정보만 조회 가능 | Controller (헤더의 loginId로 조회) | +| BR-2 | 탈퇴 회원은 조회 불가 | Repository (deletedAt IS NULL 조건) | +| BR-3 | 비밀번호 검증은 BCrypt.matches() 사용 | Service | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| 인증 방식? | 헤더 기반 (X-Login-Id, X-Login-Pw) | JWT 미도입 상태, MVP용 단순 인증 | +| 비밀번호 평문 전송? | Yes (HTTPS 전제) | MVP 단계, 추후 JWT로 전환 | + +--- + +## Feature 3: 비밀번호 변경 + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | 회원이 자신의 비밀번호를 변경한다 | +| Actor | 회원 | +| 우선순위 | P1 | +| 선행 조건 | Feature 1(회원가입) 완료 | +| 후행 영향 | 이후 인증 시 새 비밀번호 사용 | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | PATCH | +| Endpoint | /api/v1/users/me/password | +| Auth | 회원 (헤더 인증) | + +#### Request Header +| 헤더 | 필수 | 설명 | +|------|------|------| +| X-Login-Id | Y | 로그인 ID | +| X-Login-Pw | Y | 현재 비밀번호 | + +#### Request Body +| 필드 | 타입 | 필수 | 검증 규칙 | 예시 | +|------|------|------|----------|------| +| newPassword | String | Y | 8-20자, 영문+숫자+특수문자 | "NewPass1234!" | + +#### Response Body (200 OK) +빈 응답 (성공 여부만 상태코드로 판단) + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 유효한 인증 + 유효한 새 비밀번호 | PATCH /api/v1/users/me/password | 200, 비밀번호 변경됨 | + +#### 실패 케이스 - 입력값 검증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-2 | newPassword 7자 | PATCH /api/v1/users/me/password | 400 | +| AC-3 | newPassword 특수문자 없음 | PATCH /api/v1/users/me/password | 400 | + +#### 실패 케이스 - 인증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-4 | 인증 헤더 누락 | PATCH /api/v1/users/me/password | 401 | +| AC-5 | 현재 비밀번호 불일치 | PATCH /api/v1/users/me/password | 401 | + +#### 실패 케이스 - 비즈니스 규칙 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-6 | 새 비밀번호 = 현재 비밀번호 | PATCH /api/v1/users/me/password | 400, "현재 비밀번호와 다른 비밀번호를 입력해주세요" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 새 비밀번호는 현재 비밀번호와 달라야 함 | Service | +| BR-2 | 새 비밀번호도 BCrypt로 암호화 | Service | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| 이전 비밀번호 재사용 금지? | No | MVP 범위 외, 히스토리 관리 필요 | +| 비밀번호 변경 후 재로그인 강제? | No | 현재 세션/토큰 없음 | + +--- + +## 용어 정의 (Glossary) + +| 용어 | 정의 | +|------|------| +| 회원 | 서비스에 가입한 사용자 | +| 비회원 | 가입하지 않은 방문자 | +| Soft Delete | deletedAt 필드로 논리 삭제, 실제 데이터는 유지 | +| 마스킹 | 개인정보 보호를 위해 일부 문자를 *로 대체 | + +--- + +## 변경 이력 + +| 버전 | 일자 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| v1.0 | 2024-01-15 | AI | 최초 작성 | diff --git a/.claude/skills/prd-writer/templates/prd-template.md b/.claude/skills/prd-writer/templates/prd-template.md new file mode 100644 index 00000000..b2a5ba9e --- /dev/null +++ b/.claude/skills/prd-writer/templates/prd-template.md @@ -0,0 +1,129 @@ +# PLAN: [프로젝트명] + +> 작성일: YYYY-MM-DD +> 버전: v1.0 + +--- + +## 프로젝트 컨텍스트 + +> 이 섹션은 `.claude/rules/` 참조 결과입니다. + +| 항목 | 프로젝트 표준 | +|------|--------------| +| HTTP 상태코드 | 성공: 200 통일, 실패: 400/401/404/409 | +| 에러 응답 | `ApiResponse.fail(ErrorType.XXX)` | +| 검증 위치 | 형식: Request DTO, 불변식: Domain Entity | +| 비밀번호 암호화 | BCrypt | + +--- + +## Feature 1: [기능명] + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | [이 기능이 해결하는 문제] | +| Actor | [비회원 / 회원 / 관리자] | +| 우선순위 | [P0: 필수 / P1: 중요 / P2: 선택] | +| 선행 조건 | [필요한 사전 조건, 없으면 "없음"] | +| 후행 영향 | [다른 Feature에 미치는 영향] | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | [GET / POST / PATCH / DELETE] | +| Endpoint | [/api/v1/...] | +| Auth | [불필요 / 회원 / 관리자] | + +#### Request Body +| 필드 | 타입 | 필수 | 검증 규칙 | 예시 | +|------|------|------|----------|------| +| field1 | String | Y | [규칙] | "example" | +| field2 | Integer | N | [규칙] | 100 | + +#### Request Header (인증 필요 시) +| 헤더 | 필수 | 설명 | +|------|------|------| +| X-Login-Id | Y | 로그인 ID | +| X-Login-Pw | Y | 비밀번호 | + +#### Path Variable (있는 경우) +| 변수 | 타입 | 설명 | +|------|------|------| +| id | Long | 리소스 ID | + +#### Query Parameter (있는 경우) +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|----------|------|------|--------|------| +| page | Integer | N | 0 | 페이지 번호 | + +#### Response Body (성공 시) +| 필드 | 타입 | 설명 | +|------|------|------| +| field1 | String | 설명 | +| field2 | Integer | 설명 | + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | [조건] | [HTTP Method + Endpoint] | [상태코드, 결과] | +| AC-2 | [조건] | [HTTP Method + Endpoint] | [상태코드, 결과] | + +#### 실패 케이스 - 입력값 검증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-3 | [필드] 누락 | [HTTP Method + Endpoint] | 400, field: "[필드명]" | +| AC-4 | [필드] 형식 오류 | [HTTP Method + Endpoint] | 400, field: "[필드명]" | + +#### 실패 케이스 - 비즈니스 규칙 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-5 | [비즈니스 조건] | [HTTP Method + Endpoint] | [상태코드], "[에러 메시지]" | + +#### 실패 케이스 - 인증/권한 (필요 시) +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-6 | 인증 헤더 누락 | [HTTP Method + Endpoint] | 401, "인증이 필요합니다" | +| AC-7 | 비밀번호 불일치 | [HTTP Method + Endpoint] | 401, "비밀번호가 일치하지 않습니다" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | [규칙 설명] | [Entity / Service / Controller] | +| BR-2 | [규칙 설명] | [Entity / Service / Controller] | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| [논의된 질문] | [결정 내용] | [결정 이유] | + +### 미결 사항 +| 항목 | 영향 범위 | 결정 기한 | 설계 대응 | +|------|----------|----------|----------| +| [미결 항목] | [영향 범위] | [기한] | [임시 대응 방안] | + +--- + +## Feature 2: [기능명] + +(위 구조 반복) + +--- + +## 용어 정의 (Glossary) + +| 용어 | 정의 | +|------|------| +| [용어1] | [정의] | +| [용어2] | [정의] | + +--- + +## 변경 이력 + +| 버전 | 일자 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| v1.0 | YYYY-MM-DD | [이름] | 최초 작성 | diff --git a/.claude/skills/repository-patterns/SKILL.md b/.claude/skills/repository-patterns/SKILL.md new file mode 100644 index 00000000..2c806e01 --- /dev/null +++ b/.claude/skills/repository-patterns/SKILL.md @@ -0,0 +1,443 @@ +--- +name: repository-patterns +description: Repository 구현 패턴. "Repository 만들어줘", "DB 접근 계층 구현해줘", "JPA 쿼리 작성해줘" 요청 시 사용. 인터페이스/구현체 분리, JpaRepository, Soft Delete 조회, 메서드 네이밍 패턴 제공. +--- + +# Repository Patterns + +Repository 구현 가이드입니다. + +## 필수 규칙 참조 + +- `.claude/rules/core/layer-patterns.md` - Repository 역할 +- `.claude/rules/core/naming-conventions.md` - Repository 네이밍 + +--- + +## 패키지 구조 + +``` +com.loopers/ +├── domain/{domain}/ +│ └── {Domain}Repository.java # 인터페이스 (domain에 정의) +└── infrastructure/{domain}/ + ├── {Domain}RepositoryImpl.java # 구현체 + └── {Domain}JpaRepository.java # Spring Data JPA +``` + +--- + +## 1. Repository 인터페이스 (domain 패키지) + +**목적:** 도메인이 필요로 하는 영속성 메서드 정의 + +### 템플릿 + +```java +package com.loopers.domain.{domain}; + +import java.util.List; +import java.util.Optional; + +public interface {Domain}Repository { + + // 저장 + {Domain} save({Domain} {domain}); + + // 단건 조회 + Optional<{Domain}> findById(Long id); + Optional<{Domain}> findBy{Field}(String {field}); + + // 목록 조회 + List<{Domain}> findAll(); + + // 존재 여부 + boolean existsBy{Field}(String {field}); +} +``` + +### 메서드 네이밍 규칙 + +| 메서드 | 반환 타입 | 용도 | +|--------|----------|------| +| `save(entity)` | `Entity` | 저장/수정 | +| `findById(id)` | `Optional` | ID로 단건 조회 | +| `findBy{Field}(value)` | `Optional` | 필드로 단건 조회 | +| `findAll()` | `List` | 전체 조회 | +| `findAllBy{Condition}()` | `List` | 조건부 목록 조회 | +| `existsBy{Field}(value)` | `boolean` | 존재 여부 | + +### 주의사항 + +```java +// ❌ JPA 의존 노출 금지 +Page findAll(Pageable pageable); // Spring Data 타입 노출 + +// ✅ 도메인 타입만 사용 +List findAll(int page, int size); +``` + +--- + +## 2. JpaRepository (infrastructure 패키지) + +**목적:** Spring Data JPA 기능 활용 + +### 템플릿 + +```java +package com.loopers.infrastructure.{domain}; + +import com.loopers.domain.{domain}.{Domain}; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface {Domain}JpaRepository extends JpaRepository<{Domain}, Long> { + + // Soft Delete 고려한 조회 + Optional<{Domain}> findByIdAndDeletedAtIsNull(Long id); + Optional<{Domain}> findBy{Field}AndDeletedAtIsNull(String {field}); + + // 존재 여부 (Soft Delete 고려) + boolean existsBy{Field}AndDeletedAtIsNull(String {field}); + + // 전체 조회 (삭제되지 않은 것만) + List<{Domain}> findAllByDeletedAtIsNull(); +} +``` + +### Soft Delete 조회 패턴 + +| 용도 | 메서드명 | +|------|---------| +| 삭제 안된 것만 | `findBy{Field}AndDeletedAtIsNull()` | +| 삭제된 것만 | `findBy{Field}AndDeletedAtIsNotNull()` | +| 전체 (삭제 포함) | `findBy{Field}()` | + +--- + +## 3. RepositoryImpl (infrastructure 패키지) + +**목적:** 도메인 인터페이스 구현 + +### 템플릿 + +```java +package com.loopers.infrastructure.{domain}; + +import com.loopers.domain.{domain}.{Domain}; +import com.loopers.domain.{domain}.{Domain}Repository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class {Domain}RepositoryImpl implements {Domain}Repository { + + private final {Domain}JpaRepository jpaRepository; + + @Override + public {Domain} save({Domain} {domain}) { + return jpaRepository.save({domain}); + } + + @Override + public Optional<{Domain}> findById(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional<{Domain}> findBy{Field}(String {field}) { + return jpaRepository.findBy{Field}AndDeletedAtIsNull({field}); + } + + @Override + public List<{Domain}> findAll() { + return jpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public boolean existsBy{Field}(String {field}) { + return jpaRepository.existsBy{Field}AndDeletedAtIsNull({field}); + } +} +``` + +### 역할 분리 + +| 클래스 | 역할 | +|--------|------| +| `{Domain}Repository` | 도메인이 필요한 메서드 정의 (인터페이스) | +| `{Domain}JpaRepository` | Spring Data JPA 기능 활용 | +| `{Domain}RepositoryImpl` | 도메인 인터페이스 구현, JPA 호출 위임 | + +--- + +## 4. QueryDSL (복잡한 쿼리) + +### 언제 사용하는가? + +| 상황 | 도구 | +|------|------| +| 단순 CRUD | Spring Data JPA | +| 동적 조건 쿼리 | QueryDSL | +| 복잡한 조인 | QueryDSL | +| 집계/통계 | QueryDSL or Native Query | + +### 구조 + +```java +// QueryDSL 전용 인터페이스 +public interface {Domain}RepositoryCustom { + List<{Domain}> searchByCondition({Domain}SearchCondition condition); +} + +// QueryDSL 구현 +@RequiredArgsConstructor +public class {Domain}RepositoryCustomImpl implements {Domain}RepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List<{Domain}> searchByCondition({Domain}SearchCondition condition) { + return queryFactory + .selectFrom({domain}) + .where( + loginIdContains(condition.loginId()), + nameContains(condition.name()), + deletedAtIsNull() + ) + .fetch(); + } + + private BooleanExpression loginIdContains(String loginId) { + return StringUtils.hasText(loginId) + ? {domain}.loginId.contains(loginId) + : null; + } + + private BooleanExpression nameContains(String name) { + return StringUtils.hasText(name) + ? {domain}.name.contains(name) + : null; + } + + private BooleanExpression deletedAtIsNull() { + return {domain}.deletedAt.isNull(); + } +} + +// JpaRepository에 Custom 상속 +public interface {Domain}JpaRepository + extends JpaRepository<{Domain}, Long>, {Domain}RepositoryCustom { +} +``` + +--- + +## 5. Soft Delete 처리 전략 + +### 조회 시 기본 원칙 + +```java +// ❌ 삭제된 데이터도 조회됨 +Optional findByLoginId(String loginId); + +// ✅ 삭제되지 않은 데이터만 조회 +Optional findByLoginIdAndDeletedAtIsNull(String loginId); +``` + +### RepositoryImpl에서 일괄 처리 + +```java +@Override +public Optional findById(Long id) { + // Impl에서 항상 DeletedAtIsNull 적용 + return jpaRepository.findByIdAndDeletedAtIsNull(id); +} + +@Override +public Optional findByLoginId(String loginId) { + return jpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); +} +``` + +### 삭제된 데이터 조회가 필요한 경우 + +```java +// 도메인 인터페이스에 별도 메서드 추가 +public interface UserRepository { + Optional findById(Long id); // 삭제 안된 것 + Optional findByIdIncludeDeleted(Long id); // 삭제 포함 +} + +// 구현 +@Override +public Optional findByIdIncludeDeleted(Long id) { + return jpaRepository.findById(id); // DeletedAt 조건 없이 +} +``` + +--- + +## 6. 페이징 처리 + +### 도메인 인터페이스 + +```java +public interface UserRepository { + // 도메인 타입으로 반환 + List findAll(int page, int size); + long count(); +} +``` + +### RepositoryImpl + +```java +@Override +public List findAll(int page, int size) { + return jpaRepository.findAllByDeletedAtIsNull( + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ).getContent(); +} + +@Override +public long count() { + return jpaRepository.countByDeletedAtIsNull(); +} +``` + +### JpaRepository + +```java +public interface UserJpaRepository extends JpaRepository { + Page findAllByDeletedAtIsNull(Pageable pageable); + long countByDeletedAtIsNull(); +} +``` + +--- + +## 체크리스트 + +### 구조 +- [ ] 인터페이스는 `domain/{domain}/` 패키지 +- [ ] JpaRepository, Impl은 `infrastructure/{domain}/` 패키지 +- [ ] `@Repository`는 Impl에만 적용 + +### 네이밍 +- [ ] 인터페이스: `{Domain}Repository` +- [ ] JPA: `{Domain}JpaRepository` +- [ ] 구현체: `{Domain}RepositoryImpl` + +### Soft Delete +- [ ] 조회 메서드에 `AndDeletedAtIsNull` 적용 +- [ ] RepositoryImpl에서 일괄 처리 + +### 메서드 +- [ ] `save()` → Entity 반환 +- [ ] `findBy*()` → `Optional` 반환 +- [ ] `existsBy*()` → `boolean` 반환 + +--- + +## 트러블슈팅 + +### 1. 삭제된 데이터가 조회됨 + +**원인:** `AndDeletedAtIsNull` 조건 누락 +```java +// ❌ 삭제된 데이터도 조회됨 +Optional findByLoginId(String loginId); + +// ✅ JpaRepository에서 조건 추가 +Optional findByLoginIdAndDeletedAtIsNull(String loginId); +``` + +### 2. Repository 인터페이스에 JPA 타입 노출 + +**문제:** 도메인이 infrastructure에 의존하게 됨 +```java +// ❌ domain 패키지에서 Spring Data 타입 사용 +public interface UserRepository { + Page findAll(Pageable pageable); +} + +// ✅ 도메인 타입만 사용 +public interface UserRepository { + List findAll(int page, int size); +} +``` + +### 3. `@Repository`를 인터페이스에 붙임 + +**문제:** `@Repository`는 구현체에만 적용 +```java +// ❌ 인터페이스에 @Repository +@Repository +public interface UserRepository { ... } + +// ✅ 구현체에만 @Repository +public interface UserRepository { ... } + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { ... } +``` + +### 4. RepositoryImpl 없이 JpaRepository만 사용 + +**문제:** 도메인이 infrastructure에 직접 의존 +```java +// ❌ Service에서 JpaRepository 직접 사용 +@Service +public class UserService { + private final UserJpaRepository jpaRepository; // infrastructure 의존 +} + +// ✅ 도메인 인터페이스 사용 +@Service +public class UserService { + private final UserRepository userRepository; // domain 의존 +} +``` + +### 5. exists 쿼리에서 Soft Delete 미고려 + +**원인:** exists도 `AndDeletedAtIsNull` 필요 +```java +// ❌ 삭제된 데이터도 존재로 판단 +boolean existsByLoginId(String loginId); + +// ✅ 삭제되지 않은 데이터만 체크 +boolean existsByLoginIdAndDeletedAtIsNull(String loginId); +``` + +### 6. QueryDSL에서 deletedAt 조건 누락 + +**원인:** 동적 쿼리에서도 Soft Delete 필수 +```java +// ❌ deletedAt 조건 없음 +return queryFactory.selectFrom(user) + .where(nameContains(name)) + .fetch(); + +// ✅ deletedAt.isNull() 항상 포함 +return queryFactory.selectFrom(user) + .where( + nameContains(name), + user.deletedAt.isNull() // 필수 + ) + .fetch(); +``` + +--- + +## 참조 문서 + +| 문서 | 설명 | +|------|------| +| [user-repository.md](./examples/user-repository.md) | 회원 Repository 예시 | diff --git a/.claude/skills/repository-patterns/examples/user-repository.md b/.claude/skills/repository-patterns/examples/user-repository.md new file mode 100644 index 00000000..387a75d9 --- /dev/null +++ b/.claude/skills/repository-patterns/examples/user-repository.md @@ -0,0 +1,447 @@ +# 회원 Repository 예시 + +User 도메인의 Repository 구현 예시입니다. + +--- + +## 파일 구조 + +``` +com.loopers/ +├── domain/user/ +│ └── UserRepository.java # 인터페이스 +└── infrastructure/user/ + ├── UserRepositoryImpl.java # 구현체 + └── UserJpaRepository.java # Spring Data JPA +``` + +--- + +## 1. UserRepository.java (domain 패키지) + +```java +package com.loopers.domain.user; + +import java.util.List; +import java.util.Optional; + +/** + * 회원 Repository 인터페이스 + * - domain 패키지에 위치 + * - 도메인이 필요로 하는 영속성 메서드만 정의 + */ +public interface UserRepository { + + // ======================================== + // 저장 + // ======================================== + + User save(User user); + + // ======================================== + // 단건 조회 + // ======================================== + + Optional findById(Long id); + + Optional findByLoginId(String loginId); + + Optional findByEmail(String email); + + // ======================================== + // 목록 조회 + // ======================================== + + List findAll(); + + List findAll(int page, int size); + + // ======================================== + // 존재 여부 + // ======================================== + + boolean existsByLoginId(String loginId); + + boolean existsByEmail(String email); + + // ======================================== + // 카운트 + // ======================================== + + long count(); +} +``` + +--- + +## 2. UserJpaRepository.java (infrastructure 패키지) + +```java +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Spring Data JPA Repository + * - infrastructure 패키지에 위치 + * - Soft Delete 조건 (DeletedAtIsNull) 포함 + */ +public interface UserJpaRepository extends JpaRepository { + + // ======================================== + // 단건 조회 (Soft Delete 고려) + // ======================================== + + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByLoginIdAndDeletedAtIsNull(String loginId); + + Optional findByEmailAndDeletedAtIsNull(String email); + + // ======================================== + // 목록 조회 (Soft Delete 고려) + // ======================================== + + List findAllByDeletedAtIsNull(); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + // ======================================== + // 존재 여부 (Soft Delete 고려) + // ======================================== + + boolean existsByLoginIdAndDeletedAtIsNull(String loginId); + + boolean existsByEmailAndDeletedAtIsNull(String email); + + // ======================================== + // 카운트 (Soft Delete 고려) + // ======================================== + + long countByDeletedAtIsNull(); +} +``` + +--- + +## 3. UserRepositoryImpl.java (infrastructure 패키지) + +```java +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Repository 구현체 + * - infrastructure 패키지에 위치 + * - 도메인 인터페이스 구현 + * - JpaRepository에 위임 + */ +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository jpaRepository; + + // ======================================== + // 저장 + // ======================================== + + @Override + public User save(User user) { + return jpaRepository.save(user); + } + + // ======================================== + // 단건 조회 + // ======================================== + + @Override + public Optional findById(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByLoginId(String loginId) { + return jpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); + } + + @Override + public Optional findByEmail(String email) { + return jpaRepository.findByEmailAndDeletedAtIsNull(email); + } + + // ======================================== + // 목록 조회 + // ======================================== + + @Override + public List findAll() { + return jpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAll(int page, int size) { + return jpaRepository.findAllByDeletedAtIsNull( + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ).getContent(); + } + + // ======================================== + // 존재 여부 + // ======================================== + + @Override + public boolean existsByLoginId(String loginId) { + return jpaRepository.existsByLoginIdAndDeletedAtIsNull(loginId); + } + + @Override + public boolean existsByEmail(String email) { + return jpaRepository.existsByEmailAndDeletedAtIsNull(email); + } + + // ======================================== + // 카운트 + // ======================================== + + @Override + public long count() { + return jpaRepository.countByDeletedAtIsNull(); + } +} +``` + +--- + +## 핵심 포인트 + +### 1. 패키지 분리 + +``` +domain/user/UserRepository.java ← 인터페이스 (도메인) +infrastructure/user/UserJpaRepository ← JPA (인프라) +infrastructure/user/UserRepositoryImpl ← 구현체 (인프라) +``` + +**왜 분리하는가?** +- 도메인 레이어가 인프라(JPA)에 의존하지 않음 +- 테스트 시 Mock Repository 주입 용이 +- JPA 외 다른 구현으로 교체 가능 + +### 2. Soft Delete 일괄 처리 + +```java +// RepositoryImpl에서 항상 DeletedAtIsNull 적용 +@Override +public Optional findById(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id); // ✅ +} + +// Service에서는 신경 쓸 필요 없음 +User user = userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND)); +``` + +### 3. 도메인 타입만 노출 + +```java +// ❌ JPA 타입 노출 +Page findAll(Pageable pageable); + +// ✅ 도메인 타입만 노출 +List findAll(int page, int size); +``` + +### 4. @Repository는 Impl에만 + +```java +// ❌ 인터페이스에 @Repository +@Repository +public interface UserRepository { } + +// ✅ 구현체에만 @Repository +@Repository +public class UserRepositoryImpl implements UserRepository { } +``` + +--- + +## 관련 테스트 + +```java +class UserRepositoryIT extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Nested + class 저장 { + + @Test + void 성공() { + // given + User user = User.create("john123", "password", "홍길동", "john@test.com", null); + + // when + User saved = userRepository.save(user); + + // then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getCreatedAt()).isNotNull(); + } + } + + @Nested + class 조회 { + + @Test + void ID로_조회_성공() { + // given + User user = User.create("john123", "password", "홍길동", "john@test.com", null); + User saved = userRepository.save(user); + + // when + Optional found = userRepository.findById(saved.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getLoginId()).isEqualTo("john123"); + } + + @Test + void 삭제된_회원은_조회되지_않는다() { + // given + User user = User.create("john123", "password", "홍길동", "john@test.com", null); + User saved = userRepository.save(user); + saved.delete(); // Soft Delete + + // when + Optional found = userRepository.findById(saved.getId()); + + // then + assertThat(found).isEmpty(); + } + + @Test + void 로그인_ID로_조회_성공() { + // given + User user = User.create("john123", "password", "홍길동", "john@test.com", null); + userRepository.save(user); + + // when + Optional found = userRepository.findByLoginId("john123"); + + // then + assertThat(found).isPresent(); + } + } + + @Nested + class 존재_여부 { + + @Test + void 존재하면_true() { + // given + User user = User.create("john123", "password", "홍길동", "john@test.com", null); + userRepository.save(user); + + // when & then + assertThat(userRepository.existsByLoginId("john123")).isTrue(); + } + + @Test + void 존재하지_않으면_false() { + assertThat(userRepository.existsByLoginId("notexist")).isFalse(); + } + + @Test + void 삭제된_회원은_존재하지_않음() { + // given + User user = User.create("john123", "password", "홍길동", "john@test.com", null); + User saved = userRepository.save(user); + saved.delete(); + + // when & then + assertThat(userRepository.existsByLoginId("john123")).isFalse(); + } + } +} +``` + +--- + +## QueryDSL 확장 예시 + +```java +// 검색 조건 +public record UserSearchCondition( + String loginId, + String name, + String email +) {} + +// Custom 인터페이스 +public interface UserRepositoryCustom { + List search(UserSearchCondition condition); +} + +// Custom 구현 +@RequiredArgsConstructor +public class UserRepositoryCustomImpl implements UserRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List search(UserSearchCondition condition) { + return queryFactory + .selectFrom(user) + .where( + loginIdContains(condition.loginId()), + nameContains(condition.name()), + emailContains(condition.email()), + user.deletedAt.isNull() + ) + .orderBy(user.createdAt.desc()) + .fetch(); + } + + private BooleanExpression loginIdContains(String loginId) { + return StringUtils.hasText(loginId) + ? user.loginId.contains(loginId) + : null; + } + + private BooleanExpression nameContains(String name) { + return StringUtils.hasText(name) + ? user.name.contains(name) + : null; + } + + private BooleanExpression emailContains(String email) { + return StringUtils.hasText(email) + ? user.email.contains(email) + : null; + } +} + +// JpaRepository에 상속 +public interface UserJpaRepository + extends JpaRepository, UserRepositoryCustom { + // ... +} +``` diff --git a/.claude/skills/service-patterns/SKILL.md b/.claude/skills/service-patterns/SKILL.md new file mode 100644 index 00000000..572f90b2 --- /dev/null +++ b/.claude/skills/service-patterns/SKILL.md @@ -0,0 +1,495 @@ +--- +name: service-patterns +description: Domain Service 구현 패턴. "Service 만들어줘", "비즈니스 로직 구현해줘", "트랜잭션 처리해줘" 요청 시 사용. 트랜잭션 관리, 중복 체크, CoreException, 외부 의존성 처리 패턴 제공. +--- + +# Service Patterns + +Domain Service 구현 가이드입니다. + +## 필수 규칙 참조 + +- `.claude/rules/core/layer-patterns.md` - Service 역할 +- `.claude/rules/core/design-principles.md` - 중복 체크 전략 +- `.claude/rules/core/exception-patterns.md` - CoreException 사용 +- `.claude/rules/core/naming-conventions.md` - Service 네이밍 + +--- + +## 패키지 구조 + +``` +com.loopers.domain.{domain}/ +├── {Domain}.java # Entity +├── {Domain}Repository.java # Repository 인터페이스 +├── {Domain}Service.java # Domain Service ← 여기 +└── PasswordEncoder.java # 외부 의존성 인터페이스 (필요시) +``` + +--- + +## 1. 기본 구조 + +### 템플릿 + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 기본: 읽기 전용 +public class {Domain}Service { + + private final {Domain}Repository {domain}Repository; + // 필요시 외부 의존성 인터페이스 주입 + private final PasswordEncoder passwordEncoder; + + // 조회 메서드 (readOnly = true 적용됨) + public {Domain} getById(Long id) { ... } + + // 쓰기 메서드 (개별 @Transactional 적용) + @Transactional + public {Domain} create(...) { ... } +} +``` + +### 어노테이션 규칙 + +| 어노테이션 | 위치 | 설명 | +|-----------|------|------| +| `@Service` | 클래스 | Spring Bean 등록 | +| `@RequiredArgsConstructor` | 클래스 | 생성자 주입 | +| `@Transactional(readOnly = true)` | 클래스 | 기본 읽기 전용 | +| `@Transactional` | 쓰기 메서드 | 쓰기 트랜잭션 | + +--- + +## 2. 트랜잭션 관리 + +### 읽기 vs 쓰기 분리 + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 클래스 레벨: 읽기 전용 +public class UserService { + + // 조회 메서드 → 클래스 레벨 트랜잭션 사용 + public User getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + } + + public User getByLoginId(String loginId) { + return userRepository.findByLoginIdAndDeletedAtIsNull(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + } + + // 쓰기 메서드 → 개별 @Transactional + @Transactional + public User signUp(...) { ... } + + @Transactional + public User update(...) { ... } + + @Transactional + public void delete(Long id) { ... } +} +``` + +### 왜 분리하는가? + +| 구분 | readOnly = true | readOnly = false | +|------|-----------------|------------------| +| 플러시 | 비활성화 | 활성화 | +| 더티 체킹 | 비활성화 | 활성화 | +| DB 부하 | 낮음 (읽기 복제본 사용 가능) | 높음 | + +--- + +## 3. 중복 체크 + +### 패턴: exists 쿼리로 명시적 수행 + +```java +@Transactional +public User signUp(String loginId, String password, String name, + String email, LocalDate birthDate) { + // 1. 중복 체크 (exists 쿼리) + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } + if (userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 이메일입니다"); + } + + // 2. Entity 생성 및 저장 (외부 의존성은 파라미터로 전달) + User user = User.create(loginId, password, name, birthDate, email, passwordEncoder); + return userRepository.save(user); +} +``` + +### 금지 사항 + +```java +// ❌ 금지: DataIntegrityViolationException try-catch +try { + return userRepository.save(user); +} catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "중복된 데이터입니다"); +} + +// ✅ 올바름: exists 쿼리로 사전 체크 +if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); +} +``` + +### 왜 exists 쿼리인가? + +| 방식 | 장점 | 단점 | +|------|------|------| +| exists 쿼리 | 명확한 에러 메시지, 빠른 실패 | 쿼리 1회 추가 | +| DB 제약 의존 | 쿼리 절약 | 에러 메시지 불명확, 롤백 비용 | + +> DB unique constraint는 **최종 방어선**으로 반드시 설정. 글로벌 핸들러가 처리. + +--- + +## 4. CoreException 사용 + +### ErrorType별 사용 상황 + +| ErrorType | HTTP | 사용 상황 | 예시 | +|-----------|------|----------|------| +| `NOT_FOUND` | 404 | 리소스 없음 | 회원 조회 실패 | +| `CONFLICT` | 409 | 중복 리소스 | 로그인 ID 중복 | +| `UNAUTHORIZED` | 401 | 인증 실패 | 비밀번호 불일치 | +| `BAD_REQUEST` | 400 | 비즈니스 규칙 위반 | 본인만 수정 가능 | + +### 패턴 + +```java +// 조회 실패 +User user = userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + +// 중복 체크 +if (userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 이메일입니다"); +} + +// 인증 실패 +if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"); +} + +// 권한 검증 +if (!order.getUserId().equals(currentUserId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 수정할 수 있습니다"); +} +``` + +### 금지 사항 + +```java +// ❌ 금지: try-catch 사용 +try { + userService.signUp(...); +} catch (CoreException e) { + // 처리 +} + +// ✅ 올바름: 글로벌 핸들러가 처리 +userService.signUp(...); // 예외는 그대로 전파 +``` + +--- + +## 5. 외부 의존성 처리 + +### 원칙 + +- 외부 의존성은 **도메인 패키지에 인터페이스** 정의 +- 구현체는 **infrastructure 패키지**에 배치 +- **Entity.create() 파라미터로 전달**, Entity가 내부에서 처리 + +### 예시: PasswordEncoder + +**인터페이스 (domain 패키지)** +```java +// domain/user/PasswordEncoder.java +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} +``` + +**구현체 (infrastructure 패키지)** +```java +// infrastructure/user/BcryptPasswordEncoder.java +@Component +public class BcryptPasswordEncoder implements PasswordEncoder { + + private final BCryptPasswordEncoder delegate = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return delegate.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return delegate.matches(rawPassword, encodedPassword); + } +} +``` + +**Service → Entity 전달** +```java +@Service +@RequiredArgsConstructor +public class UserService { + + private final PasswordEncoder passwordEncoder; // 인터페이스로 주입 + + @Transactional + public User signUp(String loginId, String rawPassword, ...) { + // Entity.create()에 파라미터로 전달, Entity 내부에서 암호화 수행 + User user = User.create(loginId, rawPassword, ..., passwordEncoder); + return userRepository.save(user); + } + + public void login(String loginId, String rawPassword) { + User user = getByLoginId(loginId); + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"); + } + } +} +``` + +--- + +## 6. 파라미터 규칙 + +### 6개 이하: 원시값 파라미터 + +```java +@Transactional +public User signUp(String loginId, String password, String name, + String email, LocalDate birthDate) { + // 5개 파라미터 → 원시값으로 전달 +} +``` + +### 7개 이상: Command 객체 + +```java +// Command 정의 +public record CreateOrderCommand( + Long userId, + String productId, + Integer quantity, + String shippingAddress, + String recipientName, + String recipientPhone, + String memo +) {} + +// Service 메서드 +@Transactional +public Order createOrder(CreateOrderCommand command) { + // 7개 이상 → Command 객체로 그루핑 +} +``` + +--- + +## 7. 메서드 패턴 + +### 생성 (Create) + +```java +@Transactional +public User signUp(String loginId, String password, String name, + String email, LocalDate birthDate) { + // 1. 중복 체크 + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } + + // 2. Entity 생성 및 저장 (외부 의존성은 파라미터로 전달) + User user = User.create(loginId, password, name, birthDate, email, passwordEncoder); + return userRepository.save(user); +} +``` + +### 조회 (Read) + +```java +public User getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); +} + +public List getAll() { + return userRepository.findAllByDeletedAtIsNull(); +} +``` + +### 수정 (Update) + +```java +@Transactional +public User updateProfile(Long id, String name, String email) { + // 1. 조회 + User user = getById(id); + + // 2. 중복 체크 (변경된 경우만) + if (!user.getEmail().equals(email) && userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 이메일입니다"); + } + + // 3. 수정 + user.updateProfile(name, email); + + return user; // 더티 체킹으로 자동 저장 +} +``` + +### 삭제 (Delete) + +```java +@Transactional +public void delete(Long id) { + User user = getById(id); + user.delete(); // Soft Delete +} +``` + +--- + +## 체크리스트 + +### 기본 구조 +- [ ] `@Service`, `@RequiredArgsConstructor` 적용 +- [ ] 클래스 레벨 `@Transactional(readOnly = true)` +- [ ] 쓰기 메서드에만 `@Transactional` + +### 중복 체크 +- [ ] exists 쿼리로 사전 체크 +- [ ] CoreException(CONFLICT) 사용 +- [ ] DataIntegrityViolationException try-catch 금지 + +### 예외 처리 +- [ ] 조회 실패 → `NOT_FOUND` +- [ ] 중복 → `CONFLICT` +- [ ] 인증 실패 → `UNAUTHORIZED` +- [ ] try-catch 사용 안 함 + +### 외부 의존성 +- [ ] 인터페이스 domain 패키지에 정의 +- [ ] 구현체 infrastructure 패키지에 배치 +- [ ] Entity.create() 파라미터로 전달 + +### 파라미터 +- [ ] 6개 이하 → 원시값 +- [ ] 7개 이상 → Command 객체 + +--- + +## 트러블슈팅 + +### 1. 조회 메서드에 `@Transactional` 붙임 + +**문제:** 불필요한 쓰기 트랜잭션 오버헤드 +```java +// ❌ 조회인데 쓰기 트랜잭션 +@Transactional +public User getById(Long id) { ... } + +// ✅ 클래스 레벨 readOnly=true 상속 +public User getById(Long id) { ... } +``` + +### 2. Service에서 암호화 후 Entity 생성 + +**문제:** 외부 의존성은 Entity.create() 파라미터로 전달 +```java +// ❌ Service에서 암호화 +String encoded = passwordEncoder.encode(rawPassword); +User user = User.create(loginId, encoded, ...); + +// ✅ Entity.create()에 PasswordEncoder 전달 +User user = User.create(loginId, rawPassword, ..., passwordEncoder); +``` + +### 3. DataIntegrityViolationException try-catch + +**문제:** 중복 체크는 exists 쿼리로 사전 수행 +```java +// ❌ DB 예외 catch +try { + return userRepository.save(user); +} catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "중복"); +} + +// ✅ exists로 사전 체크 +if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); +} +``` + +### 4. Entity 검증에 CoreException 사용 + +**문제:** Entity는 `IllegalArgumentException`, Service는 `CoreException` +```java +// ❌ Entity에서 CoreException +User.create() { + throw new CoreException(ErrorType.BAD_REQUEST, "..."); +} + +// ✅ Entity → IllegalArgumentException, Service → CoreException +// Entity +throw new IllegalArgumentException("로그인 ID는 필수입니다"); +// Service +throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); +``` + +### 5. 수정 후 명시적 save() 호출 + +**문제:** 더티 체킹으로 자동 저장됨 +```java +// ❌ 불필요한 save() +user.updateProfile(name, email); +userRepository.save(user); + +// ✅ 더티 체킹으로 자동 저장 +user.updateProfile(name, email); +return user; +``` + +### 6. Service 간 직접 호출 + +**문제:** 순환 참조 발생 가능 +```java +// ❌ Service → Service 직접 호출 +@Service +public class OrderService { + private final UserService userService; // 순환 참조 위험 +} + +// ✅ Repository 직접 사용 또는 Facade로 조합 +@Service +public class OrderService { + private final UserRepository userRepository; // Repository 사용 +} +``` + +--- + +## 참조 문서 + +| 문서 | 설명 | +|------|------| +| [user-service.md](./examples/user-service.md) | 회원 Service 예시 | diff --git a/.claude/skills/service-patterns/examples/user-service.md b/.claude/skills/service-patterns/examples/user-service.md new file mode 100644 index 00000000..cf59bdc5 --- /dev/null +++ b/.claude/skills/service-patterns/examples/user-service.md @@ -0,0 +1,388 @@ +# 회원 Service 예시 + +User 도메인의 Service 구현 예시입니다. + +--- + +## 파일 위치 + +``` +com.loopers.domain.user/ +├── User.java # Entity +├── UserRepository.java # Repository 인터페이스 +├── UserService.java # Domain Service ← 여기 +└── PasswordEncoder.java # 외부 의존성 인터페이스 +``` + +--- + +## UserService.java + +```java +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // ======================================== + // 조회 메서드 (readOnly = true) + // ======================================== + + public User getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + } + + public User getByLoginId(String loginId) { + return userRepository.findByLoginIdAndDeletedAtIsNull(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + } + + public List getAll() { + return userRepository.findAllByDeletedAtIsNull(); + } + + public boolean existsByLoginId(String loginId) { + return userRepository.existsByLoginId(loginId); + } + + // ======================================== + // 쓰기 메서드 (@Transactional) + // ======================================== + + @Transactional + public User signUp(String loginId, String password, String name, + String email, LocalDate birthDate) { + // 1. 중복 체크 + validateDuplicateLoginId(loginId); + validateDuplicateEmail(email); + + // 2. Entity 생성 및 저장 (외부 의존성은 파라미터로 전달) + User user = User.create(loginId, password, name, birthDate, email, passwordEncoder); + return userRepository.save(user); + } + + @Transactional + public User updateProfile(Long id, String name, String email) { + // 1. 조회 + User user = getById(id); + + // 2. 이메일 변경 시 중복 체크 + if (!user.getEmail().equals(email)) { + validateDuplicateEmail(email); + } + + // 3. 수정 (Entity 비즈니스 메서드 호출) + user.updateProfile(name, email); + + return user; // 더티 체킹으로 자동 저장 + } + + @Transactional + public void changePassword(Long id, String currentPassword, String newPassword) { + // 1. 조회 + User user = getById(id); + + // 2. 현재 비밀번호 검증 + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다"); + } + + // 3. 비밀번호 변경 (Entity가 암호화 처리) + user.changePassword(newPassword, passwordEncoder); + } + + @Transactional + public void delete(Long id) { + User user = getById(id); + user.delete(); // Soft Delete + } + + @Transactional + public void restore(Long id) { + User user = userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + user.restore(); + } + + // ======================================== + // 중복 체크 (private) + // ======================================== + + private void validateDuplicateLoginId(String loginId) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } + } + + private void validateDuplicateEmail(String email) { + if (userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 이메일입니다"); + } + } +} +``` + +--- + +## 핵심 포인트 + +### 1. 트랜잭션 분리 + +```java +@Transactional(readOnly = true) // 클래스 레벨: 기본 읽기 전용 +public class UserService { + + // 조회 → readOnly = true (클래스 레벨 적용) + public User getById(Long id) { ... } + + // 쓰기 → @Transactional (메서드 레벨 오버라이드) + @Transactional + public User signUp(...) { ... } +} +``` + +### 2. 중복 체크는 exists 쿼리로 + +```java +// ✅ exists 쿼리로 사전 체크 +private void validateDuplicateLoginId(String loginId) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } +} + +// ❌ try-catch 금지 +try { + userRepository.save(user); +} catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT); +} +``` + +### 3. 외부 의존성은 Entity에 파라미터로 전달 + +```java +@Transactional +public User signUp(String loginId, String password, ...) { + // Entity.create()에 rawPassword와 PasswordEncoder를 전달 + // Entity가 내부에서 암호화 수행 + User user = User.create(loginId, password, ..., passwordEncoder); + return userRepository.save(user); +} +``` + +### 4. 수정은 더티 체킹 활용 + +```java +@Transactional +public User updateProfile(Long id, String name, String email) { + User user = getById(id); + user.updateProfile(name, email); + return user; // save() 호출 불필요, 더티 체킹으로 자동 저장 +} +``` + +### 5. 조회 메서드 재사용 + +```java +// getById를 내부에서 재사용 +@Transactional +public User updateProfile(Long id, String name, String email) { + User user = getById(id); // 조회 로직 재사용 + ... +} + +@Transactional +public void delete(Long id) { + User user = getById(id); // 조회 로직 재사용 + user.delete(); +} +``` + +--- + +## 관련 테스트 + +```java +class UserServiceIT extends IntegrationTestSupport { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Nested + class 회원가입 { + + @Test + void 성공() { + // given + String loginId = "john123"; + String password = "Pass1234!"; + String name = "홍길동"; + String email = "john@test.com"; + + // when + User user = userService.signUp(loginId, password, name, email, null); + + // then + assertThat(user.getId()).isNotNull(); + assertThat(user.getLoginId()).isEqualTo(loginId); + + // DB 저장 확인 + assertThat(userRepository.findByLoginIdAndDeletedAtIsNull(loginId)) + .isPresent(); + } + + @Test + void 로그인_ID_중복이면_예외발생() { + // given + String loginId = "john123"; + userService.signUp(loginId, "Pass1234!", "홍길동", "john1@test.com", null); + + // when & then + assertThatThrownBy(() -> + userService.signUp(loginId, "Pass1234!", "김철수", "john2@test.com", null) + ).isInstanceOf(CoreException.class) + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.CONFLICT); + }); + } + + @Test + void 비밀번호가_암호화되어_저장된다() { + // given + String rawPassword = "Pass1234!"; + + // when + User user = userService.signUp("john123", rawPassword, "홍길동", "john@test.com", null); + + // then + assertThat(user.getPassword()).isNotEqualTo(rawPassword); + assertThat(user.getPassword()).startsWith("$2"); // BCrypt prefix + } + } + + @Nested + class 조회 { + + @Test + void ID로_조회_성공() { + // given + User saved = userService.signUp("john123", "Pass1234!", "홍길동", "john@test.com", null); + + // when + User found = userService.getById(saved.getId()); + + // then + assertThat(found.getLoginId()).isEqualTo("john123"); + } + + @Test + void 존재하지_않는_ID면_예외발생() { + assertThatThrownBy(() -> userService.getById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + }); + } + } + + @Nested + class 비밀번호_변경 { + + @Test + void 성공() { + // given + String oldPassword = "Pass1234!"; + User user = userService.signUp("john123", oldPassword, "홍길동", "john@test.com", null); + + // when + userService.changePassword(user.getId(), oldPassword, "NewPass5678!"); + + // then + User updated = userService.getById(user.getId()); + assertThat(updated.getPassword()).isNotEqualTo(user.getPassword()); + } + + @Test + void 현재_비밀번호_불일치면_예외발생() { + // given + User user = userService.signUp("john123", "Pass1234!", "홍길동", "john@test.com", null); + + // when & then + assertThatThrownBy(() -> + userService.changePassword(user.getId(), "WrongPassword!", "NewPass5678!") + ).isInstanceOf(CoreException.class) + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + }); + } + } + + @Nested + class 삭제 { + + @Test + void Soft_Delete_성공() { + // given + User user = userService.signUp("john123", "Pass1234!", "홍길동", "john@test.com", null); + + // when + userService.delete(user.getId()); + + // then + assertThat(userRepository.findByLoginIdAndDeletedAtIsNull("john123")) + .isEmpty(); // 삭제된 데이터는 조회 안됨 + assertThat(userRepository.findById(user.getId())) + .isPresent(); // 실제 데이터는 존재 + } + } +} +``` + +--- + +## 복잡한 비즈니스 로직 예시 + +```java +@Transactional +public void transferPoints(Long fromUserId, Long toUserId, Integer amount) { + // 1. 조회 + User fromUser = getById(fromUserId); + User toUser = getById(toUserId); + + // 2. 비즈니스 규칙 검증 + if (fromUser.getPoints() < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다"); + } + if (fromUserId.equals(toUserId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인에게 전송할 수 없습니다"); + } + + // 3. 상태 변경 (Entity 메서드 호출) + fromUser.deductPoints(amount); + toUser.addPoints(amount); + + // 더티 체킹으로 자동 저장 +} +``` diff --git a/.claude/skills/test-patterns/SKILL.md b/.claude/skills/test-patterns/SKILL.md new file mode 100644 index 00000000..223dcc02 --- /dev/null +++ b/.claude/skills/test-patterns/SKILL.md @@ -0,0 +1,634 @@ +--- +name: test-patterns +description: 테스트 작성 패턴. "테스트 작성해줘", "단위 테스트 만들어줘", "통합 테스트 구현해줘", "E2E 테스트 추가해줘" 요청 시 사용. 단위/통합/E2E 테스트 구조, Mock 원칙, 네이밍 컨벤션 제공. +--- + +# 테스트 패턴 및 컨벤션 + +## 테스트 유형 + +| 유형 | 대상 | 파일 네이밍 | 도구 | +|------|------|-----------|------| +| **단위 테스트** | Entity, VO, Domain Service | `{Domain}Test.java` | JUnit, AssertJ | +| **통합 테스트** | Service (DB 연동) | `{Domain}ServiceIntegrationTest.java` | @SpringBootTest, TestContainers | +| **E2E 테스트** | API 엔드포인트 | `{Domain}ApiE2ETest.java` | TestRestTemplate | + +### 파일 네이밍 예시 + +| 도메인 | 단위 테스트 | 통합 테스트 | E2E 테스트 | +|--------|-----------|-----------|-----------| +| User | `UserTest.java` | `UserServiceIntegrationTest.java` | `UserApiE2ETest.java` | +| Order | `OrderTest.java` | `OrderServiceIntegrationTest.java` | `OrderApiE2ETest.java` | + +### Mock 사용 원칙 + +> **우리가 상태를 제어할 수 없는 외부 시스템만 Mock으로 stubbing 한다** + +#### "외부"의 정의 + +외부란 **JVM 밖에 있어서 우리가 상태를 보장할 수 없는 시스템**을 말한다. + +#### "외부"의 판단 기준 +``` +"저쪽 상태를 우리가 관리할 수 있는가?" + → Yes: 실제 객체 사용 + → No: Mock/Fake 사용 +``` + +#### 도메인 모델과 외부 라이브러리의 경계 + +JVM 내 라이브러리라도 **도메인 모델이 직접 의존하면 안 된다.** +도메인 모델 안으로 들어올 때는 반드시 **인터페이스로 감싸거나, +변환된 값으로 전달**한다. +```java +// ❌ 도메인 모델이 외부 라이브러리를 직접 참조 +public class User { + public void changePassword(String raw, PasswordEncoder encoder) { + this.password = encoder.encode(raw); + } +} + +// ✅ 암호화된 값을 받는다 (도메인은 암호화 방식을 모른다) +public class User { + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } +} + +// ✅ 또는 도메인 인터페이스로 감싼다 +public interface PasswordEncryptor { // 도메인 패키지에 위치 + String encrypt(String raw); +} + +public class User { + public void changePassword(String raw, PasswordEncryptor encryptor) { + this.password = encryptor.encrypt(raw); + } +} +``` + +**핵심:** 실제 객체를 쓰느냐 Mock을 쓰느냐는 **테스트 전략**이고, +인터페이스로 감싸느냐는 **설계 원칙**이다. +이 둘은 별개의 문제다. + +| 관점 | 질문 | 결정 | +|------|------|------| +| 테스트 전략 | Mock 할 것인가? | 상태 제어 가능하면 실제 객체 | +| 설계 원칙 | 도메인이 직접 참조해도 되는가? | 외부 라이브러리는 인터페이스로 격리 | + +--- + +## 공통 규칙 + +### 필수 구조: @Nested 사용 + +- **모든 테스트는 @Nested 클래스로 그룹화** (테스트가 1개여도 필수) +- 이유: 일관성 유지, 확장 용이 + +### 네이밍 컨벤션 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| @Nested 클래스명 | 한글 (기능/유스케이스) | `class 생성`, `class 회원가입` | +| 테스트 메서드명 | `조건_결과` 한글 서술형 | `유효한_값이면_회원이_생성된다()` | +| @DisplayName | **사용 금지** | `ReplaceUnderscores` 설정 사용 | + +### 클래스 설정 + +```java +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserTest { + // ... +} +``` + +### 한글화 원칙 + +- 영어 메서드명 혼용 금지 (`confirm_호출시` ❌ → `확정` ✅) +- 영어 상태/값 직접 사용 금지 (`CONFIRMED` ❌ → `확정됨` ✅) + +--- + +## 1. 단위 테스트 + +### 목적 + +- 도메인 불변식/비즈니스 규칙 검증 +- 빠른 피드백 (ms 단위) +- Mock 없이 순수 객체 테스트 + +### 테스트 대상 + +| 구분 | 예시 | +|------|------| +| 생성 규칙 | `User.create()` 검증 | +| 불변식 검증 | 로그인ID 형식, 이메일 형식 | +| 비즈니스 메서드 | `getMaskedName()`, `changePassword()` | +| 예외 케이스 | null, 빈값, 형식 오류 | + +### 기본 구조 + +```java +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserTest { + + private static final PasswordEncoder PASSWORD_ENCODER = new FakePasswordEncoder(); + + // 외부 의존성은 Fake 구현체로 + static class FakePasswordEncoder implements PasswordEncoder { + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } + } + + @Nested + class 생성 { + + @Test + void 유효한_값이면_회원이_생성된다() { + // given + String loginId = "testuser123"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 1, 15); + String email = "test@example.com"; + + // when + User user = User.create(loginId, rawPassword, name, birthDate, email, PASSWORD_ENCODER); + + // then + assertThat(user.getLoginId()).isEqualTo(loginId); + assertThat(user.getPassword()).isNotEqualTo(rawPassword); + assertThat(user.getName()).isEqualTo(name); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 로그인ID가_null_또는_빈값이면_예외(String loginId) { + assertThatThrownBy(() -> User.create(loginId, "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("로그인 ID"); + } + } + + @Nested + class 비밀번호_변경 { + + @Test + void 유효한_새_비밀번호면_변경된다() { + // given + User user = User.create("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER); + String oldPassword = user.getPassword(); + + // when + user.changePassword("NewPass123!", PASSWORD_ENCODER); + + // then + assertThat(user.getPassword()).isNotEqualTo(oldPassword); + } + + @Test + void 현재_비밀번호와_동일하면_예외() { + // given + User user = User.create("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER); + + // when & then + assertThatThrownBy(() -> user.changePassword("Test1234!", PASSWORD_ENCODER)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호와 동일"); + } + } +} +``` + +--- + +## 2. 통합 테스트 + +### 목적 + +- Service → Repository → DB 전체 흐름 검증 +- 트랜잭션, 중복 체크 등 검증 +- 리팩터링 내성 확보 + +### 테스트 대상 + +| 구분 | 예시 | +|------|------| +| 핵심 유스케이스 | 회원가입, 인증, 비밀번호 변경 | +| 중복 체크 | 로그인ID 중복, 이메일 중복 | +| 예외 시나리오 | 존재하지 않는 회원, 인증 실패 | + +### 기본 구조 + +```java +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 회원가입 { + + @Test + void 유효한_정보로_회원가입하면_회원이_생성된다() { + // given + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 1, 15); + String email = "test@example.com"; + + // when + User result = userService.signUp(loginId, rawPassword, name, birthDate, email); + + // then + assertThat(result.getLoginId()).isEqualTo(loginId); + assertThat(result.getName()).isEqualTo(name); + } + + @Test + void 이미_존재하는_로그인ID로_가입하면_예외() { + // given + String loginId = "testuser"; + userService.signUp(loginId, "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com"); + + // when & then + assertThatThrownBy(() -> userService.signUp(loginId, "Test5678!", "김철수", + LocalDate.of(1995, 5, 20), "other@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.CONFLICT)); + } + } + + @Nested + class 인증 { + + @Test + void 유효한_인증정보로_인증하면_회원을_반환한다() { + // given + String loginId = "testuser"; + String rawPassword = "Test1234!"; + userService.signUp(loginId, rawPassword, "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com"); + + // when + User user = userService.authenticate(loginId, rawPassword); + + // then + assertThat(user.getLoginId()).isEqualTo(loginId); + } + + @Test + void 비밀번호가_일치하지_않으면_예외() { + // given + String loginId = "testuser"; + userService.signUp(loginId, "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com"); + + // when & then + assertThatThrownBy(() -> userService.authenticate(loginId, "WrongPass1!")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.UNAUTHORIZED)); + } + } +} +``` + +--- + +## 3. E2E 테스트 + +### 목적 + +- HTTP 요청/응답 전체 파이프라인 검증 +- API 스펙 검증 (상태 코드, 응답 형식) +- 실제 사용자 시나리오 검증 + +### 테스트 대상 + +| 구분 | 예시 | +|------|------| +| 성공 시나리오 | 회원가입 성공, 조회 성공 | +| HTTP 상태 코드 | 200, 400, 401, 404, 409 | +| 응답 데이터 검증 | 마스킹된 이름, 필드 확인 | + +### 기본 구조 + +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserApiE2ETest { + + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + private static final String MY_INFO_ENDPOINT = "/api/v1/users/me"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 회원가입 { + + @Test + void 유효한_정보로_회원가입하면_회원정보가_반환된다() { + // arrange + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + + // act + ResponseEntity> response = postSignUp(request); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*") + ); + } + + @Test + void 이미_존재하는_로그인ID로_가입하면_409_응답() { + // arrange + signUp("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.SignUpRequest duplicateRequest = new UserV1Dto.SignUpRequest( + "testuser", "Test5678!", "김철수", + LocalDate.of(1995, 5, 20), "other@example.com" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(duplicateRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 유효하지_않은_입력이면_400_응답() { + // arrange - 개별 검증 규칙은 단위 테스트에서 검증 + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "test-user!", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + class 내_정보_조회 { + + @Test + void 유효한_인증정보로_조회하면_정보가_반환된다() { + // arrange + signUp("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com"); + + // act + ResponseEntity> response = + getMyInfo("testuser", "Test1234!"); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data().loginId()).isEqualTo("testuser"); + } + + @Test + void 인증헤더가_누락되면_401_응답() { + // arrange & act + ResponseEntity> response = testRestTemplate.exchange( + MY_INFO_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // --- 헬퍼 메서드 --- + + private void signUp(String loginId, String password, String name, + LocalDate birthDate, String email) { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + loginId, password, name, birthDate, email); + postSignUp(request); + } + + private ResponseEntity> postSignUp( + UserV1Dto.SignUpRequest request) { + return testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getMyInfo( + String loginId, String password) { + return testRestTemplate.exchange( + MY_INFO_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } +} +``` + +--- + +## 체크리스트 + +### 공통 +- [ ] `@DisplayNameGeneration(ReplaceUnderscores.class)` 적용 +- [ ] `@Nested` 클래스로 그룹화 +- [ ] 한글 메서드명 사용 +- [ ] 3A 패턴: Arrange - Act - Assert (또는 given - when - then) + +### 단위 테스트 +- [ ] Mock 사용 안 함 +- [ ] 외부 의존성은 Fake 구현체 +- [ ] 도메인 불변식 검증 + +### 통합 테스트 +- [ ] `@SpringBootTest` 적용 +- [ ] `DatabaseCleanUp` 사용 +- [ ] Service 메서드 호출 + +### E2E 테스트 +- [ ] `RANDOM_PORT` 설정 +- [ ] `TestRestTemplate` 사용 +- [ ] HTTP 상태 코드 검증 + +--- + +## 트러블슈팅 + +### 1. `@Nested` 없이 테스트 작성 + +**문제:** 일관성과 확장성 저하 +```java +// ❌ 플랫한 테스트 +class UserTest { + @Test + void 유효한_값이면_회원이_생성된다() { ... } + @Test + void 로그인ID가_null이면_예외() { ... } +} + +// ✅ @Nested로 그룹화 +class UserTest { + @Nested + class 생성 { + @Test + void 유효한_값이면_회원이_생성된다() { ... } + @Test + void 로그인ID가_null이면_예외() { ... } + } +} +``` + +### 2. `@DisplayName` 사용 + +**문제:** `ReplaceUnderscores` 설정과 중복 +```java +// ❌ @DisplayName 사용 +@Test +@DisplayName("유효한 값이면 회원이 생성된다") +void createUser() { ... } + +// ✅ 한글 메서드명 + ReplaceUnderscores +@Test +void 유효한_값이면_회원이_생성된다() { ... } +``` + +### 3. 단위 테스트에서 @SpringBootTest 사용 + +**문제:** 불필요한 컨텍스트 로딩, 느린 실행 +```java +// ❌ 단위 테스트에 Spring 컨텍스트 +@SpringBootTest +class UserTest { ... } + +// ✅ 순수 Java 테스트 +class UserTest { + @Nested + class 생성 { ... } +} +``` + +### 4. DB 관련 테스트에서 Mock Repository 사용 + +**문제:** 실제 동작을 검증하지 못함 +```java +// ❌ Repository Mock +@Mock +private UserRepository userRepository; + +when(userRepository.existsByLoginId("test")).thenReturn(false); + +// ✅ 실제 DB 사용 (통합 테스트) +@SpringBootTest +class UserServiceIntegrationTest { + @Autowired + private UserService userService; +} +``` + +### 5. 테스트 간 데이터 오염 + +**문제:** 이전 테스트 데이터가 영향 +```java +// ❌ 데이터 정리 안 함 +class UserServiceIntegrationTest { ... } + +// ✅ @AfterEach로 정리 +@AfterEach +void tearDown() { + databaseCleanUp.truncateAllTables(); +} +``` + +### 6. 영어 메서드명과 한글 혼용 + +**문제:** 일관성 저하 +```java +// ❌ 혼용 +@Test +void confirm_호출시_상태가_CONFIRMED로_변경된다() { ... } + +// ✅ 전체 한글 +@Test +void 확정하면_상태가_확정됨으로_변경된다() { ... } +``` + +### 7. 외부 라이브러리를 단위 테스트에서 직접 사용 + +**문제:** 의존성 격리 안 됨 +```java +// ❌ 실제 PasswordEncoder 사용 +@Test +void 비밀번호가_암호화된다() { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + User user = User.create(..., encoder); +} + +// ✅ Fake 구현체 사용 +private static final PasswordEncoder ENCODER = new FakePasswordEncoder(); + +@Test +void 비밀번호가_암호화된다() { + User user = User.create(..., ENCODER); +} +``` diff --git a/.claude/skills/test-patterns/examples/domain-service.md b/.claude/skills/test-patterns/examples/domain-service.md new file mode 100644 index 00000000..8a714b8f --- /dev/null +++ b/.claude/skills/test-patterns/examples/domain-service.md @@ -0,0 +1,441 @@ +# Domain Service 테스트 예제 + +## 원칙 +- Mock 금지, 실제 객체만 사용 +- 여러 도메인 객체를 조합하는 비즈니스 로직 테스트 +- 순수 Java 로직 (Infrastructure 의존 없음) + +--- + +## Domain Service란? + +Entity나 VO에 넣기 어색한 도메인 로직을 담는 순수 객체 + +```java +// 할인 계산은 Order가 하기엔 책임이 큼 +// → Domain Service로 분리 +public class DiscountCalculator { + + public Money calculate(Order order, List coupons, MemberGrade grade) { + var couponDiscount = calculateCouponDiscount(order, coupons); + var gradeDiscount = calculateGradeDiscount(order, grade); + + return couponDiscount.add(gradeDiscount); + } +} +``` + +--- + +## 기본 테스트 구조 + +```java +class DiscountCalculatorTest { + + private DiscountCalculator calculator; + + @BeforeEach + void setUp() { + calculator = new DiscountCalculator(); + } + + @Nested + class 할인_금액_계산 { + + @Test + void 쿠폰할인_적용() { + // given + var order = OrderFixture.withTotalPrice(100_000); + var coupons = List.of(CouponFixture.percentOff(10)); // 10% 할인 + var grade = MemberGrade.NORMAL; + + // when + var discount = calculator.calculate(order, coupons, grade); + + // then + assertThat(discount).isEqualTo(Money.of(10_000)); + } + + @Test + void 등급할인_적용() { + // given + var order = OrderFixture.withTotalPrice(100_000); + var coupons = List.of(); + var grade = MemberGrade.VIP; // VIP 5% 할인 + + // when + var discount = calculator.calculate(order, coupons, grade); + + // then + assertThat(discount).isEqualTo(Money.of(5_000)); + } + + @Test + void 쿠폰과_등급할인_합산() { + // given + var order = OrderFixture.withTotalPrice(100_000); + var coupons = List.of(CouponFixture.percentOff(10)); // 10% + var grade = MemberGrade.VIP; // 5% + + // when + var discount = calculator.calculate(order, coupons, grade); + + // then + assertThat(discount).isEqualTo(Money.of(15_000)); // 10% + 5% + } + + @Test + void 최대할인금액_초과_불가() { + // given + var order = OrderFixture.withTotalPrice(100_000); + var coupons = List.of( + CouponFixture.percentOff(50), // 50% + CouponFixture.percentOff(30) // 30% + ); + var grade = MemberGrade.VIP; // 5% + + // when + var discount = calculator.calculate(order, coupons, grade); + + // then - 최대 50% 제한 + assertThat(discount).isEqualTo(Money.of(50_000)); + } + } +} +``` + +--- + +## 복잡한 비즈니스 규칙 테스트 + +```java +public class OrderEligibilityChecker { + + public EligibilityResult check(Member member, Order order) { + if (member.isBlocked()) { + return EligibilityResult.blocked("차단된 회원"); + } + + if (member.hasUnpaidOrder()) { + return EligibilityResult.rejected("미결제 주문 존재"); + } + + if (order.containsAgeRestrictedItem() && member.isMinor()) { + return EligibilityResult.rejected("미성년자 구매 불가 상품"); + } + + if (order.getTotalPrice().isGreaterThan(member.getDailyLimit())) { + return EligibilityResult.rejected("일일 한도 초과"); + } + + return EligibilityResult.eligible(); + } +} + +class OrderEligibilityCheckerTest { + + private OrderEligibilityChecker checker; + + @BeforeEach + void setUp() { + checker = new OrderEligibilityChecker(); + } + + @Nested + class 주문_자격_검사 { + + @Test + void 정상_회원_정상_주문() { + // given + var member = MemberFixture.normal(); + var order = OrderFixture.normal(); + + // when + var result = checker.check(member, order); + + // then + assertThat(result.isEligible()).isTrue(); + } + + @Test + void 차단된_회원() { + // given + var member = MemberFixture.blocked(); + var order = OrderFixture.normal(); + + // when + var result = checker.check(member, order); + + // then + assertThat(result.isEligible()).isFalse(); + assertThat(result.getReason()).contains("차단"); + } + + @Test + void 미결제_주문_존재() { + // given + var member = MemberFixture.withUnpaidOrder(); + var order = OrderFixture.normal(); + + // when + var result = checker.check(member, order); + + // then + assertThat(result.isEligible()).isFalse(); + assertThat(result.getReason()).contains("미결제"); + } + + @Test + void 미성년자_연령제한_상품() { + // given + var member = MemberFixture.minor(); + var order = OrderFixture.withAlcohol(); + + // when + var result = checker.check(member, order); + + // then + assertThat(result.isEligible()).isFalse(); + assertThat(result.getReason()).contains("미성년자"); + } + + @Test + void 일일_한도_초과() { + // given + var member = MemberFixture.withDailyLimit(100_000); + var order = OrderFixture.withTotalPrice(150_000); + + // when + var result = checker.check(member, order); + + // then + assertThat(result.isEligible()).isFalse(); + assertThat(result.getReason()).contains("한도 초과"); + } + + @Test + void 검사_우선순위_차단이_먼저() { + // given - 차단 + 한도초과 동시 만족 + var member = MemberFixture.blocked().withDailyLimit(100_000); + var order = OrderFixture.withTotalPrice(150_000); + + // when + var result = checker.check(member, order); + + // then - 차단이 먼저 검사됨 + assertThat(result.getReason()).contains("차단"); + } + } +} +``` + +--- + +## 정책 분기 테스트 + +```java +public class ShippingFeePolicy { + + private static final Money FREE_SHIPPING_THRESHOLD = Money.of(50_000); + private static final Money BASE_FEE = Money.of(3_000); + private static final Money ISLAND_EXTRA_FEE = Money.of(3_000); + + public Money calculate(Order order, Address address) { + if (order.getTotalPrice().isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD)) { + return calculateFreeShipping(address); + } + return calculatePaidShipping(address); + } + + private Money calculateFreeShipping(Address address) { + // 무료배송이어도 도서산간은 추가비용 + if (address.isIsland()) { + return ISLAND_EXTRA_FEE; + } + return Money.ZERO; + } + + private Money calculatePaidShipping(Address address) { + if (address.isIsland()) { + return BASE_FEE.add(ISLAND_EXTRA_FEE); + } + return BASE_FEE; + } +} + +class ShippingFeePolicyTest { + + private ShippingFeePolicy policy; + + @BeforeEach + void setUp() { + policy = new ShippingFeePolicy(); + } + + @Nested + class 배송비_계산 { + + @Nested + class 무료배송_기준_미만 { + + @Test + void 일반지역_기본배송비() { + var order = OrderFixture.withTotalPrice(30_000); + var address = AddressFixture.normal(); + + var fee = policy.calculate(order, address); + + assertThat(fee).isEqualTo(Money.of(3_000)); + } + + @Test + void 도서산간_추가배송비() { + var order = OrderFixture.withTotalPrice(30_000); + var address = AddressFixture.island(); + + var fee = policy.calculate(order, address); + + assertThat(fee).isEqualTo(Money.of(6_000)); // 3000 + 3000 + } + } + + @Nested + class 무료배송_기준_이상 { + + @Test + void 일반지역_무료() { + var order = OrderFixture.withTotalPrice(50_000); + var address = AddressFixture.normal(); + + var fee = policy.calculate(order, address); + + assertThat(fee).isEqualTo(Money.ZERO); + } + + @Test + void 도서산간_추가비용만() { + var order = OrderFixture.withTotalPrice(50_000); + var address = AddressFixture.island(); + + var fee = policy.calculate(order, address); + + assertThat(fee).isEqualTo(Money.of(3_000)); // 도서산간 추가비만 + } + } + + @Nested + class 경계값 { + + @Test + void _49999원_유료배송() { + var order = OrderFixture.withTotalPrice(49_999); + var address = AddressFixture.normal(); + + var fee = policy.calculate(order, address); + + assertThat(fee).isEqualTo(Money.of(3_000)); + } + + @Test + void _50000원_무료배송() { + var order = OrderFixture.withTotalPrice(50_000); + var address = AddressFixture.normal(); + + var fee = policy.calculate(order, address); + + assertThat(fee).isEqualTo(Money.ZERO); + } + } + } +} +``` + +--- + +## 시간 의존 로직 테스트 + +```java +public class PromotionValidator { + + private final Clock clock; + + public PromotionValidator(Clock clock) { + this.clock = clock; + } + + public boolean isActive(Promotion promotion) { + var now = LocalDateTime.now(clock); + return promotion.isActiveAt(now); + } +} + +class PromotionValidatorTest { + + @Test + void 프로모션_기간_내() { + // given + var fixedClock = Clock.fixed( + LocalDateTime.of(2024, 6, 15, 12, 0).toInstant(ZoneOffset.UTC), + ZoneOffset.UTC + ); + var validator = new PromotionValidator(fixedClock); + + var promotion = PromotionFixture.activeBetween( + LocalDateTime.of(2024, 6, 1, 0, 0), + LocalDateTime.of(2024, 6, 30, 23, 59) + ); + + // when & then + assertThat(validator.isActive(promotion)).isTrue(); + } + + @Test + void 프로모션_시작_전() { + // given + var fixedClock = Clock.fixed( + LocalDateTime.of(2024, 5, 31, 23, 59).toInstant(ZoneOffset.UTC), + ZoneOffset.UTC + ); + var validator = new PromotionValidator(fixedClock); + + var promotion = PromotionFixture.activeBetween( + LocalDateTime.of(2024, 6, 1, 0, 0), + LocalDateTime.of(2024, 6, 30, 23, 59) + ); + + // when & then + assertThat(validator.isActive(promotion)).isFalse(); + } +} +``` + +--- + +## 안티패턴 + +```java +// ❌ Repository 의존 - Domain Service가 아님 +public class BadDomainService { + private final OrderRepository orderRepository; // Infrastructure 의존! + + public void process(Long orderId) { + var order = orderRepository.findById(orderId); // 금지! + } +} + +// ❌ Mock 사용 +@Test +void bad_mock_사용() { + var order = mock(Order.class); + when(order.getTotalPrice()).thenReturn(Money.of(50_000)); // 금지! +} + +// ❌ 외부 서비스 호출 +public class BadDomainService { + private final PaymentClient paymentClient; // 외부 API 의존! + + public void process(Order order) { + paymentClient.pay(order); // 금지! Application Service에서 해야 함 + } +} +``` \ No newline at end of file diff --git a/.claude/skills/test-patterns/examples/entity.md b/.claude/skills/test-patterns/examples/entity.md new file mode 100644 index 00000000..272403d1 --- /dev/null +++ b/.claude/skills/test-patterns/examples/entity.md @@ -0,0 +1,287 @@ +# Entity 테스트 예제 + +## 원칙 +- Mock 금지, 실제 객체만 사용 +- 비즈니스 정책/상태 전이 검증에 집중 +- 생성 규칙, 불변식 위반 검증 + +--- + +## 기본 구조 + +```java +class OrderTest { + + @Nested + class 생성 { + + @Test + void 성공() { + // given + var member = MemberFixture.adult(); + var orderItems = OrderItemsFixture.withTotalPrice(50_000); + + // when + var order = Order.create(member, orderItems); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(order.getMemberId()).isEqualTo(member.getId()); + } + + @Test + void 최소주문금액_미만이면_예외() { + // given + var member = MemberFixture.adult(); + var orderItems = OrderItemsFixture.withTotalPrice(9_000); // 최소 10,000원 + + // when & then + assertThatThrownBy(() -> Order.create(member, orderItems)) + .isInstanceOf(OrderMinimumAmountException.class) + .hasMessageContaining("최소 주문 금액"); + } + + @Test + void 미성년자는_주류_주문_불가() { + // given + var minor = MemberFixture.minor(); + var orderItems = OrderItemsFixture.withAlcohol(); + + // when & then + assertThatThrownBy(() -> Order.create(minor, orderItems)) + .isInstanceOf(AgeRestrictionException.class); + } + } + + @Nested + class 취소 { + + @Test + void 성공() { + // given + var order = OrderFixture.created(); + + // when + order.cancel("고객 변심"); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(order.getCancelReason()).isEqualTo("고객 변심"); + } + + @Test + void 이미_배송중이면_취소_불가() { + // given + var order = OrderFixture.shipping(); + + // when & then + assertThatThrownBy(() -> order.cancel("고객 변심")) + .isInstanceOf(OrderCannotCancelException.class) + .hasMessageContaining("배송 중"); + } + + @Test + void 이미_취소된_주문은_재취소_불가() { + // given + var order = OrderFixture.cancelled(); + + // when & then + assertThatThrownBy(() -> order.cancel("중복 취소")) + .isInstanceOf(OrderAlreadyCancelledException.class); + } + } + + @Nested + class 취소_가능_여부_판단 { + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"CREATED", "PAID"}) + void 취소_가능한_상태(OrderStatus status) { + // given + var order = OrderFixture.withStatus(status); + + // when & then + assertThat(order.canCancel()).isTrue(); + } + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"SHIPPING", "DELIVERED", "CANCELLED"}) + void 취소_불가능한_상태(OrderStatus status) { + // given + var order = OrderFixture.withStatus(status); + + // when & then + assertThat(order.canCancel()).isFalse(); + } + } +} +``` + +--- + +## 경계값 테스트 + +```java +@Nested +class 배송비_계산 { + + @Test + void _5만원_미만은_배송비_3000원() { + // given + var order = OrderFixture.withTotalPrice(49_999); + + // when + var fee = order.calculateDeliveryFee(); + + // then + assertThat(fee).isEqualTo(Money.of(3_000)); + } + + @Test + void _5만원_이상은_무료() { + // given + var order = OrderFixture.withTotalPrice(50_000); + + // when + var fee = order.calculateDeliveryFee(); + + // then + assertThat(fee).isEqualTo(Money.ZERO); + } + + @Test + void 경계값_49999원() { + var order = OrderFixture.withTotalPrice(49_999); + assertThat(order.calculateDeliveryFee()).isEqualTo(Money.of(3_000)); + } + + @Test + void 경계값_50000원() { + var order = OrderFixture.withTotalPrice(50_000); + assertThat(order.calculateDeliveryFee()).isEqualTo(Money.ZERO); + } +} +``` + +--- + +## 상태 전이 테스트 + +```java +@Nested +class 상태_전이 { + + @Test + void 주문생성_결제완료_배송시작_배송완료_정상흐름() { + // given + var order = OrderFixture.created(); + + // when & then - 상태 전이 순서대로 검증 + order.pay(PaymentInfo.of("card", 50_000)); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); + + order.startShipping(TrackingNumber.of("1234567890")); + assertThat(order.getStatus()).isEqualTo(OrderStatus.SHIPPING); + + order.completeDelivery(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.DELIVERED); + } + + @Test + void 결제전_배송시작_불가() { + // given + var order = OrderFixture.created(); // CREATED 상태 + + // when & then + assertThatThrownBy(() -> order.startShipping(TrackingNumber.of("123"))) + .isInstanceOf(InvalidOrderStateException.class) + .hasMessageContaining("결제 완료 후"); + } +} +``` + +--- + +## Fixture 예시 + +```java +public class OrderFixture { + + public static Order created() { + return Order.create( + MemberFixture.adult(), + OrderItemsFixture.default() + ); + } + + public static Order paid() { + var order = created(); + order.pay(PaymentInfo.of("card", 50_000)); + return order; + } + + public static Order shipping() { + var order = paid(); + order.startShipping(TrackingNumber.of("1234567890")); + return order; + } + + public static Order cancelled() { + var order = created(); + order.cancel("테스트 취소"); + return order; + } + + public static Order withStatus(OrderStatus status) { + return switch (status) { + case CREATED -> created(); + case PAID -> paid(); + case SHIPPING -> shipping(); + case DELIVERED -> { + var order = shipping(); + order.completeDelivery(); + yield order; + } + case CANCELLED -> cancelled(); + }; + } + + public static Order withTotalPrice(int price) { + return Order.create( + MemberFixture.adult(), + OrderItemsFixture.withTotalPrice(price) + ); + } +} +``` + +--- + +## 안티패턴 + +```java +// ❌ Mock 사용 +@Test +void bad_mock_사용() { + var member = mock(Member.class); + when(member.isAdult()).thenReturn(true); // 금지! + + var order = Order.create(member, items); +} + +// ❌ 구현 세부사항 검증 +@Test +void bad_내부_필드_직접_검증() { + var order = Order.create(member, items); + + // 내부 구현에 의존 + assertThat(order).extracting("internalState").isEqualTo("INIT"); +} + +// ❌ 단순 getter 테스트 +@Test +void bad_getter_테스트() { + var order = Order.create(member, items); + assertThat(order.getMemberId()).isNotNull(); // 의미 없음 +} +``` \ No newline at end of file diff --git a/.claude/skills/test-patterns/examples/enum.md b/.claude/skills/test-patterns/examples/enum.md new file mode 100644 index 00000000..a55d7bee --- /dev/null +++ b/.claude/skills/test-patterns/examples/enum.md @@ -0,0 +1,319 @@ +# Enum 테스트 예제 + +## 원칙 +- `values()` 존재 여부 테스트 ❌ (언어 스펙) +- 비즈니스 로직이 있는 메서드만 테스트 ✅ +- 상태 전이, 그룹화, 매핑 로직 테스트 ✅ + +--- + +## 테스트 대상 vs 제외 대상 + +```java +public enum OrderStatus { + CREATED("주문생성"), + PAID("결제완료"), + SHIPPING("배송중"), + DELIVERED("배송완료"), + CANCELLED("취소됨"); + + private final String description; + + // ❌ 테스트 제외: 단순 getter + public String getDescription() { + return description; + } + + // ✅ 테스트 대상: 비즈니스 로직 + public boolean canCancel() { + return this == CREATED || this == PAID; + } + + // ✅ 테스트 대상: 상태 전이 검증 + public boolean canTransitionTo(OrderStatus next) { + return switch (this) { + case CREATED -> next == PAID || next == CANCELLED; + case PAID -> next == SHIPPING || next == CANCELLED; + case SHIPPING -> next == DELIVERED; + case DELIVERED, CANCELLED -> false; + }; + } + + // ✅ 테스트 대상: 그룹화 + public boolean isTerminal() { + return this == DELIVERED || this == CANCELLED; + } + + // ✅ 테스트 대상: 외부 코드 매핑 + public static OrderStatus fromExternalCode(String code) { + return switch (code) { + case "ORD_NEW" -> CREATED; + case "ORD_PAID" -> PAID; + case "ORD_SHIP" -> SHIPPING; + case "ORD_DONE" -> DELIVERED; + case "ORD_CANCEL" -> CANCELLED; + default -> throw new IllegalArgumentException("Unknown code: " + code); + }; + } +} +``` + +--- + +## 상태 전이 테스트 + +```java +class OrderStatusTest { + + @Nested + class 상태_전이_가능_여부 { + + @Test + void CREATED에서_PAID_가능() { + assertThat(OrderStatus.CREATED.canTransitionTo(OrderStatus.PAID)).isTrue(); + } + + @Test + void CREATED에서_CANCELLED_가능() { + assertThat(OrderStatus.CREATED.canTransitionTo(OrderStatus.CANCELLED)).isTrue(); + } + + @Test + void CREATED에서_SHIPPING_불가() { + assertThat(OrderStatus.CREATED.canTransitionTo(OrderStatus.SHIPPING)).isFalse(); + } + + @Test + void DELIVERED에서_어디로도_전이_불가() { + for (OrderStatus next : OrderStatus.values()) { + assertThat(OrderStatus.DELIVERED.canTransitionTo(next)).isFalse(); + } + } + + // 또는 ParameterizedTest 활용 + @ParameterizedTest + @CsvSource({ + "CREATED, PAID, true", + "CREATED, CANCELLED, true", + "CREATED, SHIPPING, false", + "PAID, SHIPPING, true", + "PAID, CANCELLED, true", + "SHIPPING, DELIVERED, true", + "SHIPPING, CANCELLED, false", + "DELIVERED, CREATED, false", + "CANCELLED, CREATED, false" + }) + void 상태전이_매트릭스( + OrderStatus from, + OrderStatus to, + boolean expected) { + assertThat(from.canTransitionTo(to)).isEqualTo(expected); + } + } +} +``` + +--- + +## 비즈니스 정책 테스트 + +```java +class OrderStatusTest { + + @Nested + class 취소_가능_여부 { + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"CREATED", "PAID"}) + void 취소_가능한_상태(OrderStatus status) { + assertThat(status.canCancel()).isTrue(); + } + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"SHIPPING", "DELIVERED", "CANCELLED"}) + void 취소_불가능한_상태(OrderStatus status) { + assertThat(status.canCancel()).isFalse(); + } + } + + @Nested + class 종료_상태_여부 { + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"DELIVERED", "CANCELLED"}) + void 종료_상태(OrderStatus status) { + assertThat(status.isTerminal()).isTrue(); + } + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"CREATED", "PAID", "SHIPPING"}) + void 진행중_상태(OrderStatus status) { + assertThat(status.isTerminal()).isFalse(); + } + } +} +``` + +--- + +## 매핑/변환 테스트 + +```java +class OrderStatusTest { + + @Nested + class 외부_코드_변환 { + + @ParameterizedTest + @CsvSource({ + "ORD_NEW, CREATED", + "ORD_PAID, PAID", + "ORD_SHIP, SHIPPING", + "ORD_DONE, DELIVERED", + "ORD_CANCEL, CANCELLED" + }) + void 정상_변환(String code, OrderStatus expected) { + assertThat(OrderStatus.fromExternalCode(code)).isEqualTo(expected); + } + + @Test + void 알수없는_코드는_예외() { + assertThatThrownBy(() -> OrderStatus.fromExternalCode("UNKNOWN")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown code"); + } + + @Test + void null은_예외() { + assertThatThrownBy(() -> OrderStatus.fromExternalCode(null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} +``` + +--- + +## 그룹화 테스트 + +```java +public enum PaymentMethod { + CREDIT_CARD("신용카드"), + DEBIT_CARD("체크카드"), + BANK_TRANSFER("계좌이체"), + VIRTUAL_ACCOUNT("가상계좌"), + KAKAO_PAY("카카오페이"), + NAVER_PAY("네이버페이"), + TOSS_PAY("토스페이"); + + private final String description; + + // ✅ 테스트 대상: 그룹화 로직 + public boolean isCard() { + return this == CREDIT_CARD || this == DEBIT_CARD; + } + + public boolean isSimplePay() { + return this == KAKAO_PAY || this == NAVER_PAY || this == TOSS_PAY; + } + + public boolean requiresCallback() { + return this == VIRTUAL_ACCOUNT || isSimplePay(); + } +} + +class PaymentMethodTest { + + @Nested + class 카드_결제_여부 { + + @ParameterizedTest + @EnumSource(value = PaymentMethod.class, names = {"CREDIT_CARD", "DEBIT_CARD"}) + void 카드_결제(PaymentMethod method) { + assertThat(method.isCard()).isTrue(); + } + + @ParameterizedTest + @EnumSource(value = PaymentMethod.class, names = {"CREDIT_CARD", "DEBIT_CARD"}, mode = EnumSource.Mode.EXCLUDE) + void 카드_외_결제(PaymentMethod method) { + assertThat(method.isCard()).isFalse(); + } + } + + @Nested + class 콜백_필요_여부 { + + @ParameterizedTest + @EnumSource(value = PaymentMethod.class, names = {"VIRTUAL_ACCOUNT", "KAKAO_PAY", "NAVER_PAY", "TOSS_PAY"}) + void 콜백_필요(PaymentMethod method) { + assertThat(method.requiresCallback()).isTrue(); + } + } +} +``` + +--- + +## 새 값 추가 시 테스트 누락 방지 + +```java +class OrderStatusTest { + + /** + * 새로운 상태가 추가되면 이 테스트가 실패하여 + * canCancel() 로직 검토를 강제함 + */ + @Test + void 모든_상태_커버_확인() { + var testedStatuses = Set.of( + OrderStatus.CREATED, // canCancel: true + OrderStatus.PAID, // canCancel: true + OrderStatus.SHIPPING, // canCancel: false + OrderStatus.DELIVERED, // canCancel: false + OrderStatus.CANCELLED // canCancel: false + ); + + var allStatuses = Set.of(OrderStatus.values()); + + assertThat(testedStatuses) + .as("새 상태 추가 시 canCancel 테스트 업데이트 필요") + .containsExactlyInAnyOrderElementsOf(allStatuses); + } +} +``` + +--- + +## 안티패턴 + +```java +// ❌ values() 존재 테스트 - 언어 스펙 +@Test +void bad_values_테스트() { + assertThat(OrderStatus.values()).hasSize(5); +} + +// ❌ valueOf 테스트 - 언어 스펙 +@Test +void bad_valueOf_테스트() { + assertThat(OrderStatus.valueOf("CREATED")).isEqualTo(OrderStatus.CREATED); +} + +// ❌ name() 테스트 - 언어 스펙 +@Test +void bad_name_테스트() { + assertThat(OrderStatus.CREATED.name()).isEqualTo("CREATED"); +} + +// ❌ 단순 getter 테스트 +@Test +void bad_description_테스트() { + assertThat(OrderStatus.CREATED.getDescription()).isEqualTo("주문생성"); +} + +// ❌ ordinal 테스트 - 순서 의존성 만듦 +@Test +void bad_ordinal_테스트() { + assertThat(OrderStatus.CREATED.ordinal()).isEqualTo(0); +} +``` \ No newline at end of file diff --git a/.claude/skills/test-patterns/examples/fake.md b/.claude/skills/test-patterns/examples/fake.md new file mode 100644 index 00000000..cbbfcd9f --- /dev/null +++ b/.claude/skills/test-patterns/examples/fake.md @@ -0,0 +1,493 @@ +# Fake 구현체 예제 + +## 원칙 +- 외부 시스템(PG, 배송, 알림 등)만 Fake로 대체 +- `@Profile("test")`로 테스트 환경에서만 활성화 +- 실제 동작과 유사하게 구현 (단순 Mock과 다름) +- 테스트에서 시나리오 제어 가능하게 설계 + +--- + +## Mock vs Fake vs Dummy + +| 종류 | 특징 | 용도 | +|------|------|------| +| **Mock** | 호출 검증용, 동작 없음 | 지양 | +| **Fake** | 실제와 유사한 동작 구현 | 외부 API | +| **Dummy** | 아무 동작 안 함 | 부수효과 무시 | + +--- + +## 결제 클라이언트 Fake + +### 인터페이스 + +```java +public interface PaymentClient { + PaymentResult pay(PaymentRequest request); + PaymentResult cancel(PaymentCancelRequest request); + PaymentStatus getStatus(String transactionId); +} +``` + +### 실제 구현 + +```java +@Profile("!test") +@Component +@RequiredArgsConstructor +public class TossPaymentClient implements PaymentClient { + + private final RestTemplate restTemplate; + private final PaymentProperties properties; + + @Override + public PaymentResult pay(PaymentRequest request) { + var response = restTemplate.postForEntity( + properties.getPayUrl(), + request, + TossPaymentResponse.class + ); + return PaymentResult.from(response.getBody()); + } + + // ... +} +``` + +### Fake 구현 + +```java +@Profile("test") +@Component +public class FakePaymentClient implements PaymentClient { + + private PaymentScenario scenario = PaymentScenario.SUCCESS; + private String failReason = null; + private final Map transactions = new ConcurrentHashMap<>(); + + // 테스트에서 시나리오 제어 + public void willSucceed() { + this.scenario = PaymentScenario.SUCCESS; + this.failReason = null; + } + + public void willFail(String reason) { + this.scenario = PaymentScenario.FAIL; + this.failReason = reason; + } + + public void willTimeout() { + this.scenario = PaymentScenario.TIMEOUT; + } + + public void reset() { + this.scenario = PaymentScenario.SUCCESS; + this.failReason = null; + this.transactions.clear(); + } + + @Override + public PaymentResult pay(PaymentRequest request) { + return switch (scenario) { + case SUCCESS -> { + var txId = UUID.randomUUID().toString(); + transactions.put(txId, PaymentStatus.PAID); + yield PaymentResult.success(txId, request.amount()); + } + case FAIL -> PaymentResult.fail(failReason); + case TIMEOUT -> throw new PaymentTimeoutException("결제 응답 시간 초과"); + }; + } + + @Override + public PaymentResult cancel(PaymentCancelRequest request) { + var status = transactions.get(request.transactionId()); + if (status == null) { + return PaymentResult.fail("존재하지 않는 거래"); + } + if (status == PaymentStatus.CANCELLED) { + return PaymentResult.fail("이미 취소된 거래"); + } + + transactions.put(request.transactionId(), PaymentStatus.CANCELLED); + return PaymentResult.success(request.transactionId(), request.amount()); + } + + @Override + public PaymentStatus getStatus(String transactionId) { + return transactions.getOrDefault(transactionId, PaymentStatus.NOT_FOUND); + } + + private enum PaymentScenario { + SUCCESS, FAIL, TIMEOUT + } +} +``` + +### 테스트에서 사용 + +```java +@SpringBootTest +class PaymentServiceIT { + + @Autowired + private PaymentService paymentService; + + @Autowired + private FakePaymentClient fakePaymentClient; + + @BeforeEach + void setUp() { + fakePaymentClient.reset(); + } + + @Test + void 결제_성공() { + fakePaymentClient.willSucceed(); + + var result = paymentService.pay(command); + + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void 결제_실패_잔액부족() { + fakePaymentClient.willFail("잔액 부족"); + + assertThatThrownBy(() -> paymentService.pay(command)) + .isInstanceOf(PaymentFailedException.class) + .hasMessageContaining("잔액 부족"); + } + + @Test + void 결제_타임아웃_재시도_안내() { + fakePaymentClient.willTimeout(); + + assertThatThrownBy(() -> paymentService.pay(command)) + .isInstanceOf(PaymentTimeoutException.class); + } +} +``` + +--- + +## 알림 발송 Dummy + +부수효과만 무시하면 되는 경우 Dummy 사용 + +```java +public interface NotificationSender { + void send(Notification notification); + void sendBulk(List notifications); +} + +@Profile("!test") +@Component +@RequiredArgsConstructor +public class SlackNotificationSender implements NotificationSender { + + private final SlackClient slackClient; + + @Override + public void send(Notification notification) { + slackClient.postMessage(notification.toSlackMessage()); + } + + @Override + public void sendBulk(List notifications) { + notifications.forEach(this::send); + } +} + +@Profile("test") +@Component +public class DummyNotificationSender implements NotificationSender { + + // 발송 기록 (필요시 검증용) + private final List sentNotifications = new ArrayList<>(); + + @Override + public void send(Notification notification) { + sentNotifications.add(notification); // 기록만 하고 실제 발송 안 함 + } + + @Override + public void sendBulk(List notifications) { + sentNotifications.addAll(notifications); + } + + // 테스트 검증용 + public List getSentNotifications() { + return List.copyOf(sentNotifications); + } + + public void reset() { + sentNotifications.clear(); + } +} +``` + +### 테스트에서 발송 여부 검증 + +```java +@Test +void 주문완료시_알림_발송() { + // given + dummyNotificationSender.reset(); + var order = createOrder(); + + // when + orderService.complete(order.getId()); + + // then + var notifications = dummyNotificationSender.getSentNotifications(); + assertThat(notifications).hasSize(1); + assertThat(notifications.get(0).getType()).isEqualTo(NotificationType.ORDER_COMPLETED); +} +``` + +--- + +## 외부 API Fake (배송 조회) + +```java +public interface DeliveryTracker { + DeliveryStatus track(String trackingNumber); +} + +@Profile("!test") +@Component +public class CJDeliveryTracker implements DeliveryTracker { + + @Override + public DeliveryStatus track(String trackingNumber) { + // 실제 CJ 대한통운 API 호출 + } +} + +@Profile("test") +@Component +public class FakeDeliveryTracker implements DeliveryTracker { + + private final Map trackingData = new ConcurrentHashMap<>(); + + // 테스트 데이터 설정 + public void setStatus(String trackingNumber, DeliveryStatus status) { + trackingData.put(trackingNumber, status); + } + + public void setDelivered(String trackingNumber) { + trackingData.put(trackingNumber, DeliveryStatus.DELIVERED); + } + + public void setInTransit(String trackingNumber) { + trackingData.put(trackingNumber, DeliveryStatus.IN_TRANSIT); + } + + @Override + public DeliveryStatus track(String trackingNumber) { + return trackingData.getOrDefault(trackingNumber, DeliveryStatus.NOT_FOUND); + } + + public void reset() { + trackingData.clear(); + } +} +``` + +--- + +## 시간 의존성 처리 + +```java +// Clock을 Bean으로 등록 +@Configuration +public class ClockConfig { + + @Bean + @Profile("!test") + public Clock clock() { + return Clock.systemDefaultZone(); + } + + @Bean + @Profile("test") + public Clock testClock() { + return Clock.fixed( + LocalDateTime.of(2024, 1, 15, 12, 0).toInstant(ZoneOffset.UTC), + ZoneOffset.UTC + ); + } +} + +// 또는 테스트에서 조작 가능한 Clock +@Profile("test") +@Component +public class TestClock extends Clock { + + private Instant instant = Instant.now(); + private ZoneId zone = ZoneId.systemDefault(); + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + this.zone = zone; + return this; + } + + @Override + public Instant instant() { + return instant; + } + + // 테스트에서 시간 조작 + public void setTime(LocalDateTime dateTime) { + this.instant = dateTime.toInstant(ZoneOffset.UTC); + } + + public void advanceTime(Duration duration) { + this.instant = instant.plus(duration); + } +} +``` + +### 테스트에서 사용 + +```java +@Test +void 프로모션_만료_확인() { + // given + var promotion = createPromotion(endDate: "2024-01-31"); + + // 1월 15일 - 진행 중 + testClock.setTime(LocalDateTime.of(2024, 1, 15, 12, 0)); + assertThat(promotionService.isActive(promotion.getId())).isTrue(); + + // 2월 1일 - 만료 + testClock.setTime(LocalDateTime.of(2024, 2, 1, 0, 0)); + assertThat(promotionService.isActive(promotion.getId())).isFalse(); +} +``` + +--- + +## 파일 저장소 Fake + +```java +public interface FileStorage { + String upload(String path, byte[] content); + byte[] download(String path); + void delete(String path); +} + +@Profile("!test") +@Component +public class S3FileStorage implements FileStorage { + // 실제 S3 연동 +} + +@Profile("test") +@Component +public class InMemoryFileStorage implements FileStorage { + + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public String upload(String path, byte[] content) { + storage.put(path, content); + return "http://fake-storage/" + path; + } + + @Override + public byte[] download(String path) { + var content = storage.get(path); + if (content == null) { + throw new FileNotFoundException(path); + } + return content; + } + + @Override + public void delete(String path) { + storage.remove(path); + } + + public void reset() { + storage.clear(); + } + + public boolean exists(String path) { + return storage.containsKey(path); + } +} +``` + +--- + +## Fake 작성 가이드라인 + +### ✅ 좋은 Fake +- 실제 동작과 유사하게 동작 +- 테스트에서 시나리오 제어 가능 +- 상태 초기화 메서드 제공 (`reset()`) +- 검증용 메서드 제공 (필요시) + +### ❌ 나쁜 Fake +```java +// 너무 단순 - 항상 성공만 반환 +@Profile("test") +@Component +public class BadFakePaymentClient implements PaymentClient { + @Override + public PaymentResult pay(PaymentRequest request) { + return PaymentResult.success("tx-123", request.amount()); // 항상 성공 + } +} + +// 테스트 불가능 - 시나리오 제어 불가 +// 실패 케이스, 타임아웃 케이스 테스트 불가 +``` + +--- + +## 디렉토리 구조 + +``` +src/ +├── main/java/ +│ └── com/example/ +│ └── infrastructure/ +│ └── external/ +│ ├── payment/ +│ │ ├── PaymentClient.java # 인터페이스 +│ │ └── TossPaymentClient.java # 실제 구현 +│ └── notification/ +│ ├── NotificationSender.java +│ └── SlackNotificationSender.java +└── test/java/ + └── com/example/ + ├── fake/ + │ ├── FakePaymentClient.java # Fake 구현 + │ ├── DummyNotificationSender.java # Dummy 구현 + │ └── InMemoryFileStorage.java + └── support/ + └── IntegrationTestSupport.java +``` + +또는 `src/main/java`에 `@Profile("test")`로 함께 배치 + +``` +src/main/java/ +└── com/example/ + └── infrastructure/ + └── external/ + └── payment/ + ├── PaymentClient.java + ├── TossPaymentClient.java # @Profile("!test") + └── FakePaymentClient.java # @Profile("test") +``` \ No newline at end of file diff --git a/.claude/skills/test-patterns/examples/service-it.md b/.claude/skills/test-patterns/examples/service-it.md new file mode 100644 index 00000000..0866e029 --- /dev/null +++ b/.claude/skills/test-patterns/examples/service-it.md @@ -0,0 +1,474 @@ +# Application Service 통합 테스트 예제 + +## 원칙 +- 실제 DB 사용 (TestContainers) +- Service → Domain → Repository → DB 전체 흐름 검증 +- 외부 연동만 Fake 구현체 사용 +- 내부 구현 모르는 블랙박스 테스트 +- 리팩터링 내성 확보 + +--- + +## 기본 설정 + +### 테스트 Base 클래스 + +```java +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +@Transactional +public abstract class IntegrationTestSupport { + + @Container + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl); + registry.add("spring.datasource.username", mysql::getUsername); + registry.add("spring.datasource.password", mysql::getPassword); + } +} +``` + +### application-test.yml + +```yaml +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true +``` + +--- + +## 기본 통합 테스트 구조 + +```java +class OrderCommandServiceIT extends IntegrationTestSupport { + + @Autowired + private OrderCommandService orderService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private OrderRepository orderRepository; + + @Nested + class 주문_생성 { + + @Test + void 성공() { + // given + var member = memberRepository.save(MemberFixture.adult()); + var product = productRepository.save(ProductFixture.normal()); + + var command = CreateOrderCommand.of( + member.getId(), + List.of(OrderItemCommand.of(product.getId(), 2)) + ); + + // when + var result = orderService.createOrder(command); + + // then + assertThat(result.orderId()).isNotNull(); + assertThat(result.status()).isEqualTo(OrderStatus.CREATED); + + // DB 확인 + var savedOrder = orderRepository.findById(result.orderId()).orElseThrow(); + assertThat(savedOrder.getMemberId()).isEqualTo(member.getId()); + assertThat(savedOrder.getOrderItems()).hasSize(1); + } + + @Test + void 재고부족_실패() { + // given + var member = memberRepository.save(MemberFixture.adult()); + var product = productRepository.save(ProductFixture.withStock(5)); + + var command = CreateOrderCommand.of( + member.getId(), + List.of(OrderItemCommand.of(product.getId(), 10)) // 재고보다 많이 주문 + ); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(command)) + .isInstanceOf(InsufficientStockException.class); + } + + @Test + void 존재하지않는_회원() { + // given + var product = productRepository.save(ProductFixture.normal()); + + var command = CreateOrderCommand.of( + 999L, // 존재하지 않는 회원 ID + List.of(OrderItemCommand.of(product.getId(), 1)) + ); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(command)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + void 최소주문금액_미달() { + // given + var member = memberRepository.save(MemberFixture.adult()); + var product = productRepository.save(ProductFixture.withPrice(1_000)); + + var command = CreateOrderCommand.of( + member.getId(), + List.of(OrderItemCommand.of(product.getId(), 1)) // 1,000원 (최소 10,000원) + ); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(command)) + .isInstanceOf(OrderMinimumAmountException.class); + } + } + + @Nested + class 주문_취소 { + + @Test + void 성공() { + // given + var order = createAndSaveOrder(OrderStatus.CREATED); + var command = CancelOrderCommand.of(order.getId(), "고객 변심"); + + // when + var result = orderService.cancelOrder(command); + + // then + assertThat(result.status()).isEqualTo(OrderStatus.CANCELLED); + + // 재고 복구 확인 + var product = productRepository.findById(order.getFirstProductId()).orElseThrow(); + assertThat(product.getStock()).isEqualTo(10); // 원래 재고로 복구 + } + + @Test + void 배송중_실패() { + // given + var order = createAndSaveOrder(OrderStatus.SHIPPING); + var command = CancelOrderCommand.of(order.getId(), "고객 변심"); + + // when & then + assertThatThrownBy(() -> orderService.cancelOrder(command)) + .isInstanceOf(OrderCannotCancelException.class) + .hasMessageContaining("배송 중"); + } + } + + private Order createAndSaveOrder(OrderStatus status) { + var member = memberRepository.save(MemberFixture.adult()); + var product = productRepository.save(ProductFixture.withStock(10)); + + var order = Order.create(member, OrderItems.of( + List.of(OrderItem.of(product, 2)) + )); + + if (status == OrderStatus.SHIPPING) { + order.pay(PaymentInfo.card(order.getTotalPrice())); + order.startShipping(TrackingNumber.of("1234567890")); + } + + return orderRepository.save(order); + } +} +``` + +--- + +## 트랜잭션 경계 테스트 + +```java +@Nested +class 트랜잭션_롤백 { + + @Test + void 결제실패시_주문상태_롤백() { + // given + var order = createAndSaveOrder(OrderStatus.CREATED); + + // FakePaymentClient가 실패 응답 반환하도록 설정 + fakePaymentClient.willFail(); + + var command = PayOrderCommand.of(order.getId(), "card"); + + // when & then + assertThatThrownBy(() -> orderService.payOrder(command)) + .isInstanceOf(PaymentFailedException.class); + + // 주문 상태는 CREATED 유지 + var savedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CREATED); + } + + @Test + void 재고차감중_예외시_전체_롤백() { + // given + var member = memberRepository.save(MemberFixture.adult()); + var product1 = productRepository.save(ProductFixture.withStock(10)); + var product2 = productRepository.save(ProductFixture.withStock(1)); // 재고 부족할 상품 + + var command = CreateOrderCommand.of( + member.getId(), + List.of( + OrderItemCommand.of(product1.getId(), 5), // 성공 + OrderItemCommand.of(product2.getId(), 10) // 실패 (재고 1개뿐) + ) + ); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(command)) + .isInstanceOf(InsufficientStockException.class); + + // product1 재고도 롤백되어야 함 + var savedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(10); // 원래대로 + } +} +``` + +--- + +## 동시성 테스트 + +```java +@Nested +class 동시성 { + + @Test + void 동시_주문시_재고_정합성() throws InterruptedException { + // given + var member1 = memberRepository.save(MemberFixture.adult()); + var member2 = memberRepository.save(MemberFixture.adult()); + var product = productRepository.save(ProductFixture.withStock(10)); + + var executor = Executors.newFixedThreadPool(2); + var latch = new CountDownLatch(2); + var successCount = new AtomicInteger(0); + var failCount = new AtomicInteger(0); + + // when - 동시에 10개씩 주문 (총 20개, 재고는 10개) + executor.submit(() -> { + try { + orderService.createOrder(CreateOrderCommand.of( + member1.getId(), + List.of(OrderItemCommand.of(product.getId(), 10)) + )); + successCount.incrementAndGet(); + } catch (InsufficientStockException e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + executor.submit(() -> { + try { + orderService.createOrder(CreateOrderCommand.of( + member2.getId(), + List.of(OrderItemCommand.of(product.getId(), 10)) + )); + successCount.incrementAndGet(); + } catch (InsufficientStockException e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + latch.await(10, TimeUnit.SECONDS); + + // then - 하나만 성공해야 함 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(1); + + // 재고는 0이어야 함 + var savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(0); + } +} +``` + +--- + +## 외부 연동 테스트 (Fake 활용) + +```java +class PaymentServiceIT extends IntegrationTestSupport { + + @Autowired + private OrderCommandService orderService; + + @Autowired + private FakePaymentClient fakePaymentClient; // Fake 구현체 주입 + + @BeforeEach + void setUp() { + fakePaymentClient.reset(); // 상태 초기화 + } + + @Test + void 결제성공시_주문상태_PAID로_변경() { + // given + var order = createAndSaveOrder(OrderStatus.CREATED); + fakePaymentClient.willSucceed(); + + var command = PayOrderCommand.of(order.getId(), "card"); + + // when + var result = orderService.payOrder(command); + + // then + assertThat(result.status()).isEqualTo(OrderStatus.PAID); + } + + @Test + void 결제실패시_예외_및_상태유지() { + // given + var order = createAndSaveOrder(OrderStatus.CREATED); + fakePaymentClient.willFail("잔액 부족"); + + var command = PayOrderCommand.of(order.getId(), "card"); + + // when & then + assertThatThrownBy(() -> orderService.payOrder(command)) + .isInstanceOf(PaymentFailedException.class) + .hasMessageContaining("잔액 부족"); + + var savedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CREATED); + } + + @Test + void 결제타임아웃시_재시도_안내() { + // given + var order = createAndSaveOrder(OrderStatus.CREATED); + fakePaymentClient.willTimeout(); + + var command = PayOrderCommand.of(order.getId(), "card"); + + // when & then + assertThatThrownBy(() -> orderService.payOrder(command)) + .isInstanceOf(PaymentTimeoutException.class); + } +} +``` + +--- + +## 데이터 셋업 패턴 + +### @Sql 활용 + +```java +@Nested +class 대량_데이터_조회 { + + @Test + @Sql("/test-data/orders-100.sql") + void 주문목록_페이징_조회() { + // given + var query = OrderListQuery.of(memberId, 0, 10); + + // when + var result = orderQueryService.getOrders(query); + + // then + assertThat(result.getContent()).hasSize(10); + assertThat(result.getTotalElements()).isEqualTo(100); + } +} +``` + +### TestDataBuilder 활용 + +```java +public class TestDataBuilder { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private OrderRepository orderRepository; + + public Member createMember() { + return memberRepository.save(MemberFixture.adult()); + } + + public Product createProduct(int stock, int price) { + return productRepository.save( + ProductFixture.of(stock, price) + ); + } + + public Order createOrder(Member member, Product product, int quantity) { + var order = Order.create(member, OrderItems.of( + List.of(OrderItem.of(product, quantity)) + )); + return orderRepository.save(order); + } +} +``` + +--- + +## 안티패턴 + +```java +// ❌ 도메인 로직 세부 검증 - Track 1에서 해야 함 +@Test +void bad_도메인_로직_검증() { + var result = orderService.createOrder(command); + + // 할인 계산 로직 검증 - Domain 테스트에서 해야 함 + assertThat(result.getDiscountAmount()).isEqualTo(Money.of(5_000)); +} + +// ❌ Mock 사용 +@Test +void bad_mock_사용() { + when(memberRepository.findById(any())).thenReturn(Optional.of(member)); // 금지! +} + +// ❌ 내부 구현 의존 +@Test +void bad_내부구현_의존() { + orderService.createOrder(command); + + // 내부에서 어떤 메서드가 호출되는지 검증 - 금지! + verify(stockService).decrease(any(), any()); +} + +// ❌ Repository 기본 CRUD 테스트 +@Test +void bad_crud_테스트() { + var member = memberRepository.save(MemberFixture.adult()); + var found = memberRepository.findById(member.getId()); + + assertThat(found).isPresent(); // Spring Data JPA 스펙 테스트 +} +``` \ No newline at end of file diff --git a/.claude/skills/test-patterns/examples/vo.md b/.claude/skills/test-patterns/examples/vo.md new file mode 100644 index 00000000..993ce812 --- /dev/null +++ b/.claude/skills/test-patterns/examples/vo.md @@ -0,0 +1,453 @@ +# VO (Value Object) 테스트 예제 + +## 원칙 +- 생성 시 검증 로직 테스트 +- 불변성 보장 확인 +- 동등성(equals/hashCode) 검증은 record 사용 시 생략 +- 경계값, null, empty, blank 검증 + +--- + +## Money (금액) + +```java +class MoneyTest { + + @Nested + class 생성 { + + @Test + void 정상_생성() { + var money = Money.of(10_000); + + assertThat(money.getValue()).isEqualTo(10_000); + } + + @Test + void _0원_허용() { + var money = Money.of(0); + + assertThat(money.getValue()).isEqualTo(0); + } + + @Test + void 음수는_예외() { + assertThatThrownBy(() -> Money.of(-1)) + .isInstanceOf(InvalidMoneyException.class) + .hasMessageContaining("음수"); + } + + @Test + void null은_예외() { + assertThatThrownBy(() -> Money.of(null)) + .isInstanceOf(InvalidMoneyException.class); + } + } + + @Nested + class 덧셈 { + + @Test + void 정상_덧셈() { + var money1 = Money.of(10_000); + var money2 = Money.of(5_000); + + var result = money1.add(money2); + + assertThat(result.getValue()).isEqualTo(15_000); + } + + @Test + void 불변성_보장() { + var original = Money.of(10_000); + var other = Money.of(5_000); + + original.add(other); + + // 원본은 변경되지 않음 + assertThat(original.getValue()).isEqualTo(10_000); + } + + @Test + void null이면_예외() { + var money = Money.of(10_000); + + assertThatThrownBy(() -> money.add(null)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class 뺄셈 { + + @Test + void 정상_뺄셈() { + var money1 = Money.of(10_000); + var money2 = Money.of(3_000); + + var result = money1.subtract(money2); + + assertThat(result.getValue()).isEqualTo(7_000); + } + + @Test + void 결과가_음수면_예외() { + var money1 = Money.of(3_000); + var money2 = Money.of(5_000); + + assertThatThrownBy(() -> money1.subtract(money2)) + .isInstanceOf(InsufficientMoneyException.class); + } + } + + @Nested + class 곱셈 { + + @Test + void 정상_곱셈() { + var money = Money.of(10_000); + + var result = money.multiply(3); + + assertThat(result.getValue()).isEqualTo(30_000); + } + + @Test + void _0배는_0원() { + var money = Money.of(10_000); + + var result = money.multiply(0); + + assertThat(result).isEqualTo(Money.ZERO); + } + + @Test + void 음수배는_예외() { + var money = Money.of(10_000); + + assertThatThrownBy(() -> money.multiply(-1)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class 비교 { + + @Test + void 크면_true() { + var money1 = Money.of(10_000); + var money2 = Money.of(5_000); + + assertThat(money1.isGreaterThan(money2)).isTrue(); + } + + @Test + void 같거나_크면_true() { + var money1 = Money.of(10_000); + var money2 = Money.of(10_000); + + assertThat(money1.isGreaterThanOrEqual(money2)).isTrue(); + } + + @Test + void 작으면_true() { + var money1 = Money.of(5_000); + var money2 = Money.of(10_000); + + assertThat(money1.isLessThan(money2)).isTrue(); + } + } +} +``` + +--- + +## Email + +```java +class EmailTest { + + @Nested + class 생성 { + + @Test + void 정상_이메일() { + var email = Email.of("test@example.com"); + + assertThat(email.getValue()).isEqualTo("test@example.com"); + } + + @ParameterizedTest + @ValueSource(strings = { + "user@domain.com", + "user.name@domain.com", + "user+tag@domain.co.kr", + "user@sub.domain.com" + }) + void 유효한_형식(String value) { + assertThatCode(() -> Email.of(value)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid", + "@domain.com", + "user@", + "user@.com", + "user@domain", + "" + }) + void 유효하지_않은_형식(String value) { + assertThatThrownBy(() -> Email.of(value)) + .isInstanceOf(InvalidEmailException.class); + } + + @Test + void null이면_예외() { + assertThatThrownBy(() -> Email.of(null)) + .isInstanceOf(InvalidEmailException.class); + } + + @Test + void 공백이면_예외() { + assertThatThrownBy(() -> Email.of(" ")) + .isInstanceOf(InvalidEmailException.class); + } + } + + @Nested + class 도메인_추출 { + + @Test + void 도메인_반환() { + var email = Email.of("user@example.com"); + + assertThat(email.getDomain()).isEqualTo("example.com"); + } + } + + @Nested + class 로컬_파트_추출 { + + @Test + void 로컬파트_반환() { + var email = Email.of("user@example.com"); + + assertThat(email.getLocalPart()).isEqualTo("user"); + } + } +} +``` + +--- + +## PhoneNumber + +```java +class PhoneNumberTest { + + @Nested + class 생성 { + + @ParameterizedTest + @ValueSource(strings = { + "010-1234-5678", + "01012345678", + "010 1234 5678" + }) + void 다양한_형식_허용(String value) { + var phone = PhoneNumber.of(value); + + // 정규화된 형식으로 저장 + assertThat(phone.getValue()).isEqualTo("01012345678"); + } + + @ParameterizedTest + @ValueSource(strings = { + "02-123-4567", // 지역번호 + "1588-1234", // 대표번호 + "12345", // 너무 짧음 + "abc-defg-hijk" // 문자 + }) + void 휴대폰_형식_아니면_예외(String value) { + assertThatThrownBy(() -> PhoneNumber.of(value)) + .isInstanceOf(InvalidPhoneNumberException.class); + } + + @Test + void null이면_예외() { + assertThatThrownBy(() -> PhoneNumber.of(null)) + .isInstanceOf(InvalidPhoneNumberException.class); + } + } + + @Nested + class 포맷팅 { + + @Test + void 하이픈_포함_형식() { + var phone = PhoneNumber.of("01012345678"); + + assertThat(phone.formatWithHyphen()).isEqualTo("010-1234-5678"); + } + + @Test + void 마스킹_형식() { + var phone = PhoneNumber.of("01012345678"); + + assertThat(phone.formatMasked()).isEqualTo("010-****-5678"); + } + } +} +``` + +--- + +## DateRange (기간) + +```java +class DateRangeTest { + + @Nested + class 생성 { + + @Test + void 정상_생성() { + var start = LocalDate.of(2024, 1, 1); + var end = LocalDate.of(2024, 12, 31); + + var range = DateRange.of(start, end); + + assertThat(range.getStart()).isEqualTo(start); + assertThat(range.getEnd()).isEqualTo(end); + } + + @Test + void 시작일과_종료일_같아도_허용() { + var date = LocalDate.of(2024, 1, 1); + + var range = DateRange.of(date, date); + + assertThat(range.getDays()).isEqualTo(1); + } + + @Test + void 시작일이_종료일보다_늦으면_예외() { + var start = LocalDate.of(2024, 12, 31); + var end = LocalDate.of(2024, 1, 1); + + assertThatThrownBy(() -> DateRange.of(start, end)) + .isInstanceOf(InvalidDateRangeException.class) + .hasMessageContaining("시작일이 종료일보다 늦을 수 없습니다"); + } + } + + @Nested + class 포함_여부 { + + @Test + void 범위_내_날짜() { + var range = DateRange.of( + LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 12, 31) + ); + + assertThat(range.contains(LocalDate.of(2024, 6, 15))).isTrue(); + } + + @Test + void 시작일_포함() { + var range = DateRange.of( + LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 12, 31) + ); + + assertThat(range.contains(LocalDate.of(2024, 1, 1))).isTrue(); + } + + @Test + void 종료일_포함() { + var range = DateRange.of( + LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 12, 31) + ); + + assertThat(range.contains(LocalDate.of(2024, 12, 31))).isTrue(); + } + + @Test + void 범위_밖_날짜() { + var range = DateRange.of( + LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 12, 31) + ); + + assertThat(range.contains(LocalDate.of(2025, 1, 1))).isFalse(); + } + } + + @Nested + class 겹침_여부 { + + @Test + void 부분_겹침() { + var range1 = DateRange.of( + LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 6, 30) + ); + var range2 = DateRange.of( + LocalDate.of(2024, 4, 1), + LocalDate.of(2024, 12, 31) + ); + + assertThat(range1.overlaps(range2)).isTrue(); + } + + @Test + void 겹치지_않음() { + var range1 = DateRange.of( + LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 3, 31) + ); + var range2 = DateRange.of( + LocalDate.of(2024, 4, 1), + LocalDate.of(2024, 12, 31) + ); + + assertThat(range1.overlaps(range2)).isFalse(); + } + } +} +``` + +--- + +## 안티패턴 + +```java +// ❌ equals/hashCode 테스트 (record 사용 시 불필요) +@Test +void bad_record_equals_테스트() { + var money1 = Money.of(10_000); + var money2 = Money.of(10_000); + + assertThat(money1).isEqualTo(money2); // record는 자동 생성, 테스트 불필요 +} + +// ❌ toString 테스트 +@Test +void bad_toString_테스트() { + var email = Email.of("test@example.com"); + + assertThat(email.toString()).contains("test@example.com"); // 의미 없음 +} + +// ❌ getter만 테스트 +@Test +void bad_getter만_테스트() { + var money = Money.of(10_000); + + assertThat(money.getValue()).isEqualTo(10_000); // 생성 검증과 중복 +} +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a979af6..dbe54668 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c07731ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,168 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with this codebase. + +## Project Overview + +**loopers-java-spring-template** is an enterprise-grade multi-module Spring Boot project template implementing Domain-Driven Design (DDD) with Hexagonal Architecture. + +## Technology Stack + +- **Java**: 21 +- **Spring Boot**: 3.4.4 +- **Build System**: Gradle (Kotlin DSL) +- **Database**: MySQL 8.0 with Spring Data JPA + QueryDSL +- **Cache**: Redis (Master-Replica) +- **Messaging**: Apache Kafka +- **Testing**: JUnit 5, TestContainers, Mockito + +## Project Structure + +``` +├── apps/ # Executable Spring Boot Applications +│ ├── commerce-api # REST API (port 8080) +│ ├── commerce-batch # Batch processing +│ └── commerce-streamer # Kafka stream processing +├── modules/ # Infrastructure Modules +│ ├── jpa # JPA/Hibernate configuration +│ ├── redis # Redis configuration +│ └── kafka # Kafka configuration +├── supports/ # Cross-cutting Support Modules +│ ├── jackson # JSON serialization +│ ├── logging # Logging configuration +│ └── monitoring # Actuator/Prometheus metrics +``` + +## Build Commands + +```bash +# Build all modules +./gradlew build + +# Build without tests +./gradlew build -x test + +# Run specific application +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun +./gradlew :apps:commerce-streamer:bootRun + +# Run tests +./gradlew test + +# Run tests with coverage +./gradlew test jacocoTestReport + +# Clean build +./gradlew clean build +``` + +## Architecture + +### Layered Package Structure (DDD) + +``` +com.loopers/ +├── interfaces/ # REST controllers, DTOs, API specs +├── application/ # Facades, use cases, application DTOs +├── domain/ # Entities, domain services, repository interfaces +├── infrastructure/ # Repository implementations, external adapters +└── support/ # Cross-cutting concerns (errors, utils) +``` + +### Key Patterns + +1. **Repository Pattern**: Interface in `domain/`, implementation in `infrastructure/` +2. **Facade Pattern**: Application layer orchestrates domain services +3. **Records for DTOs**: Immutable data transfer objects +4. **Soft Delete**: Entities use `deletedAt` field instead of hard delete + +### Base Entity + +All JPA entities extend `BaseEntity` providing: +- Auto-generated ID +- `createdAt`, `updatedAt` timestamps +- `deletedAt` for soft delete +- `delete()` and `restore()` methods + +## Coding Conventions + +### Creating New Features + +1. **Controller** (`interfaces/api/`): Define `*ApiSpec` interface + `*Controller` implementation +2. **Facade** (`application/`): Create facade for orchestration + `*Info` records +3. **Service** (`domain/`): Domain service with business logic +4. **Repository** (`domain/`): Define repository interface +5. **Repository Impl** (`infrastructure/`): Implement repository with JPA + +### API Response Format + +```java +ApiResponse.success(data) // Successful response +ApiResponse.fail(errorType) // Error response +``` + +### Exception Handling + +```java +throw new CoreException(ErrorType.NOT_FOUND); +throw new CoreException(ErrorType.BAD_REQUEST, "Custom message"); +``` + +### Error Types + +- `INTERNAL_ERROR` (500) +- `BAD_REQUEST` (400) +- `NOT_FOUND` (404) +- `CONFLICT` (409) + +## Configuration + +### Profiles + +- `local`: Local development with Docker services +- `dev`, `qa`, `prd`: Environment-specific configurations + +### Ports + +- Application: 8080 +- Actuator/Monitoring: 8081 + +### Docker Services (Local) + +```bash +# Start local infrastructure +docker-compose -f docker/docker-compose.yml up -d +``` + +- MySQL: localhost:3306 +- Redis Master: localhost:6379 +- Redis Replica: localhost:6380 +- Kafka: localhost:19092 + +## Testing + +### Test Categories + +- **Unit Tests**: Domain model validation +- **Integration Tests**: Service + Repository with TestContainers +- **E2E Tests**: Full API endpoint testing + +### Test Utilities + +- `DatabaseCleanUp`: Cleans tables after each test +- TestContainers: Provides real MySQL, Redis, Kafka instances +- Test fixtures in `supports/` modules + +### Running Tests + +```bash +# All tests +./gradlew test + +# Specific module tests +./gradlew :apps:commerce-api:test + +# With coverage report +./gradlew test jacocoTestReport +``` diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..cb54a44b 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + 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"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..ae557e02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,29 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class UserFacade { + + private final UserService userService; + + public UserInfo signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + User user = userService.signUp(loginId, rawPassword, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(Long id) { + User user = userService.getById(id); + return UserInfo.from(user); + } + + public void changePassword(Long id, String newRawPassword) { + userService.changePassword(id, newRawPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..aa69f576 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +public record UserInfo( + String loginId, + String maskedName, + LocalDate birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getLoginId(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 00000000..cd6373cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,60 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.regex.Pattern; + +@Embeddable +public class Password { + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_CHARS_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?~`]+$"); + + @Column(name = "password", nullable = false) + private String value; + + protected Password() {} + + private Password(String value) { + this.value = value; + } + + public static Password of(String rawPassword, PasswordEncoder encoder) { + validate(rawPassword); + return new Password(encoder.encode(rawPassword)); + } + + public Password change(String newRawPassword, PasswordEncoder encoder) { + validate(newRawPassword); + if (encoder.matches(newRawPassword, value)) { + throw new CoreException(ErrorType.BAD_REQUEST,"현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다"); + } + return new Password(encoder.encode(newRawPassword)); + } + + public boolean matches(String rawPassword, PasswordEncoder encoder) { + validateNotBlank(rawPassword); + return encoder.matches(rawPassword, value); + } + + private static void validate(String rawPassword) { + validateNotBlank(rawPassword); + + if (rawPassword.length() < MIN_LENGTH || rawPassword.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST,"비밀번호는 8~16자여야 합니다"); + } + + if (!ALLOWED_CHARS_PATTERN.matcher(rawPassword).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST,"비밀번호는 영문/숫자/특수문자만 가능합니다"); + } + } + + private static void validateNotBlank(String rawPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST,"비밀번호는 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java new file mode 100644 index 00000000..3393d4fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -0,0 +1,8 @@ +package com.loopers.domain.user; + +public interface PasswordEncoder { + + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..f4e8f52c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,122 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +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; +import lombok.AccessLevel; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +@Entity +@Table(name = "users") +@Getter +public class User extends BaseEntity { + + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + @Column(nullable = false, unique = true) + private String loginId; + + @Embedded + @Getter(AccessLevel.NONE) + private Password password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; // yyyy-MM-dd + + @Column(nullable = false) + private String email; + + protected User() {} + + private User(String loginId, Password password, String name, LocalDate birthDate, String email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static User create(String loginId, String rawPassword, String name, LocalDate birthDate, String email, PasswordEncoder encoder) { + validateLoginId(loginId); + validateBirthDate(birthDate); + validateName(name); + validateEmail(email); + + validatePasswordNotContainsBirthDate(rawPassword, birthDate); + + Password password = Password.of(rawPassword, encoder); + return new User(loginId, password, name, birthDate, email); + } + + public void changePassword(String newRawPassword, PasswordEncoder encoder) { + validatePasswordNotContainsBirthDate(newRawPassword, birthDate); + this.password = password.change(newRawPassword, encoder); + } + + public boolean matchesPassword(String rawPassword, PasswordEncoder encoder) { + return password.matches(rawPassword, encoder); + } + + public String getMaskedName() { + return name.substring(0, name.length() - 1) + "*"; + } + + private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST,"로그인 ID는 필수입니다"); + } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST,"로그인 ID는 영문/숫자만 가능합니다"); + } + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST,"이름은 필수입니다"); + } + } + + private static void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST,"생년월일은 필수입니다"); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST,"생년월일은 미래일 수 없습니다"); + } + } + + private static void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST,"이메일은 필수입니다"); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST,"올바른 이메일 형식이 아닙니다"); + } + } + + private static void validatePasswordNotContainsBirthDate(String rawPassword, LocalDate birthDate) { + if (rawPassword == null || birthDate == null) { + return; + } + + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); + String MMdd = birthDate.format(DateTimeFormatter.ofPattern("MMdd")); + + if (rawPassword.contains(yyyyMMdd) || rawPassword.contains(yyMMdd) || rawPassword.contains(MMdd)) { + throw new CoreException(ErrorType.BAD_REQUEST,"비밀번호에 생년월일을 포함할 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..226c05f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + User save(User user); + + Optional findById(Long id); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..4ec5ef30 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,48 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public User signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); + } + + User user = User.create(loginId, rawPassword, name, birthDate, email, passwordEncoder); + return userRepository.save(user); + } + + @Transactional + public void changePassword(Long id, String newRawPassword) { + User user = getById(id); + user.changePassword(newRawPassword, passwordEncoder); + } + + public User getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); + } + + public User authenticate(String loginId, String rawPassword) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다")); + if (!user.matchesPassword(rawPassword, passwordEncoder)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다"); + } + return user; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java new file mode 100644 index 00000000..af9954ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Component; + +@Component +public class BcryptPasswordEncoder implements PasswordEncoder { + + @Override + public String encode(String rawPassword) { + return BCrypt.hashpw(rawPassword, BCrypt.gensalt()); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return BCrypt.checkpw(rawPassword, encodedPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..12cdd51b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..33f98c73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..11611ad9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -107,6 +107,12 @@ public ResponseEntity> handleNotFound(NoResourceFoundException e) return failureResponse(ErrorType.NOT_FOUND, null); } + @ExceptionHandler + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + log.warn("IllegalArgumentException : {}", e.getMessage(), e); + return failureResponse(ErrorType.BAD_REQUEST, e.getMessage()); + } + @ExceptionHandler public ResponseEntity> handle(Throwable e) { log.error("Exception : {}", e.getMessage(), e); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b52..1636d1ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -15,7 +15,7 @@ public static Metadata fail(String errorCode, String errorMessage) { } } - public static ApiResponse success() { + public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } @@ -23,7 +23,7 @@ public static ApiResponse success(T data) { return new ApiResponse<>(Metadata.success(), data); } - public static ApiResponse fail(String errorCode, String errorMessage) { + public static ApiResponse fail(String errorCode, String errorMessage) { return new ApiResponse<>( Metadata.fail(errorCode, errorMessage), null diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java new file mode 100644 index 00000000..7b69d157 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.config; + +import com.loopers.support.auth.AuthUserResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthUserResolver authUserResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authUserResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java new file mode 100644 index 00000000..b6a1bfcd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User API", description = "회원 관리 API") +public interface UserApiV1Spec { + + @Operation( + summary = "회원가입", + description = "새로운 회원을 등록합니다. 이름은 마지막 글자가 마스킹되어 반환됩니다." + ) + ApiResponse signUp(UserV1Dto.SignUpRequest request); + + @Operation( + summary = "내 정보 조회", + description = "로그인한 회원의 정보를 조회합니다. 이름은 마지막 글자가 마스킹되어 반환됩니다." + ) + ApiResponse getMyInfo(@Parameter(hidden = true) AuthenticatedUser authUser); + + @Operation( + summary = "비밀번호 변경", + description = "회원의 비밀번호를 변경합니다." + ) + ApiResponse changePassword( + @Parameter(hidden = true) AuthenticatedUser authUser, + UserV1Dto.ChangePasswordRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..47fcfe50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUser; +import com.loopers.support.auth.AuthenticatedUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserV1Controller implements UserApiV1Spec { + + private final UserFacade userFacade; + + @PostMapping + @Override + public ApiResponse signUp(@Valid @RequestBody UserV1Dto.SignUpRequest request) { + UserInfo info = userFacade.signUp( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo(@AuthUser AuthenticatedUser authUser) { + UserInfo info = userFacade.getMyInfo(authUser.id()); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @AuthUser AuthenticatedUser authUser, + @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { + userFacade.changePassword(authUser.id(), request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..ebec7d38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record SignUpRequest( + @NotBlank(message = "로그인 ID는 필수입니다") + String loginId, + @NotBlank(message = "비밀번호는 필수입니다") + String password, + @NotBlank(message = "이름은 필수입니다") + String name, + @NotNull(message = "생년월일은 필수입니다") + LocalDate birthDate, + @NotBlank(message = "이메일은 필수입니다") + String email + ) {} + + public record UserResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record ChangePasswordRequest( + @NotBlank(message = "새 비밀번호는 필수입니다") + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java new file mode 100644 index 00000000..a66e38d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java new file mode 100644 index 00000000..0db44055 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java @@ -0,0 +1,46 @@ +package com.loopers.support.auth; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthUserResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserService userService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다"); + } + + User user = userService.authenticate(loginId, password); + return AuthenticatedUser.from(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticatedUser.java new file mode 100644 index 00000000..6e1ea4f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticatedUser.java @@ -0,0 +1,10 @@ +package com.loopers.support.auth; + +import com.loopers.domain.user.User; + +public record AuthenticatedUser(Long id) { + + public static AuthenticatedUser from(User user) { + return new AuthenticatedUser(user.getId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..bc9fb4c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java new file mode 100644 index 00000000..a84dcfbe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java @@ -0,0 +1,13 @@ +package com.loopers.domain.user; + +public class FakePasswordEncoder implements PasswordEncoder { + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 00000000..ed87c65f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,122 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class PasswordTest { + + private static final PasswordEncoder PASSWORD_ENCODER = new FakePasswordEncoder(); + + @Nested + class 생성 { + + @ParameterizedTest + @ValueSource(strings = { + "Abcd123!", // 최소 길이 8자 + "Abcd1234!Abcd123", // 최대 길이 16자 + "Abcd1234!@#" // 일반 케이스 + }) + void 유효한_비밀번호면_통과한다(String password) { + assertThatCode(() -> Password.of(password, PASSWORD_ENCODER)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 비밀번호가_null_또는_빈값이면_예외(String password) { + assertThatThrownBy(() -> Password.of(password, PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 필수"); + } + + @Test + void 비밀번호가_8자_미만이면_예외() { + assertThatThrownBy(() -> Password.of("Abcd123", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 8~16자여야 합니다"); + } + + @Test + void 비밀번호가_16자_초과면_예외() { + assertThatThrownBy(() -> Password.of("Abcd1234Abcd1234!", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 8~16자여야 합니다"); + } + + @Test + void 비밀번호에_허용되지_않은_문자가_포함되면_예외() { + assertThatThrownBy(() -> Password.of("가Abcd1234!", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 영문/숫자/특수문자만 가능합니다"); + } + } + + @Nested + class 변경 { + + @Test + void 유효한_새_비밀번호로_변경하면_새_객체를_반환한다() { + Password password = Password.of("Abcd1234!", PASSWORD_ENCODER); + + Password changed = password.change("Efgh5678!", PASSWORD_ENCODER); + + assertThat(changed).isNotSameAs(password); + } + + @Test + void 현재_비밀번호와_동일하면_예외() { + Password password = Password.of("Abcd1234!", PASSWORD_ENCODER); + + assertThatThrownBy(() -> password.change("Abcd1234!", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다"); + } + + @Test + void 유효하지_않은_비밀번호로_변경하면_예외() { + Password password = Password.of("Abcd1234!", PASSWORD_ENCODER); + + assertThatThrownBy(() -> password.change("short", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class); + } + } + + @Nested + class 매칭 { + + @Test + void 동일한_비밀번호면_true() { + Password password = Password.of("Abcd1234!", PASSWORD_ENCODER); + + assertThat(password.matches("Abcd1234!", PASSWORD_ENCODER)).isTrue(); + } + + @Test + void 다른_비밀번호면_false() { + Password password = Password.of("Abcd1234!", PASSWORD_ENCODER); + + assertThat(password.matches("Efgh5678!", PASSWORD_ENCODER)).isFalse(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 비밀번호가_null_또는_빈값이면_예외(String rawPassword) { + Password password = Password.of("Abcd1234!", PASSWORD_ENCODER); + + assertThatThrownBy(() -> password.matches(rawPassword, PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 필수"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..726fc7d8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 회원가입 { + + @Test + void 유효한_정보로_회원가입하면_회원이_생성된다() { + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 1, 15); + String email = "test@example.com"; + + User result = userService.signUp(loginId, rawPassword, name, birthDate, email); + + assertThat(result.getLoginId()).isEqualTo(loginId); + assertThat(result.getName()).isEqualTo(name); + assertThat(result.getBirthDate()).isEqualTo(birthDate); + assertThat(result.getEmail()).isEqualTo(email); + } + + @Test + void 이미_존재하는_로그인ID로_가입하면_예외() { + String loginId = "testuser"; + userService.signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + assertThatThrownBy(() -> userService.signUp(loginId, "Test5678!", "김철수", LocalDate.of(1995, 5, 20), "other@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) + .hasMessageContaining("이미 사용 중인 로그인 ID입니다"); + } + } + + @Nested + class 인증 { + + @Test + void 유효한_인증정보로_인증하면_회원을_반환한다() { + String loginId = "testuser"; + String rawPassword = "Test1234!"; + userService.signUp(loginId, rawPassword, "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + User user = userService.authenticate(loginId, rawPassword); + + assertThat(user.getLoginId()).isEqualTo(loginId); + } + + @Test + void 존재하지_않는_로그인ID로_인증하면_예외() { + assertThatThrownBy(() -> userService.authenticate("notexist", "Test1234!")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)) + .hasMessageContaining("아이디 또는 비밀번호가 일치하지 않습니다"); + } + + @Test + void 비밀번호가_일치하지_않으면_예외() { + String loginId = "testuser"; + userService.signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + assertThatThrownBy(() -> userService.authenticate(loginId, "WrongPass1!")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)) + .hasMessageContaining("아이디 또는 비밀번호가 일치하지 않습니다"); + } + } + + @Nested + class 비밀번호_변경 { + + @Test + void 유효한_새_비밀번호로_변경하면_성공한다() { + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String newPassword = "NewPass123!"; + User user = userService.signUp(loginId, rawPassword, "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + userService.changePassword(user.getId(), newPassword); + + assertThatCode(() -> userService.authenticate(loginId, newPassword)) + .doesNotThrowAnyException(); + } + + @Test + void 변경_후_이전_비밀번호로_인증하면_예외() { + String loginId = "testuser"; + User user = userService.signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + userService.changePassword(user.getId(), "NewPass123!"); + + assertThatThrownBy(() -> userService.authenticate(loginId, "Test1234!")) + .isInstanceOf(CoreException.class); + } + } + + @Nested + class 회원_조회 { + + @Test + void 존재하지_않는_ID로_조회하면_예외() { + assertThatThrownBy(() -> userService.getById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 00000000..2c08d093 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserTest { + + private static final PasswordEncoder PASSWORD_ENCODER = new FakePasswordEncoder(); + + @Nested + class 생성 { + + @Test + void 유효한_값이면_회원이_생성된다() { + String loginId = "testuser123"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 1, 15); + String email = "test@example.com"; + + User user = User.create(loginId, rawPassword, name, birthDate, email, PASSWORD_ENCODER); + + assertThat(user.getLoginId()).isEqualTo(loginId); + assertThat(user.matchesPassword(rawPassword, PASSWORD_ENCODER)).isTrue(); + assertThat(user.getName()).isEqualTo(name); + assertThat(user.getBirthDate()).isEqualTo(birthDate); + assertThat(user.getEmail()).isEqualTo(email); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 로그인ID가_null_또는_빈값이면_예외(String loginId) { + assertThatThrownBy(() -> User.create(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("로그인 ID"); + } + + @ParameterizedTest + @ValueSource(strings = {"test user", "test@user", "test-user", "테스트유저", "test_user"}) + void 로그인ID가_영문숫자가_아니면_예외(String loginId) { + assertThatThrownBy(() -> User.create(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("로그인 ID는 영문/숫자만 가능합니다"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 이름이_null_또는_빈값이면_예외(String name) { + assertThatThrownBy(() -> User.create("testuser", "Test1234!", name, LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이름"); + } + + @Test + void 생년월일이_null이면_예외() { + assertThatThrownBy(() -> User.create("testuser", "Test1234!", "홍길동", null, "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일"); + } + + @Test + void 생년월일이_미래면_예외() { + LocalDate futureDate = LocalDate.of(2999, 1, 1); + + assertThatThrownBy(() -> User.create("testuser", "Test1234!", "홍길동", futureDate, "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 미래일 수 없습니다"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 이메일이_null_또는_빈값이면_예외(String email) { + assertThatThrownBy(() -> User.create("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), email, PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이메일"); + } + + @ParameterizedTest + @ValueSource(strings = {"invalid", "@domain.com", "user@", "user@.com", "user@domain"}) + void 이메일이_형식에_맞지_않으면_예외(String email) { + assertThatThrownBy(() -> User.create("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), email, PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("올바른 이메일 형식이 아닙니다"); + } + + @ParameterizedTest + @ValueSource(strings = {"Abcd20000115!", "Abcd000115!!", "Abcd0115"}) + void 비밀번호에_생년월일이_포함되면_예외(String rawPassword) { + assertThatThrownBy(() -> User.create("testuser", rawPassword, "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일을 포함할 수 없습니다"); + } + } + + @Nested + class 이름_마스킹 { + + @ParameterizedTest + @CsvSource({ + "홍길동, 홍길*", + "김밥, 김*", + "이, *" + }) + void 마지막_글자를_마스킹한다(String name, String expected) { + User user = User.create("testuser", "Test1234!", name, LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER); + + assertThat(user.getMaskedName()).isEqualTo(expected); + } + } + + @Nested + class 비밀번호_변경 { + + @Test + void 유효한_새_비밀번호면_변경된다() { + User user = User.create("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER); + + assertThatCode(() -> user.changePassword("NewPass123!", PASSWORD_ENCODER)) + .doesNotThrowAnyException(); + } + + @Test + void 비밀번호에_생년월일이_포함되면_예외() { + User user = User.create("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER); + + assertThatThrownBy(() -> user.changePassword("Pass0115!!", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일을 포함할 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java index 1bb3dba6..70f25614 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.example; import com.loopers.domain.example.ExampleModel; import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java new file mode 100644 index 00000000..4226e285 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -0,0 +1,258 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserApiE2ETest { + + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + private static final String MY_INFO_ENDPOINT = "/api/v1/users/me"; + private static final String CHANGE_PASSWORD_ENDPOINT = "/api/v1/users/me/password"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 회원가입 { + + @Test + void 유효한_정보로_회원가입하면_회원정보가_반환된다() { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + + ResponseEntity> response = postSignUp(request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(LocalDate.of(2000, 1, 15)), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @Test + void 이미_존재하는_로그인ID로_가입하면_409_응답() { + // arrange + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.SignUpRequest duplicateRequest = new UserV1Dto.SignUpRequest( + "testuser", "Test5678!", "김철수", + LocalDate.of(1995, 5, 20), "other@example.com" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(duplicateRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 유효하지_않은_입력이면_400_응답() { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "test-user!", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + + ResponseEntity> response = testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + class 내_정보_조회 { + + @Test + void 유효한_인증정보로_조회하면_마스킹된_이름과_함께_정보가_반환된다() { + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + ResponseEntity> response = getMyInfo("testuser", "Test1234!"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(LocalDate.of(2000, 1, 15)), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @Test + void 존재하지_않는_로그인ID로_조회하면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + MY_INFO_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(authHeaders("notexist", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 비밀번호가_일치하지_않으면_401_응답() { + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + ResponseEntity> response = testRestTemplate.exchange( + MY_INFO_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(authHeaders("testuser", "WrongPass1!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + MY_INFO_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + class 비밀번호_변경 { + + @Test + void 유효한_새_비밀번호로_변경하면_새_비밀번호로_인증할_수_있다() { + // arrange + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("NewPass123!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // assert - 변경 성공 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // assert - 새 비밀번호로 인증 가능 + ResponseEntity> verifyResponse = getMyInfo("testuser", "NewPass123!"); + assertThat(verifyResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 현재_비밀번호와_동일한_비밀번호로_변경하면_400_응답() { + // arrange + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_실패하면_401_응답() { + // arrange + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("NewPass123!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("testuser", "WrongPass1!")), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증헤더가_누락되면_401_응답() { + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("NewPass123!"); + + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, + new HttpEntity<>(request, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // --- 헬퍼 메서드 --- + + private void signUp(String loginId, String password, String name, LocalDate birthDate, String email) { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest(loginId, password, name, birthDate, email); + postSignUp(request); + } + + private ResponseEntity> postSignUp(UserV1Dto.SignUpRequest request) { + return testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getMyInfo(String loginId, String password) { + return testRestTemplate.exchange( + MY_INFO_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } +} diff --git a/docs/prd/volume1.md b/docs/prd/volume1.md new file mode 100644 index 00000000..a4285fb1 --- /dev/null +++ b/docs/prd/volume1.md @@ -0,0 +1,273 @@ +# PLAN: 회원 서비스 + +> 작성일: 2026-02-06 +> 버전: v1.0 + +--- + +## 프로젝트 컨텍스트 + +> `.claude/rules/core/` 참조 결과 + +| 항목 | 프로젝트 표준 | +|------|--------------| +| HTTP 상태코드 | 성공: 200, 실패: 400/401/404/409 | +| 에러 응답 | `ApiResponse.fail(ErrorType.XXX)` | +| 검증 위치 | 형식→Request DTO (Bean Validation), 불변식→Domain Entity | +| 중복 체크 | Service에서 exists 쿼리로 명시적 수행 | +| 비밀번호 암호화 | BCrypt (도메인 인터페이스 PasswordEncoder) | +| 인증 헤더 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Soft Delete | deletedAt 필드 사용, hard delete 금지 | + +--- + +## Feature 1: 회원가입 + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | 신규 사용자가 서비스에 가입한다 | +| Actor | 비회원 | +| 우선순위 | P0 | +| 선행 조건 | 없음 | +| 후행 영향 | Feature 2(내 정보 조회), Feature 3(비밀번호 수정)에서 회원 정보 참조 | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | POST | +| Endpoint | /api/v1/users | +| Auth | 불필요 | + +#### Request Body +| 필드 | 타입 | 필수 | 검증 규칙 | 예시 | +|------|------|------|----------|------| +| loginId | String | Y | 영문과 숫자만 허용 (`^[a-zA-Z0-9]+$`) | "john123" | +| password | String | Y | 8~16자, 영문 대소문자/숫자/특수문자만 가능, 생년월일 포함 불가 | "Pass1234!" | +| name | String | Y | 한글 또는 영문 | "홍길동" | +| birthDate | LocalDate | Y | 과거 날짜만 허용 | "1995-03-15" | +| email | String | Y | 이메일 형식 | "john@test.com" | + +#### Response Body (200 OK) +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 (마스킹: 홍길동→홍길*, 이→*) | +| birthDate | LocalDate | 생년월일 | +| email | String | 이메일 | + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 모든 필수값 유효 | POST /api/v1/users | 200, 회원 생성, 비밀번호 BCrypt 암호화 저장 | +| AC-2 | 모든 필수값 유효 | POST /api/v1/users | 200, 이름 마스킹 처리된 응답 반환 | + +#### 실패 케이스 - 입력값 검증 (Request DTO) +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-3 | loginId 누락 (null/blank) | POST /api/v1/users | 400 | +| AC-4 | password 누락 (null/blank) | POST /api/v1/users | 400 | +| AC-5 | name 누락 (null/blank) | POST /api/v1/users | 400 | +| AC-6 | birthDate 누락 (null) | POST /api/v1/users | 400 | +| AC-7 | email 누락 (null/blank) | POST /api/v1/users | 400 | + +#### 실패 케이스 - 도메인 검증 (Entity) +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-8 | loginId에 특수문자 포함 | POST /api/v1/users | 400, "로그인 ID는 영문과 숫자만 허용합니다" | +| AC-9 | password 7자 (최소 미달) | POST /api/v1/users | 400, "비밀번호는 8~16자여야 합니다" | +| AC-10 | password 17자 (최대 초과) | POST /api/v1/users | 400, "비밀번호는 8~16자여야 합니다" | +| AC-11 | password에 허용되지 않는 문자 (한글 등) | POST /api/v1/users | 400, "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용 가능합니다" | +| AC-12 | password에 생년월일 포함 (yyyyMMdd) | POST /api/v1/users | 400, "비밀번호에 생년월일을 포함할 수 없습니다" | +| AC-13 | password에 생년월일 포함 (yyMMdd) | POST /api/v1/users | 400, "비밀번호에 생년월일을 포함할 수 없습니다" | +| AC-14 | password에 생년월일 포함 (MMdd) | POST /api/v1/users | 400, "비밀번호에 생년월일을 포함할 수 없습니다" | +| AC-15 | birthDate 미래 날짜 | POST /api/v1/users | 400, "생년월일은 과거 날짜여야 합니다" | +| AC-16 | email 형식 오류 (@ 없음) | POST /api/v1/users | 400, "올바른 이메일 형식이 아닙니다" | + +#### 실패 케이스 - 비즈니스 규칙 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-17 | loginId 중복 (이미 존재하는 ID) | POST /api/v1/users | 409, "이미 사용 중인 로그인 ID입니다" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 비밀번호는 BCrypt로 암호화 저장 | Service (PasswordEncoder.encode) | +| BR-2 | loginId는 생성 후 변경 불가 | 전체 (수정 API 미제공) | +| BR-3 | 이름은 응답 시 마지막 글자 마스킹 (홍길동→홍길*, 이→*) | UserInfo 변환 시 | +| BR-4 | 비밀번호에 생년월일 포함 불가 (yyyyMMdd, yyMMdd, MMdd 형식) | Domain (PasswordValidator) | +| BR-5 | 이메일 중복 체크 안함 (loginId만 unique) | Service | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| 생년월일 필수 여부? | 필수 (Y) | 비밀번호 검증에 생년월일 필요, 사용자 확인 | +| 비밀번호 길이? | 8~16자 | 요구사항 원문 준수 | +| 이메일 중복 체크? | 안함 | 요구사항에 loginId 중복만 명시 | +| 1글자 이름 마스킹? | 전체 마스킹 (*) | 마지막 글자 마스킹 규칙의 자연스러운 적용 | +| 응답에 id(PK) 포함? | No | 보안상 내부 ID 노출 지양 | + +--- + +## Feature 2: 내 정보 조회 + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | 회원이 자신의 정보를 조회한다 | +| Actor | 회원 | +| 우선순위 | P0 | +| 선행 조건 | Feature 1(회원가입) 완료 | +| 후행 영향 | 없음 | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | GET | +| Endpoint | /api/v1/users/me | +| Auth | 회원 (헤더 인증) | + +#### Request Header +| 헤더 | 필수 | 설명 | +|------|------|------| +| X-Loopers-LoginId | Y | 로그인 ID | +| X-Loopers-LoginPw | Y | 비밀번호 (평문) | + +#### Response Body (200 OK) +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 (마스킹: 마지막 글자 *) | +| birthDate | LocalDate | 생년월일 | +| email | String | 이메일 | + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 유효한 인증 정보 | GET /api/v1/users/me | 200, 본인 정보 반환 (이름 마스킹) | + +#### 실패 케이스 - 인증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-2 | X-Loopers-LoginId 헤더 누락 | GET /api/v1/users/me | 401, "인증 헤더가 필요합니다" | +| AC-3 | X-Loopers-LoginPw 헤더 누락 | GET /api/v1/users/me | 401, "인증 헤더가 필요합니다" | +| AC-4 | 존재하지 않는 loginId | GET /api/v1/users/me | 404, "회원을 찾을 수 없습니다" | +| AC-5 | 비밀번호 불일치 | GET /api/v1/users/me | 401, "비밀번호가 일치하지 않습니다" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 로그인 ID는 영문과 숫자만 허용 | Domain Entity (회원가입 시 검증) | +| BR-2 | 본인 정보만 조회 가능 (헤더의 loginId로 조회) | Controller | +| BR-3 | 비밀번호 검증은 BCrypt.matches() 사용 | Service | +| BR-4 | 이름은 마지막 글자 마스킹 처리 | UserInfo 변환 시 | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| 인증 방식? | 헤더 기반 (X-Loopers-LoginId, X-Loopers-LoginPw) | JWT 미도입 상태, MVP용 단순 인증 | +| 비밀번호 평문 전송? | Yes (HTTPS 전제) | MVP 단계, 추후 JWT로 전환 | + +--- + +## Feature 3: 비밀번호 수정 + +### 개요 +| 항목 | 내용 | +|------|------| +| 목적 | 회원이 자신의 비밀번호를 수정한다 | +| Actor | 회원 | +| 우선순위 | P1 | +| 선행 조건 | Feature 1(회원가입) 완료 | +| 후행 영향 | 이후 인증 시 새 비밀번호 사용해야 함 | + +### API 명세 +| 항목 | 내용 | +|------|------| +| Method | PATCH | +| Endpoint | /api/v1/users/me/password | +| Auth | 회원 (헤더 인증) | + +#### Request Header +| 헤더 | 필수 | 설명 | +|------|------|------| +| X-Loopers-LoginId | Y | 로그인 ID | +| X-Loopers-LoginPw | Y | 현재 비밀번호 | + +#### Request Body +| 필드 | 타입 | 필수 | 검증 규칙 | 예시 | +|------|------|------|----------|------| +| newPassword | String | Y | 8~16자, 영문 대소문자/숫자/특수문자만 가능, 생년월일 포함 불가 | "NewPass1234!" | + +#### Response Body (200 OK) +빈 응답 (성공 여부만 상태코드로 판단, data: null) + +### Acceptance Criteria + +#### 정상 케이스 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-1 | 유효한 인증 + 유효한 새 비밀번호 | PATCH /api/v1/users/me/password | 200, 비밀번호 변경됨 (BCrypt 암호화 저장) | +| AC-2 | 변경 후 새 비밀번호로 인증 | GET /api/v1/users/me (새 비밀번호 헤더) | 200, 정상 조회 | + +#### 실패 케이스 - 입력값 검증 (Request DTO) +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-3 | newPassword 누락 (null/blank) | PATCH /api/v1/users/me/password | 400 | + +#### 실패 케이스 - 도메인 검증 (Entity) +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-4 | newPassword 7자 (최소 미달) | PATCH /api/v1/users/me/password | 400, "비밀번호는 8~16자여야 합니다" | +| AC-5 | newPassword 17자 (최대 초과) | PATCH /api/v1/users/me/password | 400, "비밀번호는 8~16자여야 합니다" | +| AC-6 | newPassword 특수문자만 없음 | PATCH /api/v1/users/me/password | 400 | +| AC-7 | newPassword에 생년월일 포함 | PATCH /api/v1/users/me/password | 400, "비밀번호에 생년월일을 포함할 수 없습니다" | + +#### 실패 케이스 - 인증 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-8 | X-Loopers-LoginId 헤더 누락 | PATCH /api/v1/users/me/password | 401, "인증 헤더가 필요합니다" | +| AC-9 | X-Loopers-LoginPw 헤더 누락 | PATCH /api/v1/users/me/password | 401, "인증 헤더가 필요합니다" | +| AC-10 | 현재 비밀번호 불일치 | PATCH /api/v1/users/me/password | 401, "비밀번호가 일치하지 않습니다" | + +#### 실패 케이스 - 비즈니스 규칙 +| AC# | 조건 | 행위 | 기대 결과 | +|-----|------|------|----------| +| AC-11 | 새 비밀번호 = 현재 비밀번호 | PATCH /api/v1/users/me/password | 400, "현재 비밀번호와 다른 비밀번호를 입력해주세요" | + +### 비즈니스 규칙 +| 규칙# | 내용 | 구현 위치 | +|-------|------|----------| +| BR-1 | 새 비밀번호는 현재 비밀번호와 달라야 함 | Service (encoder.matches 비교) | +| BR-2 | 새 비밀번호도 BCrypt로 암호화 저장 | Service | +| BR-3 | 새 비밀번호도 동일한 비밀번호 RULE 적용 (8~16자, 영문/숫자/특수문자, 생년월일 불포함) | Domain (PasswordValidator) | + +### 결정 사항 +| 질문 | 결정 | 이유 | +|------|------|------| +| 이전 비밀번호 재사용 금지? | No | 요구사항에 "현재 비밀번호는 사용할 수 없습니다"만 명시, 히스토리 관리 불필요 | +| 비밀번호 변경 후 재로그인 강제? | No | 현재 세션/토큰 없음 | + +--- + +## 용어 정의 (Glossary) + +| 용어 | 정의 | +|------|------| +| 회원 | 서비스에 가입한 사용자 | +| 비회원 | 가입하지 않은 방문자 | +| Soft Delete | deletedAt 필드로 논리 삭제, 실제 데이터는 유지 | +| 마스킹 | 개인정보 보호를 위해 마지막 글자를 `*`로 대체 | +| 인증 헤더 | X-Loopers-LoginId, X-Loopers-LoginPw를 통한 요청별 인증 방식 | + +--- + +## 변경 이력 + +| 버전 | 일자 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| v1.0 | 2026-02-06 | AI | 최초 작성 | diff --git a/http/commerce-api/user.http b/http/commerce-api/user.http new file mode 100644 index 00000000..bab8db49 --- /dev/null +++ b/http/commerce-api/user.http @@ -0,0 +1,26 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "2000-01-15", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 비밀번호 변경 +PATCH {{commerce-api}}/api/v1/users/me/password +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "newPassword": "NewPass123!" +}