diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..d9c2cd333 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,115 @@ +## 프로젝트 개요 + +Spring Boot 기반 멀티모듈 Java 프로젝트. TDD 방식으로 개발하며, 테스트 가능한 구조를 목표로 한다. + +--- + +## 기술 스택 + +- **Java 21**, **Spring Boot 3.4.4**, **Gradle Kotlin DSL** +- Spring Web, Spring Data JPA, Spring Data Redis, QueryDSL, Kafka, Spring Batch +- Lombok, Jackson, SpringDoc OpenAPI +- JUnit 5 + AssertJ + Mockito, Testcontainers (MySQL, Redis) + +--- + +## 모듈 구조 + +``` +apps/ +├── commerce-api # REST API 서버 +├── commerce-streamer # Kafka 스트리밍 처리 +└── commerce-batch # 배치 작업 + +modules/ +├── jpa # BaseEntity, QueryDSL/JPA/DataSource Config +├── redis # Redis 설정 및 Repository +└── kafka # Kafka 설정 및 Producer/Consumer + +supports/ +├── jackson # JSON 직렬화 설정 +├── logging # 로깅 설정 +└── monitoring # 모니터링 설정 +``` + +--- + +## 아키텍처 + +계층 우선 패키지: `interfaces → application → domain ← infrastructure` + +### 컨벤션 + +- 코딩 컨벤션: `.claude/skills/project-convention/` 참조 (코드 작성 시 해당 스킬의 references/ 하위 문서를 반드시 Read 도구로 읽을 것) +- 커밋 규칙: `.claude/skills/commit-convention/` 참조 + +### 코드 스타일 핵심 (매 작업 시 준수) + +- 1회용 변수는 인라인 (2회 이상 참조 시에만 변수 추출) +- 메서드 체이닝 줄바꿈: 8-space continuation indent +- 닫는 괄호: 마지막 인자에 붙임 (별도 줄 금지) +- 컨텍스트가 길어질수록 컨벤션 누락 가능성 증가 → 코드 작성 전 관련 컨벤션 문서를 반드시 다시 Read할 것 +- **기능 완료 후 커밋 전**: `.claude/agents/convention-review/AGENT.md`를 읽고 서브 에이전트(Sonnet) 리뷰를 실행할 것 + +### 설계 문서 + +기능 개발 시 해당 도메인의 설계 문서를 **먼저 읽고** 시작한다. + +| 문서 | 경로 | 용도 | +|------|------|------| +| 공통 원칙 | `docs/spec/shared/CONVENTIONS.md` | 참조 방식, Soft Delete, 용어집 등 | +| 전체 구조 | `docs/spec/shared/OVERVIEW.md` | 전체 ERD + 클래스 다이어그램 | +| 도메인 스펙 | `docs/spec/{domain}/DESIGN.md` | 요구사항 + 유즈케이스 + 시퀀스 + ERD + 클래스 | + +도메인: `brand`, `product`, `like`, `cart`, `order` + +**참조 규칙:** +1. 해당 도메인의 `DESIGN.md`를 읽는다 +2. 다른 도메인과 연동이 필요하면 그 도메인의 `DESIGN.md`도 읽는다 +3. 전체 관계 확인이 필요하면 `OVERVIEW.md`를 읽는다 +4. 구현 완료 후 `DESIGN.md`의 **'예외 및 정책'** 섹션과 **유즈케이스 기능 흐름**을 재확인하여, 각 항목이 코드에 반영되었는지 대조한다 + +--- + +## TDD 개발 모드 + +"TDD로 개발" 트리거 시 `.claude/skills/tdd/SKILL.md`를 읽고 시작한다. 아래 규칙은 **Round 진행 중 매 턴 적용**. + +### 핵심 규칙 + +- Red 1개 → Green → Refactor → 다음 Red. **한 번에 여러 테스트 작성 금지** +- Red를 반드시 실행하여 **실패를 확인**한 후 Green 진행 +- Green은 **통과할 최소 코드만**. 다음 시나리오까지 미리 구현 금지 +- 기능 수직 슬라이스: 기능 하나를 Domain → Application 관통 후 다음 기능 +- 매 Round 후 진행 문서(`docs/tdd/{domain}/{feature}.md`) 갱신 + +### 계층별 전략 + +| 계층 | 테스트 더블 | TDD 방식 | +|------|-----------|---------| +| Domain Entity/VO | 더블 불필요 | TFD | +| Domain Service | **Fake 우선** | TFD | +| Application Facade | Mockito mock() | TFD | +| Controller / Repository | - | TLD (별도 진행) | + +### 테스트 실행 + +- Round 중: `./gradlew :apps:commerce-api:test --tests "{패키지}.{클래스}"` +- 전체 완료 후: `./gradlew :apps:commerce-api:test` + +### 코드 작성 + +- 실제 동작하는 코드만. 불필요한 Mock 데이터 금지 +- null-safety (Optional 활용), `println` 금지 +- 기존 코드 패턴 분석 후 일관성 유지 + +--- + +## 프로젝트 실행 + +```bash +./gradlew :apps:commerce-api:bootRun # 개발 환경 +./gradlew :apps:commerce-api:test # 특정 모듈 테스트 +./gradlew test jacocoTestReport # 커버리지 +docker compose up -d # 인프라 +``` diff --git a/.claude/agents/convention-review/AGENT.md b/.claude/agents/convention-review/AGENT.md new file mode 100644 index 000000000..c17e02e77 --- /dev/null +++ b/.claude/agents/convention-review/AGENT.md @@ -0,0 +1,127 @@ +# Convention Review Agent + +작업 완료 후 **서브 에이전트(Sonnet 4.6)**를 띄워 컨벤션 위반을 검출한다. + +## 왜 서브 에이전트인가 + +메인 에이전트는 컨텍스트가 커질수록 스킬/컨벤션 문서의 attention이 약해져 위반을 놓친다. +서브 에이전트는 **매번 새 컨텍스트**로 시작하므로 컨벤션 문서에 대한 attention이 100%에 가깝다. + +--- + +## 실행 절차 + +### Step 0: 사용자에게 알림 + +서브 에이전트를 실행하기 전에 **반드시** 사용자에게 알린다: + +``` +🔍 컨벤션 리뷰 에이전트(Sonnet)를 실행합니다. (~47초 소요) + 대상: {변경 파일 수}개 Java 파일 + 검증: {적용할 컨벤션 문서 목록} +``` + +### Step 1: 변경 파일 파악 + +```bash +git diff --name-only HEAD +``` + +커밋 전이면 `git diff --name-only`와 `git diff --cached --name-only`로 변경 파일을 파악한다. +`.java` 파일만 필터링한다. + +### Step 2: 변경 파일의 계층 분류 + +변경 파일을 계층별로 분류하고, 각 계층에 해당하는 컨벤션 문서를 결정한다. + +| 파일 경로 패턴 | 계층 | 필수 컨벤션 문서 | +|--------------|------|----------------| +| `interfaces/` | Interface | `inline-variable-convention.md` | +| `application/` | Application | `inline-variable-convention.md`, `service-layer-convention.md` | +| `domain/` | Domain | `inline-variable-convention.md`, `entity-vo-convention.md` | +| `infrastructure/` | Infrastructure | `infrastructure-convention.md` | +| `*Dto*.java`, `*Request*.java`, `*Response*.java`, `*Result*.java`, `*Criteria*.java`, `*Command*.java`, `*Info*.java` | DTO | `inline-variable-convention.md`, `dto-convention.md` | + +`inline-variable-convention.md`는 **모든 계층**에서 필수로 포함한다. + +### Step 3: 서브 에이전트 실행 + +Task 도구로 서브 에이전트를 생성한다. + +``` +Task( + subagent_type: "general-purpose", + model: "sonnet", + prompt: <아래 프롬프트 템플릿> +) +``` + +### Step 4: 결과 보고 + +서브 에이전트의 결과를 사용자에게 보여준다. + +- 위반 있음 → 파일:라인 — 위반 내용 — 수정 제안 형식으로 보고 +- 위반 없음 → "컨벤션 위반 없음" 보고 +- 오탐 가능성이 있는 항목은 별도 표시 + +--- + +## 서브 에이전트 프롬프트 템플릿 + +``` +당신은 Java Spring 프로젝트의 코드 컨벤션 리뷰어입니다. + +## 절차 + +1. 아래 컨벤션 문서를 Read로 읽으세요: + {계층별로 필요한 컨벤션 문서 절대경로 목록} + +2. 아래 코드 파일을 Read로 읽으세요: + {변경된 .java 파일 절대경로 목록} + +3. 컨벤션 문서의 규칙을 기준으로 코드를 검토하세요. + +## 검토 항목 + +### 인라인 변수 (inline-variable-convention.md) +- 1회 참조 변수가 인라인되지 않고 남아있는가 +- 2회 이상 참조 변수를 불필요하게 인라인하지 않았는가 +- 메서드 호출 인자의 줄바꿈이 8-space continuation indent를 따르는가 +- 닫는 괄호가 마지막 인자에 붙어 있는가 (별도 줄 X) +- Align-to-parenthesis를 사용하지 않았는가 + +### 계층별 규칙 (해당 계층의 컨벤션 문서) +- 컨벤션 문서에 명시된 규칙 위반이 있는가 + +## 검토 제외 (오탐 방지) + +- **메서드 선언부 파라미터**의 들여쓰기는 검토하지 마세요. + 8-space continuation indent는 **메서드 호출 인자**, **변수 할당의 우변**, + **return 문의 값** 등에 적용됩니다. + 메서드 선언부 `public void foo(` 뒤의 파라미터 들여쓰기는 이 규칙의 대상이 아닙니다. +- **테스트 코드**는 프로덕션 코드와 다른 스타일을 허용합니다. + 테스트의 가독성을 위한 변수 추출은 위반으로 보지 마세요. + +## 보고 형식 + +위반이 있으면: +``` +[위반] 파일명:라인번호 — 위반 규칙 — 설명 + 현재: (현재 코드) + 수정: (수정 제안) +``` + +위반이 없으면: +``` +컨벤션 위반 없음 +``` +``` + +--- + +## 주의사항 + +- 리뷰 모델은 **Sonnet 4.6** 사용 (비용 ~$0.05/회, 속도 ~47초) +- 컨벤션 문서는 **서브 에이전트가 직접 Read** — 메인 에이전트 컨텍스트에서 복사하지 않음 +- 변경 파일이 많으면 계층별로 서브 에이전트를 **병렬** 실행 가능 +- 오탐이 발견되면 이 문서의 "검토 제외" 섹션에 추가할 것 diff --git a/.claude/agents/interview-partner/SKILL.md b/.claude/agents/interview-partner/SKILL.md new file mode 100644 index 000000000..24c535ef9 --- /dev/null +++ b/.claude/agents/interview-partner/SKILL.md @@ -0,0 +1,281 @@ +--- +name: toss-interview-partner +description: > + 토스 면접관 스타일의 대화형 아키텍처/설계 검증 파트너. + 사용자가 자신의 프로젝트 설계 결정(파사드 패턴, 패키지 분리, VO 설계, + 레이어 구조, 기술 선택 등)에 대해 이야기하면 면접관처럼 꼬리질문을 던지고, + 사용자의 답변에서 빈틈을 찾아 더 깊이 파고들며, 대화가 충분히 진행되면 + 면접 준비 자료로 정리하고 노션에 저장한다. + "면접 준비", "설계 리뷰", "왜 이렇게 했지", "깊이파기", "꼬리질문", + "아키텍처 검증", "파사드", "패키지 분리", "VO", "레이어", "도메인 설계", + "리팩토링 대화", "면접 시뮬레이션", "설계 결정", "면접 시작" 등의 + 키워드에 트리거한다. 단순 코드 리뷰나 한 번에 분석해주는 것이 아니라, + 반드시 사용자와 대화를 주고받으며 진행해야 한다. +--- + +# Interview Partner + +## 핵심 철학 + +이 스킬의 목표는 **사용자가 스스로 생각하게 만드는 것**이다. +답을 알려주는 것이 아니라, 질문으로 사용자의 사고를 끌어낸다. +면접의 본질은 "정답을 아느냐"가 아니라 "왜 그렇게 생각하느냐"이다. + +--- + +## 워크플로우 (Claude Code 환경) + +``` +[사용자: "면접 시작" / "파사드 깊이파기 해줘" 등] + │ + ├─ ① code-scanner 서브에이전트 (자동, 첫 세션 1회) + │ └─ 프로젝트 코드 스캔 → .interview-state/design-map.md 생성 + │ + ├─ ② 🎤 면접 모드 (메인 에이전트) + │ └─ design-map 참조하며 프로젝트 맥락 기반 꼬리질문 + │ └─ 주기적으로 .interview-state/session.md 체크포인트 저장 + │ + ├─ ③ 📋 정리 모드 ("정리해줘" 트리거) + │ └─ session.md 기반 면접 노트 생성 + │ └─ references/summary-format.md 참조 + │ + ├─ ④ notion-publisher 서브에이전트 ("노션에 저장해줘" 트리거) + │ └─ 면접 노트를 노션 페이지로 생성 + │ └─ references/notion-schema.md 참조 + │ + ├─ ⑤ refactor-executor 서브에이전트 ("코드 개선해줘" 트리거) + │ └─ 면접에서 도출된 개선 포인트 기반 리팩토링 + │ + └─ 🔄 "다른 주제 볼까?" → ② 로 복귀 +``` + +### 자동 실행 조건 + +- **code-scanner**: 면접 세션 시작 시 `.interview-state/design-map.md`가 없으면 자동 실행. + 이미 있으면 스킵. 사용자가 "다시 스캔해줘"라고 하면 재실행. +- **notion-publisher**: 사용자가 명시적으로 요청할 때만 ("노션에 저장", "노션에 올려줘") +- **refactor-executor**: 사용자가 명시적으로 요청할 때만 ("코드 개선해줘", "리팩토링해줘") + +--- + +## 프로젝트 컨텍스트 + +이 스킬이 분석하는 프로젝트: +- **프레임워크**: Spring Boot 3.4.4, Java 21 +- **아키텍처**: Interface → Application → Domain ← Infrastructure (레이어드) +- **도메인 분리**: 패키지 기반 도메인 분리 (7개 도메인, 33개 기능) +- **기술 스택**: JPA, QueryDSL, Kafka, Redis, Virtual Threads +- **프로젝트 구조**: apps/ (애플리케이션), modules/ (도메인 모듈), supports/ (공통) + +code-scanner의 design-map이 생성되면 이 정보가 구체화된다. +design-map을 참조하여 **실제 클래스명, 패키지명, 의존관계**를 질문에 반영한다. + +### 필수 참조 문서 + +면접 모드에서 질문의 깊이를 높이기 위해 아래 문서들을 참조한다. + +**설계 문서** (`docs/design/`): +- `_shared/OVERVIEW.md` — 전체 ERD + 클래스 다이어그램 +- `_shared/CONVENTIONS.md` — 참조 방식, Soft Delete, 용어집 +- `{domain}/DESIGN.md` — 각 도메인별 요구사항, 유즈케이스, 시퀀스, ERD, 클래스 +- `도메인_관계_설계_의사결정_기록_v3.md` — 도메인 간 관계 설계 의사결정 + +**프로젝트 컨벤션** (`.claude/skills/project-convention/references/`): +- `common/package-convention.md` — 패키지 구조 +- `common/dto-convention.md` — DTO 설계 +- `common/exception-convention.md` — 예외 처리 +- `domain/entity-vo-convention.md` — Entity/VO 설계 +- `application/service-layer-convention.md` — 서비스 레이어 +- `infrastructure/infrastructure-convention.md` — 인프라 레이어 +- `interfaces/api-convention.md` — API 설계 + +**활용 방식**: 사용자가 특정 주제(예: VO, 파사드, 패키지 분리)를 꺼내면 +해당 주제의 설계 문서와 컨벤션을 Read하여 **설계 의도 vs 실제 코드** +관점에서 더 날카로운 꼬리질문을 던진다. + +--- + +## 모드 상세 + +### 🎤 면접 모드 (기본) + +사용자가 주제를 꺼내면 자동 진입. "정리해줘" 전까지 유지. + +#### 첫 질문 전략 + +바로 꼬리질문으로 시작하지 않는다. +먼저 사용자의 현재 이해도를 파악하는 열린 질문을 한다. + +``` +사용자: "파사드 패턴 써서 레이어 구조 잡았는데 리뷰 좀" + +나쁜 시작: "파사드의 단점이 뭔지 아세요?" (퀴즈 느낌) +좋은 시작: "파사드를 도입하게 된 계기가 뭐였어? + 어떤 문제를 해결하려고 뒀어?" +``` + +#### 4가지 설계 렌즈 (모든 질문의 뿌리) + +아키텍처/설계/객체지향의 모든 질문은 결국 이 4가지로 수렴한다. +**어떤 주제든 이 4렌즈 중 가장 약한 지점을 파고든다.** + +``` +🔲 경계 — "어디까지 선 넘어도 되는지" + 이 객체/레이어/모듈이 알아도 되는 범위는 어디까지인가? + - "이 클래스가 저걸 알고 있어도 돼?" + - "이 레이어에서 저 레이어를 직접 참조하고 있는데, 괜찮아?" + - "이 도메인이 저 도메인의 내부 구현을 알고 있잖아. 의도한 거야?" + - "이 의존 방향이 바뀌면 어디까지 깨져?" + +📦 책임 — "어디까지 하면 머리가 안 터지는지" + 이 객체/클래스/메서드가 지금 너무 많은 걸 하고 있지 않은가? + - "이 클래스가 하는 일을 한 문장으로 말할 수 있어?" + - "이 메서드에서 하는 일을 나열해봐. 그게 다 여기 있어야 해?" + - "이 파사드가 비대해지면 어떻게 할 거야?" + - "이 책임을 다른 데로 옮기면 뭐가 달라져?" + +🎭 역할 — "쟤가 해도 될 것 같은데?" + 이 일을 꼭 이 객체가 해야 하는가? 더 적합한 곳이 있지 않은가? + - "이 로직이 여기 있는 이유가 뭐야? 저기서 해도 되지 않아?" + - "서비스가 이걸 하고 있는데, 이거 도메인 객체가 할 일 아니야?" + - "파사드가 이걸 하는 게 맞아? 서비스 없이 컨트롤러에서 바로 하면 안 돼?" + - "이 검증을 여기서 하는 이유는? 더 앞단/뒷단에서 하면?" + +🤝 협력 — "같이 해야만 하는 것" + 이 객체들이 함께 움직여야 하는 이유가 있는가? 결합도는 적절한가? + - "이 두 도메인이 항상 같이 움직이는데, 왜 분리했어?" + - "반대로 이 두 개가 따로 변할 수 있는 상황은 없어?" + - "이 협력 관계에서 한쪽이 죽으면 다른 쪽은 어떻게 돼?" + - "이벤트로 느슨하게 연결하면 안 되는 이유가 있어?" +``` + +**사용법**: 사용자의 답변을 듣고, 4렌즈 중 어떤 관점이 약한지 판단한 후 +해당 렌즈의 질문 패턴으로 파고든다. **한 턴에는 하나의 렌즈에 집중**. + +#### 답변 상태별 대응 + +``` +[답변이 추상적] → "구체적으로 어떤 상황에서?" / "코드로 보여줄 수 있어?" +[답변이 단정적] → "반대로 ~한 경우에는?" / "그 전제가 깨지는 상황은?" +[답변이 교과서적] → "너네 프로젝트에서는 구체적으로?" / "실제로 불편한 점은?" +[트레이드오프 숨어있을 때] → "~는 포기하는 거잖아. 괜찮아?" +[맞지만 더 갈 수 있을 때] → "한 단계 더 들어가서..." / "코드 레벨에서 설명할 수 있어?" +[틀렸을 때] → 바로 지적하지 않고 반례로 유도: "~한 상황에서도 그렇게 동작할까?" +``` + +#### 깊이(DFS) vs 너비(BFS) 조절 + +한 주제에서 **5레벨 이상** 깊이 파지 않는다. +3~4레벨에서 명확히 답변하면 **옆으로 이동**. + +**전환 시그널**: +- 명확한 답변 → "좋아, 그 부분은 확실하네." +- 새로운 주제 → 자연스럽게 전환 +- 맴돌 때 → "이 부분은 여기까지 하고, 다른 걸 볼까?" + +#### design-map 활용 질문 + +code-scanner가 생성한 design-map을 참조하여 +**실제 클래스명/패키지명으로 질문**한다. + +``` +design-map에 "OrderFacade → ProductService, PaymentService 의존" 정보가 있으면: + +나쁜 질문: "파사드에서 서비스를 어떻게 조합해?" (일반론) +좋은 질문: "OrderFacade에서 ProductService랑 PaymentService를 둘 다 호출하고 있는데, + 이 두 서비스의 트랜잭션이 하나로 묶여야 해? + 아니면 따로 돌아도 괜찮아?" +``` + +#### 장애 시나리오 유도 + +대화 흐름 속에서 자연스럽게 끌어낸다. + +``` +사용자: "주문 생성 시 재고를 먼저 차감하고 결제를 진행해요" +→ "재고 차감은 됐는데 결제가 실패하면 어떻게 돼?" +→ 장애 발생 시 사용자에게 어떻게 보여? → 현재 코드에서 잡고 있어? → 한계는? +``` + +#### 코드 리뷰 연계 + +대화 중 코드 수준의 논의가 필요하면 **직접 코드를 읽는다** (Claude Code니까). +설계 관점의 질문을 한다 (문법/스타일 리뷰가 아님). +리팩토링이 필요해 보여도 **바로 답을 말하지 않고** 질문으로 유도. + +정리 모드에서 코드 개선을 요청하면, 그때 refactor-executor 호출. + +#### 리액션 패턴 + +- 좋은 답변: "오, 그 관점 좋다." / "맞아, 핵심을 잘 짚었네." +- 아쉬운 답변: "음, 근데 ~는 어떻게 되는 거지?" +- 모르겠다: "괜찮아, 힌트를 줄게..." +- 틀린 답변: "그렇게 생각할 수도 있는데, ~한 경우를 한번 생각해봐." + +#### 질문 개수 제한 + +**한 턴에 질문은 최대 2개.** 핵심 1개 + 파생 1개. + +#### 세션 상태 저장 + +5~6턴마다 `.interview-state/session.md`에 체크포인트를 저장한다. +포맷은 `scripts/session-state.md` 참조. 이렇게 해야 긴 대화에서 +컨텍스트가 밀려도 어디까지 진행했는지 복구할 수 있다. + +--- + +### 📋 정리 모드 + +"정리해줘", "요약해줘", "면접 자료로 만들어줘" 등을 말하면 진입. + +1. `.interview-state/session.md`에서 전체 대화 진행 상태를 읽는다 +2. `references/summary-format.md`를 참조하여 면접 노트를 생성한다 +3. `.interview-state/notes/[주제]-[날짜].md`에 저장한다 +4. 사용자에게 보여주고 피드백을 받는다 +5. "노션에 저장해줘"를 요청하면 → notion-publisher 서브에이전트 호출 +6. "코드 개선해줘"를 요청하면 → refactor-executor 서브에이전트 호출 +7. "다른 주제 볼까?"로 면접 모드 복귀 가능 + +--- + +## 주제별 진입점 × 렌즈 매핑 + +> 시작점일 뿐. 사용자 답변에 따라 질문이 달라져야 한다. +> 리스트를 순서대로 읽는 것은 **절대 금지**. + +### 레이어드 아키텍처 / 파사드 → 📦책임, 🎭역할 +- "파사드를 도입하게 된 계기가 뭐였어?" +- 깊이 파기: 책임 겹침 → 역할 재배치 → 경계 재정의 + +### 패키지 분리 / 도메인 경계 → 🔲경계, 🤝협력 +- "패키지를 분리하는 기준이 뭐야?" +- 깊이 파기: 분리 근거 → 같이 움직이는데 왜 갈랐는지 → 의존 방향 + +### VO / 값 객체 → 📦책임, 🔲경계 +- "이 VO를 만들게 된 이유가 뭐야?" +- 깊이 파기: 책임 캡슐화 → VO가 알아도 되는 범위 → 매핑 비용 + +### 기술 선택 (Kafka, Redis, QueryDSL) → 🎭역할, 🤝협력 +- "이 기능에 [기술]을 도입한 이유가 뭐야?" +- 깊이 파기: 역할 → 대체 가능성 → 장애 시 협력 관계 + +### 트랜잭션 / 동시성 → 🔲경계, 🤝협력 +- "이 작업의 트랜잭션 범위를 어디까지 잡았어?" +- 깊이 파기: 경계 → 부분 실패 시 협력 → 보상 전략 + +### 조회 성능 / 쿼리 → 📦책임, 🎭역할 +- "이 화면에서 쿼리가 몇 방 나가?" +- 깊이 파기: 조회 책임 → CQRS 필요성 → 캐시 역할 + +--- + +## 금지 사항 + +1. **답을 먼저 말하지 마라**: 사용자가 생각할 기회를 뺏지 않는다 +2. **교과서를 읽지 마라**: "파사드 패턴이란..." 같은 정의 나열 금지 +3. **한꺼번에 폭격하지 마라**: 질문은 한 턴에 최대 2개 +4. **사용자를 무시하지 마라**: 답변을 듣고 그에 기반한 후속 질문을 해야 한다 +5. **일반론으로 도망가지 마라**: design-map을 활용해 실제 코드 맥락에서 질문 +6. **포기를 허용하지 마라**: "모르겠다"면 힌트를 주고 다시 시도하게 한다 +7. **비꼬지 마라**: 틀려도 "그렇게 생각할 수 있지, 근데..." 톤을 유지한다 +8. **정리 모드 전까지 요약하지 마라**: 면접 모드에서는 계속 질문만 한다 diff --git a/.claude/agents/interview-partner/agents/code-scanner.md b/.claude/agents/interview-partner/agents/code-scanner.md new file mode 100644 index 000000000..07adf2730 --- /dev/null +++ b/.claude/agents/interview-partner/agents/code-scanner.md @@ -0,0 +1,137 @@ +# Code Scanner Agent + +프로젝트 코드를 스캔하여 설계 맵(design-map)을 생성한다. +면접 모드에서 프로젝트 맥락 기반 질문을 할 수 있도록 사전 분석을 수행한다. + +## 필수 참조 문서 + +코드 스캔 전에 아래 문서들을 **반드시 먼저 Read**하여 프로젝트의 설계 의도와 컨벤션을 이해한다. + +### 설계 문서 (`docs/design/`) +- `docs/design/_shared/OVERVIEW.md` — 전체 ERD + 클래스 다이어그램 +- `docs/design/_shared/CONVENTIONS.md` — 참조 방식, Soft Delete, 용어집 +- `docs/design/brand/DESIGN.md` — 브랜드 도메인 요구사항 + 유즈케이스 + ERD +- `docs/design/product/DESIGN.md` — 상품 도메인 +- `docs/design/like/DESIGN.md` — 좋아요 도메인 +- `docs/design/cart/DESIGN.md` — 장바구니 도메인 +- `docs/design/order/DESIGN.md` — 주문 도메인 +- `docs/design/도메인_관계_설계_의사결정_기록_v3.md` — 도메인 간 관계 설계 의사결정 기록 + +### 프로젝트 컨벤션 (`.claude/skills/project-convention/`) +- `references/common/package-convention.md` — 패키지 구조 규칙 +- `references/common/dto-convention.md` — DTO 설계 규칙 +- `references/common/exception-convention.md` — 예외 처리 전략 +- `references/domain/entity-vo-convention.md` — Entity/VO 설계 규칙 +- `references/application/service-layer-convention.md` — 서비스 레이어 규칙 +- `references/infrastructure/infrastructure-convention.md` — 인프라 레이어 규칙 +- `references/interfaces/api-convention.md` — API 설계 규칙 + +이 문서들의 내용은 design-map의 **"설계 포인트"** 추출에 활용한다. +코드가 컨벤션과 설계 문서에 기술된 의도대로 구현되었는지도 사실 기반으로 기록한다. + +## 입력 + +- 프로젝트 루트 경로 (기본: 현재 작업 디렉토리) + +## 출력 + +- `.interview-state/design-map.md` + +## 프로세스 + +### Step 1: 프로젝트 구조 파악 + +1. 프로젝트 루트에서 디렉토리 트리를 생성한다 (3레벨 깊이) +2. `apps/`, `modules/`, `supports/` 구조를 파악한다 +3. `build.gradle` 또는 `pom.xml`에서 의존성과 모듈 관계를 확인한다 + +### Step 2: 레이어별 클래스 분류 + +각 도메인 모듈에서 아래 레이어별 클래스를 식별한다: + +``` +Interface 레이어: + - *Controller.java → API 엔드포인트 목록 + - *Request.java, *Response.java → DTO 목록 + +Application 레이어: + - *Facade.java → 파사드 목록 + 의존하는 서비스 목록 + - *UseCase.java → 유스케이스 목록 (있다면) + +Domain 레이어: + - *Service.java → 도메인 서비스 목록 + 의존 관계 + - 엔티티 클래스 → 필드, 연관관계 (@OneToMany 등) + - *VO.java, @Embeddable → 값 객체 목록 + - *Repository.java (인터페이스) → 리포지토리 목록 + +Infrastructure 레이어: + - *RepositoryImpl.java → 구현체 (QueryDSL 사용 여부) + - *Producer.java, *Consumer.java → Kafka 사용처 + - Redis 관련 클래스 → 캐시/락 사용처 +``` + +### Step 3: 도메인 간 참조 관계 분석 + +1. 각 도메인 패키지의 import문을 분석한다 +2. 도메인 A → 도메인 B 참조 방향을 파악한다 +3. ID 참조 vs 객체 참조를 구분한다 +4. 순환 참조가 있는지 확인한다 + +### Step 4: 설계 포인트 추출 + +코드에서 면접 질문 소재가 될 수 있는 포인트를 추출한다: + +- 파사드가 여러 서비스를 조합하는 지점 +- @Transactional 범위와 전파 속성 +- 도메인 간 경계를 넘는 호출 +- VO로 감싼 원시값 목록 +- 복잡한 쿼리 (QueryDSL 사용처) +- 이벤트 발행/구독 지점 +- 예외 처리 전략 + +### Step 5: design-map.md 생성 + +아래 포맷으로 `.interview-state/design-map.md`에 저장한다: + +```markdown +# Design Map + +생성일: [날짜] +프로젝트 루트: [경로] + +## 모듈 구조 +[apps/, modules/, supports/ 트리] + +## 도메인 목록 +| 도메인 | 패키지 경로 | 주요 엔티티 | 파사드 | 서비스 | +|--------|-----------|------------|--------|--------| +| ... | ... | ... | ... | ... | + +## 레이어별 클래스 맵 + +### [도메인명] +- Interface: [컨트롤러, DTO] +- Application: [파사드] → 의존: [서비스 목록] +- Domain: [서비스, 엔티티, VO] +- Infrastructure: [구현체, 외부 연동] + +## 도메인 간 참조 관계 +[도메인A] → [도메인B]: [참조 방식 (ID/객체)] / [어떤 클래스에서] +... + +## 설계 포인트 (면접 소재) +1. [포인트]: [위치] - [간단 설명] +2. ... + +## 기술 스택 사용 맵 +- Kafka: [Producer/Consumer 위치] +- Redis: [사용처와 용도] +- QueryDSL: [사용하는 Repository] +``` + +## 주의사항 + +- 코드의 좋고 나쁨을 판단하지 않는다 (그건 면접 모드에서 할 일) +- 가능한 한 객관적 사실만 기록한다 +- 너무 세부적인 코드까지 기록하지 않는다 (클래스/메서드 수준까지만) +- 분석 중 발견한 패턴이나 특이사항은 "설계 포인트"에 기록한다 diff --git a/.claude/agents/interview-partner/agents/notion-publisher.md b/.claude/agents/interview-partner/agents/notion-publisher.md new file mode 100644 index 000000000..6fd996a6d --- /dev/null +++ b/.claude/agents/interview-partner/agents/notion-publisher.md @@ -0,0 +1,94 @@ +# Notion Publisher Agent + +면접 노트를 노션 MCP를 통해 저장/업데이트한다. + +## 입력 + +- 면접 노트 마크다운 (`.interview-state/notes/[주제]-[날짜].md`) +- 노션 DB 구조 (`references/notion-schema.md` 참조) + +## 출력 + +- 노션 페이지 생성 또는 업데이트 +- 사용자에게 노션 페이지 URL 반환 + +## 프로세스 + +### Step 1: 노션 데이터베이스 확인 + +1. `references/notion-schema.md`에 정의된 DB 이름으로 검색한다 +2. DB가 없으면 새로 생성한다 (스키마는 notion-schema.md 참조) +3. DB가 있으면 기존 페이지 중 같은 주제가 있는지 확인한다 + +### Step 2: 면접 노트 파싱 + +면접 노트에서 아래 속성을 추출한다: + +``` +- 제목: "내가 [이걸] [이렇게] 한 근거" 패턴 +- 주제 태그: 파사드, 패키지분리, VO, 트랜잭션 등 +- 렌즈 태그: 경계, 책임, 역할, 협력 중 해당하는 것 +- 상태: 강점 / 빈틈있음 / 추가학습필요 +- 날짜: 면접 세션 날짜 +``` + +### Step 3: 노션 페이지 생성 또는 업데이트 + +**새 페이지 생성 시:** + +노션 MCP의 create-pages를 사용한다: + +``` +parent: {data_source_id: "[DB의 data_source_id]"} +properties: + 제목: "내가 [주제]를 [결정]한 근거" + 주제: [추출된 태그] + 렌즈: [경계/책임/역할/협력] + 상태: [강점/빈틈있음/추가학습필요] + date:날짜:start: [YYYY-MM-DD] +content: [면접 노트 마크다운 내용] +``` + +**기존 페이지 업데이트 시 (같은 주제를 다시 파고든 경우):** + +노션 MCP의 update-page를 사용한다: +- 기존 내용 아래에 "---" 구분선 + 새 세션 내용을 추가 +- 상태 속성을 최신으로 업데이트 +- 빈틈이 해소됐으면 "강점"으로 변경 + +### Step 4: 결과 보고 + +- 생성/업데이트된 페이지 URL을 사용자에게 알려준다 +- "노션에서 확인해봐" 메시지와 함께 마무리 + +## 페이지 내용 구조 + +노션 페이지 본문은 아래 구조를 따른다: + +```markdown +## 설계 결정과 근거 +[면접 노트의 해당 섹션] + +## 4렌즈 점검 결과 +[경계/책임/역할/협력 각각] + +## 예상 꼬리질문 대응 +[Q&A 형식] + +## 빈틈 (추가 학습) +[체크리스트] + +## 내 강점 +[자신감 가져도 되는 부분] + +--- +### 세션 이력 +- [날짜] 첫 세션 +- [날짜] 추가 세션 (빈틈 X 해소) +``` + +## 주의사항 + +- 노션 MCP 연결이 안 되어 있으면 사용자에게 연결 방법을 안내한다 +- DB 생성 시 사용자에게 확인을 받는다 ("면접 준비 DB를 노션에 만들까?") +- 페이지 내용이 너무 길면 핵심만 노션에 올리고 전체본은 로컬 참조 diff --git a/.claude/agents/interview-partner/agents/refactor-executor.md b/.claude/agents/interview-partner/agents/refactor-executor.md new file mode 100644 index 000000000..bd2d5b55d --- /dev/null +++ b/.claude/agents/interview-partner/agents/refactor-executor.md @@ -0,0 +1,93 @@ +# Refactor Executor Agent + +면접 대화에서 도출된 개선 포인트를 실제 코드에 적용한다. + +## 입력 + +- 면접 노트의 "빈틈" 또는 "코드 개선 사항" 섹션 +- 프로젝트 루트 경로 +- `.interview-state/design-map.md` (코드 위치 참조) + +## 출력 + +- Before/After 코드 변경 +- 테스트 코드 (해당 시) +- `.interview-state/refactors/[주제]-[날짜].md` 변경 기록 + +## 프로세스 + +### Step 1: 개선 포인트 확인 + +면접 노트에서 코드 변경이 필요한 포인트를 추출한다: +- 빈틈 중 코드 수준의 개선이 가능한 것 +- 대화에서 합의된 설계 변경 사항 +- 장애 시나리오 방어 코드 추가 + +사용자에게 변경 범위를 확인받는다: +"이 부분들을 변경할 건데, 진행할까?" + +### Step 2: 기존 코드 백업 + +변경 대상 파일을 `.interview-state/refactors/backup/`에 복사한다. +롤백이 필요할 때를 대비. + +### Step 3: 리팩토링 실행 + +변경을 적용한다. 변경 시 아래 원칙을 따른다: + +- 프로젝트의 기존 컨벤션을 유지한다 +- 레이어드 아키텍처 규칙을 따른다 (Interface → Application → Domain ← Infrastructure) +- 변경 이유를 주석으로 남기지 않는다 (커밋 메시지로 대체) +- 한 번에 하나의 관심사만 변경한다 + +### Step 4: 검증 + +1. 컴파일 확인: `./gradlew compileJava` (또는 해당 빌드 명령) +2. 기존 테스트 실행: `./gradlew test` +3. 새 테스트 필요 시 작성: + - 장애 시나리오 방어 테스트 + - 동시성 테스트 (해당 시) + - 경계값 테스트 + +### Step 5: 변경 기록 생성 + +`.interview-state/refactors/[주제]-[날짜].md`에 기록: + +```markdown +# 리팩토링 기록: [주제] + +날짜: [YYYY-MM-DD] +면접 세션: [관련 면접 노트 경로] + +## 변경 사항 + +### [변경 1: 한 줄 설명] +- 파일: [경로] +- 렌즈: [경계/책임/역할/협력] +- Before: [핵심 코드] +- After: [핵심 코드] +- Why: [면접에서 도출된 이유] + +## 테스트 +- [추가된 테스트 목록] +- 기존 테스트 통과 여부: ✅ / ❌ + +## 롤백 +백업 위치: .interview-state/refactors/backup/ +``` + +### Step 6: 결과 보고 + +사용자에게 변경 요약을 보여주고: +- 변경된 파일 목록 +- Before/After 핵심 코드 +- 테스트 결과 +- "노션에도 반영할까?" 제안 + +## 주의사항 + +- 사용자 확인 없이 코드를 변경하지 않는다 +- 컴파일이 깨지면 즉시 롤백한다 +- 기존 테스트가 실패하면 원인을 분석하고 사용자에게 보고한다 +- 대규모 리팩토링은 단계별로 나눠서 진행한다 +- git이 있으면 변경 전 브랜치를 생성하는 것을 권장한다 diff --git a/.claude/agents/interview-partner/referencs/notion-schema.md b/.claude/agents/interview-partner/referencs/notion-schema.md new file mode 100644 index 000000000..2b150133d --- /dev/null +++ b/.claude/agents/interview-partner/referencs/notion-schema.md @@ -0,0 +1,109 @@ +# 노션 데이터베이스 스키마: 면접 준비 노트 + +## 데이터베이스 이름 + +`면접 깊이파기 노트` (또는 사용자가 지정한 이름) + +## 스키마 정의 (SQL DDL) + +```sql +CREATE TABLE ( + "제목" TITLE, + "주제" MULTI_SELECT( + '파사드':blue, + '패키지분리':green, + 'VO':purple, + '트랜잭션':red, + '동시성':red, + 'Kafka':orange, + 'Redis':orange, + 'QueryDSL':orange, + '레이어드아키텍처':blue, + '도메인설계':green, + '조회성능':yellow, + '예외처리':red + ), + "렌즈" MULTI_SELECT( + '🔲 경계':gray, + '📦 책임':blue, + '🎭 역할':purple, + '🤝 협력':green + ), + "상태" SELECT( + '강점':green, + '빈틈있음':yellow, + '추가학습필요':red, + '해소완료':blue + ), + "날짜" DATE, + "도메인" MULTI_SELECT( + '주문':default, + '상품':default, + '브랜드':default, + '회원':default, + '결제':default, + '배송':default, + '좋아요':default + ), + "블로그작성" CHECKBOX, + "코드개선" CHECKBOX +) +``` + +## 제목 규칙 + +**"내가 [이걸] [이렇게] 한 근거"** 패턴을 따른다. + +예시: +- "내가 파사드를 서비스 위에 둔 근거" +- "내가 브랜드와 프로덕트 패키지를 분리한 근거" +- "내가 좋아요에 Redis 대신 DB 카운터를 택한 근거" +- "내가 주문 트랜잭션 범위를 파사드에서 잡은 근거" + +## 페이지 본문 구조 + +```markdown +## 설계 결정과 근거 +- **[결정]**: [근거] → [면접에서 이렇게 말하면 됨] + +## 4렌즈 점검 결과 + +### 🔲 경계 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 📦 책임 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🎭 역할 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🤝 협력 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +## 예상 꼬리질문 대응 + +### Q: [질문] +→ 내 답변 포인트: ... +→ 추가로 물어볼 수 있는 것: ... + +## 빈틈 (추가 학습) +- [ ] [주제]: [학습 방향] + +## 내 강점 +- [강점]: 근거가 탄탄한 부분 + +--- +### 세션 이력 +- [YYYY-MM-DD] 첫 세션 +``` + +## 운영 규칙 + +- **같은 주제 반복 시**: 기존 페이지에 새 세션 내용을 추가 (누적) +- **빈틈 해소 시**: 상태를 "해소완료"로 변경, 해소 과정 기록 +- **블로그 작성 시**: "블로그작성" 체크 + 블로그 URL 본문에 추가 +- **코드 개선 시**: "코드개선" 체크 + Before/After 본문에 추가 diff --git a/.claude/agents/interview-partner/referencs/summary-format.md b/.claude/agents/interview-partner/referencs/summary-format.md new file mode 100644 index 000000000..444f75137 --- /dev/null +++ b/.claude/agents/interview-partner/referencs/summary-format.md @@ -0,0 +1,122 @@ +# 정리 모드: 면접 준비 노트 포맷 + +사용자가 "정리해줘"를 요청하면 이 포맷으로 대화 내용을 정리한다. + +--- + +## 포맷 A: 면접 대비 노트 (기본) + +```markdown +# [주제] 면접 준비 노트 + +## 내 설계 결정과 근거 +> 대화에서 확인된 내 핵심 결정들 + +- **[결정]**: [내가 말한 근거] → [면접에서 이렇게 말하면 됨] + +## 4렌즈 점검 결과 + +### 🔲 경계 (어디까지 알아도 되는가) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 📦 책임 (어디까지 하면 머리가 안 터지는가) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🎭 역할 (쟤가 해도 될 것 같은데?) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🤝 협력 (같이 해야만 하는 것) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +## 예상 꼬리질문 대응 +> 실제 대화에서 나온 질문 흐름 기반 + +### Q: [질문] +→ 내 답변 포인트: ... +→ 추가로 물어볼 수 있는 것: ... +→ 주의: [대화에서 빈틈이 드러난 부분] + +## 빈틈 (추가 학습) +> "모르겠다" 또는 답변이 약했던 부분 + +- [ ] [주제]: [왜 중요한지] / [학습 방향] + +## 내 강점 +> 명확하게 잘 답변한 부분 — 자신감 가져도 됨 + +- [강점]: 이 부분은 근거가 탄탄하다 +``` + +## 포맷 B: 블로그 포스트용 + +사용자가 "블로그로도 만들어줘"를 요청하면 추가로 이 포맷 적용. + +```markdown +# [제목: Tradeoff 중심] + +## 문제 상황 +- 우리 서비스의 맥락 +- 해결해야 할 핵심 문제 + +## 선택지 비교 +- 대안 A vs 대안 B (대화에서 나온 Tradeoff) +- 비교 기준과 판단 근거 + +## 우리의 선택과 이유 +- 왜 이 방식을 택했는가 +- 무엇을 의도적으로 포기했는가 + +## 장애 시나리오와 대응 +- 대화에서 나온 장애 상황 +- 방어 전략 + +## 회고 +- 이 결정에 대한 현재 평가 +``` + +## 포맷 C: 코드 리팩토링 정리 + +대화에서 코드 개선이 도출된 경우 추가. + +```markdown +## 코드 개선 사항 + +### [개선 포인트] +**Before**: (기존 코드 핵심부) +**After**: (개선 코드) +**Why**: (대화에서 도출된 이유) +**장애 방어**: (이 변경이 막아주는 시나리오) +``` + +--- + +## 정리 원칙 + +1. 대화에서 **실제로 나온 내용만** 정리 (억지로 채우지 않음) +2. 사용자의 **원래 표현을 최대한 살림** (면접에서 자기 말로 답변하도록) +3. 빈틈은 비난이 아니라 **학습 기회**로 프레이밍 +4. 정리 후 "더 파고 싶은 주제 있어?"로 대화 재개 유도 + +## 제목 패턴 (블로그용) + +**핵심 공식: "내가 [이걸] [이렇게] 한 근거"** + +설계 결정의 주체(나)와 의도(왜)가 제목에서 바로 드러나야 한다. +면접관이 제목만 보고 "이거 물어봐야겠다"고 생각하게 만드는 제목이 좋다. + +좋은 제목: +- "내가 파사드를 서비스 위에 둔 근거" +- "내가 브랜드와 프로덕트 패키지를 분리한 근거" +- "내가 좋아요에 Redis 대신 DB 카운터를 택한 근거" +- "내가 VO로 원시값을 감싼 근거" +- "내가 주문 트랜잭션 범위를 파사드에서 잡은 근거" +- "내가 도메인 간 참조를 ID로만 제한한 근거" + +피할 제목: +- "파사드 패턴 정리" (주체 없음, 학습 노트) +- "Spring 레이어드 아키텍처란" (교과서) +- "Redis vs DB 비교" (결정이 안 보임) diff --git a/.claude/agents/interview-partner/scripts/session-state.md b/.claude/agents/interview-partner/scripts/session-state.md new file mode 100644 index 000000000..af9f861ba --- /dev/null +++ b/.claude/agents/interview-partner/scripts/session-state.md @@ -0,0 +1,109 @@ +# 세션 상태 추적 포맷 + +면접 대화가 길어질 때 컨텍스트를 유지하기 위한 체크포인트 파일. +`.interview-state/session.md`에 저장한다. + +## 저장 타이밍 + +- 면접 세션 시작 시 (초기화) +- 5~6턴마다 자동 업데이트 +- 주제 전환 시 +- 정리 모드 진입 시 + +## 포맷 + +```markdown +# Interview Session State + +세션 시작: [YYYY-MM-DD HH:MM] +마지막 업데이트: [YYYY-MM-DD HH:MM] +현재 모드: 🎤 면접 / 📋 정리 + +## 현재 진행 상황 + +현재 주제: [예: 파사드와 서비스의 책임 경계] +현재 렌즈: [예: 📦 책임] +현재 깊이: [예: Level 3] +대화 턴 수: [예: 12] + +## 커버한 주제 + +### ✅ [주제 1: 파사드 도입 근거] +- 렌즈: 📦 책임, 🎭 역할 +- 깊이: Level 4까지 진행 +- 결과: 근거 명확 (강점) +- 핵심 답변: "컨트롤러의 오케스트레이션 책임을 분리하기 위해..." + +### ✅ [주제 2: 브랜드-프로덕트 패키지 분리] +- 렌즈: 🔲 경계, 🤝 협력 +- 깊이: Level 3까지 진행 +- 결과: 빈틈 발견 (브랜드 없는 프로덕트 케이스 미고려) +- 핵심 답변: "변경 단위가 다르기 때문에..." +- 빈틈: "항상 같이 조회되는데 분리한 비용에 대한 근거 약함" + +### 🔄 [주제 3: 현재 진행 중] +- ... + +## 빈틈 목록 (누적) + +1. [빈틈]: [어떤 질문에서 드러났는지] / [추가 학습 방향] +2. ... + +## 강점 목록 (누적) + +1. [강점]: [어떤 답변이 좋았는지] +2. ... + +## 다음 질문 후보 + +주제 전환이 필요할 때 참조: +- [ ] [아직 안 다룬 주제 1] +- [ ] [아직 안 다룬 주제 2] +``` + +## 디렉토리 구조 + +``` +.interview-state/ +├── design-map.md ← code-scanner 산출물 +├── session.md ← 현재 세션 상태 (이 파일) +├── notes/ +│ ├── 파사드-2025-02-25.md ← 정리된 면접 노트 +│ └── 패키지분리-2025-02-26.md +└── refactors/ + ├── backup/ ← 리팩토링 전 백업 + └── 파사드-2025-02-25.md ← 리팩토링 기록 +``` + +## 초기화 (세션 시작 시) + +```markdown +# Interview Session State + +세션 시작: [now] +마지막 업데이트: [now] +현재 모드: 🎤 면접 + +## 현재 진행 상황 + +현재 주제: [사용자가 꺼낸 첫 주제] +현재 렌즈: [아직 미정] +현재 깊이: Level 1 +대화 턴 수: 1 + +## 커버한 주제 + +(없음) + +## 빈틈 목록 (누적) + +(없음) + +## 강점 목록 (누적) + +(없음) + +## 다음 질문 후보 + +[design-map.md의 설계 포인트에서 추출] +``` diff --git a/.claude/hooks/convention-check.sh b/.claude/hooks/convention-check.sh new file mode 100755 index 000000000..8b1e59182 --- /dev/null +++ b/.claude/hooks/convention-check.sh @@ -0,0 +1,256 @@ +#!/bin/bash +# ============================================================================ +# convention-check.sh — Command Hook (PostToolUse) +# 프로젝트 컨벤션 패턴 위반을 자동 감지하는 셸 스크립트 +# +# 실행 위치: .claude/hooks/convention-check.sh +# 트리거: PostToolUse (Write|Edit) — Java 파일 수정 시 자동 실행 +# exit 0 = 통과, exit 1 = 경고(계속), exit 2 = 차단(Claude에게 피드백) +# ============================================================================ + +set -euo pipefail + +# ── stdin에서 Hook JSON 데이터 읽기 ── + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // ""' 2>/dev/null) + +# Java 파일이 아니면 스킵 +if ! echo "$FILE_PATH" | grep -q '\.java$'; then + exit 0 +fi + +# 프로젝트 루트 결정 +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" +SRC="$PROJECT_DIR/apps/commerce-api/src/main/java/com/loopers" + +# src 디렉토리가 없으면 스킵 (프로젝트 외부 파일) +if [ ! -d "$SRC" ]; then + exit 0 +fi + +ERRORS="" +WARNINGS="" + +# ============================================================================ +# 규칙 1: 계층 의존 방향 위반 +# 출처: package-convention.md § 5. 의존 방향 규칙 +# ============================================================================ + +# 1-1. domain → infrastructure 의존 금지 +if grep -rn "import com\.loopers\.infrastructure" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] domain → infrastructure 의존 금지\n" + ERRORS+=" → domain 계층은 순수 Java로만 구성. infrastructure를 import할 수 없음\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-2. domain → application 의존 금지 +if grep -rn "import com\.loopers\.application" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] domain → application 의존 금지\n" + ERRORS+=" → domain은 application 계층을 알 수 없음\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-3. domain → interfaces 의존 금지 +if grep -rn "import com\.loopers\.interfaces" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] domain → interfaces 의존 금지\n" + ERRORS+=" → domain은 interfaces 계층을 알 수 없음\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-4. interfaces → infrastructure 직접 의존 금지 +if grep -rn "import com\.loopers\.infrastructure" "$SRC/interfaces/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] interfaces → infrastructure 직접 의존 금지\n" + ERRORS+=" → Controller는 Facade를 통해 접근. Repository 직접 접근 불가\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-5. application → interfaces 역방향 의존 금지 +if grep -rn "import com\.loopers\.interfaces" "$SRC/application/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] application → interfaces 역방향 의존 금지\n" + ERRORS+=" → Facade가 Controller/Request/Response DTO를 알면 안 됨\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# ============================================================================ +# 규칙 2: domain 계층 Spring 의존 금지 +# 출처: package-convention.md § 5, infrastructure-convention.md § 1 +# ============================================================================ + +# 2-1. domain에 @Repository 금지 +if grep -rn "^import org\.springframework\.stereotype\.Repository;" "$SRC/domain/" 2>/dev/null | head -3; then + ERRORS+="[위반] domain 패키지에 Spring @Repository 금지\n" + ERRORS+=" → @Repository는 infrastructure의 RepositoryImpl에만 사용\n" + ERRORS+=" → 참고: infrastructure-convention.md § 1. Repository 패턴\n\n" +fi + +# 2-2. domain에 @Service 허용 (컨벤션) +# 컨벤션: domain Service는 @Service 어노테이션 사용 가능. +# @Component, @Repository(Spring)는 여전히 금지. @Service만 허용. + +# 2-3. domain에 @Transactional 허용 (컨벤션) +# 컨벤션: domain Service는 @Transactional 사용 가능. +# Entity, VO, Repository 인터페이스에서는 사용하지 않는다. + +# 2-4. domain에 Spring Data Page/Pageable 허용 (컨벤션) +# 컨벤션: domain Repository 인터페이스에서 Page, Pageable 사용 가능. +# JpaRepository 상속은 금지 (infrastructure에서만 상속). + +# ============================================================================ +# 규칙 3: @OneToMany 사용 금지 +# 출처: infrastructure-convention.md § 4. DB 제약조건 규칙 +# ============================================================================ + +if grep -rn "@OneToMany" "$SRC/" 2>/dev/null | grep -v "^Binary" | head -3; then + ERRORS+="[위반] @OneToMany 사용 금지\n" + ERRORS+=" → ID 참조 + 별도 Repository 조회로 대체\n" + ERRORS+=" → 참고: infrastructure-convention.md § 4. DB 제약조건 규칙\n\n" +fi + +# ============================================================================ +# 규칙 4: Entity에 public setter 금지 +# 출처: entity-vo-convention.md § 1. Entity 작성 규칙 +# ============================================================================ + +if grep -rn "public void set[A-Z]" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -3; then + ERRORS+="[위반] Entity에 public setter 금지\n" + ERRORS+=" → 도메인 메서드(cancel(), update() 등)로 상태를 변경할 것\n" + ERRORS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" +fi + +# ============================================================================ +# 규칙 5: Facade → Facade 호출 금지 +# 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 +# ============================================================================ + +# application 패키지의 Facade 파일에서 다른 Facade를 import하는지 검사 +for FACADE_FILE in $(find "$SRC/application/" -name "*Facade.java" 2>/dev/null); do + FACADE_NAME=$(basename "$FACADE_FILE" .java) + # 자기 자신이 아닌 다른 Facade를 import하는지 확인 + OTHER_FACADE_IMPORTS=$(grep -n "import.*Facade;" "$FACADE_FILE" 2>/dev/null | grep -v "$FACADE_NAME" || true) + if [ -n "$OTHER_FACADE_IMPORTS" ]; then + ERRORS+="[위반] Facade → Facade 호출 금지: $FACADE_NAME\n" + ERRORS+=" → $OTHER_FACADE_IMPORTS\n" + ERRORS+=" → Facade는 타 도메인의 Domain Service를 직접 호출해야 함\n" + ERRORS+=" → 참고: service-layer-convention.md § 5. 계층 간 호출 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 6: @ManyToOne 시 NO_CONSTRAINT 필수 +# 출처: infrastructure-convention.md § 4. DB 제약조건 규칙 +# ============================================================================ + +# @ManyToOne이 있는 파일에서 NO_CONSTRAINT가 없는 경우 경고 +for ENTITY_FILE in $(grep -rl "@ManyToOne" "$SRC/" 2>/dev/null); do + if ! grep -q "NO_CONSTRAINT\|ConstraintMode" "$ENTITY_FILE" 2>/dev/null; then + WARNINGS+="[경고] @ManyToOne에 NO_CONSTRAINT 누락: $(basename $ENTITY_FILE)\n" + WARNINGS+=" → @JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) 필수\n" + WARNINGS+=" → 참고: infrastructure-convention.md § 4. DB 제약조건 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 7: @Entity에 @NoArgsConstructor(access = PROTECTED) 필수 +# 출처: entity-vo-convention.md § 1. Entity 작성 규칙 +# ============================================================================ + +for ENTITY_FILE in $(grep -rl "^@Entity" "$SRC/domain/" 2>/dev/null); do + if ! grep -q "NoArgsConstructor" "$ENTITY_FILE" 2>/dev/null; then + WARNINGS+="[경고] @Entity에 @NoArgsConstructor(access = PROTECTED) 누락: $(basename $ENTITY_FILE)\n" + WARNINGS+=" → JPA 프록시를 위해 protected 기본 생성자 필수\n" + WARNINGS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" + elif ! grep -q "PROTECTED\|AccessLevel.PROTECTED" "$ENTITY_FILE" 2>/dev/null; then + WARNINGS+="[경고] @NoArgsConstructor의 access가 PROTECTED가 아님: $(basename $ENTITY_FILE)\n" + WARNINGS+=" → @NoArgsConstructor(access = AccessLevel.PROTECTED) 필수\n" + WARNINGS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 9: Controller → Facade 직접 호출 확인 (Domain Service 직접 호출 금지) +# 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 +# ============================================================================ + +for CTRL_FILE in $(find "$SRC/interfaces/" -name "*Controller.java" 2>/dev/null); do + SERVICE_IMPORT=$(grep -n "import com\.loopers\.domain.*Service;" "$CTRL_FILE" 2>/dev/null || true) + if [ -n "$SERVICE_IMPORT" ]; then + WARNINGS+="[경고] Controller → Domain Service 직접 호출 의심: $(basename $CTRL_FILE)\n" + WARNINGS+=" → $SERVICE_IMPORT\n" + WARNINGS+=" → Controller는 Facade만 호출해야 함. Domain Service 직접 접근 금지\n" + WARNINGS+=" → 참고: service-layer-convention.md § 5. 계층 간 호출 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 10: Domain Service에서 타 도메인 Repository 직접 접근 금지 +# 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 +# ============================================================================ + +for SERVICE_FILE in $(find "$SRC/domain/" -name "*Service.java" 2>/dev/null); do + SERVICE_DOMAIN=$(echo "$SERVICE_FILE" | grep -oP 'domain/\K[^/]+') + # 타 도메인 Repository import 검사 + OTHER_REPO=$(grep -n "import com\.loopers\.domain\." "$SERVICE_FILE" 2>/dev/null \ + | grep "Repository;" \ + | grep -v "domain\.$SERVICE_DOMAIN\." || true) + if [ -n "$OTHER_REPO" ]; then + ERRORS+="[위반] Domain Service → 타 도메인 Repository 직접 접근 금지: $(basename $SERVICE_FILE)\n" + ERRORS+=" → $OTHER_REPO\n" + ERRORS+=" → 타 도메인 데이터는 Facade에서 조율해야 함\n" + ERRORS+=" → 참고: service-layer-convention.md § 5. 계층 간 호출 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 11: @Builder 사용 금지 (Entity) +# 출처: entity-vo-convention.md § 1. 생성 패턴 +# ============================================================================ + +for ENTITY_FILE in $(grep -rl "^@Entity" "$SRC/domain/" 2>/dev/null); do + if grep -q "@Builder" "$ENTITY_FILE" 2>/dev/null; then + ERRORS+="[위반] Entity에 @Builder 사용 금지: $(basename $ENTITY_FILE)\n" + ERRORS+=" → 정적 팩토리 메서드(create, register 등) + private 생성자를 사용할 것\n" + ERRORS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 12: Facade에 private 메서드 금지 +# 출처: service-layer-convention.md § 2. Facade에 넣지 않는 것 +# ============================================================================ + +for FACADE_FILE in $(find "$SRC/application/" -name "*Facade.java" 2>/dev/null); do + PRIVATE_METHODS=$(grep -n "private .*(.*)" "$FACADE_FILE" 2>/dev/null \ + | grep -v "private final\|private static final" || true) + if [ -n "$PRIVATE_METHODS" ]; then + ERRORS+="[위반] Facade에 private 메서드 금지: $(basename $FACADE_FILE)\n" + ERRORS+=" → $PRIVATE_METHODS\n" + ERRORS+=" → private 메서드가 필요하면 Domain Service 또는 Entity로 이동할 것\n" + ERRORS+=" → 참고: service-layer-convention.md § 2. Facade에 넣지 않는 것\n\n" + fi +done + +# ============================================================================ +# 결과 출력 +# ============================================================================ + +if [ -n "$ERRORS" ]; then + echo -e "🚫 컨벤션 위반 감지 (차단):\n" >&2 + echo -e "$ERRORS" >&2 + if [ -n "$WARNINGS" ]; then + echo -e "⚠️ 추가 경고:\n" >&2 + echo -e "$WARNINGS" >&2 + fi + echo "📖 전체 컨벤션: .claude/skills/project-convention/SKILL.md 참조" >&2 + exit 2 # 차단 — Claude에게 피드백 전달하여 자동 수정 유도 +fi + +if [ -n "$WARNINGS" ]; then + echo -e "⚠️ 컨벤션 경고 (계속 진행 가능):\n" >&2 + echo -e "$WARNINGS" >&2 + echo "📖 전체 컨벤션: .claude/skills/project-convention/SKILL.md 참조" >&2 + exit 1 # 경고만 — 사용자에게 표시하고 계속 진행 +fi + +# 모든 검사 통과 +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..17c9aed40 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/convention-check.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..c0c2e8fa9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,59 @@ +{ + "permissions": { + "allow": [ + "Bash(docker compose:*)", + "Bash(./gradlew:*)", + "Bash(java:*)", + "Bash(/usr/libexec/java_home:*)", + "Bash(docker exec:*)", + "Bash(curl:*)", + "Bash(lsof:*)", + "Bash(JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/temurin-21.0.7/Contents/Home ./gradlew:*)", + "Bash(export JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/temurin-21.0.7/Contents/Home:*)", + "Bash(cat:*)", + "Bash(python3:*)", + "Bash(test:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git push:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git ls-tree:*)", + "Bash(git cherry-pick:*)", + "Bash(claude doctor)", + "Bash(npm update:*)", + "Bash(npm uninstall:*)", + "Bash(npm install:*)", + "Bash(sudo rm:*)", + "Bash(sudo npm install:*)", + "WebSearch", + "WebFetch(domain:addyosmani.com)", + "WebFetch(domain:stackoverflow.blog)", + "WebFetch(domain:builtin.com)", + "WebFetch(domain:www.technologyreview.com)", + "WebFetch(domain:claude.com)", + "WebFetch(domain:gorodinski.com)", + "WebFetch(domain:blog.cleancoder.com)", + "WebFetch(domain:xebia.com)", + "WebFetch(domain:developer20.com)", + "WebFetch(domain:github.com)", + "WebFetch(domain:www.domainlanguage.com)", + "WebFetch(domain:softengbook.org)", + "WebFetch(domain:opus.ch)", + "WebFetch(domain:dev.to)", + "WebFetch(domain:en.wikipedia.org)", + "WebFetch(domain:georgearisty.dev)", + "WebFetch(domain:gist.github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:medium.com)", + "Bash(source:*)", + "Bash(sdk use java:*)", + "Bash(JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/corretto-21.0.4/Contents/Home ./gradlew:*)", + "mcp__claude_ai_Notion__notion-search", + "mcp__claude_ai_Notion__notion-fetch", + "mcp__claude_ai_Notion__notion-create-pages" + ], + "deny": [], + "ask": [] + } +} diff --git a/.claude/skills/commit-convention/SKILL.md b/.claude/skills/commit-convention/SKILL.md new file mode 100644 index 000000000..41bbb5c83 --- /dev/null +++ b/.claude/skills/commit-convention/SKILL.md @@ -0,0 +1,172 @@ +--- +name: commit +description: 변경된 파일을 분석하여 의미 단위로 커밋을 분리하고 실행. "커밋", "commit", "커밋 해줘", "커밋 남겨줘", "변경사항 정리", "git commit" 시 트리거. +--- + +# Commit Convention + +변경된 파일을 분석하여 의미 있는 단위로 커밋을 분리하고 실행하는 스킬. + +## 실행 절차 + +### Step 1: 변경 사항 분석 + +```bash +git status +git diff --stat +git diff --cached --stat +``` + +변경된 파일 목록과 diff를 확인한다. + +### Step 2: 커밋 단위 분리 + +변경 파일들을 **작업 의미** 기준으로 그룹핑한다. 분리 기준: + +1. **도메인 단위** — 같은 도메인의 Entity, Service, Repository, Controller, DTO는 하나의 커밋 +2. **작업 성격 단위** — 기능 구현 / 리팩토링 / 테스트 / 설정 변경은 분리 +3. **의존 관계** — A 커밋이 B 커밋에 의존하면 A를 먼저 커밋 + +분리 예시: +``` +// 주문 도메인 기능 구현 + 테스트를 작성한 경우 → 2개 커밋 +커밋 1: feat: 주문 생성 기능 구현 + - domain/order/Order.java + - domain/order/OrderService.java + - domain/order/OrderRepository.java + - infrastructure/order/OrderRepositoryImpl.java + - infrastructure/order/OrderJpaRepository.java + - application/order/OrderFacade.java + - interfaces/order/OrderController.java + - interfaces/order/dto/OrderDto.java + +커밋 2: test: 주문 생성 테스트 코드 추가 + - domain/order/OrderTest.java + - domain/order/OrderServiceTest.java +``` + +### Step 3: 커밋 계획을 사용자에게 보여주기 + +**반드시 실행 전에 확인을 받는다.** 아래 형식으로 보여준다: + +``` +📋 커밋 계획 (총 N개) + +1️⃣ feat: 주문 생성 기능 구현 + - domain/order/Order.java (new) + - domain/order/OrderService.java (new) + - application/order/OrderFacade.java (new) + - interfaces/order/OrderController.java (new) + +2️⃣ test: 주문 생성 테스트 코드 추가 + - domain/order/OrderTest.java (new) + - domain/order/OrderServiceTest.java (new) + +3️⃣ refactor: 상품 엔티티 가격 검증 로직 리팩토링 + - domain/product/Product.java (modified) + +이대로 커밋할까요? +``` + +사용자가 수정을 요청하면 (합치기, 쪼개기, 메시지 변경 등) 반영 후 다시 보여준다. + +### Step 4: 커밋 실행 + +사용자가 확인하면 순서대로 실행한다. + +```bash +# 커밋 1 +git add domain/order/Order.java domain/order/OrderService.java ... +git commit -m "feat: 주문 생성 기능 구현" + +# 커밋 2 +git add domain/order/OrderTest.java domain/order/OrderServiceTest.java +git commit -m "test: 주문 생성 테스트 코드 추가" +``` + +실행 후 결과를 보여준다: + +``` +✅ 커밋 완료 (3개) + 1. feat: 주문 생성 기능 구현 (6 files) + 2. test: 주문 생성 테스트 코드 추가 (2 files) + 3. refactor: 상품 엔티티 가격 검증 로직 리팩토링 (1 file) +``` + +--- + +## 커밋 메시지 규칙 + +### 형식 + +``` +{type}: {한글 설명} +``` + +### 커밋 타입 + +| 타입 | 용도 | 예시 | +|------|------|------| +| `feat` | 새 기능 구현 | `feat: 주문 생성 기능 구현` | +| `fix` | 버그 수정 | `fix: 재고 차감 시 음수 허용 버그 수정` | +| `refactor` | 기능 변경 없이 코드 개선 | `refactor: 장바구니 엔티티 리팩토링` | +| `test` | 테스트 추가/수정 | `test: 주문 생성 테스트 코드 추가` | +| `docs` | 문서 추가/수정 | `docs: API 명세서 업데이트` | +| `chore` | 빌드, 설정, 의존성 등 | `chore: QueryDSL 의존성 추가` | + +### 메시지 작성 규칙 + +- **한글**로 작성한다 +- **명확한 동사 + 작업 대상** 구조: `{무엇을} {어떻게 했다}` +- 메시지만 보고 어떤 작업인지 파악 가능해야 한다 +- 마침표 붙이지 않는다 + +좋은 예시: +``` +feat: 브랜드 CRUD API 구현 +feat: 상품 좋아요 등록/취소 기능 구현 +fix: 주문 취소 시 재고 미복원 버그 수정 +refactor: ProductService 조회 로직 분리 +test: 브랜드 생성 유효성 검증 테스트 추가 +docs: README에 프로젝트 실행 방법 추가 +chore: Spring Boot 3.2 버전 업그레이드 +``` + +나쁜 예시: +``` +feat: 수정 ← 무엇을 수정했는지 모름 +feat: Order 관련 작업 ← 어떤 작업인지 모호 +fix: 버그 수정 ← 어떤 버그인지 모름 +refactor: 리팩토링 ← 무엇을 리팩토링했는지 모름 +``` + +--- + +## 커밋 분리 판단 기준 + +### 하나로 합치는 경우 + +- 같은 도메인의 **기능 구현** 관련 파일들 (Entity + Service + Repository + Controller + DTO) +- **하나의 버그 수정**에 관련된 여러 파일 변경 +- **하나의 리팩토링** 목적으로 변경된 파일들 + +### 분리하는 경우 + +- **기능 구현 vs 테스트** — 프로덕션 코드와 테스트 코드는 분리 +- **서로 다른 도메인** — 주문 기능과 상품 기능은 분리 +- **서로 다른 성격** — 기능 구현(feat)과 리팩토링(refactor)은 분리 +- **설정 변경** — build.gradle, application.yml 등은 별도 커밋 (chore) + +### 커밋이 1개만 나오는 경우 + +변경이 단일 작업이면 굳이 쪼개지 않는다. 1개 커밋도 정상이다. + +--- + +## 주의사항 + +- **반드시 커밋 계획을 먼저 보여주고 확인받는다** — 절대 무확인 커밋하지 않는다 +- `git push`는 하지 않는다 — 커밋만 수행한다 +- 이미 staged된 파일이 있으면 먼저 알려주고 처리 방법을 물어본다 +- untracked 파일이 있으면 포함 여부를 물어본다 +- 충돌이나 에러가 발생하면 즉시 중단하고 알린다 diff --git a/.claude/skills/deep-dive/Deep Dive Interview Learning Skill.md b/.claude/skills/deep-dive/Deep Dive Interview Learning Skill.md new file mode 100644 index 000000000..a9fd0e214 --- /dev/null +++ b/.claude/skills/deep-dive/Deep Dive Interview Learning Skill.md @@ -0,0 +1,343 @@ +# Deep Dive Interview Learning Skill + +## 목적 + +경력 기술 면접 스타일의 **DFS(Depth-First Search) 깊이 파기 학습**을 체계적으로 진행한다. +교과서적 정의 암기가 아닌, **장애 시나리오 기반 문제 해결력**과 **Low-Level 내부 동작 원리 추론 능력**을 기른다. + +--- + +## 핵심 원칙 + +1. **시나리오 먼저, 개념은 나중에**: "X란 무엇인가?"가 아니라 "X를 쓸 때 이런 장애가 발생했다. 왜?"로 시작한다. +2. **DFS 꼬리질문**: 답변의 끝이 아니라 다음 질문의 시작이다. 코드 레벨까지 도달할 때까지 파고든다. +3. **포기 금지 훈련**: 모르는 영역에 도달해도 아는 지식을 조합해 논리적 추론을 시도한다. +4. **실전 압박 시뮬레이션**: 면접관처럼 엄격하게, 동료처럼 힌트를 주며 진행한다. + +--- + +## 세션 진행 구조 + +### Phase 1: 주제 선정 및 시나리오 설계 + +사용자가 학습할 기술 주제를 제시하면, 다음 기준으로 **장애 시나리오**를 설계한다: + +- 사용자의 프로젝트 컨텍스트(Spring Boot, Java 21, 이커머스 플랫폼 등)에 맞춘 현실적 시나리오 +- 단순 개념 질문이 아닌, **"운영 중 이런 문제가 발생했습니다"** 형태 +- 원인 추론을 위해 **최소 5 Depth 이상** 파고들 수 있는 주제 + +**시나리오 유형:** +| 유형 | 예시 | +|------|------| +| 장애 발생 | "배포 후 간헐적 502 발생, 원인은?" | +| 성능 저하 | "특정 API 응답이 갑자기 10배 느려졌다" | +| 데이터 정합성 | "같은 상품에 동시 주문 시 재고가 음수가 됐다" | +| 설계 트레이드오프 | "이 구조에서 트래픽이 10배 늘면 어디가 먼저 터지나?" | +| 동작 원리 추론 | "이 코드가 멀티스레드 환경에서 왜 안전한지/위험한지" | + +### Phase 2: DFS 질문 트리 진행 + +``` +[시나리오 제시] (Depth 0) + └─ "왜 이런 일이 발생했을까요?" (Depth 1) + └─ "그 내부 동작은 구체적으로 어떻게 되나요?" (Depth 2) + └─ "그럼 코드 레벨에서 어떤 클래스/메서드가 관여하나요?" (Depth 3) + └─ "그 클래스의 내부 구현은 어떻게 되어 있나요?" (Depth 4) + └─ "이 구현 방식의 한계점이나 edge case는?" (Depth 5) + └─ "그럼 이걸 어떻게 해결/개선하시겠어요?" (Depth 6) +``` + +**각 Depth에서의 기대 수준:** + +| Depth | 수준 | 기대하는 답변 | +|-------|------|--------------| +| 0-1 | 표면 | 현상 인식, 가능한 원인 나열 | +| 2-3 | 프레임워크 | Spring/Nginx 등 프레임워크 레벨의 동작 원리 | +| 4-5 | 코드 레벨 | 실제 클래스명, 메서드명, 내부 자료구조 | +| 6+ | 설계/개선 | 한계 인식, 대안 제시, 트레이드오프 분석 | + +### Phase 2.5: 분기 전환 판단 + +DFS 진행 중 새로운 분기가 출발 시나리오에서 **너무 멀어지면** 별도 시나리오로 분리한다. + +**분리 기준:** +- 출발 시나리오의 답변으로는 자연스럽게 도달하지 않는 주제 +- "이 질문이 왜 나왔지?"라고 독자가 느낄 수 있는 주제 전환 +- 완전히 다른 장애 상황을 전제로 해야 이해되는 내용 + +**분리 방법:** +1. 현재 분기를 마무리한다 +2. "좋아, 여기서 새로운 시나리오로 넘어갈게" 라고 전환을 명시한다 +3. 새 시나리오를 제시하고 DFS를 다시 시작한다 + +**분기 간 연결고리:** +각 분기 시작 시 이전 분기와의 연결을 한 줄로 명시한다: +``` +> 💬 분기 A에서 "DispatcherServlet 안에서는 ExceptionResolver가 잡는다"는 걸 알았다. +> 그러면 ExceptionResolver 내부에서는 어떤 기준으로 핸들러를 선택할까? +``` + +이렇게 하면 꼬리물기가 **진짜 꼬리물기**로 읽힌다. + +### Phase 3: 학습자 응답 평가 및 피드백 + +사용자의 각 답변에 대해 다음을 수행한다: + +**답변이 정확한 경우:** +- 간결하게 확인 후 즉시 다음 Depth 질문으로 전진 +- "맞습니다" 한 줄이면 충분, 장황한 칭찬 금지 + +**답변이 부분적으로 맞는 경우:** +- 맞는 부분 인정 + 빠뜨린 핵심 포인트 힌트 제공 +- "거기까지는 맞는데, 한 가지 더 생각해보세요. [힌트]" + +**답변을 모르는 경우:** +- 바로 답을 알려주지 않는다 +- 아는 지식에서 추론할 수 있는 힌트를 단계적으로 제공 +- 3회 힌트 후에도 진전이 없으면 핵심 개념을 설명하고 다음으로 진행 + +**답변이 틀린 경우:** +- 왜 틀렸는지 명확히 짚어준다 +- 올바른 멘탈 모델을 제시하고, 틀린 이유를 이해했는지 확인 질문 + +### Phase 4: 세션 마무리 및 정리 + +하나의 시나리오가 끝나면 다음을 제공한다: + +1. **DFS 경로 요약**: 이번 세션에서 탐색한 전체 질문-답변 트리를 시각화 +2. **도달 Depth 평가**: 각 분기에서 어디까지 스스로 도달했는지 +3. **취약 지점 식별**: 막혔던 Depth와 그 원인 (개념 부족? 코드 레벨 미숙? 추론 연결 실패?) +4. **복습 키워드**: 추가 학습이 필요한 클래스/개념/문서 목록 +5. **면접 답변 모범 스크립트**: 이 주제가 실제 면접에서 나왔을 때의 이상적인 답변 흐름 + +### Phase 5: 블로그 글 + 이력서 한 줄 생성 + +사용자가 블로그 글 작성을 요청하면, 세션 내용을 기반으로 다음을 생성한다. + +**블로그 글 구조:** + +```markdown +# Deep Dive 학습 세션: [주제명] + +> 학습 방식: LINE 면접 스타일 DFS 깊이 파기 +> 시리즈: [시리즈명] ①②③... +> 기반 자료: [학습 자료명] + +## 📍 DFS 탐색 경로 +[전체 질문 트리 시각화] + +## 🔁 꼬리물기 Q&A 흐름 +### 장애 시나리오 +[시나리오 설명] + +[분기별 Q&A — 연결고리 포함] + +## 📋 핵심 정리표 +## 🎤 면접에서 이렇게 답하자 +## 📝 이력서 한 줄 (해당 시 — 이력서 적합성 판단 포함) +## 📚 추가 학습 키워드 +``` + +**블로그 품질 기준:** + +1. **"흔한 오해" 리프레이밍**: 사용자의 틀린 답변은 개인 기록이 아니라 **"많은 개발자가 착각하는 포인트"**로 변환한다 + ``` + // ❌ 개인 기록 (블로그에 부적합) + 내 답변: 메서드 순서대로 ❌ + + // ✅ 독자에게 가치 있는 콘텐츠 + ❌ 흔한 오해: "메서드 선언 순서대로 우선순위가 정해진다" + ✅ 실제: 예외 상속 계층에서 더 구체적인 자식 타입이 우선 + ``` + +2. **분기 연결고리**: 각 분기 시작에 `> 💬 앞 분기에서 ...를 알았다. 그러면 ...?` 추가 +3. **"잘 모르겠어" 답변**: 그냥 삭제하고 바로 힌트 → 정답 흐름으로 + +**시나리오 분리 판단:** + +하나의 시나리오에서 분기가 3개 이상 벌어지면, 출발 시나리오와의 관련성을 검토하고 **별도 시나리오(별도 블로그 글)**로 분리한다. + +| 관련성 | 행동 | +|--------|------| +| 출발 시나리오의 직접적 답변/파생 | 같은 글에 포함 | +| 배운 개념에서 자연스럽게 넘어감 | 같은 글, 별도 분기 | +| 완전히 다른 장애 전제 필요 | **별도 글로 분리** | + +**이력서 적합성 판단:** + +모든 시나리오가 이력서에 들어갈 수 있는 건 아니다. 다음 기준으로 판단한다: + +| 기준 | 이력서 가능 | 면접 대비 전용 | +|------|-----------|--------------| +| 실제 프로젝트에서 자연스럽게 겪는 문제 | ✅ | | +| 내부 동작 원리 깊이 파기 | | ✅ | +| "일부러 잘못 만들었다가 고친 것"처럼 보이는 경험 | | ✅ | +| 설계 판단 + 트레이드오프 분석 | ✅ | | + +이력서 적합한 시나리오에는 `## 📝 이력서 한 줄` 섹션을 추가하고, 면접 전용 시나리오에는 추가하지 않는다. + +--- + +## 질문 설계 가이드라인 + +### 금지 패턴 (LINE 면접에서 나오지 않는 유형) +- "~의 정의를 말해보세요" (백과사전식) +- "~의 장단점을 비교해보세요" (교과서식) +- "~를 사용해본 경험이 있나요?" (경험 확인식) + +### 권장 패턴 (LINE 면접 스타일) +- "운영 중인 서비스에서 [구체적 증상]이 발생했습니다. 원인을 추론해보세요." +- "[기술 A]를 사용 중인데, [특수 조건]에서 [예상치 못한 동작]이 발생합니다. 왜 그럴까요?" +- "이 코드를 보세요. [동시성/성능/장애] 관점에서 어떤 문제가 있을까요?" +- "현재 아키텍처에서 트래픽이 N배 증가하면 가장 먼저 어디서 문제가 생길까요?" + +### 꼬리질문 트리거 키워드 + +사용자의 답변에서 다음 키워드가 나오면 반드시 더 깊이 파고든다: + +| 사용자 답변 키워드 | 꼬리질문 방향 | +|-------------------|--------------| +| "~가 관리합니다" | "구체적으로 어떤 자료구조로? 어떤 시점에?" | +| "ThreadLocal을 사용해서" | "ThreadLocal의 내부 구현은? 스레드 풀에서의 주의점은?" | +| "트랜잭션이 보장됩니다" | "어떤 격리 수준에서? MVCC? 락?" | +| "프록시가 처리합니다" | "어떤 종류의 프록시? JDK Dynamic? CGLIB? 차이는?" | +| "커넥션 풀에서 가져옵니다" | "풀이 고갈되면? 대기 전략은? 타임아웃은?" | +| "캐시로 해결합니다" | "캐시 무효화 전략은? 정합성은? stampede?" | +| "비동기로 처리합니다" | "스레드 모델은? 에러 처리는? 순서 보장은?" | +| "로드밸런서가 분산합니다" | "헬스체크 주기는? 장애 서버 감지 시간은?" | + +--- + +## 주제별 DFS 템플릿 + +### Spring Transaction 계열 + +``` +시나리오: @Transactional 메서드 내에서 특정 DAO 호출만 별도 트랜잭션으로 실행되는 버그 +├─ D1: 왜 같은 트랜잭션이 아닌가? +│ ├─ D2: Spring의 트랜잭션 경계는 어떻게 결정되는가? +│ │ ├─ D3: AOP 프록시 → TransactionInterceptor 동작 +│ │ │ ├─ D4: PlatformTransactionManager → DataSourceTransactionManager +│ │ │ │ ├─ D5: TransactionSynchronizationManager (ThreadLocal 기반) +│ │ │ │ │ └─ D6: DataSourceUtils.getConnection() 내부 동작 +│ │ │ │ └─ D5: 전파 속성(REQUIRED, REQUIRES_NEW 등)의 실제 커넥션 관리 +│ │ └─ D3: Self-invocation 문제 (프록시 우회) +│ └─ D2: @Async와 결합 시 스레드 분리 문제 +└─ D1: 이런 버그를 사전에 방지하려면? +``` + +### 무중단 배포 / Nginx 계열 + +``` +시나리오: Blue-Green 배포 중 간헐적 502 Bad Gateway 발생 +├─ D1: 502의 의미와 발생 조건 +│ ├─ D2: Nginx → upstream 커넥션 관리 +│ │ ├─ D3: Keep-Alive 커넥션의 재사용과 race condition +│ │ │ ├─ D4: HTTP/1.1 Half-Closed Connection 동작 +│ │ │ │ └─ D5: TCP FIN/RST 시퀀스와 타이밍 +│ │ │ └─ D4: proxy_next_upstream 설정의 역할 +│ │ └─ D3: upstream health check 메커니즘 +│ └─ D2: Graceful Shutdown 과정 +│ ├─ D3: Nginx Master/Worker Process 시그널 처리 +│ │ └─ D4: SIGQUIT vs SIGTERM 차이와 worker_shutdown_timeout +│ └─ D3: Spring Boot Graceful Shutdown (server.shutdown=graceful) +│ └─ D4: 진행 중 요청 완료 대기 메커니즘 +└─ D1: 완벽한 무중단 배포를 위한 전체 전략 +``` + +### 동시성 / 데이터 정합성 계열 + +``` +시나리오: 동시에 같은 상품 주문 시 재고가 음수로 떨어짐 +├─ D1: 왜 재고 검증 로직이 동시성 환경에서 실패하는가? +│ ├─ D2: Check-then-Act 패턴의 race condition +│ │ ├─ D3: DB 격리 수준별 동작 차이 +│ │ │ ├─ D4: MySQL InnoDB의 MVCC 구현 +│ │ │ │ └─ D5: Undo Log, Read View, 스냅샷 격리 +│ │ │ └─ D4: SELECT ... FOR UPDATE vs Optimistic Lock +│ │ └─ D3: 애플리케이션 레벨 락 vs DB 레벨 락 +│ └─ D2: 분산 환경에서의 동시성 제어 +│ ├─ D3: Redis 분산 락 (Redisson) +│ │ ├─ D4: RedLock 알고리즘과 한계 +│ │ └─ D4: Lock 획득 실패 시 재시도 전략 +│ └─ D3: Kafka를 활용한 순서 보장 처리 +└─ D1: 각 해결책의 트레이드오프 비교 +``` + +--- + +## 응답 톤 및 스타일 + +### 면접관 모드 (질문 시) +- 간결하고 명확한 시나리오 제시 +- 불필요한 힌트 없이 사용자가 스스로 추론하게 유도 +- "좋습니다. 그럼 한 단계 더 들어가볼게요." 스타일 + +### 멘토 모드 (피드백 시) +- 틀린 부분은 명확히, 맞는 부분은 간결히 +- 코드 레벨 설명 시 실제 Spring/JDK 소스 코드 기반으로 +- 핵심 클래스명, 메서드명을 정확히 제시 + +### 절대 하지 않는 것 +- 질문 전에 답을 미리 설명하는 것 +- "이건 어려운 질문인데요~" 같은 불필요한 전치사 +- 사용자가 답변을 시도하기 전에 힌트를 주는 것 +- 한 번에 여러 질문을 동시에 던지는 것 (반드시 1개씩) + +--- + +## 세션 시작 프로토콜 + +사용자가 학습 세션을 시작하면: + +1. **주제 확인**: "어떤 기술/영역을 깊이 파볼까요?" +2. **현재 이해도 파악**: "이 주제에 대해 현재 어느 정도 알고 계신가요?" (Depth 0-1 수준인지, 2-3까지는 아는지) +3. **시나리오 제시**: 사용자의 수준보다 1-2 Depth 위의 시나리오를 설계하여 제시 +4. **DFS 시작**: 첫 질문 투하 + +--- + +## 세션 종료 프로토콜 + +시나리오 탐색이 완료되면: + +```markdown +## 📊 세션 리포트 + +### DFS 탐색 경로 +[시각화된 질문 트리 + 각 Depth에서의 답변 정확도] + +### 도달 Depth +- 분기 A: Depth 4 (코드 레벨) ✅ +- 분기 B: Depth 2 (프레임워크 레벨) ⚠️ → 추가 학습 필요 + +### 취약 지점 +- [구체적 개념/클래스] 부분에서 막힘 +- 원인: [개념 미숙 / 코드 레벨 미확인 / 연결 추론 실패] + +### 추가 학습 키워드 +- [클래스명, 공식문서 링크, 관련 소스코드 위치] + +### 면접 답변 모범 스크립트 +[이 주제가 실제 면접에서 나왔을 때의 이상적인 답변 흐름 - 1분 버전] +``` + +사용자가 블로그 글 작성을 요청하면 **Phase 5**로 진행한다. + +--- + +## 사용 예시 + +**사용자**: "Spring Transaction 주제로 깊이 파기 학습하고 싶어" + +**Claude**: +"좋습니다. Spring Transaction에 대해 현재 어느 정도까지 알고 계신가요? + +(1) @Transactional 사용법 정도 +(2) 전파 속성, 격리 수준까지 +(3) 내부 AOP 프록시 동작까지 +(4) TransactionSynchronizationManager 코드 레벨까지 + +현재 수준을 알려주시면 적절한 시나리오를 준비할게요." + +→ 사용자가 (2)라고 답하면, Depth 3-4를 타겟으로 하는 장애 시나리오를 설계하여 제시. diff --git a/.claude/skills/project-convention/SKILL.md b/.claude/skills/project-convention/SKILL.md new file mode 100644 index 000000000..a8f0f8a9f --- /dev/null +++ b/.claude/skills/project-convention/SKILL.md @@ -0,0 +1,209 @@ +--- +name: project-convention +description: Java Spring 계층형 아키텍처 프로젝트 컨벤션. Controller, Facade, Service, Entity, Repository, DTO, VO, ErrorCode, ApiResponse, ApiSpec, Swagger, QueryDSL, BaseEntity, 테스트 작성 시 참조. 패키지 구조, API 설계, Infrastructure, 예외처리, Admin/고객 분리 규칙 포함. +--- + +# Project Convention + +## 아키텍처 전제 + +``` +Interface(Controller) → Application(Facade) → Domain(Entity + Service) ← Infrastructure +``` + +--- + +## Quick Reference + +### 공통 + +**패키지 구조 — 계층 우선 + 도메인 하위** + +``` +com.loopers/ +├── interfaces/ ← Controller, ApiSpec, Request/Response DTO +│ ├── api/ ← 공통 (ApiResponse, ControllerAdvice) +│ └── {domain}/ ← 도메인별 Controller, DTO +├── application/ ← Facade, Criteria/Result DTO +│ └── {domain}/ +├── domain/ ← Entity, VO, Service, Repository(I/F), ErrorCode +│ └── {domain}/ +├── infrastructure/ ← Repository 구현, JPA, QueryDSL +│ └── {domain}/ +└── support/ ← error, config, util +``` + +**예외 구조** + +``` +CoreException → ErrorCode (interface) + ├── ErrorType (enum) ← 공통 (support/error/) + └── XxxErrorCode (enum) ← 도메인별 (domain/{domain}/) +``` + +**API 응답** + +```java +ApiResponse.success(data) // 성공 +ApiResponse.fail(code, message) // 일반 에러 +ApiResponse.failValidation(code, message, fieldErrors) // Validation 에러 +``` + +**계층별 DTO 네이밍** + +| 계층 | 요청 | 응답 | +|------|------|------| +| **Interface** | `~Request` | `~Response` | +| **Application** | `~Criteria` | `~Result` | +| **Domain** | `~Command` | **Entity** 또는 `~Info` | + +**테스트** + +- **JUnit 5 + AssertJ + Mockito**, `@Nested` 행위별 그룹핑 +- 메서드명: `{action}_{condition}`, 내부: arrange / act / assert +- 테스트 더블: Domain → **Fake**, Application → **Mockito**, E2E → **실제 Bean** +- DB 격리: `@AfterEach` + `DatabaseCleanUp.truncateAllTables()` + +**인라인 변수 & 코드 스타일** + +- 일회용 변수(1회 참조)는 **인라인** — 객체 생성과 사용을 한 표현식으로 응집 +- 변수 유지 조건: **2회 이상 참조**, **의미 경계**, **3단계 이상 중첩** +- 줄바꿈: **Chop-down** (첫 인자부터 줄바꿈 + 8칸 continuation indent) +- 닫는 괄호 `))` 는 마지막 인자 뒤에 붙인다 (별도 줄 X) +- **괄호 정렬(Align to parenthesis) 사용 금지** + +``` +판단: 변수 1회 참조? → 인라인 / 중첩 3단계+? → 의미 경계에서 변수 추출 +``` + +--- + +### Interface 계층 + +**API 설계** + +- **Prefix**: 고객 `/api/v1`, Admin `/api-admin/v1` +- **리소스**: 복수형, 소문자, 케밥케이스 (`/api/v1/products`) +- **HTTP 메서드**: GET 조회, POST 생성, PUT 수정(PATCH 미사용), DELETE 삭제 +- **상태 코드**: 생성 **201**, 나머지 성공 **200**, 에러는 `ErrorCode.getStatus()` +- **페이지네이션**: Offset 기반 (`page=0&size=20`) +- **엔드포인트**: 표준 CRUD / 중첩 리소스(2단계까지) / 소유자 기준 조회 + +**Controller 분리** + +- 고객 `{Domain}Controller` / Admin `Admin{Domain}Controller` +- Facade 공유 가능, Admin 로직 커지면 분리 + +**Swagger (ApiSpec 인터페이스)** + +- Swagger 어노테이션 → `{Domain}V1ApiSpec` / `Admin{Domain}V1ApiSpec` +- Spring MVC 어노테이션 → Controller에만 +- Controller가 ApiSpec을 `implements`, `@Override` 명시 +- 필수: `@Tag`(인터페이스), `@Operation`(메서드), `@Parameter`(파라미터) +- `@Schema`: 모호한 필드에만 추가 + +--- + +### Application 계층 + +- **Facade만 사용** (ApplicationService 없음) +- Facade: 유스케이스 조율, 도메인 간 흐름 조합, DTO 변환 +- @Transactional: **메서드 레벨**, 조회는 `readOnly = true` +- Facade + Domain Service **양쪽 모두** @Transactional (REQUIRED 전파) +- Facade → 타 도메인 **Service 직접 호출** OK, 타 **Facade 호출 금지** + +--- + +### Domain 계층 + +**Domain Service 허용 사항** + +- `@Service`, `@Transactional` 사용 가능 (Facade와 REQUIRED 전파) +- `Page`, `Pageable` (Spring Data 페이지네이션) 사용 가능 +- 금지: `@Component`, `@Repository`(Spring), `JpaRepository` 상속 + +**Entity** + +- 생성: **정적 팩토리 메서드** (`Order.create(...)`) +- 접근: `@NoArgsConstructor(PROTECTED)`, Setter 금지 +- 검증 훅: `guard()` override → `@PrePersist`/`@PreUpdate` 시 호출 +- 로직 배치: 자기 필드로 완결 → Entity, 그 외 → Domain Service + +**Value Object** + +- **기본 원칙: VO를 만들지 않는다** — 검증/행위는 Entity 도메인 메서드 또는 Domain Service에서 처리 +- 필드는 원시값(`int`, `String`, `LocalDate` 등)으로 선언 +- 예외적 VO 생성 조건: 도메인 행위 2개 이상 + 여러 도메인 중복 + `record`로 구현 (`@Embeddable` 지양) +- 검증: 자기 필드 → Entity `private static validateXxx`, 외부 의존 → Domain Service + +--- + +### Infrastructure 계층 + +**Repository 3-클래스 패턴** + +- `domain/{domain}/OrderRepository` — 순수 Java 인터페이스 (Spring 의존 없음) +- `infrastructure/{domain}/OrderJpaRepository` — Spring Data JPA +- `infrastructure/{domain}/OrderRepositoryImpl` — `@Repository`, 어댑터 + +**QueryDSL** + +- RepositoryImpl에 `JPAQueryFactory` 주입하여 직접 작성 +- 메서드 5개 이상이면 `{Domain}QueryRepository`로 분리 + +**DB 규칙** + +- **FK 미사용**: 같은 도메인 → 객체참조(`NO_CONSTRAINT`), 다른 도메인 → ID 참조 +- **@OneToMany 미사용**: ID 참조 + 별도 Repository 조회 +- **유니크 제약 사용**: 동시성 중복 방지 +- **BaseEntity**: `modules/jpa` 제공. id, createdAt, updatedAt, deletedAt, guard(), delete(), restore() + +--- + +## 상세 참조 가이드 + +**중요: 코드를 작성하거나 수정하기 전에, 해당 작업과 관련된 아래 reference 파일을 반드시 Read 도구로 읽어라.** +경로는 이 SKILL.md 파일 기준 상대경로이며, 절대경로로 변환하여 읽는다. + +### 공통 + +**패키지 구조** → `references/common/package-convention.md` +- 새 도메인 패키지 생성, 클래스 배치, 의존 방향, 도메인 간 참조 + +**예외처리 / API 응답** → `references/common/exception-convention.md` +- ErrorCode enum 추가, CoreException throw, ControllerAdvice, ApiResponse, Validation 에러 + +**기존 코드 마이그레이션** → `references/common/exception-migration-guide.md` +- ErrorType → ErrorCode 전환, CoreException/ApiControllerAdvice/ApiResponse 수정 + +**DTO** → `references/common/dto-convention.md` +- DTO 신규 생성, 계층 간 전달 객체, 변환 메서드(toCriteria, toCommand, from), record Inner Class + +**테스트** → `references/common/test-convention.md` +- 테스트 클래스 생성, 네이밍, 단위/통합/E2E 구분, Fake vs Mockito, DB 정리 + +**인라인 변수 & 코드 스타일** → `references/common/inline-variable-convention.md` +- 일회용 변수 인라인 판단, 변수 유지 조건, Chop-down 줄바꿈, 닫는 괄호 위치, 레이어별 예시 + +### Interface 계층 + +**API 설계** → `references/interfaces/api-convention.md` +- Controller 생성, URL 설계, HTTP 메서드/상태 코드, Admin/고객 분리, 페이지네이션 + +**Swagger 문서화** → `references/interfaces/swagger-convention.md` +- ApiSpec 인터페이스 생성, @Operation/@Tag/@Parameter, Controller 연결, @Schema + +### Application 계층 + +**서비스 계층** → `references/application/service-layer-convention.md` +- Facade/Domain Service 생성, 책임 배치, @Transactional, 타 도메인 접근, Facade 분리 + +### Domain 계층 + +**Entity / VO** → `references/domain/entity-vo-convention.md` +- Entity 생성, 정적 팩토리, VO 판단/구현, 도메인 로직 배치, 검증 위치 + +### Infrastructure 계층 + +**Infrastructure** → `references/infrastructure/infrastructure-convention.md` +- Repository 생성, QueryDSL, BaseEntity/guard(), FK/유니크, Soft delete 필터링 diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md new file mode 100644 index 000000000..3db5968fc --- /dev/null +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -0,0 +1,358 @@ +# 서비스 계층 책임 분리 컨벤션 + +## 목차 + +1. [계층 구조 개요](#1-계층-구조-개요) +2. [Facade — Application 계층](#2-facade--application-계층) +3. [Domain Service — Domain 계층](#3-domain-service--domain-계층) +4. [트랜잭션 규칙](#4-트랜잭션-규칙) +5. [계층 간 호출 규칙](#5-계층-간-호출-규칙) +6. [Facade가 커질 때](#6-facade가-커질-때) +7. [체크리스트](#체크리스트) + +--- + +## 1. 계층 구조 개요 + +``` +Controller + ↓ +Facade (@Transactional) ← Application 계층: 유스케이스 조율 + ├── OrderService (@Transactional) ← Domain 계층: 자기 도메인 비즈니스 로직 + ├── ProductService (@Transactional) ← Domain 계층: 타 도메인 Service 호출 가능 + └── MemberService (@Transactional) +``` + +| 계층 | 클래스 | 역할 | +|------|--------|------| +| **Application** | `{Domain}Facade` | 유스케이스 조율, 도메인 간 흐름 조합, DTO 변환 | +| **Domain** | `{Domain}Service` | 자기 도메인 비즈니스 로직, Repository 접근, Entity 조작 | + +Application 계층에는 **Facade만 둔다**. 별도 ApplicationService 개념을 두지 않는다. + +--- + +## 2. Facade — Application 계층 + +### 역할 + +- **유스케이스 조율**: 여러 Domain Service를 호출하여 하나의 비즈니스 흐름을 완성한다 +- **DTO 변환**: Interface ↔ Application DTO 변환의 중간 지점 +- **도메인 간 데이터 조합**: 여러 도메인의 Info를 Result로 묶어 반환한다 +- **타 도메인 Result → Command 변환**: 타 도메인 결과를 자기 도메인 Command로 변환한다 +- **트랜잭션 경계**: 여러 Domain Service 호출의 원자성을 보장한다 + +### Facade에 넣는 것 + +- 여러 Domain Service 조합 흐름 +- Entity → Result 변환, 여러 Info → Result 조합 +- 타 도메인 Result/Info → Command DTO 변환 후 자기 도메인 Service에 전달 + +### Facade에 넣지 않는 것 + +- 비즈니스 규칙, 검증 로직 → Domain Service 또는 Entity +- Repository 직접 호출 → Domain Service +- Entity 상태 변경 로직 → Entity 메서드 +- **private 메서드** → Facade에 private 메서드를 생성하지 않는다. private 메서드가 필요하다면 해당 로직은 Domain Service 또는 Entity에 속해야 한다는 신호이다 + +### 일괄 조회 원칙 (N+1 방지) + +Facade에서 여러 엔티티를 다룰 때 **반드시 IN절 일괄 조회**를 사용한다. 루프 안에서 개별 조회(`getById`)를 반복 호출하지 않는다. + +```java +// ❌ 금지: 루프 내 개별 조회 (N+1 쿼리) +criteria.items().stream() + .map(item -> { + ProductModel product = productService.getById(item.productId()); + ... + }); + +// ✅ 필수: ID 목록 추출 → IN절 일괄 조회 → Map으로 매핑 +List productIds = criteria.items().stream() + .map(CreateItem::productId) + .toList(); + +Map productMap = productService.getAllByIds(productIds).stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + +criteria.items().stream() + .map(item -> { + ProductModel product = productMap.get(item.productId()); + ... + }); +``` + +이 패턴은 Facade의 **흐름 조율** 역할에 부합한다: +- 필요한 데이터를 한 번에 확보한 후 흐름을 진행한다 +- Domain Service에 `getAllByIds(List)` 메서드를 제공하고, 조회 결과 개수 검증은 Service가 담당한다 + +### 예시 + +```java +@Service +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final MemberService memberService; + + @Transactional + public OrderResult createOrder(OrderCriteria.Create criteria) { + // 1. 타 도메인에서 필요한 데이터 조회 + MemberInfo member = memberService.getMember(criteria.memberId()); + List products = productService.getProducts(criteria.productIds()); + + // 2. 타 도메인 Info → 자기 도메인 Command 변환 + OrderMemberCommand memberCommand = OrderMemberCommand.from(member); + List productCommands = products.stream() + .map(OrderProductCommand::from) + .toList(); + + // 3. 자기 도메인 Service에 위임 + Order order = orderService.create(memberCommand, productCommands); + + // 4. Entity → Result 변환 후 반환 + return OrderResult.from(order); + } + + @Transactional(readOnly = true) + public OrderDetailResult getOrderDetail(OrderCriteria.Detail criteria) { + OrderInfo order = orderService.getOrderInfo(criteria.orderId()); + ProductInfo product = productService.getProduct(order.productId()); + MemberInfo member = memberService.getMember(order.memberId()); + + return new OrderDetailResult(order, product, member); + } +} +``` + +--- + +## 3. Domain Service — Domain 계층 + +### 역할 + +- **자기 도메인 비즈니스 로직**: Entity 생성, 상태 변경, 검증 +- **Repository 접근**: 조회, 저장, 삭제 +- **도메인 규칙 실행**: Entity만으로 완결되지 않는 로직 (Repository 조회 필요, 여러 Entity 조율) + +### Domain Service에 넣는 것 + +- Repository를 통한 Entity 조회/저장 +- Entity 생성 → 저장 흐름 +- Entity 자기 필드만으로 완결되지 않는 비즈니스 규칙 +- 같은 도메인 내 여러 Entity 조율 + +### Domain Service에 넣지 않는 것 + +- 타 도메인 Facade/Service 호출 → Facade에서 조율 +- DTO 변환 (Info → Response 등) → Facade 또는 Interface 계층 +- Entity 자기 필드만으로 완결되는 로직 → Entity 메서드 + +### CUD 메서드의 반환 규칙 + +Domain Service의 생성/수정/삭제(CUD) 메서드는 **기본적으로 void**를 반환한다. 명령과 조회를 분리하여 메서드의 의도를 명확하게 한다. + +**Entity 반환이 허용되는 경우:** +- Facade에서 생성된 Entity의 **ID나 상태를 즉시 사용**해야 할 때 (예: 생성 후 하위 엔티티에 ID 전달, 응답 반환) +- **별도 조회가 비효율적**일 때 (예: save() 직후 동일 Entity를 다시 findById()하는 것은 불필요) + +```java +// ✅ Entity 반환 — 생성 후 ID/상태를 Facade에서 즉시 사용 +@Transactional +public Order create(OrderMemberCommand member, List products) { + Order order = Order.create(member.memberId(), products); + return orderRepository.save(order); // 생성된 ID를 Facade에서 활용 +} + +// ✅ void — 상태 변경 후 반환할 필요 없음 +@Transactional +public void cancel(Long orderId) { + Order order = getOrder(orderId); + order.cancel(); +} +``` + +### 예시 + +```java +@Service +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional + public Order create(OrderMemberCommand member, List products) { + List lines = products.stream() + .map(p -> OrderLine.create(p.productId(), p.name(), p.price())) + .toList(); + Order order = Order.create(member.memberId(), lines); + return orderRepository.save(order); + } + + @Transactional + public void cancel(Long orderId) { + Order order = getOrder(orderId); + order.cancel(); // Entity에 위임 + } + + @Transactional(readOnly = true) + public Order getOrder(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderInfo(Long orderId) { + return OrderInfo.from(getOrder(orderId)); + } +} +``` + +--- + +## 4. 트랜잭션 규칙 + +### 메서드 레벨에 @Transactional을 붙인다 + +클래스 레벨이 아닌 **메서드 레벨**에 붙인다. 각 메서드의 트랜잭션 성격을 명시적으로 표현하기 위함이다. + +```java +@Service +public class OrderService { + + @Transactional // 변경 + public Order create(...) { ... } + + @Transactional // 변경 + public void cancel(Long orderId) { ... } + + @Transactional(readOnly = true) // 조회 + public Order getOrder(Long id) { ... } +} +``` + +클래스 레벨에 붙이지 않는 이유: +- 조회 메서드에도 불필요한 flush가 발생한다 +- 메서드별 트랜잭션 성격이 코드에서 보이지 않는다 +- 트랜잭션이 불필요한 메서드까지 포함될 수 있다 + +### 조회는 readOnly = true + +조회 전용 메서드에는 반드시 `@Transactional(readOnly = true)`를 붙인다. + +이점: +- JPA dirty checking flush 생략 → 성능 향상 +- DB 읽기 전용 힌트 → DB 최적화 가능 +- 실수로 변경 로직이 포함되면 예외 발생 → 안전장치 + +### Facade + Domain Service 양쪽 모두 @Transactional + +``` +Facade (@Transactional) ← 트랜잭션 시작 + └── OrderService (@Transactional) ← 기존 트랜잭션에 참여 (REQUIRED 전파) + └── ProductService (@Transactional) ← 기존 트랜잭션에 참여 +``` + +- Spring 기본 전파 옵션은 `REQUIRED` — 기존 트랜잭션이 있으면 참여, 없으면 새로 시작 +- Facade에서 시작한 트랜잭션에 Domain Service들이 참여한다 +- Domain Service를 단독 호출하면 자기가 트랜잭션을 시작한다 +- **어디서 호출하든 트랜잭션이 보장된다** + +### readOnly 전파 주의 + +```java +// ⚠️ 주의: Facade가 readOnly인데 하위가 변경하는 경우 +@Transactional(readOnly = true) // Facade +public OrderDetailResult getOrderDetail(...) { + orderService.updateViewCount(...); // ❌ readOnly 트랜잭션 안에서 변경 시도 → 문제 +} +``` + +Facade의 readOnly가 하위로 전파되므로, 조회 Facade에서 변경 Service를 호출하면 안 된다. + +--- + +## 5. 계층 간 호출 규칙 + +### 허용되는 호출 + +``` +Controller → Facade ✅ +Facade → 자기 도메인 Service ✅ +Facade → 타 도메인 Service ✅ +Domain Service → 자기 도메인 Repository ✅ +Entity → Entity (같은 Aggregate 내부) ✅ +``` + +### 금지되는 호출 + +``` +Facade → 타 Facade ❌ 순환 의존, 트랜잭션 경계 혼란 +Domain Service → 타 도메인 Service ❌ 도메인 간 결합 +Domain Service → Facade ❌ 하위 → 상위 역방향 +Domain Service → Repository (타 도메인) ❌ 도메인 간 결합 +Controller → Domain Service 직접 ❌ Facade 우회 +``` + +### Facade가 타 도메인에 접근하는 방법 + +Facade는 타 도메인의 **Domain Service**를 직접 주입받아 호출한다. + +```java +// ✅ 타 도메인 Service 직접 호출 +@Service +public class OrderFacade { + private final OrderService orderService; // 자기 도메인 + private final ProductService productService; // 타 도메인 + private final MemberService memberService; // 타 도메인 +} + +// ❌ 타 Facade 호출 금지 +@Service +public class OrderFacade { + private final ProductFacade productFacade; // 금지 +} +``` + +--- + +## 6. Facade가 커질 때 + +Facade가 비대해지면 **유스케이스 단위로 분리**한다. ApplicationService 개념을 도입하지 않는다. + +```java +// 처음: 하나의 Facade +OrderFacade + +// 커지면: 유스케이스별 분리 +OrderCommandFacade // 생성, 취소, 변경 +OrderQueryFacade // 조회, 검색, 목록 +``` + +분리 기준: +- Command(변경)와 Query(조회)가 자연스러운 첫 번째 분리 지점 +- 그래도 크면 유스케이스 단위로 더 분리 (OrderCheckoutFacade, OrderReturnFacade 등) + +--- + +## 체크리스트 + +**Facade** +- [ ] Facade에 비즈니스 규칙/검증 로직이 없는가? (Domain Service나 Entity에 있어야 함) +- [ ] Facade에 private 메서드가 없는가? (있다면 Domain Service 또는 Entity로 이동) +- [ ] Facade에서 Repository를 직접 호출하지 않는가? +- [ ] Facade가 타 Facade를 호출하지 않는가? +- [ ] 타 도메인 접근 시 타 도메인의 Domain Service를 직접 호출하는가? +- [ ] 여러 엔티티를 다룰 때 IN절 일괄 조회를 사용하는가? (루프 내 개별 조회 금지) + +**Domain Service** +- [ ] 자기 도메인의 Repository만 접근하는가? +- [ ] 타 도메인 Service나 Repository를 직접 호출하지 않는가? +- [ ] Entity만으로 완결되는 로직이 Entity에 있는가? (Service에 빼앗지 않았는가) + +**트랜잭션** +- [ ] @Transactional이 메서드 레벨에 붙어 있는가? (클래스 레벨 아님) +- [ ] 조회 메서드에 `readOnly = true`가 붙어 있는가? +- [ ] readOnly Facade에서 변경 Service를 호출하지 않는가? +- [ ] Facade와 Domain Service 양쪽 모두 @Transactional이 있는가? diff --git a/.claude/skills/project-convention/references/common/dto-convention.md b/.claude/skills/project-convention/references/common/dto-convention.md new file mode 100644 index 000000000..6f1a73cb6 --- /dev/null +++ b/.claude/skills/project-convention/references/common/dto-convention.md @@ -0,0 +1,233 @@ +# DTO 컨벤션 + +## 목차 + +1. [계층별 DTO 네이밍](#계층별-dto-네이밍) +2. [DTO 작성 규칙](#dto-작성-규칙) +3. [파라미터 전달 규칙](#파라미터-전달-규칙) +4. [계층 간 흐름](#계층-간-흐름-요약) +5. [체크리스트](#체크리스트) + +--- + +## 계층별 DTO 네이밍 + +| 계층 | 요청 (입력) | 응답 (출력) | 비고 | +|------|------------|------------|------| +| **Interface** | `~Request` | `~Response` | API 스펙 종속, `@Valid` 부착 | +| **Application** | `~Criteria` | `~Result` | 유스케이스 단위 입출력. Criteria는 Domain의 Command를 참조/조합 가능 | +| **Domain Service** | `~Command` | **Entity** 또는 `~Info` | 자기 도메인 비즈니스 입력 (타 도메인 정보 명세 포함) | + +- Domain Service는 **Entity를 직접 반환하는 게 기본**. `Info`는 Entity 하나로 표현이 안 될 때만 생성한다. +- Application `~Criteria`는 유스케이스 입력을 표현하며, 내부에서 Domain Service의 `~Command`를 참조하거나 조합할 수 있다. +- Application `~Result`는 유스케이스 결과를 표현한다 (단일/조합 구분 없이 통합). +- Domain Service `~Command`는 자기 도메인 비즈니스 명령과 타 도메인 정보 명세를 모두 포함한다. +- Domain 계층 자체(Entity)는 DTO를 사용하지 않는다. + +--- + +## DTO 작성 규칙 + +### 1. Inner Class + Record 활용 + +DTO는 **record**로 작성하고, 관련 DTO끼리 **Inner Class**로 그룹핑한다. + +```java +public class ProductDto { + + public record CreateRequest( + @NotBlank String name, + @Positive int price + ) { + public ProductCriteria.Create toCreateCriteria() { + return new ProductCriteria.Create(name, price); + } + } + + public record DetailResponse(Long id, String name, int price) { + public static DetailResponse from(ProductResult result) { + return new DetailResponse(result.id(), result.name(), result.price()); + } + } +} +``` + +```java +public class ProductCriteria { + public record Create(String name, int price) { + public ProductCommand.Create toCommand() { + return new ProductCommand.Create(name, price); + } + } +} +``` + +```java +public class ProductCommand { + public record Create(String name, int price) {} + public record Update(Long id, String name, int price) {} + public record StockDeduction(Long productId, int quantity, int expectedPrice) {} +} +``` + +```java +public class ProductInfo { + public record StockDeduction(Long productId, String name, int price, int quantity, Long brandId) {} +} +``` + +```java +public record ProductResult(Long id, String name, int price) { + public static ProductResult from(Product entity) { + return new ProductResult(entity.getId(), entity.getName(), entity.getPrice()); + } +} +``` + +> **Domain DTO 배치**: `~Command`와 `~Info`는 모두 `domain/{도메인}/dto/` 패키지에 배치한다. +> `{Domain}Command`, `{Domain}Info` 그룹 클래스 아래 Inner Class(record)로 그룹핑한다. + +### 2. 변환 메서드 위치: "아는 쪽"에 둔다 + +의존 방향(상위 → 하위)을 지키며, **변환 대상을 아는 쪽**에 메서드를 배치한다. + +| 변환 | 메서드 위치 | 형태 | 예시 | +|------|-----------|------|------| +| Request → Criteria | Request | `toCriteria()` | `request.toCreateCriteria()` | +| Criteria → Command | Criteria | `toCommand()` | `criteria.toCommand()` | +| Entity → Result | Result | `static from()` | `ProductResult.from(entity)` | +| Result → Response | Response | `static from()` | `DetailResponse.from(result)` | +| Entity/Info → Command (타 도메인) | Command | `static from()` | `OrderProductCommand.from(product)` | + +> **금지**: 하위 계층이 상위 계층을 아는 것. Domain이 Application DTO를, Application이 Interface DTO를 알면 안 된다. + +### 3. Domain Service의 Command / 반환 + +```java +// 주문 도메인이 상품 도메인에 요구하는 정보 명세 +public record OrderProductCommand(Long productId, String name, int price, Long shopId) { + public static OrderProductCommand from(ProductResult product) { + return new OrderProductCommand( + product.id(), product.name(), product.price(), product.shopId() + ); + } +} + +// 기본: Entity 직접 반환 +public Order create(OrderMemberCommand member, List products) { + return Order.create(member.memberId(), products); +} + +// Entity로 표현 불가능할 때만 Info 생성 — {Domain}Info의 Inner Class로 작성 +// 위치: domain/{도메인}/dto/{Domain}Info.java +public class ProductInfo { + public record StockDeduction(Long productId, String name, int price, int quantity, Long brandId) {} +} +``` + +--- + +## 파라미터 전달 규칙 + +### 1. Application → Domain Service 입력 + +파라미터 개수에 따라 전달 방식을 결정한다. + +| 파라미터 수 | 전달 방식 | 예시 | +|------------|----------|------| +| **1~3개** | 원시 타입 직접 전달 | `orderService.create(memberId, address, shopId)` | +| **4개 이상** | DTO(`~Command`) 사용 | `orderService.create(orderProductCommand)` | + +> **주의 — Entity 필드는 원시값으로 전달한다.** +> Entity 필드에 대응하는 값은 원시 타입(`int`, `String`, `LocalDate` 등)으로 직접 전달한다 (→ entity-vo-convention 참조). + +```java +// ✅ 파라미터 3개 이하 → 원시 타입 +public Order create(Long memberId, String address, Long shopId) { ... } + +// ✅ 파라미터 4개 이상 → Command DTO +public Order create(OrderProductCommand productCommand) { ... } +``` + +### 2. 절대 금지: Domain Service에 타 도메인 객체 노출 + +Domain Service의 메서드 시그니처에 **타 도메인의 Entity/VO가 직접 나타나면 안 된다.** + +```java +// ❌ 절대 금지 - 주문 도메인이 Product 엔티티를 직접 참조 +public class OrderService { + public Order create(Member member, List products) { ... } +} + +// ✅ Command로 변환하여 전달 - 도메인 간 결합 제거 +public class OrderService { + public Order create(OrderMemberCommand member, List products) { ... } +} +``` + +> Application 계층(Facade)이 타 도메인 Entity/Result → Command 변환을 책임진다. + +### 3. 타 도메인 출력 조합: Application에서 `~Result` + +여러 도메인의 Info를 합쳐야 할 때, **Application 계층이 `~Result`로 조합**한다. + +```java +public record OrderDetailResult( + OrderInfo order, + ProductInfo product, + MemberInfo member +) {} +``` + +```java +@Service +public class OrderFacade { + + public OrderDetailResult getDetail(OrderCriteria.Detail criteria) { + OrderInfo order = orderService.getOrder(criteria.orderId()); + ProductInfo product = productService.getProduct(order.productId()); + MemberInfo member = memberService.getMember(order.memberId()); + + return new OrderDetailResult(order, product, member); + } +} +``` + +### Domain Service 응답 기준 + +| 상황 | Domain Service 응답 | 사용 시점 | +|------|-------------------|----------| +| Entity로 충분 | **Entity 직접 반환** | 대부분의 경우 | +| Entity로 표현 불가 | `~Info` | 복합 결과가 필요할 때 | + +--- + +## 계층 간 흐름 요약 + +``` +Client + → ProductCreateRequest (Interface 입력) + → ProductCriteria.Create (Application 입력 — Command 참조 가능) + → ProductCommand.Create (Domain 입력) + ← Entity 또는 ~Info (Domain 출력) + ← ProductResult (Application 출력) + ← ProductCreateResponse (Interface 출력) +``` + +--- + +## 체크리스트 + +- [ ] DTO는 record로 작성했는가? +- [ ] 관련 DTO끼리 Inner Class로 그룹핑했는가? +- [ ] 변환 메서드가 "아는 쪽"에 있는가? (의존 방향 위반 없는가?) +- [ ] Interface DTO에만 `@Valid`, `@JsonProperty` 등이 붙어 있는가? +- [ ] Application DTO(Criteria/Result)에 API 스펙 관련 어노테이션이 없는가? +- [ ] Domain Service가 Application DTO를 참조하지 않는가? +- [ ] Domain Service의 Info는 Entity로 충분하지 않을 때만 만들었는가? +- [ ] Domain Service 파라미터 1~3개는 원시 타입, 4개+는 Command DTO인가? +- [ ] Domain Service 메서드 시그니처에 타 도메인 Entity가 노출되지 않는가? +- [ ] 여러 도메인 Info 조합 시 Application에서 `~Result`로 합치는가? +- [ ] Domain DTO(`~Command`, `~Info`)가 `domain/{도메인}/dto/` 패키지에 있는가? +- [ ] Domain `~Command`는 `{Domain}Command`의 Inner Class로 그룹핑되었는가? +- [ ] Domain `~Info`는 `{Domain}Info`의 Inner Class로 그룹핑되었는가? diff --git a/.claude/skills/project-convention/references/common/exception-convention.md b/.claude/skills/project-convention/references/common/exception-convention.md new file mode 100644 index 000000000..cdaf3f113 --- /dev/null +++ b/.claude/skills/project-convention/references/common/exception-convention.md @@ -0,0 +1,333 @@ +# 예외처리 및 API 응답 컨벤션 + +## 목차 + +1. [예외 구조](#1-예외-구조) +2. [에러코드 규칙](#2-에러코드-규칙) +3. [API 응답 포맷](#3-api-응답-포맷) +4. [ControllerAdvice 구조](#4-controlleradvice-구조) +5. [예외 흐름](#5-예외-흐름) +6. [패키지 배치](#6-패키지-배치) +7. [도메인별 ErrorCode 추가 가이드](#7-도메인별-errorcode-추가-가이드) +8. [체크리스트](#체크리스트) + +--- + +## 1. 예외 구조 + +### 클래스 다이어그램 + +``` +CoreException (단일 예외 클래스) + └─ ErrorCode (interface) + ├── ErrorType (enum) ← 공통 에러 + ├── OrderErrorCode (enum) ← 주문 도메인 + ├── ProductErrorCode (enum) ← 상품 도메인 + └── ... ← 필요 시 추가 +``` + +### ErrorCode 인터페이스 + +```java +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} +``` + +### ErrorType — 공통 에러 + +```java +@Getter +@RequiredArgsConstructor +public enum ErrorType implements ErrorCode { + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "Bad Request", "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증에 실패했습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "Not Found", "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, "Conflict", "이미 존재하는 리소스입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +- 공통 에러의 code는 **HttpStatus reason phrase**를 그대로 사용 +- 도메인 에러와 구분: code에 `_`가 포함되면 도메인, 아니면 공통 + +### CoreException + +```java +@Getter +public class CoreException extends RuntimeException { + private final ErrorCode errorCode; + private final String customMessage; + + public CoreException(ErrorCode errorCode) { + this(errorCode, null); + } + + public CoreException(ErrorCode errorCode, String customMessage) { + super(customMessage != null ? customMessage : errorCode.getMessage()); + this.errorCode = errorCode; + this.customMessage = customMessage; + } +} +``` + +### 도메인별 ErrorCode + +```java +@Getter +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "ORDER_001", "이미 취소된 주문입니다."), + STOCK_INSUFFICIENT(HttpStatus.BAD_REQUEST, "ORDER_002", "재고가 부족합니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +--- + +## 2. 에러코드 규칙 + +### code 체계 + +| 구분 | 형식 | 예시 | +|------|------|------| +| 공통 | HttpStatus reason phrase | `"Bad Request"`, `"Not Found"` | +| 도메인 | `{DOMAIN}_{NNN}` | `"ORDER_001"`, `"PRODUCT_002"` | + +### 번호 규칙 + +- 001부터 단순 순번 +- enum 선언 순서 = 번호 순서 +- 삭제된 번호는 결번 처리 (재사용 금지) + +### 사용법 + +```java +throw new CoreException(ErrorType.NOT_FOUND); // 공통 +throw new CoreException(OrderErrorCode.ALREADY_CANCELLED); // 도메인 +throw new CoreException(OrderErrorCode.STOCK_INSUFFICIENT, "상품 A 재고 부족"); // 커스텀 메시지 +``` + +--- + +## 3. API 응답 포맷 + +### ApiResponse 구조 + +```java +public record ApiResponse(Metadata meta, T data) { + + public record Metadata(Result result, String errorCode, String message) { + public enum Result { SUCCESS, FAIL } + + public static Metadata success() { + return new Metadata(Result.SUCCESS, null, null); + } + + public static Metadata fail(String errorCode, String errorMessage) { + return new Metadata(Result.FAIL, errorCode, errorMessage); + } + } + + public record FieldError(String field, Object value, String reason) {} + + // 성공 + public static ApiResponse success() { + return new ApiResponse<>(Metadata.success(), null); + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(Metadata.success(), data); + } + + // 실패 — 일반 에러 + public static ApiResponse fail(String errorCode, String errorMessage) { + return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), null); + } + + // 실패 — Validation 에러 (필드별 상세) + public static ApiResponse> failValidation( + String errorCode, String errorMessage, List fieldErrors) { + return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), fieldErrors); + } +} +``` + +### 응답 예시 + +```json +// 성공 (데이터 없음) +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": null +} + +// 성공 (데이터 있음) +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { "id": 1, "name": "상품A", "price": 50000 } +} + +// 실패 — 비즈니스 에러 +{ + "meta": { "result": "FAIL", "errorCode": "ORDER_001", "message": "이미 취소된 주문입니다." }, + "data": null +} + +// 실패 — 공통 에러 +{ + "meta": { "result": "FAIL", "errorCode": "Not Found", "message": "존재하지 않는 요청입니다." }, + "data": null +} + +// 실패 — Validation 에러 +{ + "meta": { "result": "FAIL", "errorCode": "Bad Request", "message": "잘못된 요청입니다." }, + "data": [ + { "field": "price", "value": -1000, "reason": "0보다 커야 합니다" }, + { "field": "name", "value": "", "reason": "공백일 수 없습니다" } + ] +} +``` + +--- + +## 4. ControllerAdvice 구조 + +### 처리 우선순위 + +| 예외 타입 | 성격 | 응답 형태 | +|----------|------|----------| +| `CoreException` | 비즈니스/도메인 에러 | `meta` + `data: null` | +| `MethodArgumentNotValidException` | @Valid 검증 실패 | `meta` + `data: FieldError[]` | +| `MethodArgumentTypeMismatchException` | 파라미터 타입 불일치 | `meta` + `data: null` | +| `MissingServletRequestParameterException` | 필수 파라미터 누락 | `meta` + `data: null` | +| `HttpMessageNotReadableException` | JSON 파싱 실패 (세분화) | `meta` + `data: null` | +| `NoResourceFoundException` | 존재하지 않는 리소스 | `meta` + `data: null` | +| `Throwable` | 예상 못한 에러 (최후 방어) | `meta` + `data: null` | + +### 핵심 원칙 + +- `@RestControllerAdvice` **하나**로 모든 예외 일괄 처리 +- `CoreException` 핸들러에서 `ErrorCode` 인터페이스로 공통/도메인 에러 통합 처리 +- `HttpMessageNotReadableException`은 `InvalidFormatException`, `MismatchedInputException`, `JsonMappingException`까지 세분화 +- `Throwable` 최후 방어 핸들러 필수 +- 예외는 발생 지점에서 그대로 전파, Application에서 잡아서 변환하지 않는다 +- **`@ExceptionHandler`는 응답 생성 책임만 가진다** — DB 저장 등 부가 작업을 핸들러 내부에서 수행하면, 부가 작업 예외 시 핸들러가 삼켜져(swallowed) 디버깅이 극히 어려워진다. 부가 작업이 필요하면 `ApplicationEventPublisher` + `@Async @EventListener`로 분리한다. + +### ⚠️ `@ExceptionHandler`의 처리 경계 + +`@ExceptionHandler`는 `DispatcherServlet` **내부에서만** 동작한다. Filter에서 발생한 예외는 잡을 수 없다. + +``` +WAS → Filter → DispatcherServlet → Interceptor → Controller + ↑ ↑ + @ExceptionHandler 못 잡음 @ExceptionHandler 잡음 +``` + +Filter 예외 처리 시 `HttpServletResponse`에 직접 JSON 응답을 작성해야 하며, 이때 `ApiResponse` 형식을 맞추기 위해 `ObjectMapper`를 사용한다. + +```java +// Filter 내 예외 처리 패턴 +catch (AuthenticationException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse errorResponse = ApiResponse.fail( + ErrorType.UNAUTHORIZED.getCode(), e.getMessage()); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); +} +``` + +--- + +## 5. 예외 흐름 + +### DispatcherServlet 내부 (일반 흐름) + +``` +Domain에서 throw new CoreException(OrderErrorCode.STOCK_INSUFFICIENT) + → Application 통과 (잡지 않음) + → Controller 통과 + → @RestControllerAdvice에서 catch + → ErrorCode에서 status, code, message 추출 + → ApiResponse.fail(code, message) 반환 + +@Valid 실패 시 + → Controller 진입 전 MethodArgumentNotValidException 발생 + → @RestControllerAdvice에서 catch + → FieldError 목록 추출 + → ApiResponse.failValidation(code, message, fieldErrors) 반환 +``` + +### Filter 예외 (DispatcherServlet 외부) + +``` +Filter에서 인증 실패 등 예외 발생 + → @RestControllerAdvice 도달 불가 (DispatcherServlet 바깥) + → Filter 내부에서 HttpServletResponse에 직접 JSON 작성 + → ApiResponse.fail() 형식으로 응답 (ObjectMapper 사용) +``` + +--- + +## 6. 패키지 배치 + +``` +support/ +└── error/ + ├── ErrorCode.java ← 인터페이스 + ├── ErrorType.java ← 공통 에러 enum + └── CoreException.java ← 단일 예외 클래스 + +domain/ +├── order/ +│ └── OrderErrorCode.java ← 도메인 패키지 내 배치 +└── product/ + └── ProductErrorCode.java + +interfaces/ +└── api/ + ├── ApiResponse.java ← 공통 응답 포맷 + └── ApiControllerAdvice.java ← 글로벌 예외 핸들러 +``` + +--- + +## 7. 도메인별 ErrorCode 추가 가이드 + +1. `{Domain}ErrorCode` enum 생성, `ErrorCode` 인터페이스 구현 +2. code는 `{DOMAIN}_{001}`부터 순번 부여 +3. 도메인 패키지 내 배치 +4. 사용: `throw new CoreException({Domain}ErrorCode.XXX)` + +--- + +## 체크리스트 + +**예외 설계** +- [ ] 새 에러가 공통(`ErrorType`)인가 도메인(`XxxErrorCode`)인가 판단했는가? +- [ ] 도메인 에러코드 번호가 기존 순번 다음인가? (결번 재사용 금지) +- [ ] `CoreException`에 `ErrorCode` 인터페이스 구현체를 넘기고 있는가? + +**예외 흐름** +- [ ] Domain 예외를 Application에서 잡아서 변환하고 있지 않은가? (그대로 전파) +- [ ] ControllerAdvice에 `Throwable` 최후 방어 핸들러가 있는가? +- [ ] Filter 예외 처리 시 `ApiResponse` 형식으로 직접 JSON을 작성하고 있는가? + +**ControllerAdvice 설계** +- [ ] `@ExceptionHandler`가 응답 생성 외 부가 작업(DB 저장 등)을 직접 수행하고 있지 않은가? + +**API 응답** +- [ ] 성공 응답이 `ApiResponse.success()` 또는 `ApiResponse.success(data)`를 사용하는가? +- [ ] Validation 에러는 `failValidation`으로 `FieldError[]`를 반환하는가? +- [ ] 일반 에러는 `fail(code, message)`로 `data: null`을 반환하는가? diff --git a/.claude/skills/project-convention/references/common/exception-migration-guide.md b/.claude/skills/project-convention/references/common/exception-migration-guide.md new file mode 100644 index 000000000..b8c6a12f7 --- /dev/null +++ b/.claude/skills/project-convention/references/common/exception-migration-guide.md @@ -0,0 +1,170 @@ +# 예외처리 마이그레이션 가이드 + +기존 코드에서 수정하거나 추가해야 할 항목 목록. + +--- + +## 1. 신규 생성 + +### `ErrorCode` 인터페이스 +- 위치: `com.loopers.support.error.ErrorCode` + +```java +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} +``` + +### `FieldError` record (ApiResponse 내부 또는 별도) +- Validation 에러 필드별 상세용 + +--- + +## 2. 기존 코드 수정 + +### `ErrorType` — `ErrorCode` 인터페이스 구현 추가 + +```diff +- public enum ErrorType { ++ public enum ErrorType implements ErrorCode { +``` + +변경 없이 `implements ErrorCode`만 추가하면 기존 필드(`status`, `code`, `message`)가 인터페이스를 이미 만족하므로 다른 수정 불필요. + +--- + +### `CoreException` — `ErrorType` → `ErrorCode`로 변경 + +```diff + @Getter + public class CoreException extends RuntimeException { +- private final ErrorType errorType; ++ private final ErrorCode errorCode; + private final String customMessage; + +- public CoreException(ErrorType errorType) { +- this(errorType, null); ++ public CoreException(ErrorCode errorCode) { ++ this(errorCode, null); + } + +- public CoreException(ErrorType errorType, String customMessage) { +- super(customMessage != null ? customMessage : errorType.getMessage()); +- this.errorType = errorType; ++ public CoreException(ErrorCode errorCode, String customMessage) { ++ super(customMessage != null ? customMessage : errorCode.getMessage()); ++ this.errorCode = errorCode; + this.customMessage = customMessage; + } + } +``` + +--- + +### `ApiResponse` — `failValidation` 메서드 추가 + +```diff + public record ApiResponse(Metadata meta, T data) { + ++ public record FieldError(String field, Object value, String reason) {} + + // 기존 메서드 유지 ... + ++ public static ApiResponse> failValidation( ++ String errorCode, String errorMessage, List fieldErrors) { ++ return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), fieldErrors); ++ } + } +``` + +--- + +### `ApiControllerAdvice` — 3곳 수정 + +#### (1) `handle(CoreException e)` — getter 이름 변경 + +```diff + @ExceptionHandler + public ResponseEntity> handle(CoreException e) { + log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); +- return failureResponse(e.getErrorType(), e.getCustomMessage()); ++ return failureResponse(e.getErrorCode(), e.getCustomMessage()); + } +``` + +#### (2) `handleBadRequest(MethodArgumentNotValidException e)` — 필드별 에러 반환 + +```diff + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { +- FieldError fieldError = e.getBindingResult().getFieldError(); +- String message = fieldError != null ? fieldError.getDefaultMessage() : "잘못된 요청입니다."; +- return failureResponse(ErrorType.BAD_REQUEST, message); ++ List fieldErrors = e.getBindingResult() ++ .getFieldErrors() ++ .stream() ++ .map(error -> new ApiResponse.FieldError( ++ error.getField(), ++ error.getRejectedValue(), ++ error.getDefaultMessage() ++ )) ++ .toList(); ++ ++ return ResponseEntity.badRequest() ++ .body(ApiResponse.failValidation( ++ ErrorType.BAD_REQUEST.getCode(), ++ ErrorType.BAD_REQUEST.getMessage(), ++ fieldErrors ++ )); + } +``` + +#### (3) `failureResponse` — `ErrorType` → `ErrorCode` + +```diff +- private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { +- return ResponseEntity.status(errorType.getStatus()) +- .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); ++ private ResponseEntity> failureResponse(ErrorCode errorCode, String errorMessage) { ++ return ResponseEntity.status(errorCode.getStatus()) ++ .body(ApiResponse.fail(errorCode.getCode(), errorMessage != null ? errorMessage : errorCode.getMessage())); + } +``` + +--- + +## 3. 도메인별 ErrorCode enum — 필요할 때 추가 + +예시: `com.loopers.domain.order.OrderErrorCode` + +```java +@Getter +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "ORDER_001", "이미 취소된 주문입니다."), + STOCK_INSUFFICIENT(HttpStatus.BAD_REQUEST, "ORDER_002", "재고가 부족합니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +--- + +## 변경 영향 범위 + +| 파일 | 변경 유형 | 영향도 | +|------|----------|--------| +| `ErrorCode.java` | **신규** | 없음 | +| `ErrorType.java` | `implements ErrorCode` 추가 | 없음 (기존 호환) | +| `CoreException.java` | 필드 타입 변경 | **기존 `getErrorType()` 호출부 전체** | +| `ApiResponse.java` | `FieldError`, `failValidation` 추가 | 없음 (기존 호환) | +| `ApiControllerAdvice.java` | 3곳 수정 | 해당 파일만 | +| 기존 `throw new CoreException(ErrorType.XXX)` | **변경 불필요** | `ErrorType`이 `ErrorCode` 구현하므로 호환 | diff --git a/.claude/skills/project-convention/references/common/inline-variable-convention.md b/.claude/skills/project-convention/references/common/inline-variable-convention.md new file mode 100644 index 000000000..88778fd33 --- /dev/null +++ b/.claude/skills/project-convention/references/common/inline-variable-convention.md @@ -0,0 +1,229 @@ +# 인라인 변수 컨벤션 (Inline Variable Convention) + +> 일회용 지역변수를 제거하고, 객체 생성과 사용을 한 흐름으로 응집시킨다. + +## 목차 + +1. [적용 범위](#적용-범위) +2. [핵심 원칙](#핵심-원칙) +3. [줄바꿈 & 들여쓰기 스타일](#줄바꿈--들여쓰기-스타일) +4. [레이어별 적용 예시](#레이어별-적용-예시) +5. [판단 플로우차트](#판단-플로우차트) +6. [체크리스트](#체크리스트) + +--- + +## 적용 범위 + +모든 레이어 (Controller, Facade, Service, Domain) + +--- + +## 핵심 원칙 + +### 1. 일회용 지역변수는 인라인한다 + +변수가 **생성 직후 단 한 번만 참조**되면 인라인한다. + +```java +// ❌ Bad — 일회용 변수가 코드만 늘린다 +OrderCriteria.ListByDate criteria = new OrderCriteria.ListByDate(startAt, endAt); +List results = orderFacade.getMyOrders(loginUser.id(), criteria); +OrderResponse.ListResponse listResponse = new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList()); +return ApiResponse.success(listResponse); + +// ✅ Good — 흐름이 한눈에 읽힌다 +List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderCriteria.ListByDate(startAt, endAt)); + +return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); +``` + +### 2. 객체 생성과 사용을 분리하지 않는다 + +객체를 만들어 변수에 담고 → 다른 곳에 넘기는 2단계 패턴은 응집도를 떨어뜨린다. + +```java +// ❌ Bad — 생성과 사용이 분리되어 응집도 저하 +OrderItem orderItem = new OrderItem(product, quantity); +order.add(orderItem); + +// ✅ Good — 생성과 사용이 한 표현식 +order.add(new OrderItem(product, quantity)); +``` + +```java +// ❌ Bad +Address address = new Address(city, street, zipCode); +member.changeAddress(address); + +// ✅ Good +member.changeAddress(new Address(city, street, zipCode)); +``` + +### 3. 변수를 유지하는 경우 + +다음 조건 중 하나라도 해당하면 변수로 추출한다. + +| 조건 | 이유 | 예시 | +|------|------|------| +| **2회 이상 참조** | 중복 호출 방지 | `results`를 응답 변환 + 로깅에 사용 | +| **의미 경계가 달라지는 지점** | 가독성, 디버깅 용이 | Facade/Service 호출 결과 | +| **인라인 시 한 줄이 3단계 이상 중첩** | 가독성 한계 | 아래 예시 참고 | + +```java +// 인라인 시 중첩이 너무 깊어지는 경우 → 변수 추출 +// ❌ 과도한 인라인 +return ApiResponse.success( + new OrderResponse.DetailResponse( + OrderResponse.OrderDetail.from( + orderFacade.getOrderDetail( + loginUser.id(), + new OrderCriteria.Detail(orderId))))); + +// ✅ 의미 경계에서 끊는다 +OrderResult.OrderDetail result = + orderFacade.getOrderDetail( + loginUser.id(), + new OrderCriteria.Detail(orderId)); + +return ApiResponse.success( + new OrderResponse.DetailResponse( + OrderResponse.OrderDetail.from(result))); +``` + +--- + +## 줄바꿈 & 들여쓰기 스타일 + +### Chop-down 스타일 (권장) + +메서드 인자가 한 줄에 안 들어가면, **첫 번째 인자부터 줄바꿈** + **8칸(continuation indent)** 적용한다. + +```java +// 한 줄에 들어가면 그대로 +order.add(new OrderItem(product, quantity)); + +// 안 들어가면 chop-down +List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderCriteria.ListByDate(startAt, endAt)); +``` + +**왜 이 스타일인가?** + +| 스타일 | 문제점 | +|--------|--------| +| 괄호 정렬 (Align to parenthesis) | 메서드명 길이에 따라 들여쓰기가 변동, diff가 지저분함 | +| **Chop-down (권장)** | **일관된 indent, 리네임해도 diff 깔끔** | + +### IntelliJ 설정 + +``` +Settings > Editor > Code Style > Java > Wrapping and Braces +├── Method call arguments: Chop down if long +├── Continuation indent: 8 +└── Align when multiline: OFF (체크 해제) +``` + +### 닫는 괄호 위치 + +연쇄된 닫는 괄호 `))` 는 마지막 인자 뒤에 붙인다 (별도 줄 X). + +```java +// ✅ 닫는 괄호는 마지막 인자에 붙인다 +return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); + +// ❌ 닫는 괄호를 별도 줄에 내리지 않는다 +return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList() + ) +); +``` + +--- + +## 레이어별 적용 예시 + +### Controller + +```java +@GetMapping +@Override +public ApiResponse list( + @Login LoginUser loginUser, + @RequestParam ZonedDateTime startAt, + @RequestParam ZonedDateTime endAt +) { + List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderCriteria.ListByDate(startAt, endAt)); + + return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); +} +``` + +### Service / Facade + +```java +public OrderResult.OrderDetail getOrderDetail(Long userId, OrderCriteria.Detail criteria) { + Order order = orderReader.read(criteria.orderId()); + order.validateOwner(userId); + + return OrderResult.OrderDetail.from(order); +} +``` + +### Domain + +```java +// ❌ Bad +public void addItem(Product product, int quantity) { + OrderItem orderItem = new OrderItem(product, quantity, product.getPrice()); + this.orderItems.add(orderItem); + this.totalAmount = calculateTotal(); +} + +// ✅ Good +public void addItem(Product product, int quantity) { + this.orderItems.add(new OrderItem(product, quantity, product.getPrice())); + this.totalAmount = calculateTotal(); +} +``` + +--- + +## 판단 플로우차트 + +``` +변수 선언을 만났다 + └─ 이 변수가 2회 이상 참조되는가? + ├─ YES → 변수 유지 + └─ NO → 인라인 시 중첩이 3단계 이상인가? + ├─ YES → 의미 경계에서 변수 추출 + └─ NO → 인라인한다 +``` + +--- + +## 체크리스트 + +- [ ] 일회용 지역변수(생성 직후 1회만 참조)를 인라인했는가? +- [ ] 객체 생성과 사용이 한 표현식으로 응집되어 있는가? +- [ ] 2회 이상 참조되는 변수는 변수로 유지했는가? +- [ ] 인라인 시 3단계 이상 중첩이 발생하면 의미 경계에서 변수를 추출했는가? +- [ ] 줄바꿈이 Chop-down 스타일(8칸 continuation indent)을 따르는가? +- [ ] 닫는 괄호 `))` 가 마지막 인자 뒤에 붙어 있는가? (별도 줄 X) +- [ ] 괄호 정렬(Align to parenthesis)을 사용하지 않았는가? diff --git a/.claude/skills/project-convention/references/common/package-convention.md b/.claude/skills/project-convention/references/common/package-convention.md new file mode 100644 index 000000000..47cc0170d --- /dev/null +++ b/.claude/skills/project-convention/references/common/package-convention.md @@ -0,0 +1,280 @@ +# 패키지 구조 컨벤션 + +## 목차 + +1. [전체 구조](#1-전체-구조) +2. [계층별 패키지 상세](#2-계층별-패키지-상세) +3. [공통 패키지 상세](#3-공통-패키지-상세) +4. [계층별 클래스 배치 규칙](#4-계층별-클래스-배치-규칙) +5. [의존 방향 규칙](#5-의존-방향-규칙) +6. [새 도메인 추가 가이드](#6-새-도메인-추가-가이드) +7. [체크리스트](#체크리스트) + +--- + +## 1. 전체 구조 + +**계층 우선 + 도메인 하위** 방식을 사용한다. 최상위는 계층(interfaces/application/domain/infrastructure)으로 나누고, 각 계층 안에서 도메인별로 분리한다. + +``` +com.loopers/ +│ +├── interfaces/ ← Interface 계층 +│ ├── api/ ← 공통 (ApiResponse, ControllerAdvice) +│ ├── order/ ← 주문 Controller, Request/Response DTO +│ ├── product/ ← 상품 Controller, Request/Response DTO +│ └── like/ ← 좋아요 Controller, Request/Response DTO +│ +├── application/ ← Application 계층 +│ ├── order/ ← 주문 Facade, Criteria/Result DTO +│ ├── product/ +│ └── like/ +│ +├── domain/ ← Domain 계층 +│ ├── order/ ← 주문 Entity, Service, Repository(I/F), ErrorCode +│ ├── product/ +│ └── like/ +│ +├── infrastructure/ ← Infrastructure 계층 +│ ├── order/ ← 주문 Repository 구현, JPA +│ ├── product/ +│ └── like/ +│ +└── support/ ← 공통 지원 (에러, 설정, 유틸) + ├── error/ + ├── config/ + └── util/ +``` + +왜 계층 우선인가: +- 계층 간 의존 방향이 패키지 레벨에서 시각적으로 명확하다 +- 같은 계층의 클래스를 한 곳에서 파악할 수 있다 (모든 Controller가 interfaces/ 아래) +- 계층별 공통 패턴이나 Base 클래스를 자연스럽게 배치할 수 있다 + +--- + +## 2. 계층별 패키지 상세 + +### interfaces/ — 풀 구조 (대규모 도메인) + +``` +interfaces/ +├── api/ ← 공통 +│ ├── ApiResponse.java +│ └── ApiControllerAdvice.java +│ +└── order/ ← 도메인별 + ├── OrderController.java ← 고객용 Controller + ├── AdminOrderController.java ← Admin용 Controller + └── dto/ + ├── OrderDto.java ← Inner Class: CreateRequest, DetailResponse ... + └── AdminOrderDto.java ← Admin용 Request/Response +``` + +### application/ — 풀 구조 + +``` +application/ +└── order/ + ├── OrderFacade.java + └── dto/ + ├── OrderCriteria.java ← Inner Class: Create, Detail ... + ├── OrderResult.java ← 유스케이스 결과 + └── OrderDetailResult.java ← 다중 도메인 조합 응답 (필요 시) +``` + +### domain/ — 풀 구조 + +``` +domain/ +└── order/ + ├── Order.java ← Entity (Aggregate Root) + ├── OrderLine.java ← Entity (하위) + ├── OrderStatus.java ← enum + ├── OrderService.java ← Domain Service + ├── OrderRepository.java ← Repository 인터페이스 + ├── OrderErrorCode.java ← 도메인 에러코드 + └── dto/ ← 도메인 DTO (필요 시) + ├── OrderProductCommand.java ← 타 도메인 정보 명세 + └── OrderMemberCommand.java +``` + +### infrastructure/ — 풀 구조 + +``` +infrastructure/ +└── order/ + ├── OrderRepositoryImpl.java ← Repository 구현체 + └── OrderJpaRepository.java ← Spring Data JPA 인터페이스 +``` + +### 간소 구조 (소규모 도메인) + +빈 계층 패키지는 만들지 않는다. 필요해지면 그때 추가한다. + +``` +interfaces/ +└── wishlist/ + ├── WishlistController.java + └── dto/ + └── WishlistDto.java + +application/ +└── wishlist/ + ├── WishlistFacade.java + └── dto/ + └── WishlistResult.java + +domain/ +└── wishlist/ + ├── Wishlist.java + ├── WishlistService.java + └── WishlistRepository.java +``` + +infrastructure가 JpaRepository 하나뿐이면 domain 패키지에 인터페이스만 두고 Spring Data JPA가 자동 구현하도록 한다. + +--- + +## 3. 공통 패키지 상세 + +### 공통 인터페이스 + +도메인에 속하지 않는 API 레벨 공통 클래스. + +``` +interfaces/ +└── api/ + ├── ApiResponse.java ← 공통 응답 포맷 + └── ApiControllerAdvice.java ← 글로벌 예외 핸들러 +``` + +### support + +도메인 로직이 아닌 **기술 지원** 클래스. + +``` +support/ +├── error/ +│ ├── ErrorCode.java ← 인터페이스 +│ ├── ErrorType.java ← 공통 에러 enum +│ └── CoreException.java ← 단일 예외 클래스 +├── config/ +│ ├── WebMvcConfig.java +│ ├── SecurityConfig.java +│ └── JpaConfig.java +└── util/ ← 필요 시만 생성 + └── DateUtils.java +``` + +support에 넣으면 안 되는 것: +- 도메인 로직이 포함된 클래스 → 해당 도메인 패키지로 +- 특정 도메인에만 쓰이는 유틸 → 해당 도메인 패키지로 + +--- + +## 4. 계층별 클래스 배치 규칙 + +### interfaces/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Controller (고객) | `{Domain}Controller` | `OrderController` | +| Controller (Admin) | `Admin{Domain}Controller` | `AdminOrderController` | +| Request DTO | `{Domain}Dto.{Action}Request` | `OrderDto.CreateRequest` | +| Response DTO | `{Domain}Dto.{Action}Response` | `OrderDto.DetailResponse` | +| Admin DTO | `Admin{Domain}Dto.{Action}Response` | `AdminOrderDto.DetailResponse` | + +### application/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Facade | `{Domain}Facade` | `OrderFacade` | +| Criteria DTO | `{Domain}Criteria.{Action}` | `OrderCriteria.Create` | +| Result DTO | `{Domain}Result` | `OrderResult` | +| 조합 Result DTO | `{Domain}{Detail}Result` | `OrderDetailResult` | + +### domain/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Entity | `{Domain}` | `Order` | +| Domain Service | `{Domain}Service` | `OrderService` | +| Repository (인터페이스) | `{Domain}Repository` | `OrderRepository` | +| ErrorCode | `{Domain}ErrorCode` | `OrderErrorCode` | +| enum | 의미에 맞게 | `OrderStatus` | +| Command DTO | `{Target}Command` | `OrderProductCommand` | + +### infrastructure/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Repository 구현체 | `{Domain}RepositoryImpl` | `OrderRepositoryImpl` | +| JPA Repository | `{Domain}JpaRepository` | `OrderJpaRepository` | +| 외부 API 클라이언트 | `{External}Client` | `PaymentClient` | + +--- + +## 5. 의존 방향 규칙 + +``` +interfaces → application → domain ← infrastructure +``` + +- **interfaces**는 application을 알 수 있다. domain을 직접 참조하지 않는다. +- **application**은 domain을 알 수 있다. interfaces를 알면 안 된다. +- **domain**은 아무 계층도 알지 못한다. 단, domain Service는 `@Service`, `@Transactional`, `Page`/`Pageable` 사용을 허용한다 (service-layer-convention.md § 3~4). +- **infrastructure**는 domain을 알 수 있다 (Repository 인터페이스 구현). + +### 도메인 간 의존 + +- 도메인 간 **Entity 직접 참조 금지** +- Application 계층(Facade)에서 타 도메인의 Domain Service를 호출하여 조합한다 +- 필요 시 Domain의 Command DTO로 정보를 전달한다 + +```java +// ✅ Application에서 타 도메인 Service 호출 +// application/order/OrderFacade.java +@Service +public class OrderFacade { + private final ProductService productService; // domain/product/ + private final OrderService orderService; // domain/order/ +} + +// ❌ Domain에서 타 도메인 직접 참조 +// domain/order/OrderService.java +public class OrderService { + private final ProductRepository productRepository; // 금지 +} +``` + +--- + +## 6. 새 도메인 추가 가이드 + +1. `domain/{domain}/` 패키지부터 시작 (Entity, Repository 인터페이스) +2. API가 필요하면 `interfaces/{domain}/`, `application/{domain}/` 추가 +3. 커스텀 Repository 구현이 필요하면 `infrastructure/{domain}/` 추가 +4. 도메인 에러가 필요하면 `domain/{domain}/{Domain}ErrorCode.java` 추가 +5. 빈 계층은 만들지 않는다 — 필요할 때 추가 + +--- + +## 체크리스트 + +**구조** +- [ ] 최상위가 계층(interfaces/application/domain/infrastructure)으로 나뉘어 있는가? +- [ ] 각 계층 안에서 도메인별로 패키지가 분리되어 있는가? +- [ ] 빈 계층 패키지가 없는가? (불필요한 빈 폴더 금지) +- [ ] 공통 클래스가 interfaces/api/ 또는 support/ 아래에 있는가? + +**의존 방향** +- [ ] interfaces → application → domain ← infrastructure 방향을 지키는가? +- [ ] domain Entity/Repository 인터페이스에 Spring 어노테이션(`@Component`, `@Repository`)이 없는가? +- [ ] domain Service의 `@Service`, `@Transactional`, `Page`/`Pageable` 사용은 컨벤션 허용 (service-layer-convention.md § 3~4) +- [ ] 도메인 간 Entity 직접 참조가 없는가? + +**네이밍** +- [ ] Controller, Facade, Service, Repository 네이밍이 규칙을 따르는가? +- [ ] DTO가 해당 계층의 dto/ 패키지에 있는가? +- [ ] ErrorCode가 domain/{domain}/ 패키지에 있는가? diff --git a/.claude/skills/project-convention/references/common/test-convention.md b/.claude/skills/project-convention/references/common/test-convention.md new file mode 100644 index 000000000..02bbc8997 --- /dev/null +++ b/.claude/skills/project-convention/references/common/test-convention.md @@ -0,0 +1,397 @@ +# 테스트 컨벤션 + +## 목차 + +1. [프레임워크 및 도구](#1-프레임워크-및-도구) +2. [테스트 피라미드 — 계층별 전략](#2-테스트-피라미드--계층별-전략) +3. [테스트 클래스 구조](#3-테스트-클래스-구조) +4. [네이밍 규칙](#4-네이밍-규칙) +5. [테스트 더블 전략](#5-테스트-더블-전략) +6. [테스트 패키지 배치](#6-테스트-패키지-배치) +7. [DB 정리 전략](#7-db-정리-전략) +8. [체크리스트](#체크리스트) + +--- + +## 1. 프레임워크 및 도구 + +| 도구 | 용도 | +|------|------| +| JUnit 5 | 테스트 프레임워크 | +| AssertJ | 가독성 높은 검증 (assertThat, assertThatThrownBy) | +| Mockito | 테스트 더블 (mock, stub, verify) | +| @SpringBootTest | 통합 테스트, E2E | +| TestRestTemplate | E2E HTTP 요청 | +| DatabaseCleanUp | 테스트 간 DB 격리 | + +--- + +## 2. 테스트 피라미드 — 계층별 전략 + +### 단위 테스트 (Unit Test) + +| 항목 | 내용 | +|------|------| +| 대상 | Entity, Domain Service | +| 환경 | **Spring 없이 순수 JVM** | +| 테스트 더블 | **Fake 우선**, 필요 시 Mockito | +| 속도 | 빠름 (ms 단위) | +| 비중 | 가장 많이 작성 | + +```java +class UserModelTest { + @Test + void create_whenAllDataProvided() { + UserModel user = UserModel.create("testuser", "encPw", "홍길동", LocalDate.of(2000, 1, 1), "test@email.com"); + assertThat(user.getName()).isEqualTo("홍길동"); + } +} +``` + +### 통합 테스트 (Integration Test) + +| 항목 | 내용 | +|------|------| +| 대상 | Service, Facade (여러 컴포넌트 연결 상태) | +| 환경 | `@SpringBootTest`, 실제 Bean, Test DB | +| 테스트 더블 | **실제 Bean 사용** (DB 포함) | +| 속도 | 보통 | +| 비중 | 핵심 비즈니스 흐름 위주 | + +```java +@SpringBootTest +class UserServiceIntegrationTest { + @Autowired UserService userService; + @Autowired DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } +} +``` + +### E2E 테스트 (End-to-End Test) + +| 항목 | 내용 | +|------|------| +| 대상 | Controller → Service → DB 전체 | +| 환경 | `@SpringBootTest(webEnvironment = RANDOM_PORT)` | +| 도구 | `TestRestTemplate` | +| 속도 | 느림 | +| 비중 | 주요 시나리오만 선별 | + +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + @Autowired TestRestTemplate testRestTemplate; + @Autowired DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } +} +``` + +--- + +## 3. 테스트 클래스 구조 + +### @Nested로 행위별 그룹핑 + +테스트 클래스 내부를 `@Nested`로 행위(기능) 단위로 그룹핑한다. 부모 `@DisplayName`에 행위를, 자식에 조건+결과를 작성한다. + +```java +class UserModelTest { + + @DisplayName("유저 모델을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 주어지면, 정상적으로 생성된다.") + @Test + void create_whenAllDataProvided() { + // act + UserModel user = UserModel.create( + "testuser123", "encryptedPw", "홍길동", + LocalDate.of(2000, 1, 1), "test@email.com"); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo("testuser123"), + () -> assertThat(user.getName()).isEqualTo("홍길동") + ); + } + + @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") + @Test + void create_whenLoginIdIsNull() { + assertThatThrownBy(() -> UserModel.create( + null, "encryptedPw", "홍길동", + LocalDate.of(2000, 1, 1), "test@email.com")) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("비밀번호를 변경할 때, ") + @Nested + class ChangePassword { + // ... + } +} +``` + +### 테스트 메서드 내부 구조: arrange / act / assert + +주석으로 세 섹션을 구분한다. 단, arrange가 없으면 생략 가능. + +```java +@Test +void createOrder_whenAllDataProvided() { + // arrange + Long memberId = 1L; + int price = 50000; + + // act + Order order = Order.create(memberId, price); + + // assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); +} +``` + +예외 검증처럼 한 줄로 끝나면 주석 없이 작성해도 된다. + +```java +@Test +void create_whenNameIsNull() { + assertThatThrownBy(() -> UserModel.create( + "testuser", "encPw", null, + LocalDate.of(2000, 1, 1), "test@email.com")) + .isInstanceOf(CoreException.class); +} +``` + +### 검증: assertAll로 다중 검증 묶기 + +여러 필드를 한번에 검증할 때 `assertAll`을 사용한다. 첫 번째 실패에서 멈추지 않고 모든 검증 결과를 보여준다. + +```java +assertAll( + () -> assertThat(result.loginId()).isEqualTo(loginId), + () -> assertThat(result.name()).isEqualTo(name), + () -> assertThat(result.email()).isEqualTo(email) +); +``` + +### @ParameterizedTest로 경계값 테스트 + +같은 로직에 여러 입력을 테스트할 때 사용한다. + +```java +@DisplayName("2자 이상 10자 이하의 이름이 주어지면, 정상적으로 생성된다.") +@ParameterizedTest +@ValueSource(strings = {"홍길", "홍길동", "가나다라마바사아자차"}) +void create_whenValidNameProvided(String validName) { + UserModel user = UserModel.create( + "testuser", "encPw", validName, + LocalDate.of(2000, 1, 1), "test@email.com"); + assertThat(user.getName()).isEqualTo(validName); +} +``` + +--- + +## 4. 네이밍 규칙 + +### 테스트 클래스명 + +| 테스트 유형 | 클래스명 패턴 | 예시 | +|-----------|-----------|------| +| 단위 (Entity) | `{클래스명}Test` | `UserModelTest`, `OrderModelTest` | +| 단위 (Domain Service) | `{클래스명}Test` | `OrderServiceTest` | +| 통합 | `{클래스명}IntegrationTest` | `UserServiceIntegrationTest` | +| E2E | `{API명}E2ETest` | `UserV1ApiE2ETest` | + +### 테스트 메서드명 + +**영문 camelCase** + `@DisplayName` 한글 조합. + +패턴: `{action}_{condition}` + +```java +// @DisplayName이 의도를 전달, 메서드명은 식별용 +@DisplayName("로그인 ID가 누락되면 예외가 발생한다.") +@Test +void createUserModel_whenLoginIdIsNull() { ... } + +@DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호가 주어지면 비밀번호가 변경된다.") +@Test +void changePassword_success() { ... } +``` + +조건이 없는 성공 케이스는 `_{condition}` 대신 `_success` 또는 `_whenAllDataProvided`를 사용한다. + +### @DisplayName 규칙 + +| 위치 | 형식 | 예시 | +|------|------|------| +| `@Nested` 클래스 | `"{행위}할 때, "` | `"유저 모델을 생성할 때, "` | +| `@Test` 메서드 | `"{조건}이면, {결과}한다."` | `"로그인 ID가 누락되면 예외가 발생한다."` | + +부모 + 자식을 이어 읽으면 자연스러운 한국어 문장이 된다: +> "유저 모델을 생성할 때, 로그인 ID가 누락되면 예외가 발생한다." + +--- + +## 5. 테스트 더블 전략 + +### 계층별 테스트 더블 선택 + +| 테스트 대상 | 더블 전략 | 이유 | +|-----------|---------|------| +| **Entity** | 더블 불필요 (순수 로직) | 외부 의존 없음 | +| **Domain Service** | **Fake 우선** | 실제 동작과 유사, 상태 검증 가능 | +| **Application Facade** | **Mockito mock()** | 여러 Service 조합, Fake 비용 큼 | +| **통합 / E2E** | **실제 Bean** | 연동 검증이 목적 | + +### Fake — Domain 단위 테스트의 기본 + +```java +// 인터페이스를 구현하는 가짜 객체 — 실제처럼 동작 +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); + } +} +``` + +Fake를 사용하는 이유: +- encode()와 matches()가 **실제처럼 연동**된다 — mock은 각각 별도로 stub해야 함 +- **상태 기반 검증**이 가능하다 — "ENCODED_Test1234!@#"이 실제로 저장되었는지 확인 +- 테스트가 **구현 세부사항에 결합하지 않는다** — mock은 어떤 메서드가 호출되는지에 결합 + +### Mockito — Application 계층 테스트 + +```java +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock OrderService orderService; + @Mock ProductService productService; + @InjectMocks OrderFacade orderFacade; + + @Test + void createOrder_success() { + // arrange (stub) + when(productService.getProduct(1L)).thenReturn(productInfo); + when(orderService.create(any())).thenReturn(order); + + // act + OrderDetailResult result = orderFacade.createOrder(criteria); + + // assert + assertThat(result).isNotNull(); + verify(orderService).create(any()); + } +} +``` + +### 테스트 더블 선택 판단 플로우 + +``` +테스트 대상이 외부 의존이 있는가? + ├── NO → 더블 불필요 (Entity) + └── YES → 의존이 인터페이스로 분리되어 있는가? + ├── YES → Fake를 만들 가치가 있는가? + │ ├── 상태 연동이 중요 → Fake + │ └── 단순 위임 → Mockito mock() + └── NO → Mockito mock() +``` + +### Fake 배치 + +Fake 클래스는 테스트 소스 내에 배치한다. + +``` +src/test/java/com/loopers/ +├── domain/ +│ ├── UserModelTest.java +│ └── FakePasswordEncoder.java ← 테스트 소스에 배치 +└── utils/ + └── DatabaseCleanUp.java +``` + +--- + +## 6. 테스트 패키지 배치 + +테스트 클래스는 **프로덕션 코드와 동일한 패키지 구조**를 따른다. + +``` +src/test/java/com/loopers/ +├── domain/ +│ ├── order/ +│ │ ├── OrderTest.java ← Entity 단위 +│ │ └── OrderServiceTest.java ← Domain Service 단위 +│ ├── product/ +│ │ └── ProductTest.java +│ └── member/ +│ └── ... +├── application/ +│ └── order/ +│ └── OrderFacadeTest.java ← Application mock 테스트 +├── interfaces/ +│ └── order/ +│ └── OrderV1ApiE2ETest.java ← E2E +└── utils/ + ├── DatabaseCleanUp.java + └── FakePasswordEncoder.java ← 공통 Fake +``` + +--- + +## 7. DB 정리 전략 + +통합/E2E 테스트에서 테스트 간 격리를 위해 `@AfterEach`에서 DB를 정리한다. + +```java +@AfterEach +void tearDown() { + databaseCleanUp.truncateAllTables(); +} +``` + +왜 `truncate`인가: +- `@Transactional` 롤백은 `RANDOM_PORT` E2E에서 동작하지 않는다 (별도 스레드) +- `deleteAll()`은 외래키 순서를 관리해야 하고 느리다 +- `truncate`는 빠르고 auto_increment도 초기화된다 + +--- + +## 체크리스트 + +**구조** +- [ ] 행위별로 `@Nested`로 그룹핑했는가? +- [ ] `@DisplayName`이 부모+자식 이어 읽으면 자연스러운 문장인가? +- [ ] 메서드 내부가 arrange / act / assert 순서인가? +- [ ] 다중 검증 시 `assertAll`을 사용했는가? +- [ ] 경계값 테스트에 `@ParameterizedTest`를 활용했는가? + +**네이밍** +- [ ] 테스트 클래스명이 `{클래스}Test` / `IntegrationTest` / `E2ETest` 패턴인가? +- [ ] 메서드명이 `{action}_{condition}` 패턴 영문 camelCase인가? +- [ ] `@DisplayName`이 한글로 의도를 명확히 전달하는가? + +**테스트 더블** +- [ ] Domain 단위 테스트에서 Fake를 우선 사용했는가? +- [ ] Application 테스트에서 Mockito mock()을 사용했는가? +- [ ] 통합/E2E에서 실제 Bean을 사용했는가? +- [ ] Fake가 테스트 소스에 배치되어 있는가? + +**DB 격리** +- [ ] 통합/E2E 테스트에 `@AfterEach` + `truncateAllTables()`가 있는가? diff --git a/.claude/skills/project-convention/references/domain/entity-vo-convention.md b/.claude/skills/project-convention/references/domain/entity-vo-convention.md new file mode 100644 index 000000000..8b4421b40 --- /dev/null +++ b/.claude/skills/project-convention/references/domain/entity-vo-convention.md @@ -0,0 +1,372 @@ +# 엔티티 / VO 설계 컨벤션 + +## 목차 + +1. [Entity 작성 규칙](#1-entity-작성-규칙) +2. [VO 설계 규칙](#2-vo-설계-규칙) +3. [검증 위치 규칙](#3-검증-위치-규칙) +4. [Entity vs Domain Service 로직 배치](#4-entity-vs-domain-service-로직-배치) +5. [체크리스트](#체크리스트) + +--- + +## 1. Entity 작성 규칙 + +### 기본 구조 + +```java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long memberId; + + @Embedded + private Money totalPrice; + + @Enumerated(EnumType.STRING) + private OrderStatus status; + + // === 생성 === // + + private Order(Long memberId, Money totalPrice, OrderStatus status) { + this.memberId = memberId; + this.totalPrice = totalPrice; + this.status = status; + } + + public static Order create(Long memberId, int price) { + return new Order(memberId, Money.of(price), OrderStatus.CREATED); + } + + // === 도메인 로직 === // + + public void cancel() { + validateCancellable(); + this.status = OrderStatus.CANCELLED; + } + + // === 검증 === // + + private void validateCancellable() { + if (this.status != OrderStatus.CREATED) { + throw new CoreException(OrderErrorCode.ALREADY_CANCELLED); + } + } +} +``` + +### 생성 패턴: 정적 팩토리 메서드 + private 생성자 + +모든 Entity는 **정적 팩토리 메서드**로 생성한다. 생성자를 직접 노출하지 않는다. +내부 생성은 **private 생성자**를 사용하여 필드를 초기화한다. + +```java +// ✅ private 생성자 + 정적 팩토리 메서드 (권장) +private Order(Long memberId, Money totalPrice, OrderStatus status) { + this.memberId = memberId; + this.totalPrice = totalPrice; + this.status = status; +} + +public static Order create(Long memberId, int price) { + return new Order(memberId, Money.of(price), OrderStatus.CREATED); +} + +// ❌ 생성자 직접 노출 +public Order(Long memberId, int price) { ... } + +// ❌ @Builder +@Builder +public Order(Long memberId, int price) { ... } +``` + +왜 정적 팩토리 + private 생성자인가: +- 생성 의도를 메서드 이름으로 표현할 수 있다 (`create`, `register`, `createFromImport`) +- 생성 시점에 VO 변환, 초기값 설정, 검증을 Entity가 통제한다 +- 불변식(invariant)을 생성 시점부터 보장한다 +- private 생성자로 필드 초기화가 한 곳에서 완결되어 의도가 명확하다 + +### 접근 제어 + +| 규칙 | 설정 | +|------|------| +| 기본 생성자 | `@NoArgsConstructor(access = PROTECTED)` — JPA 프록시 전용 | +| Setter | **사용 금지** — 도메인 메서드로 상태 변경 | +| Getter | `@Getter` 허용 — 읽기는 자유 | +| 필드 접근 | `private` — 직접 할당은 Entity 내부에서만 | + +```java +// ❌ Setter 금지 +order.setStatus(OrderStatus.CANCELLED); + +// ✅ 도메인 메서드 +order.cancel(); +``` + +### Entity 내부 구조 순서 + +```java +@Entity +public class Order { + // 1. 필드 (id, 일반 필드, VO, 연관관계) + // 2. 정적 팩토리 메서드 (create, register ...) + // 3. 도메인 로직 메서드 (cancel, changeStatus ...) + // 4. private 검증 메서드 (validateXxx ...) +} +``` + +--- + +## 2. VO 설계 규칙 + +### 기본 원칙: VO를 만들지 않는다 + +**모든 검증과 행위는 Entity 도메인 메서드 또는 Domain Service에서 처리한다.** VO를 만들지 않는 이유: + +1. **설계**: 검증만 있는 VO는 Entity 메서드로 충분하고, VO 관리 부담이 캡슐화 이득보다 크다 +2. **실무**: 필드마다 "이 필드는 VO인가?" 확인하는 인지 비용이 개발 속도를 떨어뜨린다 +3. **기술(JPA)**: `@Embeddable`은 같은 타입 2개 사용 시 `@AttributeOverride` 보일러플레이트, 내부 필드 전부 null이면 객체 자체 null 등 기술적 마찰이 있다 + +| 검증/행위 유형 | VO 생성 | 처리 위치 | +|----------|---------|----------| +| null/길이/범위 검증 | ❌ | Entity 도메인 메서드 (`private static validateXxx`) | +| 형식 규칙 (이메일, 비밀번호 정책) | ❌ | Entity 도메인 메서드 또는 Domain Service | +| 외부 인프라 의존 (암호화 등) | ❌ | Domain Service | +| 도메인 행위 (계산, 변환) | ❌ | Entity 도메인 메서드 | + +```java +// Entity에서 직접 검증 + 행위 처리 +@Entity +public class ProductModel extends BaseEntity { + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "stock", nullable = false) + private int stock; + + public static ProductModel create(BrandModel brand, String name, int price, int stock) { + validatePriceRange(price); + validateStockRange(stock); + return new ProductModel(brand, name, price, stock); + } + + public void decreaseStock(int quantity) { + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; + } + + private static void validatePriceRange(int price) { + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } +} +``` + +### 예외적으로 VO를 만드는 조건 + +아래 **세 가지를 모두** 충족할 때만 record VO를 만든다: + +1. 도메인 행위(계산, 변환, 비교)가 **2개 이상** 존재 +2. **여러 도메인**에서 동일 행위가 중복 +3. DB 저장과 무관 (**record**로 구현, `@Embeddable` 지양) + +```java +// ✅ 예외적 VO — 행위 2개 이상 + 다도메인 중복 + 비저장 +public record DateRange(LocalDate start, LocalDate end) { + + public DateRange { + if (start.isAfter(end)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "시작일이 종료일보다 늦을 수 없습니다."); + } + } + + public boolean contains(LocalDate date) { + return !date.isBefore(start) && !date.isAfter(end); + } +} +``` + +record는 불변, equals/hashCode/toString 자동 생성. compact constructor에서 자기 검증. + +### 공유 VO 배치 규칙 + +예외적으로 VO를 만들 경우, 아키텍처에 따라 배치한다: + +| 아키텍처 | 배치 위치 | +|----------|----------| +| 레이어 우선 (현재) | `domain.common` 패키지 | +| 도메인 우선 | `common.domain` 패키지 | +| 멀티모듈 | `common-domain` 모듈 | + +**주의**: common이 비대해지지 않게 진입 기준을 엄격히 적용한다. + +### @Embeddable 개념 그룹핑 + +Entity 내에서 관련 필드가 하나의 개념 단위를 이룰 때 `@Embeddable`로 그룹핑한다. 행위 중심의 record VO와는 목적이 다르다. + +**사용 기준 — 아래 세 가지를 모두 충족할 때:** + +1. **3개 이상**의 필드가 하나의 개념을 표현 (예: 스냅샷, 주소, 좌표) +2. 해당 필드들이 항상 **함께 생성**되고 **함께 조회**됨 +3. 도메인 성장에 따라 필드가 **늘어날 가능성**이 있음 + +**규칙:** + +- 행위 없이 **데이터 그룹핑만** 담당 (행위가 필요하면 Entity 메서드에서 처리) +- 같은 Entity에 같은 타입 2개 사용 금지 (`@AttributeOverride` 보일러플레이트 방지) +- 클래스명은 `{개념}Snapshot`, `{개념}Info` 등 역할이 드러나는 이름 사용 + +```java +// ✅ @Embeddable 그룹핑 — 스냅샷 필드 3개 이상, 함께 생성/조회, 확장 가능성 +@Embeddable +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "image_url") + private String imageUrl; + + protected ProductSnapshot() { + } + + public ProductSnapshot(String productName, String brandName, String imageUrl) { + this.productName = productName; + this.brandName = brandName; + this.imageUrl = imageUrl; + } +} +``` + +--- + +## 3. 검증 위치 규칙 + +두 수준으로 나눈다. + +| 검증 수준 | 위치 | 기준 | +|----------|------|------| +| **단일 값 / 크로스필드 검증** | Entity `private static validateXxx` 메서드 | 자기 필드만으로 판단 가능 | +| **외부 의존 검증** | Domain Service | Repository 조회, 타 도메인 데이터, 인프라(암호화 등) 필요 | + +### 판단 플로우 + +``` +이 검증이 자기 필드만으로 완결되는가? + ├── YES → Entity의 private static validateXxx 메서드 + └── NO → 뭐가 더 필요한가? + ├── Repository 조회 → Domain Service + ├── 타 도메인 정보 → Domain Service + └── 외부 인프라 (암호화 등) → Domain Service +``` + +### 예시 + +```java +// 1. 단일 값 검증 → Entity 내부 +@Entity +public class UserModel { + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$"); + + 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, "이메일 형식이 올바르지 않습니다."); + } + } +} + +// 2. 외부 의존 검증 → Domain Service +public class UserService { + private static final Pattern PASSWORD_PATTERN = Pattern.compile("..."); + + public UserModel signup(String loginId, String rawPassword, ...) { + validatePasswordFormat(rawPassword); // 형식 검증도 Service에서 (암호화 전 raw 값 필요) + validateBirthDateNotInPassword(rawPassword, birthDate); // 크로스필드 + 인프라 의존 + UserModel.create(loginId, passwordEncoder.encode(rawPassword), ...); // 암호화된 값 전달 + } +} +``` + +--- + +## 4. Entity vs Domain Service 로직 배치 + +핵심 기준: **"자기 상태(필드)만으로 완결되는가?"** + +### Entity에 둔다 + +- 자기 상태 변경: `order.cancel()` +- 자기 상태 검증: `order.validateCancellable()` +- 자기 상태로 계산: `order.calculateTotalPrice()` +- 생성 로직: `Order.create(...)` + +### Domain Service에 둔다 + +- Repository 조회 필요: `orderService.findOrThrow(id)` +- 타 도메인 데이터 필요: `orderService.create(memberData, productData)` +- 여러 Entity 조율: `orderService.transferOwnership(from, to)` +- 외부 시스템 연동: `orderService.requestPayment(order)` + +### 판단 플로우 + +``` +이 로직이 자기 필드만으로 완결되는가? + ├── YES → Entity에 둔다 + └── NO → 뭐가 더 필요한가? + ├── Repository 조회 → Domain Service + ├── 타 도메인 정보 → Domain Service + ├── 여러 Entity 조율 → Domain Service + └── 외부 시스템 → Domain Service +``` + +### 분리 신호 + +Entity에 먼저 넣고, 아래 신호가 보이면 Domain Service로 추출한다. + +| 신호 | 액션 | +|------|------| +| Entity 메서드가 20개 이상 | 관련 로직 묶어서 Domain Service로 추출 | +| Entity 테스트에 mock이 필요해짐 | 외부 의존이 있다는 뜻 → Domain Service로 | +| 같은 검증 로직이 여러 Entity에 중복 | Domain Service 또는 공통 VO로 추출 | + +--- + +## 체크리스트 + +**Entity** +- [ ] 정적 팩토리 메서드로 생성하는가? +- [ ] `@NoArgsConstructor(access = PROTECTED)`가 있는가? +- [ ] Setter 없이 도메인 메서드로 상태를 변경하는가? +- [ ] 자기 필드만으로 완결되는 로직만 Entity에 있는가? +- [ ] 필드는 원시값(`int`, `String`, `LocalDate` 등)으로 선언하는가? +- [ ] 각 도메인 메서드에서 필요한 검증을 수행하는가? + +**VO (예외적 생성 시)** +- [ ] 도메인 행위가 2개 이상 + 여러 도메인에서 중복되는 경우에만 생성했는가? +- [ ] `record`로 구현했는가? (`@Embeddable` 지양) +- [ ] `domain.common` (또는 아키텍처별 공유 영역)에 배치했는가? + +**검증 위치** +- [ ] 자기 필드 검증이 Entity `private static validateXxx` 메서드에 있는가? +- [ ] 외부 의존(인프라, 타 도메인) 검증이 Domain Service에 있는가? + +**로직 배치** +- [ ] Repository/타 도메인 필요한 로직이 Domain Service에 있는가? +- [ ] Entity에 외부 의존이 침투하지 않았는가? diff --git a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md new file mode 100644 index 000000000..0e9a1ff15 --- /dev/null +++ b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md @@ -0,0 +1,497 @@ +# Infrastructure 계층 컨벤션 + +## 목차 + +1. [Repository 패턴](#1-repository-패턴) +2. [QueryDSL 규칙](#2-querydsl-규칙) +3. [BaseEntity](#3-baseentity) +4. [DB 제약조건 규칙](#4-db-제약조건-규칙) +5. [멀티 모듈 구조](#5-멀티-모듈-구조) +6. [체크리스트](#체크리스트) + +--- + +## 1. Repository 패턴 + +### 3-클래스 패턴 + +Repository는 **domain 인터페이스 + infrastructure 구현체 + JpaRepository** 3개로 구성한다. + +``` +domain/ +└── order/ + └── OrderRepository.java ← 순수 인터페이스 (Spring 의존 없음) + +infrastructure/ +└── order/ + ├── OrderJpaRepository.java ← Spring Data JPA 인터페이스 + └── OrderRepositoryImpl.java ← 어댑터: OrderRepository 구현, JpaRepository에 위임 +``` + +왜 3-클래스인가: +- **domain이 Spring을 모른다** — `OrderRepository`는 순수 Java 인터페이스. JPA/Spring Data 의존이 없어서 domain 계층의 순수성이 보장된다 +- **테스트가 쉽다** — domain 단위 테스트에서 `OrderRepository`의 Fake를 만들면 DB 없이 테스트 가능 +- **구현 교체가 자유롭다** — JPA에서 다른 저장소로 바꿔도 domain은 변경 불필요 + +### domain Repository 인터페이스 + +Spring 의존 없는 **순수 Java 인터페이스**로 작성한다. domain이 필요로 하는 메서드만 선언한다. + +```java +// domain/order/OrderRepository.java +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findByUserId(Long userId); +} +``` + +**금지:** +- `JpaRepository` 상속 +- Spring 어노테이션 (`@Repository`, `@Query` 등) + +**예외 허용 — `Page`, `Pageable`:** +- `Page`와 `Pageable`은 단순 데이터 구조(페이지네이션 메타정보)에 가까워 도메인 오염이 적다 +- 별도 래퍼를 만들면 RepositoryImpl에서 매번 변환 보일러플레이트가 발생하므로, 실용성을 우선한다 +- domain Repository 인터페이스에서 `Page`, `Pageable`을 직접 사용할 수 있다 + +### JpaRepository 인터페이스 + +Spring Data JPA의 자동 구현을 활용하는 인터페이스. infrastructure에 배치한다. + +```java +// infrastructure/order/OrderJpaRepository.java +public interface OrderJpaRepository extends JpaRepository { + + List findByUserId(Long userId); + + Optional findByIdAndDeletedAtIsNull(Long id); +} +``` + +### RepositoryImpl — 어댑터 + +domain Repository를 구현하고, JpaRepository에 위임한다. `@Repository`를 사용한다. + +```java +// infrastructure/order/OrderRepositoryImpl.java +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findByUserId(Long userId) { + return orderJpaRepository.findByUserId(userId); + } +} +``` + +`@Repository`를 사용하는 이유: +- **의미론적 명확성** — 이 클래스가 데이터 접근 계층임을 명시한다 +- **영속성 예외 변환** — JPA 벤더별 예외를 Spring `DataAccessException`으로 자동 변환한다 +- **Spring 스테레오타입 관례** — `@Controller`/`@Service`/`@Repository`는 계층별 표준 어노테이션이다 + +### Soft Delete 조회 처리 + +soft delete된 엔티티 필터링은 **RepositoryImpl에서만 처리**한다. Domain Service에서 `isDeleted()` 등을 이중 체크하지 않는다. + +**핵심 규칙: RepositoryImpl의 모든 조회 메서드는 `deletedAt IS NULL`을 기본 적용한다.** + +```java +// domain 인터페이스 — soft delete를 모른다 +Optional findById(Long id); +Optional findByName(String name); + +// RepositoryImpl — 모든 조회에서 soft delete 필터링 +@Override +public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); +} + +@Override +public Optional findByName(String name) { + return orderJpaRepository.findByNameAndDeletedAtIsNull(name); +} +``` + +왜 RepositoryImpl에서만 처리하는가: +- **단일 책임**: soft delete는 저장소 세부사항이므로 infrastructure가 담당한다 +- **domain 순수성**: domain이 "삭제된 데이터" 개념을 알 필요 없다 +- **일관성**: 모든 조회 메서드에 동일한 규칙이 적용되므로 누락 위험이 없다 +- **Service 단순화**: Domain Service에서 `isDeleted()` 체크가 불필요하다 + +### 네이밍 규칙 + +| 클래스 | 네이밍 | 어노테이션 | 위치 | +|--------|--------|-----------|------| +| domain 인터페이스 | `{Domain}Repository` | 없음 | `domain/{domain}/` | +| JPA 인터페이스 | `{Domain}JpaRepository` | 없음 (자동) | `infrastructure/{domain}/` | +| 어댑터 구현체 | `{Domain}RepositoryImpl` | `@Repository` | `infrastructure/{domain}/` | + +--- + +## 2. QueryDSL 규칙 + +### RepositoryImpl에 직접 작성 + +QueryDSL 쿼리는 **RepositoryImpl에 직접 작성**한다. RepositoryImpl이 이미 어댑터 역할을 하고 있으므로, 단순 CRUD(JpaRepository 위임)와 복잡 쿼리(QueryDSL)를 한 곳에서 관리한다. + +```java +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + // === 단순 CRUD: JpaRepository에 위임 === // + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + // === 복잡 쿼리: QueryDSL 직접 작성 === // + + @Override + public Page search(ProductSearchCondition condition, Pageable pageable) { + QProduct product = QProduct.product; + QBrand brand = QBrand.brand; + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(product.deletedAt.isNull()); + builder.and(brand.deletedAt.isNull()); + + if (condition.brandId() != null) { + builder.and(product.brand.id.eq(condition.brandId())); + } + + List content = queryFactory + .selectFrom(product) + .join(product.brand, brand) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(buildOrderSpecifier(condition.sort(), product)) + .fetch(); + + Long total = queryFactory + .select(product.count()) + .from(product) + .join(product.brand, brand) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } +} +``` + +### 분리 시점 + +RepositoryImpl의 QueryDSL 메서드가 **5개 이상**이 되거나 쿼리 복잡도가 높아지면, 별도 클래스로 분리한다. + +``` +// 초기 — RepositoryImpl에 직접 +infrastructure/ +└── product/ + ├── ProductJpaRepository.java + └── ProductRepositoryImpl.java ← JPA 위임 + QueryDSL 모두 + +// 쿼리가 많아지면 — 분리 +infrastructure/ +└── product/ + ├── ProductJpaRepository.java + ├── ProductQueryRepository.java ← QueryDSL 전용 (NEW) + └── ProductRepositoryImpl.java ← JPA 위임 + QueryRepository에 위임 +``` + +```java +// 분리 후 QueryRepository +@Repository +@RequiredArgsConstructor +public class ProductQueryRepository { + + private final JPAQueryFactory queryFactory; + + public Page search(ProductSearchCondition condition, Pageable pageable) { + // QueryDSL 쿼리 ... + } +} + +// 분리 후 RepositoryImpl +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final ProductQueryRepository productQueryRepository; + + @Override + public Page search(ProductSearchCondition condition, Pageable pageable) { + return productQueryRepository.search(condition, pageable); + } +} +``` + +### 동적 정렬 + +정렬 조건은 **QueryDSL `OrderSpecifier`로 변환**한다. Controller에서 받은 `sort` 파라미터를 기반으로 한다. + +```java +private OrderSpecifier buildOrderSpecifier(String sort, QProduct product) { + if (sort == null) return product.createdAt.desc(); + + return switch (sort) { + case "price_asc" -> product.price.asc(); + default -> product.createdAt.desc(); // latest + }; +} +``` + +### 검색 조건 DTO + +QueryDSL 검색 조건은 domain 패키지에 **record**로 정의한다. 쿼리 파라미터를 담는 용도이므로 domain DTO(`~Condition`)로 둔다. + +```java +// domain/product/dto/ProductSearchCondition.java +public record ProductSearchCondition( + Long brandId, + String sort +) {} +``` + +--- + +## 3. BaseEntity + +### 구조 + +모든 Entity는 `BaseEntity`를 상속한다. `modules/jpa` 모듈에 위치하여 전 앱에서 재사용한다. + +```java +// modules/jpa — com.loopers.domain.BaseEntity +@MappedSuperclass +@Getter +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private final Long id = 0L; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected void guard() {} // 검증 훅 (PrePersist/PreUpdate) + + @PrePersist + private void prePersist() { ... } // createdAt, updatedAt 자동 설정 + + @PreUpdate + private void preUpdate() { ... } // updatedAt 자동 갱신 + + public void delete() { ... } // 멱등 soft delete + public void restore() { ... } // 멱등 복원 +} +``` + +### 제공하는 것 + +| 기능 | 메서드/필드 | 동작 | +|------|-----------|------| +| PK 자동 생성 | `id` (IDENTITY) | DB에서 자동 할당 | +| 생성일 자동 기록 | `createdAt` | `@PrePersist`에서 설정, 이후 변경 불가 | +| 수정일 자동 갱신 | `updatedAt` | `@PrePersist`/`@PreUpdate`에서 갱신 | +| Soft Delete | `deletedAt` + `delete()` | 멱등, null이면 삭제 안 됨 | +| 복원 | `restore()` | 멱등, `deletedAt`을 null로 | +| 검증 훅 | `guard()` | 하위 Entity가 override하여 PrePersist/PreUpdate 시 검증 | + +### Entity에서의 사용 + +```java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Brand extends BaseEntity { + + @Column(nullable = false, unique = true) + private String name; + + public static Brand create(String name) { + Brand brand = new Brand(); + brand.name = name; + return brand; + } + + public void update(String name) { + this.name = name; + } + + // guard()를 override하여 저장 시점 검증 추가 가능 + @Override + protected void guard() { + if (name == null || name.isBlank()) { + throw new CoreException(BrandErrorCode.NAME_REQUIRED); + } + } +} +``` + +### 주의사항 + +- Entity에서 `id`, `createdAt`, `updatedAt`, `deletedAt`을 **직접 설정하지 않는다** — BaseEntity가 관리 +- `delete()`는 BaseEntity의 메서드를 그대로 사용한다 — 도메인별 삭제 로직은 Service에서 조율 +- 정적 팩토리 메서드에서 `id`를 파라미터로 받지 않는다 — DB가 할당 + +--- + +## 4. DB 제약조건 규칙 + +### FK 제약 미사용 + +테이블 간 외래키 제약조건을 **사용하지 않는다**. 무결성은 애플리케이션 레벨에서 보장한다. + +FK를 쓰지 않는 이유: +- 잠금 전파로 인한 데드락 위험 +- 삭제 순서 강제로 운영 복잡도 증가 +- 테이블 간 결합으로 독립 배포/마이그레이션 어려움 + +### 참조 방식 + +| 관계 | 참조 방식 | 예시 | +|------|----------|------| +| **같은 도메인** (Brand → Product) | 객체참조 + FK 없음 | `@ManyToOne` + `NO_CONSTRAINT` | +| **다른 도메인** 간 | ID 참조 | `private Long userId` | + +```java +// 같은 도메인: 객체참조 (Brand → Product는 같은 상품 도메인) +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn( + name = "brand_id", + foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT) +) +private Brand brand; + +// 다른 도메인: ID 참조 (Order → User는 다른 도메인) +@Column(name = "user_id", nullable = false) +private Long userId; +``` + +### @OneToMany 미사용 + +`@OneToMany`를 사용하지 않는다. 하위 엔티티는 ID로 참조하고, 조회는 별도 Repository로 한다. + +```java +// ❌ @OneToMany 사용 +@OneToMany(mappedBy = "order") +private List orderItems; + +// ✅ ID 참조 + 별도 조회 +// Order에는 orderItems 필드 없음 +// OrderItem에 orderId 필드 +@Column(name = "order_id", nullable = false) +private Long orderId; + +// 조회는 Service/Repository에서 +List items = orderItemRepository.findByOrderId(orderId); +``` + +### 유니크 제약 사용 + +테이블 **내부** 유니크 제약은 사용한다. 동시성(더블클릭 등) 상황에서 중복을 방지한다. + +```java +// 단일 컬럼 유니크 +@Column(nullable = false, unique = true) +private String name; + +// 복합 유니크 +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) +}) +public class Like extends BaseEntity { ... } +``` + +--- + +## 5. 멀티 모듈 구조 + +### 현재 구조 + +``` +loop-pack-be-l2-vol3-java/ +├── apps/ +│ ├── commerce-api/ ← 메인 API 앱 +│ ├── commerce-streamer/ ← Kafka 컨슈머 +│ └── commerce-batch/ ← 배치 +└── modules/ + ├── jpa/ ← BaseEntity, QueryDSL/JPA/DataSource Config + ├── kafka/ + └── redis/ +``` + +### 모듈 간 역할 분담 + +| 위치 | 포함하는 것 | +|------|-----------| +| `modules/jpa` | `BaseEntity`, `QueryDslConfig`, `JpaConfig`, `DataSourceConfig` | +| `apps/commerce-api` | 도메인 코드, Controller, Facade, Service, Entity, Repository, Infrastructure | + +### 패키지 배치 원칙 + +- `modules/jpa`의 BaseEntity는 `com.loopers.domain` 패키지에 위치 — 앱의 Entity가 자연스럽게 상속 +- Config 클래스는 `com.loopers.config.jpa` 패키지에 위치 — 앱의 `support/config`와 분리 +- **도메인 로직은 modules에 넣지 않는다** — modules는 기술 인프라만 제공 + +--- + +## 체크리스트 + +**Repository 패턴** +- [ ] domain Repository가 순수 Java 인터페이스인가? (Spring 의존 없음) +- [ ] JpaRepository가 infrastructure에 있는가? +- [ ] RepositoryImpl에 `@Repository`가 붙어 있는가? +- [ ] RepositoryImpl이 domain Repository를 implements하는가? +- [ ] soft delete 필터링이 RepositoryImpl에서 처리되는가? + +**QueryDSL** +- [ ] QueryDSL 쿼리가 RepositoryImpl에 작성되어 있는가? (또는 분리 시 QueryRepository) +- [ ] JPAQueryFactory를 생성자 주입으로 받는가? +- [ ] 동적 정렬이 OrderSpecifier로 처리되는가? + +**BaseEntity** +- [ ] 모든 Entity가 BaseEntity를 상속하는가? +- [ ] Entity에서 id, createdAt, updatedAt, deletedAt을 직접 설정하지 않는가? +- [ ] 저장 시점 검증이 필요하면 guard()를 override하는가? + +**DB 제약조건** +- [ ] FK 제약조건을 사용하지 않는가? (NO_CONSTRAINT) +- [ ] 같은 도메인은 객체참조, 다른 도메인은 ID 참조인가? +- [ ] @OneToMany를 사용하지 않는가? +- [ ] 유니크 제약이 필요한 곳에 적용되어 있는가? diff --git a/.claude/skills/project-convention/references/interfaces/api-convention.md b/.claude/skills/project-convention/references/interfaces/api-convention.md new file mode 100644 index 000000000..17739e16c --- /dev/null +++ b/.claude/skills/project-convention/references/interfaces/api-convention.md @@ -0,0 +1,449 @@ +# API 설계 컨벤션 + +## 목차 + +1. [URL 구조](#1-url-구조) +2. [HTTP 메서드 규칙](#2-http-메서드-규칙) +3. [HTTP 상태 코드](#3-http-상태-코드) +4. [엔드포인트 설계 패턴](#4-엔드포인트-설계-패턴) +5. [쿼리 파라미터 규칙](#5-쿼리-파라미터-규칙) +6. [Controller 분리 규칙](#6-controller-분리-규칙) +7. [요청/응답 본문 규칙](#7-요청응답-본문-규칙) +8. [체크리스트](#체크리스트) + +--- + +## 1. URL 구조 + +### API Prefix — 액터별 이중 prefix + +| 대상 | Prefix | 예시 | +|------|--------|------| +| 대고객 (Guest/User) | `/api/v1` | `/api/v1/products` | +| 어드민 (Admin) | `/api-admin/v1` | `/api-admin/v1/products` | + +같은 도메인이라도 액터별로 prefix가 다르다. 고객용과 Admin용은 인증 방식, 요청/응답 DTO, 비즈니스 정책이 모두 다르기 때문이다. + +### 버전 전략 — URL 경로 기반 + +버전은 **URL 경로**에 포함한다. Header 기반이나 쿼리 파라미터 방식보다 직관적이고, 디버깅/문서화가 쉽다. + +``` +/api/v1/products ✅ URL 경로 +/api/products?version=1 ❌ 쿼리 파라미터 +Accept: application/v1 ❌ Header 기반 +``` + +### 리소스 네이밍 — 복수형, 소문자, 케밥케이스 + +리소스명은 **복수형**을 사용한다. REST에서 리소스는 "컬렉션"을 나타내며, 단건 접근은 `/{id}`로 구분한다. + +``` +/api/v1/products ✅ 복수형 +/api/v1/products/{productId} ✅ 컬렉션 → 단건 +/api/v1/product ❌ 단수형 +/api/v1/Products ❌ 대문자 +``` + +다중 단어 리소스는 **케밥케이스(kebab-case)**를 사용한다. + +``` +/api/v1/order-items ✅ 케밥케이스 +/api/v1/orderItems ❌ camelCase +/api/v1/order_items ❌ snake_case +``` + +### 경로 변수 네이밍 + +경로 변수는 **camelCase**로 작성하고, 어떤 리소스의 ID인지 명확히 표현한다. + +``` +/api/v1/products/{productId} ✅ 리소스명 + Id +/api/v1/products/{id} ❌ 모호한 id +``` + +--- + +## 2. HTTP 메서드 규칙 + +### 메서드별 용도 + +| Method | 용도 | 멱등성 | 요청 Body | +|--------|------|--------|----------| +| **GET** | 리소스 조회 (목록/단건) | ✅ | 없음 | +| **POST** | 리소스 생성, 비CRUD 행위 | ❌ | 있음 | +| **PUT** | 리소스 전체 수정 | ✅ | 있음 | +| **DELETE** | 리소스 삭제 | ✅ | 없음 | + +### PUT만 사용, PATCH 미사용 + +수정 API는 **PUT**으로 통일한다. 클라이언트가 수정 가능한 필드를 전부 보내는 "전체 교체" 방식이다. + +```java +// PUT /api/v1/products/{productId} +// → 클라이언트가 모든 수정 가능 필드를 전송 +public record UpdateRequest( + @NotBlank String name, + @Positive int price, + @PositiveOrZero int stock + ) {} +``` + +PATCH를 사용하지 않는 이유: +- 현재 도메인의 수정 대상 필드가 적어 부분 수정의 실익이 없다 +- 전체 교체 방식이 구현/검증이 단순하다 +- null과 "값을 지우겠다"의 구분이 불필요하다 + +향후 필드가 많아져서 부분 수정이 자연스러운 경우가 생기면, 해당 API에 한해 PATCH를 도입할 수 있다. + +### GET에 Body를 넣지 않는다 + +GET 요청의 필터/정렬/페이지네이션은 **쿼리 파라미터**로 전달한다. GET Body는 일부 인프라에서 무시될 수 있다. + +``` +GET /api/v1/products?brandId=1&sort=latest&page=0&size=20 ✅ +GET /api/v1/products body: { "brandId": 1 } ❌ +``` + +--- + +## 3. HTTP 상태 코드 + +### 성공 응답 + +| 상황 | 상태 코드 | 응답 Body | +|------|----------|----------| +| 조회 성공 | **200 OK** | `ApiResponse.success(data)` | +| 생성 성공 | **200 OK** | `ApiResponse.success(data)` | +| 수정 성공 | **200 OK** | `ApiResponse.success(data)` 또는 `ApiResponse.success()` | +| 삭제 성공 | **200 OK** | `ApiResponse.success()` | + +모든 성공 응답은 **200 OK**로 통일한다. `ApiResponse` 래퍼가 `meta.result = SUCCESS/FAIL`로 성공/실패를 명확히 구분하므로, HTTP 상태 코드를 세분화할 실익이 없다. 상태 코드를 개발자가 매번 기억하고 관리해야 하는 부담도 제거된다. 삭제에 204(No Content)를 쓰지 않는 이유는 `ApiResponse` 래퍼를 일관되게 유지하기 위함이다 — 204는 body가 비어야 하므로 `ApiResponse` 포맷과 충돌한다. + +```java +// Controller 예시 +@PostMapping +public ApiResponse create(...) { + ProductInfo info = productFacade.create(request.toCommand()); + return ApiResponse.success(ProductDetailResponse.from(info)); +} + +@DeleteMapping("/{productId}") +public ApiResponse delete(...) { + productFacade.delete(productId); + return ApiResponse.success(); +} +``` + +### 에러 응답 + +에러 응답의 HTTP 상태 코드는 **ErrorCode.getStatus()가 결정**한다. 에러를 200으로 보내지 않는다. + +| 상황 | 상태 코드 | 결정 주체 | +|------|----------|----------| +| Validation 실패 | **400** | ControllerAdvice 고정 | +| 비즈니스 에러 | **ErrorCode.getStatus()** | 도메인 ErrorCode enum | +| 서버 에러 | **500** | ControllerAdvice 최후 방어 | + +ErrorCode에 정의된 status가 그대로 HTTP 상태 코드가 된다: + +``` +OrderErrorCode.STOCK_INSUFFICIENT → HttpStatus.BAD_REQUEST → 400 +ErrorType.NOT_FOUND → HttpStatus.NOT_FOUND → 404 +BrandErrorCode.DUPLICATE_NAME → HttpStatus.CONFLICT → 409 +``` + +### 자주 쓰는 에러 상태 코드 + +| 상태 코드 | 의미 | 사용 시점 | +|----------|------|----------| +| 400 | Bad Request | Validation 실패, 잘못된 요청 | +| 401 | Unauthorized | 인증 실패 (로그인 필요) | +| 403 | Forbidden | 권한 없음 (본인 리소스 아님) | +| 404 | Not Found | 리소스 없음, soft delete된 리소스 | +| 409 | Conflict | 중복 (브랜드명 중복 등) | +| 500 | Internal Server Error | 예상치 못한 서버 에러 | + +--- + +## 4. 엔드포인트 설계 패턴 + +### 패턴 ①: 표준 CRUD + +대부분의 리소스는 이 패턴을 따른다. + +``` +GET /api/v1/{resources} ← 목록 조회 +GET /api/v1/{resources}/{id} ← 단건 조회 +POST /api/v1/{resources} ← 생성 +PUT /api/v1/{resources}/{id} ← 수정 +DELETE /api/v1/{resources}/{id} ← 삭제 +``` + +``` +// 예시: 브랜드 Admin CRUD +GET /api-admin/v1/brands +GET /api-admin/v1/brands/{brandId} +POST /api-admin/v1/brands +PUT /api-admin/v1/brands/{brandId} +DELETE /api-admin/v1/brands/{brandId} +``` + +### 패턴 ②: 중첩 리소스 (Nested Resource) + +리소스가 상위 리소스에 **종속**될 때 사용한다. "이 상품에 대한 좋아요"처럼 소속 관계가 명확한 경우다. + +``` +POST /api/v1/{parent}/{parentId}/{child} +DELETE /api/v1/{parent}/{parentId}/{child} +``` + +``` +// 예시: 상품의 좋아요 +POST /api/v1/products/{productId}/likes ← 좋아요 등록 +DELETE /api/v1/products/{productId}/likes ← 좋아요 취소 +``` + +중첩 리소스 사용 기준: +- 하위 리소스가 상위 리소스 없이는 의미가 없을 때 +- URL만 보고 "무엇에 대한 행위인지" 파악 가능해야 할 때 +- 깊이는 **2단계까지만** 허용한다 (`/a/{aId}/b` ✅, `/a/{aId}/b/{bId}/c` ❌) + +### 패턴 ③: 소유자 기준 조회 + +"내 리소스 목록"을 조회할 때, 소유자를 URL에 표현한다. + +``` +GET /api/v1/{owner}/{ownerId}/{resources} +``` + +``` +// 예시: 내가 좋아요한 상품 목록 +GET /api/v1/users/{userId}/likes +``` + +### 비CRUD 행위 표현 + +CRUD로 매핑이 어려운 행위는 **리소스 하위에 동사를 붙인다**. 단, 가능하면 리소스 중심으로 먼저 설계하고, 정말 안 될 때만 사용한다. + +``` +// 리소스로 표현 가능하면 리소스 방식 우선 +POST /api/v1/orders ✅ 주문 "생성"으로 표현 + +// 리소스로 표현이 어려운 행위 +POST /api/v1/products/{productId}/restock ← 재입고 (향후 예시) +``` + +--- + +## 5. 쿼리 파라미터 규칙 + +### 페이지네이션 — Offset 기반 기본 + +| 파라미터 | 설명 | 기본값 | +|----------|------|--------| +| `page` | 페이지 번호 (0부터 시작) | `0` | +| `size` | 페이지당 항목 수 | `20` | + +``` +GET /api-admin/v1/brands?page=0&size=20 +GET /api/v1/products?page=1&size=10 +``` + +Spring Data의 `Pageable`과 자연스럽게 연동된다. Cursor 기반 페이지네이션은 무한 스크롤 등 필요한 API에 한해 별도 도입한다. + +### 필터링 — 쿼리 파라미터로 전달 + +필터 조건은 **필드명을 그대로** 쿼리 파라미터명으로 사용한다. + +``` +GET /api/v1/products?brandId=1 +GET /api-admin/v1/products?brandId=1 +``` + +### 정렬 — sort 파라미터 + +정렬 기준은 `sort` 파라미터로 전달한다. 값은 **snake_case**로 표현한다. + +``` +GET /api/v1/products?sort=latest ← 최신순 (기본값) +GET /api/v1/products?sort=price_asc ← 가격 낮은순 +GET /api/v1/products?sort=likes_desc ← 좋아요 많은순 +``` + +### 날짜 범위 필터 + +기간 필터는 `startAt`, `endAt` 파라미터를 사용한다. + +``` +GET /api/v1/orders?startAt=2025-01-01&endAt=2025-01-31 +``` + +### 파라미터 네이밍 규칙 + +쿼리 파라미터명은 **camelCase**를 사용한다. JSON 필드명과 일관성을 유지한다. + +``` +?brandId=1&startAt=2025-01-01 ✅ camelCase +?brand_id=1&start_at=2025-01-01 ❌ snake_case +?brand-id=1 ❌ kebab-case +``` + +--- + +## 6. Controller 분리 규칙 + +### 고객 / Admin Controller 분리 + +같은 도메인이라도 **고객용과 Admin용 Controller를 분리**한다. 인증 방식, 요청/응답 DTO, prefix가 모두 다르기 때문이다. + +``` +interfaces/ +└── product/ + ├── ProductV1Controller.java ← /api/v1/products (고객) + ├── AdminProductV1Controller.java ← /api-admin/v1/products (Admin) + └── dto/ + ├── ProductDto.java ← 고객용 Request/Response + └── AdminProductDto.java ← Admin용 Request/Response +``` + +### Controller 네이밍 + +| 대상 | 네이밍 | RequestMapping | +|------|--------|----------------| +| 고객 | `{Domain}V1Controller` | `@RequestMapping("/api/v1/{resources}")` | +| Admin | `Admin{Domain}V1Controller` | `@RequestMapping("/api-admin/v1/{resources}")` | + +Controller 이름에 **V1**을 포함한다. URL에 `/api/v1`이 명시되어 있으므로, V2 API 추가 시 `{Domain}V2Controller`로 자연스럽게 확장된다. ApiSpec 인터페이스(`{Domain}V1ApiSpec`)와 네이밍이 일관된다. + +```java +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + public ApiResponse getProduct(...) { ... } +} + +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductV1Controller { + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + public ApiResponse getProduct(...) { ... } +} +``` + +### Facade 공유 규칙 + +고객 Controller와 Admin Controller는 **같은 Facade를 공유할 수 있다**. 단, Admin 전용 로직이 커지면 별도 Facade로 분리한다. + +``` +// 초기 — Facade 공유 +ProductV1Controller → ProductFacade +AdminProductV1Controller → ProductFacade + +// Admin 로직이 커지면 — Facade 분리 +ProductV1Controller → ProductFacade +AdminProductV1Controller → AdminProductFacade +``` + +분리 시점: Admin 전용 메서드가 Facade의 절반 이상을 차지하거나, Admin만의 복잡한 유스케이스가 생길 때. + +### 도메인 간 API가 겹칠 때 + +좋아요 목록 조회(`GET /api/v1/users/{userId}/likes`)처럼 URL의 루트 리소스와 실제 도메인이 다른 경우: + +``` +// 좋아요 도메인이 담당한다 — URL의 "likes"가 핵심 리소스 +interfaces/ +└── like/ + └── LikeController.java ← /api/v1/users/{userId}/likes + ← /api/v1/products/{productId}/likes +``` + +Controller를 어디에 둘지는 **핵심 리소스(행위의 주체)**가 기준이다. 좋아요 등록/취소/조회 모두 Like 도메인의 행위이므로 Like의 interfaces에 둔다. + +--- + +## 7. 요청/응답 본문 규칙 + +### 생성 요청 → 생성된 리소스 반환 + +POST로 리소스를 생성하면, **생성된 리소스 정보를 응답에 포함**한다. 클라이언트가 별도 조회 없이 바로 사용할 수 있다. + +```json +// POST /api-admin/v1/brands → 201 Created +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { "id": 1, "name": "ACNE STUDIOS" } +} +``` + +### 수정 요청 → 수정된 리소스 반환 (선택) + +PUT 수정 후 변경된 리소스를 반환한다. 반환할 필요가 없으면 `ApiResponse.success()`만 반환해도 된다. + +### 삭제 요청 → 빈 data + +DELETE 성공 시 `data: null`로 반환한다. + +```json +// DELETE /api-admin/v1/brands/{brandId} → 200 OK +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": null +} +``` + +### 목록 응답 구조 + +목록 조회 시 페이지네이션 메타 정보를 포함한다. + +```json +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { + "content": [ ... ], + "page": 0, + "size": 20, + "totalElements": 58, + "totalPages": 3 + } +} +``` + +--- + +## 체크리스트 + +**URL 구조** +- [ ] 고객 API는 `/api/v1/`, Admin API는 `/api-admin/v1/` prefix인가? +- [ ] 리소스명이 복수형 소문자인가? +- [ ] 경로 변수가 `{리소스명Id}` 형태의 camelCase인가? +- [ ] 중첩 리소스가 2단계를 초과하지 않는가? + +**HTTP 메서드/상태 코드** +- [ ] GET 조회, POST 생성, PUT 수정, DELETE 삭제를 지키는가? +- [ ] GET 요청에 Body가 없는가? +- [ ] 모든 성공 응답이 200인가? (`@ResponseStatus`, `ResponseEntity` 불필요) +- [ ] 에러 응답이 200이 아닌 ErrorCode.getStatus() 기준인가? + +**쿼리 파라미터** +- [ ] 필터/정렬/페이지네이션이 쿼리 파라미터로 전달되는가? +- [ ] 파라미터명이 camelCase인가? +- [ ] 페이지네이션이 `page` + `size` 형태인가? + +**Controller 분리** +- [ ] 고객/Admin Controller가 분리되어 있는가? +- [ ] Controller 네이밍이 `{Domain}V1Controller` / `Admin{Domain}V1Controller`인가? +- [ ] 고객/Admin DTO가 분리되어 있는가? +- [ ] Controller가 핵심 리소스의 도메인 패키지에 배치되어 있는가? + +**요청/응답** +- [ ] 생성 응답에 생성된 리소스 정보가 포함되는가? +- [ ] 삭제 응답이 `ApiResponse.success()`인가? +- [ ] 목록 응답에 페이지네이션 메타 정보가 있는가? +- [ ] 모든 응답이 `ApiResponse` 래퍼로 감싸져 있는가? diff --git a/.claude/skills/project-convention/references/interfaces/swagger-convention.md b/.claude/skills/project-convention/references/interfaces/swagger-convention.md new file mode 100644 index 000000000..4f50d0f81 --- /dev/null +++ b/.claude/skills/project-convention/references/interfaces/swagger-convention.md @@ -0,0 +1,389 @@ +# API 문서화 (Swagger) 컨벤션 + +## 목차 + +1. [ApiSpec 인터페이스 패턴](#1-apispec-인터페이스-패턴) +2. [패키지 배치와 네이밍](#2-패키지-배치와-네이밍) +3. [어노테이션 규칙](#3-어노테이션-규칙) +4. [Controller 연결](#4-controller-연결) +5. [DTO와 Schema 규칙](#5-dto와-schema-규칙) +6. [에러 응답 문서화](#6-에러-응답-문서화) +7. [체크리스트](#체크리스트) + +--- + +## 1. ApiSpec 인터페이스 패턴 + +### Swagger 어노테이션을 별도 인터페이스로 분리한다 + +Controller에 Swagger 어노테이션을 직접 달지 않는다. **ApiSpec 인터페이스**에 문서화 어노테이션을 몰아넣고, Controller가 이를 구현한다. + +``` +interfaces/ +└── user/ + ├── UserV1ApiSpec.java ← Swagger 어노테이션 (인터페이스) + ├── UserV1Controller.java ← implements UserV1ApiSpec + └── dto/ + └── UserV1Dto.java +``` + +왜 분리하는가: +- **Controller가 깨끗하다** — 비즈니스 흐름(요청 → Facade → 응답)만 보인다. Swagger 어노테이션 10줄이 메서드마다 붙으면 가독성이 급격히 떨어진다 +- **문서와 구현이 독립적으로 변경된다** — 문서 설명을 바꿔도 Controller diff가 생기지 않는다 +- **리뷰가 분리된다** — API 스펙 리뷰와 구현 리뷰를 따로 할 수 있다 + +### ApiSpec 구조 + +```java +@Tag(name = "User V1 API", description = "사용자 API 입니다.") +public interface UserV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 사용자를 등록합니다." + ) + ApiResponse signup( + @RequestBody(description = "회원가입 요청 정보") + UserV1Dto.SignupRequest request + ); + + @Operation( + summary = "내 정보 조회", + description = "인증된 사용자의 정보를 조회합니다. 헤더에 X-Loopers-LoginId와 X-Loopers-LoginPw를 포함해야 합니다." + ) + ApiResponse getMyInfo( + @Parameter(description = "로그인 ID", required = true) + String loginId, + @Parameter(description = "비밀번호", required = true) + String password + ); +} +``` + +핵심 원칙: +- **반환 타입**과 **파라미터 타입**은 Controller와 동일하게 맞춘다 +- **메서드명**도 Controller와 동일하게 맞춘다 +- ApiSpec에는 **Swagger 어노테이션만** 둔다. Spring MVC 어노테이션(`@GetMapping`, `@PathVariable` 등)은 Controller에만 둔다 + +--- + +## 2. 패키지 배치와 네이밍 + +### 파일 위치 + +ApiSpec 인터페이스는 **Controller와 같은 패키지**에 둔다. + +``` +interfaces/ +├── user/ +│ ├── UserV1ApiSpec.java +│ ├── UserV1Controller.java +│ └── dto/ +│ └── UserV1Dto.java +│ +├── product/ +│ ├── ProductV1ApiSpec.java +│ ├── AdminProductV1ApiSpec.java +│ ├── ProductV1Controller.java +│ ├── AdminProductV1Controller.java +│ └── dto/ +│ ├── ProductV1Dto.java +│ └── AdminProductV1Dto.java +│ +└── like/ + ├── LikeV1ApiSpec.java + ├── LikeV1Controller.java + └── dto/ + └── LikeV1Dto.java +``` + +### 네이밍 규칙 + +| 대상 | 네이밍 | 예시 | +|------|--------|------| +| 고객 ApiSpec | `{Domain}V1ApiSpec` | `ProductV1ApiSpec` | +| Admin ApiSpec | `Admin{Domain}V1ApiSpec` | `AdminProductV1ApiSpec` | + +`V1`을 포함하는 이유: +- API 버전이 URL에 `/api/v1`으로 명시되어 있다 +- 향후 V2 API가 추가될 때 `ProductV2ApiSpec`으로 자연스럽게 확장된다 +- `@Tag`의 name에도 버전이 들어간다 (`"Product V1 API"`) + +### DTO 네이밍과의 연관 + +ApiSpec의 DTO 이름도 **V1**을 포함한다. 같은 도메인이라도 API 버전별로 요청/응답이 달라질 수 있기 때문이다. + +``` +dto/ +├── UserV1Dto.java ← V1 API용 Request/Response +└── AdminUserV1Dto.java ← Admin V1 API용 +``` + +--- + +## 3. 어노테이션 규칙 + +### 필수 어노테이션 + +| 어노테이션 | 위치 | 용도 | +|-----------|------|------| +| `@Tag` | 인터페이스 레벨 | API 그룹 이름과 설명 | +| `@Operation` | 메서드 레벨 | API 요약과 상세 설명 | +| `@Parameter` | 파라미터 레벨 | 경로 변수, 헤더, 쿼리 파라미터 설명 | +| `@RequestBody` | 파라미터 레벨 | 요청 본문 설명 | + +### @Tag — 인터페이스 레벨 + +하나의 ApiSpec 인터페이스에 하나의 `@Tag`를 붙인다. + +```java +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { ... } + +@Tag(name = "Admin Product V1 API", description = "상품 관리 API 입니다.") +public interface AdminProductV1ApiSpec { ... } +``` + +Tag name 규칙: +- 형식: `"{Domain} V1 API"` / `"Admin {Domain} V1 API"` +- description: 한글, 간결하게 + +### @Operation — 메서드 레벨 + +모든 API 메서드에 `@Operation`을 붙인다. + +```java +@Operation( + summary = "상품 목록 조회", + description = "브랜드, 정렬 조건으로 상품 목록을 조회합니다. 페이지네이션을 지원합니다." +) +``` + +| 속성 | 규칙 | 예시 | +|------|------|------| +| `summary` | 한 줄, 동사로 시작 | `"상품 목록 조회"`, `"주문 생성"` | +| `description` | 상세 설명. 인증 요구사항, 특이사항 포함 | `"헤더에 X-Loopers-LoginId를 포함해야 합니다."` | + +### @Parameter — 경로 변수, 헤더, 쿼리 파라미터 + +```java +@Operation(summary = "상품 단건 조회") +ApiResponse getProduct( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId +); +``` + +```java +@Operation(summary = "상품 목록 조회") +ApiResponse> getProducts( + @Parameter(description = "브랜드 ID (필터)") + Long brandId, + @Parameter(description = "정렬 기준", example = "latest") + String sort, + @Parameter(description = "페이지 번호", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size +); +``` + +| 속성 | 사용 시점 | +|------|----------| +| `description` | **항상** 작성 | +| `required` | 필수 파라미터일 때 `true` | +| `example` | ID, 페이지 번호 등 구체적 값이 도움될 때 | +| `hidden` | Swagger UI에서 숨길 파라미터 (내부용 헤더 등) | + +### @RequestBody — 요청 본문 + +```java +@Operation(summary = "상품 등록") +ApiResponse create( + @RequestBody(description = "상품 등록 요청 정보") + AdminProductV1Dto.CreateRequest request +); +``` + +`io.swagger.v3.oas.annotations.parameters.RequestBody`를 사용한다 (Spring의 `@RequestBody`와 다른 패키지). + +--- + +## 4. Controller 연결 + +### Controller가 ApiSpec을 implements한다 + +```java +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @Override + @PostMapping("/signup") + public ApiResponse signup( + @org.springframework.web.bind.annotation.RequestBody @Valid + UserV1Dto.SignupRequest request + ) { + UserInfo info = userFacade.signup(request.toCommand()); + return ApiResponse.success(UserV1Dto.SignupResponse.from(info)); + } + + @Override + @GetMapping("/me") + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + return ApiResponse.success(UserV1Dto.MyInfoResponse.from(info)); + } +} +``` + +핵심 포인트: +- **Spring MVC 어노테이션**(`@GetMapping`, `@PathVariable`, `@RequestHeader`, `@Valid`)은 **Controller에만** 둔다 +- **Swagger 어노테이션**(`@Operation`, `@Parameter`, `@Tag`)은 **ApiSpec에만** 둔다 +- `@RequestBody`는 주의: Swagger의 `io.swagger.v3.oas.annotations.parameters.RequestBody`는 ApiSpec에, Spring의 `org.springframework.web.bind.annotation.RequestBody`는 Controller에 각각 사용 +- `@Override`를 명시하여 ApiSpec과의 연결을 코드에서 확인한다 + +### Admin Controller도 동일 패턴 + +```java +@RestController +@RequestMapping("/api-admin/v1/products") +@RequiredArgsConstructor +public class AdminProductV1Controller implements AdminProductV1ApiSpec { + + private final ProductFacade productFacade; + + @Override + @PostMapping + public ResponseEntity> create( + @org.springframework.web.bind.annotation.RequestBody @Valid + AdminProductV1Dto.CreateRequest request + ) { + ProductInfo info = productFacade.create(request.toCommand()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(AdminProductV1Dto.DetailResponse.from(info))); + } +} +``` + +--- + +## 5. DTO와 Schema 규칙 + +### record DTO는 자동으로 Schema가 생성된다 + +SpringDoc은 record의 필드를 자동으로 Swagger Schema에 반영한다. 대부분의 경우 `@Schema`를 별도로 붙이지 않아도 된다. + +```java +// 이것만으로도 Swagger UI에 필드가 표시된다 +public record CreateRequest( + @NotBlank String name, + @Positive int price, + @PositiveOrZero int stock +) {} +``` + +### @Schema가 필요한 경우 + +필드명만으로는 의미가 불명확하거나, 예시 값이 필요한 경우에만 `@Schema`를 추가한다. + +```java +public record CreateRequest( + @Schema(description = "상품명", example = "오버사이즈 코트") + @NotBlank String name, + + @Schema(description = "판매가 (원)", example = "129000") + @Positive int price, + + @Schema(description = "재고 수량", example = "50") + @PositiveOrZero int stock, + + @Schema(description = "브랜드 ID", example = "1") + @NotNull Long brandId +) {} +``` + +`@Schema` 추가 기준: +- **필드명이 모호한 경우** — `status`, `type` 등 여러 의미를 가질 수 있을 때 +- **단위가 중요한 경우** — 가격(원), 무게(g) 등 +- **enum이나 특정 형식이 있는 경우** — 날짜 포맷, 정렬 값 등 +- **example이 이해를 돕는 경우** — API 테스트 시 Swagger UI에서 바로 사용 가능 + +### Response에도 동일 기준 적용 + +```java +public record DetailResponse( + Long id, + String name, + int price, + @Schema(description = "생성일시", example = "2025-01-15T10:30:00+09:00") + ZonedDateTime createdAt +) { + public static DetailResponse from(ProductInfo info) { ... } +} +``` + +--- + +## 6. 에러 응답 문서화 + +### 공통 에러는 ControllerAdvice 레벨에서 문서화 + +개별 ApiSpec 메서드마다 에러 응답을 반복하지 않는다. 공통 에러(400, 401, 500 등)는 SpringDoc의 글로벌 설정이나 ControllerAdvice에서 한 번만 정의한다. + +### 도메인별 특수 에러만 ApiSpec에 명시 + +해당 API에서만 발생하는 특수한 에러가 있다면 `@ApiResponse`로 명시할 수 있다. + +```java +@Operation( + summary = "상품 좋아요", + description = "상품에 좋아요를 등록합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "이미 좋아요한 상품" + ) + } +) +ApiResponse like( + @Parameter(description = "상품 ID", required = true) + Long productId, + @Parameter(description = "사용자 ID", required = true) + String loginId +); +``` + +단, 모든 에러를 나열하지 않는다. 프론트엔드 개발자가 "이 API에서 이런 에러가 나올 수 있구나"를 알아야 하는 경우에만 추가한다. + +--- + +## 체크리스트 + +**ApiSpec 인터페이스** +- [ ] 모든 Controller에 대응하는 ApiSpec 인터페이스가 있는가? +- [ ] ApiSpec에 `@Tag`가 붙어 있는가? +- [ ] 모든 API 메서드에 `@Operation(summary, description)`이 있는가? +- [ ] 파라미터에 `@Parameter(description)`이 있는가? +- [ ] 요청 본문에 Swagger `@RequestBody(description)`이 있는가? + +**Controller 연결** +- [ ] Controller가 ApiSpec을 `implements`하는가? +- [ ] Controller 메서드에 `@Override`가 명시되어 있는가? +- [ ] Spring MVC 어노테이션은 Controller에만, Swagger 어노테이션은 ApiSpec에만 있는가? +- [ ] Swagger `@RequestBody`와 Spring `@RequestBody`가 혼동되지 않는가? + +**네이밍/배치** +- [ ] ApiSpec 네이밍이 `{Domain}V1ApiSpec` / `Admin{Domain}V1ApiSpec`인가? +- [ ] ApiSpec이 Controller와 같은 패키지에 있는가? +- [ ] DTO 네이밍이 `{Domain}V1Dto` / `Admin{Domain}V1Dto`인가? + +**DTO Schema** +- [ ] 모호한 필드에 `@Schema(description)`이 추가되어 있는가? +- [ ] `@Schema`를 불필요하게 모든 필드에 붙이지 않았는가? diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..ae4b67192 --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 diff --git a/.claude/skills/tdd/SKILL.md b/.claude/skills/tdd/SKILL.md new file mode 100644 index 000000000..8c26f5bc4 --- /dev/null +++ b/.claude/skills/tdd/SKILL.md @@ -0,0 +1,192 @@ +--- +name: tdd +description: | + TDD 방식으로 기능을 개발할 때 사용. DESIGN.md에서 테스트 케이스를 도출하고, + Red-Green-Refactor 루프를 기능 단위 수직 슬라이스(Domain → Application 관통)로 실행한다. + "TDD로 개발", "테스트 먼저", "TDD 모드", "Red-Green-Refactor" 시 트리거. +--- + +# TDD Skill + +DESIGN.md 기반 Red-Green-Refactor 실행 스킬. 핵심 규칙과 계층별 전략은 CLAUDE.md에 있으므로, 이 파일은 **시작 시 한 번만** 읽는다. + +--- + +## 진행 모드 선택 + +TDD 시작 시 사용자에게 진행 모드를 묻는다. + +| 모드 | 설명 | +|------|------| +| **Solo** | Claude가 Red → Green → Refactor를 모두 수행. 결과를 진행 문서에 기록하며 연속 진행 | +| **Pair** | Claude가 Red(실패하는 테스트) 작성 → 사용자가 Green(통과 코드) 작성 → 함께 리뷰 + Refactor | + +모드를 선택하지 않으면 **Pair를 기본값**으로 제안한다. + +### Solo 모드 + +- Claude가 Red → Green → Refactor를 연속으로 수행 +- 매 Round 후 진행 문서 갱신 +- 전체 완료 후 사용자에게 결과 보고 + +### Pair 모드 (협력 개발) + +각 Round를 다음 순서로 진행한다: + +``` +1. 🔴 Red: Claude가 실패하는 테스트를 작성하고, 테스트를 실행하여 실패를 확인한다 + → 사용자에게 "Red 확인. Green을 작성해주세요" 안내 + → 사용자가 직접 Green 코드를 작성하거나, Claude에게 Green 작성을 요청할 수 있다 + +2. 🟢 Green: 사용자(또는 사용자 요청 시 Claude)가 최소 코드로 테스트를 통과시킨다 + → 테스트 실행하여 통과 확인 + → 함께 Green 코드를 리뷰한다 + +3. 🔵 Refactor: 함께 리팩터링 필요 여부를 논의한다 + → 리팩터링이 필요하면 수행 후 테스트 재실행 + → 필요 없으면 skip + +4. 다음 Round로 이동 +``` + +**Pair 모드 핵심 규칙:** +- Claude는 Red만 작성하고 **멈춘다** — 사용자 턴을 기다린다 +- 사용자가 "Green 해줘" / "통과시켜줘" 등 요청하면 Claude가 Green을 작성한다 +- Refactor는 항상 사용자와 함께 논의 후 진행한다 +- Round 사이에 사용자가 질문하거나 방향을 바꿀 수 있다 + +--- + +## 사전 조건 + +스킬 실행 전 반드시 Read: + +1. `docs/spec/{domain}/DESIGN.md` — 유즈케이스, 시퀀스, 클래스, ERD +2. `.claude/skills/project-convention/references/common/test-convention.md` — 테스트 구조, 네이밍, 더블 전략 + +--- + +## Step 1: 테스트 목록표 + 진행 문서 생성 + +DESIGN.md에서 테스트 케이스를 도출하여 정리한다. + +``` +┌──────────────────────────────────────────────────────┐ +│ Feature: {기능명} │ +├───────────┬──────────────────┬────────────────────────┤ +│ 계층 │ 테스트 대상 │ 테스트 케이스 │ +├───────────┼──────────────────┼────────────────────────┤ +│ Domain │ {Entity/VO} │ {케이스} │ +│ Domain │ {Service} │ {케이스} │ +│ App │ {Facade} │ {케이스} │ +└───────────┴──────────────────┴────────────────────────┘ +``` + +**반드시 사용자에게 보여주고 확인을 받은 후 진행한다.** + +확인 후 진행 문서를 `docs/tdd/{domain}/{feature}.md`에 생성한다. (템플릿은 하단 참조) + +## Step 2: Round 루프 실행 + +CLAUDE.md의 TDD 핵심 규칙에 따라 Red → Green → Refactor를 반복한다. 매 Round 후 진행 문서의 해당 Round 상태를 갱신한다. + +- 🔴 Red 후: `🔴 Red: ✅ 실패 확인 — {요약}` +- 🟢 Green 후: `🟢 Green: ✅ 통과 — {요약}` +- 🔵 Refactor 후: `🔵 Refactor: ✅ {요약}` 또는 `skip` + +## Step 3: 전체 테스트 + +`./gradlew :apps:commerce-api:test` 실행. 진행 문서에 결과 기록. + +## Step 4: 완료 보고 + +상태를 `✅ 완료`로 변경, 산출물 경로 기록, 커밋 제안. + +``` +✅ TDD 완료 +Feature: {기능명} +- 테스트: {N}개 작성, 전체 통과 +- 구현: {파일 목록 요약} +커밋을 진행할까요? +``` + +--- + +## Fake 작성 규칙 + +Domain Service 테스트에서 Repository 대체 시 Fake 우선 사용. 기존 `FakeBrandRepository` 참고. + +- `HashMap` + `AtomicLong`으로 저장소 구현 +- `save()`: id 없으면 자동 생성, store에 저장 +- `findById()`: store에서 조회, soft delete 필터링 +- 테스트 소스(`src/test/java`)에 배치 + +### Fake vs Mockito 판단 + +``` +외부 의존 없음 → 더블 불필요 (Entity, VO) +외부 의존 있음 + Domain 계층 → Fake +외부 의존 있음 + Application 계층 → Mockito mock() +``` + +--- + +## 프로덕션 코드 작성 시 참조 + +| 대상 | 경로 | +|------|------| +| Entity, VO | `.claude/skills/project-convention/references/domain/entity-vo-convention.md` | +| Service | `.claude/skills/project-convention/references/application/service-layer-convention.md` | +| 테스트 | `.claude/skills/project-convention/references/common/test-convention.md` | + +--- + +## 템플릿: 진행 문서 + +```markdown +# TDD: {Feature 한글명} + +| 항목 | 내용 | +|------|------| +| 도메인 | {domain} | +| 상태 | 🟡 진행 중 | +| DESIGN.md | docs/spec/{domain}/DESIGN.md | + +--- + +## 테스트 목록표 + +{Step 1에서 도출한 테스트 목록표} + +--- + +## Round 진행 현황 + +### Round 1: {테스트 케이스명} +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +(모든 항목을 Round로 나열) + +--- + +## 전체 테스트 결과 + +(Step 3 완료 후 기록) + +--- + +## 산출물 + +(Step 4 완료 후 기록) + +### 테스트 파일 +- `src/test/java/.../{TestClass}.java` (new) + +### 프로덕션 파일 +- `src/main/java/.../{Class}.java` (new) + +### Fake +- `src/test/java/.../Fake{Repository}.java` (new) +``` diff --git a/.gitignore b/.gitignore index 88ce09aad..28570c022 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,10 @@ out/ .kotlin ### Claude Code ### -.claude/ +claude/* +!.claude/skills/ +.interview-state/ ### Documentation ### -docs/ +docs/* +!docs/design/ diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 000000000..006ec937c --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=21.0.7-tem diff --git a/README.md b/README.md index 49ee650a3..e69de29bb 100644 --- a/README.md +++ b/README.md @@ -1,77 +0,0 @@ -# Round-1 -### 시작 전 목표 -``` -- 나의 의도를 테스트 코드로 작성한다. -- TDD 방식으로 AI와 함께 기능 구현해본다. -- TDD로 요구사항을 먼저 정리하는 장점을 느껴본다. -- 작게 쪼개고 점진적으로 설계하는 과정을 느껴본다. -- 리팩토링이 가능하다는것을 느껴본다. -``` - -### 시작 후 목표 -- 요구사항을 AI 실행 프롬프트로 구조화하는 방법 알아보기 -``` -내 현재 학습 초점은 '설계는 내가, 구현은 AI가'라는 명확한 역할 분리다. -TDD 방식으로 AI에게 코딩을 위임하면서도 설계 결정권은 내가 가져가는 방식이다. -여기서 질문이 생겼는데, 기능 요구사항을 받았을 때, 어떤 변환 과정을 거쳐 AI가 정확히 구현할 수 있는 프롬프트 형태로 만들어지는가?' - -이 변환 과정을 알아보는 것을 목표로 했다. -``` - -### 내가 한 시도 - -시도 1: AI가 설계하고, AI가 결정 -- 방식: 기능 요구사항을 그대로 던져주고 TDD(Red-Green-Refactor)로 개발하라고 했다. -- 문제점: 이 과정에서 내 설계도, 내 의도도 없다는 걸 자각했다. AI가 알아서 다 했을 뿐이었다. - -시도 2: AI가 설계하고, 내가 결정 -- 방식: AI가 설계 보고서를 작성하면 내가 읽고 결정하는 위치에 서기로 했다. -- 문제점: - - AI가 너무 복잡한 설계를 제시했다. - - 그 문서를 읽는 데 시간이 많이 들었고, 전부 읽을 수도 없었다. - - 내 의도가 담긴 설계라고 전혀 느껴지지 않았다. - -시도 3: 내가 설계하고, AI의 피드백을 받기 -- 방식: 기능 요구사항으로부터 객체와 메시지 정의, 검증 및 예외 케이스를 내가 직접 문서로 정리했다. -- 좋았던 점: - - 오직 내 설계 틀 안에서 AI가 고려해줘서, 유용한 피드백을 받았다. - - 레이어별 특징, 해야 할 것, 하지 말아야 할 것을 먼저 정의해야 한다는 걸 배웠다. - - 내가 놓친 엣지 케이스나 잠재적 문제를 해상도 높게 알려주고, 판단을 요구해줬다. -- 문제점: - - 회원가입 기능을 도메인부터 API까지 전부 수도코드로 문서화하고 있었는데, 이러다 보니 생각이 들었다. - - "수도코드로 문서화할 바에, 그냥 내가 코드로 먼저 작성하고 이 스타일대로 나머지 기능을 구현하라고 하는 게 더 내 의도가 담긴 코드 아닌가?" - - 요구사항 규칙뿐만 아니라 개발 규칙들을 명확히 하는 것이 부족했다. - - 내 의도를 설명하면서 개발 스타일을 알려줄 때, 지금처럼 문서로 전달하는 게 맞는지, 아니면 내가 기능 구현 하나를 예시로 만들어주고 이 스타일을 참고해서 개발하라고 해야 할지 고민이 들었다. - -시도 4: 내가 설계하고 시범 구현을 작성하고, AI에게 이 방식대로 하라고 명령 -- 방식: 회원가입 기능을 TDD(Red-Green-Refactor)로 도메인에서 API까지 직접 구현하고, 내 코드 스타일대로 다른 기능 개발을 맡겼다. -- 진행 과정: - - AI는 A부터 Z까지 전부 만들어냈다. - - 나는 왜 그렇게 설계했고 만들었는지 물어보는 식으로 AI의 설계 사고를 배우고 있었다. - - 이 내용들은 내가 설계를 진행했어도 AI에게 물어볼 내용들이었다. - - 내 의도가 담긴 코드라기보다는, 나는 그 의도를 이해해 나가고, 동의하지 않으면 내 설계를 담아내는 식으로 진행했다. -- 문제점: - - 한 번 구현하라고 할 때마다 오래 걸렸다. 처음부터 API까지 관련 모든 코드를 20분 동안 작업하고 있었다. - - 전부 한 큐에 완성시키기 때문에 양이 방대했다. - - 나는 추가된 main 코드 확인과 테스트 통과 여부만 확인하고 있었다. - - 이게 맞는 방식인지는 아직 잘 모르겠다. - - - -### 과제 후 느낀점 -- AI 협업에 대한 미해결 고민 - - 아직 어떻게 AI를 파트너로 협업하는 것인지 깨닫지 못했다. - - 이번 과제는 사실 처음부터 완벽한 설계를 만들고 AI에게 개발하라고 하는 게 아니라, 기능 요구사항으로부터 개발자가 직접 TDD를 구현하면서 이 과정에서 궁금하거나, 막히거나, 노가다 과정을 AI에게 맡기는 것을 기대한 과제였던 걸까? -- 클로드 코드 사용 경험 - - 이번에 클로드 코드를 사용해서 개발해보는 건 처음인데, 한 번의 명령으로 A부터 Z까지 기능 구현, 문서화, 테스트 코드 전부 구현해줘서 놀라웠다. - - 더 이상 구현은 중요하지 않다는 것을 이번에 체감했다. - - 대신 개발이 되는 환경을 잘 이해하는 것, 계층 책임이나 객체 책임 및 검증 스코프를 잘 정의하는 것, 이런 것들이 더 중요한 것 같은 느낌을 받았다. - - 클로드 스킬에 관심이 생겼다. - - - ---- - -## 📋 기능 요구 사항 - -기능 요구 사항 정리 [ToDoList.md](./ToDoList.md) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..7ba37e7f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,47 @@ +package com.loopers.application.brand; + +import com.loopers.application.brand.dto.BrandCriteria; +import com.loopers.application.brand.dto.BrandResult; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + @Transactional + public void registerBrand(BrandCriteria.Register criteria) { + brandService.register(criteria.name()); + } + + @Transactional(readOnly = true) + public BrandResult getBrand(Long id) { + BrandModel brandModel = brandService.getById(id); + return BrandResult.from(brandModel); + } + + @Transactional + public void updateBrand(Long id, BrandCriteria.Update criteria) { + brandService.update(id, criteria.name()); + } + + @Transactional + public void deleteBrand(Long id) { + brandService.delete(id); + productService.deleteAllByBrandId(id); + } + + @Transactional(readOnly = true) + public Page getBrands(Pageable pageable) { + return brandService.getAll(pageable).map(BrandResult::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java new file mode 100644 index 000000000..f71745650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java @@ -0,0 +1,18 @@ +package com.loopers.application.brand.dto; + +import com.loopers.domain.brand.dto.BrandCommand; + +public class BrandCriteria { + + public record Register(String name) { + public BrandCommand.Register toCommand() { + return new BrandCommand.Register(name); + } + } + + public record Update(String name) { + public BrandCommand.Update toCommand() { + return new BrandCommand.Update(name); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java new file mode 100644 index 000000000..fc6540cef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java @@ -0,0 +1,10 @@ +package com.loopers.application.brand.dto; + +import com.loopers.domain.brand.BrandModel; +import java.time.ZonedDateTime; + +public record BrandResult(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + public static BrandResult from(BrandModel model) { + return new BrandResult(model.getId(), model.getName(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..72a7cb513 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,35 @@ +package com.loopers.application.like; + +import com.loopers.application.like.dto.LikeResult; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeFacade { + private final ProductLikeService productLikeService; + private final ProductService productService; + + @Transactional + public void like(Long userId, Long productId) { + productService.validateExists(productId); + productLikeService.like(userId, productId); + } + + @Transactional + public void unlike(Long userId, Long productId) { + productLikeService.unlike(userId, productId); + } + + @Transactional(readOnly = true) + public List getMyLikedProducts(Long userId) { + return productLikeService.getLikesByUserId(userId).stream() + .filter(like -> productService.existsById(like.getProductId())) + .map(LikeResult::from) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java new file mode 100644 index 000000000..89d5eb5eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java @@ -0,0 +1,17 @@ +package com.loopers.application.like.dto; + +import com.loopers.domain.like.ProductLikeModel; +import java.time.ZonedDateTime; +import java.util.List; + +public record LikeResult(Long id, Long userId, Long productId, ZonedDateTime createdAt) { + public static LikeResult from(ProductLikeModel model) { + return new LikeResult(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt()); + } + public static List from(List models) { + return models.stream() + .map(model -> + new LikeResult(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt())) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..86df97e83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,90 @@ +package com.loopers.application.order; + +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.dto.ProductCommand; +import com.loopers.domain.product.dto.ProductInfo; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderFacade { + + private final ProductService productService; + private final BrandService brandService; + private final OrderService orderService; + + @Transactional + public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { + List deductionInfos = productService.validateAndDeductStock( + criteria.items().stream() + .map(item -> new ProductCommand.StockDeduction( + item.productId(), item.quantity(), item.expectedPrice())) + .toList()); + + Map brandNameMap = brandService.getNameMapByIds( + deductionInfos.stream() + .map(ProductInfo.StockDeduction::brandId) + .distinct() + .toList()); + + List items = deductionInfos.stream() + .map(info -> OrderItemModel.create( + info.productId(), + info.price(), + info.quantity(), + info.name(), + brandNameMap.get(info.brandId()))) + .toList(); + + return OrderResult.OrderSummary.from( + orderService.createOrder(userId, items)); + } + + @Transactional(readOnly = true) + public List getMyOrders(Long userId, OrderCriteria.ListByDate criteria) { + return orderService.getOrdersByUserIdAndPeriod(userId, criteria.startAt(), criteria.endAt()).stream() + .map(OrderResult.OrderSummary::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderResult.OrderDetail getMyOrderDetail(Long userId, Long orderId) { + return OrderResult.OrderDetail.from( + orderService.getByIdAndUserId(orderId, userId)); + } + + @Transactional(readOnly = true) + public Page getAllOrders(Pageable pageable) { + return orderService.getAllOrders(pageable) + .map(OrderResult.OrderSummary::from); + } + + @Transactional(readOnly = true) + public OrderResult.OrderDetail getOrderDetail(Long orderId) { + return OrderResult.OrderDetail.from(orderService.getById(orderId)); + } + + @Transactional + public void cancelMyOrderItem(Long userId, Long orderId, Long orderItemId) { + orderService.getByIdAndUserId(orderId, userId); + OrderItemModel cancelledItem = orderService.cancelItem(orderId, orderItemId); + productService.increaseStock(cancelledItem.getProductId(), cancelledItem.getQuantity()); + } + + @Transactional + public void cancelOrderItem(Long orderId, Long orderItemId) { + OrderItemModel cancelledItem = orderService.cancelItem(orderId, orderItemId); + productService.increaseStock(cancelledItem.getProductId(), cancelledItem.getQuantity()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java new file mode 100644 index 000000000..a0e3caeee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java @@ -0,0 +1,14 @@ +package com.loopers.application.order.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderCriteria { + + public record Create(List items) { + + public record CreateItem(Long productId, int quantity, int expectedPrice) {} + } + + public record ListByDate(ZonedDateTime startAt, ZonedDateTime endAt) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java new file mode 100644 index 000000000..77a33a07d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java @@ -0,0 +1,62 @@ +package com.loopers.application.order.dto; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderResult { + + public record OrderSummary( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderSummary from(OrderModel model) { + return new OrderSummary( + model.getId(), + model.getTotalPrice(), + model.getStatus().name(), + model.getCreatedAt()); + } + } + + public record OrderDetail( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetail from(OrderModel model) { + return new OrderDetail( + model.getId(), + model.getUserId(), + model.getTotalPrice(), + model.getStatus().name(), + model.getCreatedAt(), + model.getItems().stream().map(OrderItemDetail::from).toList()); + } + } + + public record OrderItemDetail( + Long orderItemId, + Long productId, + String productName, + String brandName, + int orderPrice, + int quantity + ) { + public static OrderItemDetail from(OrderItemModel model) { + return new OrderItemDetail( + model.getId(), + model.getProductId(), + model.getProductName(), + model.getBrandName(), + model.getOrderPrice(), + model.getQuantity()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..349a8aefb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,139 @@ +package com.loopers.application.product; + +import com.loopers.application.product.dto.ProductCriteria; +import com.loopers.application.product.dto.ProductResult; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final ProductLikeService productLikeService; + + @Transactional + public void registerProduct(ProductCriteria.Register criteria) { + brandService.validateExists(criteria.brandId()); + productService.register(criteria.brandId(), criteria.name(), criteria.price(), criteria.stock()); + } + + @Transactional(readOnly = true) + public ProductResult getProduct(Long id) { + ProductModel product = productService.getById(id); + return ProductResult.of( + product, + brandService.getById(product.getBrandId()).getName(), + productLikeService.countLikes(id)); + } + + @Transactional + public void updateProduct(Long id, ProductCriteria.Update criteria) { + productService.update(id, criteria.name(), criteria.price(), criteria.stock()); + } + + @Transactional + public void deleteProduct(Long id) { + productService.delete(id); + } + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable) { + Page products = productService.getAll(pageable); + Map brandNameMap = brandService.getNameMapByIds( + products.getContent().stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); + return products.map(product -> ProductResult.of(product, brandNameMap.get(product.getBrandId()))); + } + + @Transactional(readOnly = true) + public Page getProductsByBrandId(Long brandId, Pageable pageable) { + String brandName = brandService.getById(brandId).getName(); + return productService.getAllByBrandId(brandId, pageable) + .map(product -> ProductResult.of(product, brandName)); + } + + @Transactional(readOnly = true) + public Page getProductsWithActiveBrand(Pageable pageable) { + Page products = productService.getAll(pageable); + Map brandNameMap = brandService.getActiveNameMapByIds( + products.getContent().stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); + Map likeCountMap = productLikeService.countLikesByProductIds( + products.getContent().stream() + .map(ProductModel::getId) + .toList()); + List results = products.getContent().stream() + .filter(product -> brandNameMap.containsKey(product.getBrandId())) + .map(product -> ProductResult.of( + product, + brandNameMap.get(product.getBrandId()), + likeCountMap.getOrDefault(product.getId(), 0L))) + .toList(); + return new PageImpl<>(results, products.getPageable(), products.getTotalElements()); + } + + @Transactional(readOnly = true) + public Page getProductsWithActiveBrandByBrandId(Long brandId, Pageable pageable) { + String brandName = brandService.getById(brandId).getName(); + Page products = productService.getAllByBrandId(brandId, pageable); + Map likeCountMap = productLikeService.countLikesByProductIds( + products.getContent().stream() + .map(ProductModel::getId) + .toList()); + List results = products.getContent().stream() + .map(product -> ProductResult.of( + product, + brandName, + likeCountMap.getOrDefault(product.getId(), 0L))) + .toList(); + return new PageImpl<>(results, products.getPageable(), products.getTotalElements()); + } + + @Transactional(readOnly = true) + public Page getProductsWithActiveBrandSortedByLikes(Long brandId, int page, int size) { + List products = brandId != null + ? productService.getAllByBrandId(brandId) + : productService.getAll(); + Map brandNameMap = brandService.getActiveNameMapByIds( + products.stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); + Map likeCountMap = productLikeService.countLikesByProductIds( + products.stream() + .map(ProductModel::getId) + .toList()); + List sorted = products.stream() + .filter(product -> brandNameMap.containsKey(product.getBrandId())) + .map(product -> ProductResult.of( + product, + brandNameMap.get(product.getBrandId()), + likeCountMap.getOrDefault(product.getId(), 0L))) + .sorted(Comparator.comparingLong(ProductResult::likeCount).reversed()) + .toList(); + int start = page * size; + int end = Math.min(start + size, sorted.size()); + return new PageImpl<>( + start >= sorted.size() ? List.of() : sorted.subList(start, end), + PageRequest.of(page, size), + sorted.size()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java new file mode 100644 index 000000000..d3e25d176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java @@ -0,0 +1,18 @@ +package com.loopers.application.product.dto; + +import com.loopers.domain.product.dto.ProductCommand; + +public class ProductCriteria { + + public record Register(Long brandId, String name, int price, int stock) { + public ProductCommand.Register toCommand() { + return new ProductCommand.Register(brandId, name, price, stock); + } + } + + public record Update(String name, int price, int stock) { + public ProductCommand.Update toCommand() { + return new ProductCommand.Update(name, price, stock); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java new file mode 100644 index 000000000..95a8c5cb9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -0,0 +1,35 @@ +package com.loopers.application.product.dto; + +import com.loopers.domain.product.ProductModel; +import java.time.ZonedDateTime; + +public record ProductResult( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + long likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + public static ProductResult of(ProductModel model, String brandName) { + return of(model, brandName, 0L); + } + + public static ProductResult of(ProductModel model, String brandName, long likeCount) { + return new ProductResult( + model.getId(), + model.getBrandId(), + brandName, + model.getName(), + model.getPrice(), + model.getStock(), + likeCount, + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt()); + } +} 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 000000000..01f8990bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,35 @@ +package com.loopers.application.user; + +import com.loopers.application.user.dto.UserCriteria; +import com.loopers.application.user.dto.UserResult; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserFacade { + + private final UserService userService; + + @Transactional + public UserResult signup(UserCriteria.Signup criteria) { + UserModel userModel = userService.signup( + criteria.loginId(), criteria.rawPassword(), criteria.name(), criteria.birthDate(), criteria.email() + ); + return UserResult.from(userModel); + } + + @Transactional(readOnly = true) + public UserResult getMyInfo(String loginId) { + UserModel user = userService.getByLoginId(loginId); + return UserResult.from(user); + } + + @Transactional + public void changePassword(String loginId, UserCriteria.ChangePassword criteria) { + userService.changePassword(loginId, criteria.rawCurrentPassword(), criteria.rawNewPassword()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java new file mode 100644 index 000000000..f72973c20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java @@ -0,0 +1,18 @@ +package com.loopers.application.user.dto; + +import com.loopers.domain.user.dto.UserCommand; + +public class UserCriteria { + + public record Signup(String loginId, String rawPassword, String name, String birthDate, String email) { + public UserCommand.Signup toCommand() { + return new UserCommand.Signup(loginId, rawPassword, name, birthDate, email); + } + } + + public record ChangePassword(String rawCurrentPassword, String rawNewPassword) { + public UserCommand.ChangePassword toCommand() { + return new UserCommand.ChangePassword(rawCurrentPassword, rawNewPassword); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java new file mode 100644 index 000000000..6c530ddc1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java @@ -0,0 +1,20 @@ +package com.loopers.application.user.dto; + +import com.loopers.domain.user.UserModel; + +public record UserResult( + Long id, + String loginId, + String name, + String birthDate, + String email +) { + public static UserResult from(UserModel model) { + return new UserResult( + model.getId(), + model.getLoginId(), + model.getName(), + model.getBirthDateString(), + model.getEmail()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java deleted file mode 100644 index 8dcb741c0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class BirthDate { - - private static final DateTimeFormatter DATE_STRING_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - - private LocalDate birthDate; - - protected BirthDate() {} - - public BirthDate(LocalDate birthDate) { - validate(birthDate); - this.birthDate = birthDate; - } - - private void validate(LocalDate birthDate) { - if (birthDate == null) { - throw new CoreException(ErrorType.BAD_REQUEST,"생년월일은 필수 입력값입니다."); - } - if (birthDate.isAfter(LocalDate.now())) { - throw new CoreException(ErrorType.BAD_REQUEST,"생년월일은 과거 날짜여야 합니다."); - } - } - - public String toDateString() { - return birthDate.format(DATE_STRING_FORMATTER); - } - - public LocalDate getDate() { - return birthDate; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/Email.java deleted file mode 100644 index 767b156b5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Embeddable -@Getter -@EqualsAndHashCode -public class Email { - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$"); - - private String mail; - - protected Email() {} - - public Email(String mail) { - validateEmail(mail); - this.mail = mail; - } - - private void validateEmail(String mail) { - if (mail == null || mail.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - - if (!EMAIL_PATTERN.matcher(mail).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java deleted file mode 100644 index 98e718edf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class LoginId { - private static final int MIN_LENGTH = 4; - private static final int MAX_LENGTH = 12; - - private static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("^[a-zA-Z0-9]*$"); - - private String value; - - protected LoginId() {} - - public LoginId(String value) { - validate(value); - this.value = value; - } - - private void validate(String value) { - if (value == null || value.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); - } - if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 4자에서 12자 사이여야 합니다."); - } - if (!ALPHANUMERIC_PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); - } - } - - public String getValue() { - return value; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java deleted file mode 100644 index e7495e8c7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class Name { - private final static int MIN_LENGTH = 2; - private final static int MAX_LENGTH = 10; - private String name; - - protected Name() {} - - public Name(String name) { - validate(name); - this.name = name; - } - - private void validate(String name) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if(name.length() < MIN_LENGTH || name.length() > MAX_LENGTH){ - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 이름 길이입니다."); - } - } - - public String getMaskedName() { - if (name == null || name.isEmpty()) return name; - return name.substring(0, name.length() - 1) + "*"; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java deleted file mode 100644 index 17d4ec1db..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class Password { - // 특수문자 범위를 ~!@#$%^&*()_+=- 로 확장했습니다. - private static final Pattern PASSWORD_PATTERN = - Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); - - private String value; - - protected Password() {} - - public Password(String value) { - validate(value); - this.value = value; - } - - private Password(String value, boolean skipValidation) { - this.value = value; - } - - public static Password fromEncoded(String encodedValue) { - return new Password(encodedValue, true); - } - - private void validate(String value) { - if (value == null || !PASSWORD_PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); - } - } - - public void validateNotContainBirthday(BirthDate birthDate) { - String birthDateString = birthDate.toDateString(); - - if (this.value.contains(birthDateString)) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); - } - } - - public void validateNotSameAs(Password other) { - if (this.equals(other)) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); - } - } - - public String getValue() { - return value; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java deleted file mode 100644 index 4c76f23d5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.Column; -import jakarta.persistence.Embedded; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "users") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class UserModel extends BaseEntity { - - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "login_id")) - private LoginId loginId; - - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "password")) - private Password password; - - @Embedded - @AttributeOverride(name = "name", column = @Column(name = "name")) - private Name name; - - @Embedded - @AttributeOverride(name = "birthDate", column = @Column(name = "birth_date")) - private BirthDate birthDate; - - @Embedded - @AttributeOverride(name = "mail", column = @Column(name = "email")) - private Email email; - - public UserModel(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { - validate(loginId, password, name, birthDate, email); - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } - - private void validate(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { - validateNotNull(loginId, "로그인 ID"); - validateNotNull(password, "비밀번호"); - validateNotNull(name, "이름"); - validateNotNull(birthDate, "생년월일"); - validateNotNull(email, "이메일"); - } - private void validateNotNull(Object value, String fieldName) { - if (value == null) { - throw new CoreException(ErrorType.BAD_REQUEST,fieldName + "은(는) 필수 입력값입니다."); - } - } - - public void changePassword(Password currentPassword, Password newPassword) { - // 검증은 UserService에서 수행 - this.password = newPassword; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java deleted file mode 100644 index 874c1f87a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.loopers.domain; - -import com.loopers.infrastructure.PasswordEncoder; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserService { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional - public UserModel signup(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { - - if(userRepository.find(loginId).isPresent()) { - throw new CoreException(ErrorType.BAD_REQUEST,"이미 존재하는 아이디입니다."); - } - - password.validateNotContainBirthday(birthDate); - - String encodedPasswordValue = passwordEncoder.encode(password.getValue()); - Password encryptedPassword = Password.fromEncoded(encodedPasswordValue); - - UserModel userModel = new UserModel(loginId,encryptedPassword,name,birthDate,email); - - return userRepository.save(userModel); - } - - public UserModel getMyInfo(LoginId loginId) { - return userRepository.find(loginId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); - } - - @Transactional - public void changePassword(LoginId loginId, Password currentPassword, Password newPassword) { - UserModel user = userRepository.find(loginId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); - - // 현재 비밀번호 검증 - if (!passwordEncoder.matches(currentPassword.getValue(), user.getPassword().getValue())) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); - } - - // 새 비밀번호 검증 - if (passwordEncoder.matches(newPassword.getValue(), user.getPassword().getValue())) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); - } - - newPassword.validateNotContainBirthday(user.getBirthDate()); - - // 새 비밀번호 암호화 및 저장 - String encodedNewPassword = passwordEncoder.encode(newPassword.getValue()); - Password encryptedNewPassword = Password.fromEncoded(encodedNewPassword); - - user.changePassword(Password.fromEncoded(user.getPassword().getValue()), encryptedNewPassword); - userRepository.save(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java new file mode 100644 index 000000000..3214badac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java @@ -0,0 +1,17 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BrandErrorCode implements ErrorCode { + DUPLICATE_NAME(HttpStatus.CONFLICT, "BRAND_001", "이미 존재하는 브랜드명입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_002", "브랜드를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..2cc44b0d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,50 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "brands") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandModel extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true) + private String name; + + // === 생성 === // + + private BrandModel(String name) { + this.name = name; + } + + public static BrandModel create(String name) { + validateName(name); + return new BrandModel(name); + } + + // === 도메인 로직 === // + + public void update(String name) { + validateName(name); + this.name = name; + } + + // === 검증 === // + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수값입니다."); + } + if (name.length() > 99) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 99자 이하여야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..a0bf210af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface BrandRepository { + BrandModel save(BrandModel brandModel); + + Optional findById(Long id); + + Optional findByName(String name); + + Page findAll(Pageable pageable); + + List findAllByIdIn(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..bf66bb6d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,79 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BrandService { + private final BrandRepository brandRepository; + + @Transactional + public void register(String name) { + if (brandRepository.findByName(name).isPresent()) { + throw new CoreException(BrandErrorCode.DUPLICATE_NAME); + } + + brandRepository.save(BrandModel.create(name)); + } + + @Transactional(readOnly = true) + public BrandModel getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(BrandErrorCode.NOT_FOUND)); + } + + @Transactional + public void update(Long id, String name) { + BrandModel brandModel = getById(id); + + brandRepository.findByName(name) + .filter(existing -> !existing.getId().equals(brandModel.getId())) + .ifPresent(existing -> { + throw new CoreException(BrandErrorCode.DUPLICATE_NAME); + }); + + brandModel.update(name); + } + + @Transactional + public void delete(Long id) { + BrandModel brandModel = getById(id); + brandModel.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getAllByIds(List ids) { + return brandRepository.findAllByIdIn(ids); + } + + @Transactional(readOnly = true) + public Map getNameMapByIds(List ids) { + return brandRepository.findAllByIdIn(ids).stream() + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); + } + + @Transactional(readOnly = true) + public Map getActiveNameMapByIds(List ids) { + return brandRepository.findAllByIdIn(ids).stream() + .filter(brand -> brand.getDeletedAt() == null) + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); + } + + @Transactional(readOnly = true) + public void validateExists(Long id) { + getById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandCommand.java new file mode 100644 index 000000000..b10a348d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandCommand.java @@ -0,0 +1,10 @@ +package com.loopers.domain.brand.dto; + +public class BrandCommand { + + public record Register(String name) { + } + + public record Update(String name) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java new file mode 100644 index 000000000..ada1575d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ProductLikeErrorCode implements ErrorCode { + DUPLICATE_LIKE(HttpStatus.CONFLICT, "PRODUCT_LIKE_001", "이미 좋아요가 됐습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java new file mode 100644 index 000000000..46faa91d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java @@ -0,0 +1,58 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.ZonedDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductLikeModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private ProductLikeModel(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + this.createdAt = ZonedDateTime.now(); + } + + public static ProductLikeModel create(Long userId, Long productId) { + validate(userId,productId); + return new ProductLikeModel(userId,productId); + } + + private static void validate(Long userId, Long productId) { + if(userId == null){ + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수값입니다."); + } + if(productId== null){ + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수값입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java new file mode 100644 index 000000000..5fa108d6e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface ProductLikeRepository { + ProductLikeModel save(ProductLikeModel productLike); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + void delete(ProductLikeModel productLike); + + List findAllByUserId(Long userId); + + long countByProductId(Long productId); + + Map countByProductIds(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java new file mode 100644 index 000000000..bae92e689 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -0,0 +1,50 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductLikeService { + private final ProductLikeRepository productLikeRepository; + + @Transactional + public void like(Long userId, Long productId) { + if (productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다"); + } + productLikeRepository.save(ProductLikeModel.create(userId, productId)); + } + + @Transactional + public void unlike(Long userId, Long productId) { + ProductLikeModel like = productLikeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요 기록이 없습니다")); + productLikeRepository.delete(like); + } + + @Transactional(readOnly = true) + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent(); + } + + @Transactional(readOnly = true) + public List getLikesByUserId(Long userId) { + return productLikeRepository.findAllByUserId(userId); + } + + @Transactional(readOnly = true) + public long countLikes(Long productId) { + return productLikeRepository.countByProductId(productId); + } + + @Transactional(readOnly = true) + public Map countLikesByProductIds(List productIds) { + return productLikeRepository.countByProductIds(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java new file mode 100644 index 000000000..76106371b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_001", "주문을 찾을 수 없습니다."), + EMPTY_ORDER_ITEMS(HttpStatus.BAD_REQUEST, "ORDER_002", "주문 항목이 비어있습니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "ORDER_003", "본인의 주문만 조회할 수 있습니다."), + ALREADY_CANCELLED_ITEM(HttpStatus.BAD_REQUEST, "ORDER_004", "이미 취소된 주문 항목입니다."), + ALREADY_CANCELLED_ORDER(HttpStatus.BAD_REQUEST, "ORDER_005", "이미 취소된 주문입니다."), + ORDER_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_006", "주문 항목을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..ab9cf0f08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,113 @@ +package com.loopers.domain.order; + +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.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "order_items") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItemModel extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderModel order; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "order_price", nullable = false) + private int orderPrice; + + @Column(name = "quantity", nullable = false) + private int quantity; + + @Embedded + private ProductSnapshot productSnapshot; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderItemStatus status; + + private OrderItemModel(Long productId, int orderPrice, int quantity, + ProductSnapshot productSnapshot) { + this.productId = productId; + this.orderPrice = orderPrice; + this.quantity = quantity; + this.productSnapshot = productSnapshot; + this.status = OrderItemStatus.ORDERED; + } + + public static OrderItemModel create(Long productId, int orderPrice, int quantity, + String productName, String brandName) { + validateProductId(productId); + validateOrderPrice(orderPrice); + validateQuantity(quantity); + validateProductName(productName); + validateBrandName(brandName); + return new OrderItemModel(productId, orderPrice, quantity, + new ProductSnapshot(productName, brandName)); + } + + public String getProductName() { + return productSnapshot.getProductName(); + } + + public String getBrandName() { + return productSnapshot.getBrandName(); + } + + public void cancel() { + if (this.status == OrderItemStatus.CANCELLED) { + throw new CoreException(OrderErrorCode.ALREADY_CANCELLED_ITEM); + } + this.status = OrderItemStatus.CANCELLED; + } + + void assignOrder(OrderModel order) { + this.order = order; + } + + private static void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수값입니다."); + } + } + + private static void validateOrderPrice(int orderPrice) { + if (orderPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private static void validateQuantity(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + + private static void validateProductName(String productName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); + } + } + + private static void validateBrandName(String brandName) { + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수값입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..2f0a86e27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + OrderItemModel save(OrderItemModel orderItemModel); + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java new file mode 100644 index 000000000..01e96eb76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderItemStatus { + ORDERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..18296f4e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,117 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +@Getter +@Entity +@Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "total_price", nullable = false) + private int totalPrice; + + @Column(name = "original_total_price", nullable = false) + private int originalTotalPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @BatchSize(size = 100) + @OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + private List items = new ArrayList<>(); + + private OrderModel(Long userId, int totalPrice, OrderStatus status) { + this.userId = userId; + this.totalPrice = totalPrice; + this.originalTotalPrice = totalPrice; + this.status = status; + } + + public static OrderModel create(Long userId, List items) { + validateUserId(userId); + validateItems(items); + OrderModel order = new OrderModel(userId, 0, OrderStatus.ORDERED); + items.forEach(order::addItem); + int calculatedPrice = order.calculateTotalPrice(); + order.totalPrice = calculatedPrice; + order.originalTotalPrice = calculatedPrice; + return order; + } + + public void addItem(OrderItemModel item) { + items.add(item); + item.assignOrder(this); + } + + public OrderItemModel cancelItem(Long orderItemId) { + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(OrderErrorCode.ALREADY_CANCELLED_ORDER); + } + OrderItemModel item = items.stream() + .filter(i -> i.getId().equals(orderItemId)) + .findFirst() + .orElseThrow(() -> new CoreException(OrderErrorCode.ORDER_ITEM_NOT_FOUND)); + item.cancel(); + recalculateTotalPrice(); + if (isAllItemsCancelled()) { + this.status = OrderStatus.CANCELLED; + } + return item; + } + + private boolean isAllItemsCancelled() { + return items.stream() + .allMatch(item -> item.getStatus() == OrderItemStatus.CANCELLED); + } + + public int calculateTotalPrice() { + return items.stream() + .mapToInt(item -> item.getOrderPrice() * item.getQuantity()) + .sum(); + } + + public void recalculateTotalPrice() { + this.totalPrice = items.stream() + .filter(item -> item.getStatus() == OrderItemStatus.ORDERED) + .mapToInt(item -> item.getOrderPrice() * item.getQuantity()) + .sum(); + } + + public void validateOwner(Long userId) { + if (!userId.equals(this.userId)) { + throw new CoreException(OrderErrorCode.FORBIDDEN); + } + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수값입니다."); + } + } + + private static void validateItems(List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..1d99813eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface OrderRepository { + + OrderModel save(OrderModel orderModel); + + Optional findById(Long id); + + List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..6cd53bfbb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,57 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel createOrder(Long userId, List items) { + return orderRepository.save(OrderModel.create(userId, items)); + } + + @Transactional(readOnly = true) + public OrderModel getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(OrderErrorCode.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public OrderModel getByIdAndUserId(Long id, Long userId) { + OrderModel order = getById(id); + order.validateOwner(userId); + return order; + } + + @Transactional(readOnly = true) + public List getOrdersByUserIdAndPeriod(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional + public OrderItemModel cancelItem(Long orderId, Long orderItemId) { + OrderModel order = getById(orderId); + return order.cancelItem(orderItemId); + } + + @Transactional(readOnly = true) + public List getOrderItemsByOrderId(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + @Transactional(readOnly = true) + public Page getAllOrders(Pageable pageable) { + return orderRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..b2d11834f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ORDERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java new file mode 100644 index 000000000..afde63614 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java @@ -0,0 +1,24 @@ +package com.loopers.domain.order; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + public ProductSnapshot(String productName, String brandName) { + this.productName = productName; + this.brandName = brandName; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java new file mode 100644 index 000000000..d3b42141d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java @@ -0,0 +1,17 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ProductErrorCode implements ErrorCode { + NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_001", "상품을 찾을 수 없습니다."), + PRICE_MISMATCH(HttpStatus.BAD_REQUEST, "PRODUCT_002", "상품 가격이 변경되었습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..1baeec6db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,114 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "products") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductModel extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "stock", nullable = false) + private int stock; + + // === 생성 === // + + private ProductModel(Long brandId, String name, int price, int stock) { + this.brandId = brandId; + this.name = name; + this.price = price; + this.stock = stock; + } + + public static ProductModel create(Long brandId, String name, int price, int stock) { + validateBrandId(brandId); + validateName(name); + validatePriceRange(price); + validateStockRange(stock); + return new ProductModel(brandId, name, price, stock); + } + + // === 도메인 로직 === // + + public void update(String name, int price, int stock) { + validateName(name); + validatePriceRange(price); + validateStockRange(stock); + this.name = name; + this.price = price; + this.stock = stock; + } + + public void validateExpectedPrice(int expectedPrice) { + if (expectedPrice != this.price) { + throw new CoreException(ProductErrorCode.PRICE_MISMATCH); + } + } + + public void decreaseStock(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; + } + + public void increaseStock(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "복구 수량은 1 이상이어야 합니다."); + } + this.stock += quantity; + } + + public boolean isSoldOut() { + return this.stock == 0; + } + + // === 검증 === // + + private static void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수값입니다."); + } + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); + } + if (name.length() > 99) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 99자 이하여야 합니다."); + } + } + + private static void validatePriceRange(int price) { + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private static void validateStockRange(int stock) { + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..fafe25406 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + ProductModel save(ProductModel productModel); + + Optional findById(Long id); + + Page findAll(Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); + + List findAllByIdIn(List ids); + + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..caf676bd9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,116 @@ +package com.loopers.domain.product; + +import com.loopers.domain.product.dto.ProductCommand; +import com.loopers.domain.product.dto.ProductInfo; +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductService { + private final ProductRepository productRepository; + + @Transactional + public void register(Long brandId, String name, int price, int stock) { + productRepository.save(ProductModel.create(brandId, name, price, stock)); + } + + @Transactional(readOnly = true) + public ProductModel getById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ProductErrorCode.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public List getAllByIds(List ids) { + List products = productRepository.findAllByIdIn(ids); + if (products.size() != ids.size()) { + throw new CoreException(ProductErrorCode.NOT_FOUND); + } + return products; + } + + @Transactional + public void update(Long id, String name, int price, int stock) { + getById(id).update(name, price, stock); + } + + @Transactional + public void delete(Long id) { + getById(id).delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return productRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getAll() { + return productRepository.findAll(); + } + + @Transactional(readOnly = true) + public Page getAllByBrandId(Long brandId, Pageable pageable) { + return productRepository.findAllByBrandId(brandId, pageable); + } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + productRepository.findAllByBrandId(brandId).forEach(ProductModel::delete); + } + + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return productRepository.findById(id).isPresent(); + } + + @Transactional(readOnly = true) + public void validateExists(Long id) { + if (!existsById(id)) { + throw new CoreException(ProductErrorCode.NOT_FOUND); + } + } + + @Transactional(readOnly = true) + public List getAllByBrandId(Long brandId) { + return productRepository.findAllByBrandId(brandId); + } + + @Transactional + public void increaseStock(Long productId, int quantity) { + getById(productId).increaseStock(quantity); + } + + @Transactional + public List validateAndDeductStock( + List commands) { + List products = getAllByIds( + commands.stream().map(ProductCommand.StockDeduction::productId).toList()); + + Map productMap = products.stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + + return commands.stream() + .map(command -> { + ProductModel product = productMap.get(command.productId()); + product.validateExpectedPrice(command.expectedPrice()); + product.decreaseStock(command.quantity()); + return new ProductInfo.StockDeduction( + command.productId(), + product.getName(), + product.getPrice(), + command.quantity(), + product.getBrandId()); + }) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java new file mode 100644 index 000000000..63a46ec8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java @@ -0,0 +1,10 @@ +package com.loopers.domain.product.dto; + +public class ProductCommand { + + public record Register(Long brandId, String name, int price, int stock) {} + + public record Update(String name, int price, int stock) {} + + public record StockDeduction(Long productId, int quantity, int expectedPrice) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java new file mode 100644 index 000000000..2231ca18c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java @@ -0,0 +1,6 @@ +package com.loopers.domain.product.dto; + +public class ProductInfo { + + public record StockDeduction(Long productId, String name, int price, int quantity, Long brandId) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java similarity index 65% rename from apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java index bf148ae14..fe6496215 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java @@ -1,6 +1,5 @@ -package com.loopers.domain; +package com.loopers.domain.user; -import com.loopers.infrastructure.PasswordEncoder; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -14,12 +13,10 @@ public class AuthenticationService { private final PasswordEncoder passwordEncoder; public UserModel authenticate(String loginIdValue, String rawPassword) { - LoginId loginId = new LoginId(loginIdValue); + UserModel user = userRepository.findByLoginId(loginIdValue) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); - UserModel user = userRepository.find(loginId) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); - - if (!passwordEncoder.matches(rawPassword, user.getPassword().getValue())) { + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java index fbe0ff626..bb23c0b39 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.domain.user; public interface PasswordEncoder { String encode(String rawPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 000000000..ef283310e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +1,110 @@ +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.Entity; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserModel extends BaseEntity { + + @Column(name = "login_id") + private String loginId; + + @Column(name = "password") + private String password; + + @Column(name = "name") + private String name; + + @Column(name = "birth_date") + private LocalDate birthDate; + + @Column(name = "email") + private String email; + + // === 생성 === // + + private UserModel(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; + } + + public static UserModel create(String loginId, String encryptedPassword, + String name, LocalDate birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + return new UserModel(loginId, encryptedPassword, name, birthDate, email); + } + + // === 도메인 로직 === // + + public void changePassword(String newEncryptedPassword) { + this.password = newEncryptedPassword; + } + + public String getBirthDateString() { + return birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + // === 검증 === // + + 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,}$"); + + private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + if (loginId.length() < 4 || loginId.length() > 12) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 4자에서 12자 사이여야 합니다."); + } + 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, "이름은 비어있을 수 없습니다."); + } + if (name.length() < 2 || name.length() > 10) { + 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, "이메일 형식이 올바르지 않습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java similarity index 54% rename from apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 64706be30..4a3680695 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -1,9 +1,9 @@ -package com.loopers.domain; +package com.loopers.domain.user; import java.util.Optional; public interface UserRepository { UserModel save(UserModel userModel); - Optional find(LoginId loginId); + Optional findByLoginId(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 000000000..b51813fd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,72 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserModel signup(String loginId, String rawPassword, String name, String birthDate, String email) { + if (userRepository.findByLoginId(loginId).isPresent()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 아이디입니다."); + } + + LocalDate parsedBirthDate = LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER); + validatePasswordFormat(rawPassword); + validateBirthDateNotInPassword(rawPassword, parsedBirthDate); + + return userRepository.save( + UserModel.create(loginId, passwordEncoder.encode(rawPassword), name, parsedBirthDate, email)); + } + + @Transactional(readOnly = true) + public UserModel getByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional + public void changePassword(String loginId, String rawCurrentPassword, String rawNewPassword) { + UserModel user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + if (!passwordEncoder.matches(rawCurrentPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + if (passwordEncoder.matches(rawNewPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); + } + + validatePasswordFormat(rawNewPassword); + validateBirthDateNotInPassword(rawNewPassword, user.getBirthDate()); + + user.changePassword(passwordEncoder.encode(rawNewPassword)); + userRepository.save(user); + } + + private void validatePasswordFormat(String rawPassword) { + if (rawPassword == null || !PASSWORD_PATTERN.matcher(rawPassword).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); + } + } + + private void validateBirthDateNotInPassword(String rawPassword, LocalDate birthDate) { + if (rawPassword.contains(birthDate.format(BIRTH_DATE_FORMATTER))) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/dto/UserCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/dto/UserCommand.java new file mode 100644 index 000000000..17f49e842 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/dto/UserCommand.java @@ -0,0 +1,19 @@ +package com.loopers.domain.user.dto; + +public class UserCommand { + + public record Signup( + String loginId, + String rawPassword, + String name, + String birthDate, + String email + ) { + } + + public record ChangePassword( + String rawCurrentPassword, + String rawNewPassword + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java deleted file mode 100644 index adf3d6a3c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.infrastructure; - -import com.loopers.domain.LoginId; -import com.loopers.domain.UserModel; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserJpaRepository extends JpaRepository { - Optional findByLoginId(LoginId loginId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..bd54955e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByNameAndDeletedAtIsNull(String name); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + List findAllByIdInAndDeletedAtIsNull(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..b86a8abd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brandModel) { + return brandJpaRepository.save(brandModel); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameAndDeletedAtIsNull(name); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java new file mode 100644 index 000000000..5d189dd85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeModel; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductLikeJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(Long userId); + + long countByProductId(Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM ProductLikeModel l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIdIn(@Param("productIds") List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java new file mode 100644 index 000000000..494a64a6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeModel; +import com.loopers.domain.like.ProductLikeRepository; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProductLikeRepositoryImpl implements ProductLikeRepository { + private final ProductLikeJpaRepository productLikeJpaRepository; + @Override + public ProductLikeModel save(ProductLikeModel productLike) { + return productLikeJpaRepository.save(productLike); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return productLikeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void delete(ProductLikeModel productLike) { + productLikeJpaRepository.delete(productLike); + } + + @Override + public List findAllByUserId(Long userId) { + return productLikeJpaRepository.findAllByUserId(userId); + } + + @Override + public long countByProductId(Long productId) { + return productLikeJpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(List productIds) { + return productLikeJpaRepository.countByProductIdIn(productIds).stream() + .collect(java.util.stream.Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1])); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..fe7e3d54a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..a5c364dff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItemModel save(OrderItemModel orderItemModel) { + return orderItemJpaRepository.save(orderItemModel); + } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..ce6d266bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..13e4707cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel orderModel) { + return orderJpaRepository.save(orderModel); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..40a228055 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + List findAllByDeletedAtIsNull(); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + List findAllByIdInAndDeletedAtIsNull(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..8d84aad98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel productModel) { + return productJpaRepository.save(productModel); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public List findAll() { + return productJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public List findAllByIdIn(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java index 6545f1868..9eabf0cbf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java @@ -1,8 +1,10 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; +import com.loopers.domain.user.PasswordEncoder; + @Component public class BCryptPasswordEncoderImpl implements PasswordEncoder { 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 000000000..bc88675df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + Optional findByLoginIdAndDeletedAtIsNull(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java similarity index 51% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index afecf020b..7f46babe5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -1,14 +1,13 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; -import com.loopers.domain.LoginId; -import com.loopers.domain.UserModel; -import com.loopers.domain.UserRepository; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; @RequiredArgsConstructor -@Component +@Repository public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; @@ -18,7 +17,7 @@ public UserModel save(UserModel userModel) { } @Override - public Optional find(LoginId loginId) { - return userJpaRepository.findByLoginId(loginId); + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginIdAndDeletedAtIsNull(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 2ec0dbbd7..5d87ed9b7 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 @@ -4,11 +4,16 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorCode; import com.loopers.support.error.ErrorType; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -17,18 +22,30 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.resource.NoResourceFoundException; -import java.util.Arrays; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - @RestControllerAdvice @Slf4j public class ApiControllerAdvice { @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); - return failureResponse(e.getErrorType(), e.getCustomMessage()); + return failureResponse(e.getErrorCode(), e.getCustomMessage()); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + List fieldErrors = e.getBindingResult().getFieldErrors().stream() + .map(fe -> new ApiResponse.FieldError( + fe.getField(), + fe.getRejectedValue(), + fe.getDefaultMessage() + )) + .toList(); + return ResponseEntity.status(ErrorType.BAD_REQUEST.getStatus()) + .body(ApiResponse.failValidation( + ErrorType.BAD_REQUEST.getCode(), + ErrorType.BAD_REQUEST.getMessage(), + fieldErrors + )); } @ExceptionHandler @@ -48,12 +65,6 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } - @ExceptionHandler - public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { - FieldError fieldError = e.getBindingResult().getFieldError(); - String message = fieldError != null ? fieldError.getDefaultMessage() : "잘못된 요청입니다."; - return failureResponse(ErrorType.BAD_REQUEST, message); - } @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { @@ -128,8 +139,8 @@ private String extractMissingParameter(String message) { return matcher.find() ? matcher.group(1) : ""; } - private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { - return ResponseEntity.status(errorType.getStatus()) - .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); + private ResponseEntity> failureResponse(ErrorCode errorCode, String errorMessage) { + return ResponseEntity.status(errorCode.getStatus()) + .body(ApiResponse.fail(errorCode.getCode(), errorMessage != null ? errorMessage : errorCode.getMessage())); } } 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 33b77b529..57dedcaa5 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 @@ -1,5 +1,7 @@ package com.loopers.interfaces.api; +import java.util.List; + public record ApiResponse(Metadata meta, T data) { public record Metadata(Result result, String errorCode, String message) { public enum Result { @@ -15,6 +17,8 @@ public static Metadata fail(String errorCode, String errorMessage) { } } + public record FieldError(String field, Object value, String reason) {} + public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } @@ -29,4 +33,9 @@ public static ApiResponse fail(String errorCode, String errorMessage) { null ); } + + public static ApiResponse> failValidation( + String errorCode, String errorMessage, List fieldErrors) { + return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), fieldErrors); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} 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 deleted file mode 100644 index 2f7155150..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.domain.*; -import com.loopers.interfaces.api.ApiResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/users") -public class UserV1Controller implements UserV1ApiSpec { - - private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; - private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - - private final UserService userService; - private final AuthenticationService authenticationService; - - @PostMapping("/signup") - @Override - public ApiResponse signup( - @Valid @RequestBody UserV1Dto.SignupRequest request - ) { - LoginId loginId = new LoginId(request.loginId()); - Password password = new Password(request.password()); - Name name = new Name(request.name()); - BirthDate birthDate = new BirthDate(LocalDate.parse(request.birthDate(), BIRTH_DATE_FORMATTER)); - Email email = new Email(request.email()); - - UserModel userModel = userService.signup(loginId, password, name, birthDate, email); - UserV1Dto.SignupResponse response = UserV1Dto.SignupResponse.from(userModel); - - return ApiResponse.success(response); - } - - @GetMapping("/me") - @Override - public ApiResponse getMyInfo( - @RequestHeader(HEADER_LOGIN_ID) String loginId, - @RequestHeader(HEADER_LOGIN_PW) String password - ) { - UserModel authenticatedUser = authenticationService.authenticate(loginId, password); - UserModel userInfo = userService.getMyInfo(authenticatedUser.getLoginId()); - UserV1Dto.MyInfoResponse response = UserV1Dto.MyInfoResponse.from(userInfo); - - return ApiResponse.success(response); - } - - @PatchMapping("/password") - @Override - public ApiResponse changePassword( - @RequestHeader(HEADER_LOGIN_ID) String loginId, - @RequestHeader(HEADER_LOGIN_PW) String currentPasswordValue, - @Valid @RequestBody UserV1Dto.ChangePasswordRequest request - ) { - UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPasswordValue); - - Password currentPassword = new Password(request.currentPassword()); - Password newPassword = new Password(request.newPassword()); - - userService.changePassword(authenticatedUser.getLoginId(), currentPassword, newPassword); - - return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java new file mode 100644 index 000000000..7c6125963 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +@Order(1) +public class AdminAuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String LDAP_VALUE = "loopers.admin"; + private static final String ADMIN_PATH_PREFIX = "/api-admin/"; + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!requiresAuth(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String ldapHeader = request.getHeader(HEADER_LDAP); + + if (ldapHeader == null || !LDAP_VALUE.equals(ldapHeader)) { + writeUnauthorizedResponse(response, ErrorType.UNAUTHORIZED.getMessage()); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean requiresAuth(String uri) { + return uri.startsWith(ADMIN_PATH_PREFIX); + } + + private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse apiResponse = ApiResponse.fail(ErrorType.UNAUTHORIZED.getCode(), message); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java new file mode 100644 index 000000000..89cc3c060 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -0,0 +1,80 @@ +package com.loopers.interfaces.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.user.AuthenticationService; +import com.loopers.domain.user.UserModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class AuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final Set AUTH_REQUIRED_URLS = Set.of( + "/api/v1/users/me", + "/api/v1/users/password", + "/api/v1/users/me/likes" + ); + private static final String AUTH_REQUIRED_SUFFIX = "/likes"; + private static final String AUTH_REQUIRED_PREFIX_ORDERS = "/api/v1/orders"; + + private final AuthenticationService authenticationService; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!requiresAuth(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + writeUnauthorizedResponse(response, ErrorType.UNAUTHORIZED.getMessage()); + return; + } + + try { + UserModel user = authenticationService.authenticate(loginId, password); + request.setAttribute("loginUser", + new LoginUser(user.getId(), user.getLoginId(), user.getName())); + filterChain.doFilter(request, response); + } catch (CoreException e) { + writeUnauthorizedResponse(response, + e.getCustomMessage() != null ? e.getCustomMessage() : e.getErrorCode().getMessage()); + } + } + + private boolean requiresAuth(String uri) { + return AUTH_REQUIRED_URLS.contains(uri) + || (uri.startsWith("/api/v1/products/") && uri.endsWith(AUTH_REQUIRED_SUFFIX)) + || uri.startsWith(AUTH_REQUIRED_PREFIX_ORDERS); + } + + private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse apiResponse = ApiResponse.fail(ErrorType.UNAUTHORIZED.getCode(), message); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Login.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Login.java new file mode 100644 index 000000000..59f671daf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Login.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.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 Login { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java new file mode 100644 index 000000000..02daa62ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.auth; + +public record LoginUser(Long id, String loginId, String name) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java new file mode 100644 index 000000000..e3fd3afef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.auth; + +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 LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Login.class) + && LoginUser.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + LoginUser loginUser = (LoginUser) request.getAttribute("loginUser"); + + if (loginUser == null) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + + return loginUser; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java new file mode 100644 index 000000000..02ecbf457 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Brand V1 API", description = "브랜드 관리자 API 입니다.") +public interface AdminBrandV1ApiSpec { + + @Operation( + summary = "브랜드 등록", + description = "새로운 브랜드를 등록합니다." + ) + ApiResponse register( + @RequestBody(description = "브랜드 등록 요청 정보") + AdminBrandV1Dto.RegisterRequest request + ); + + @Operation( + summary = "브랜드 목록 조회", + description = "브랜드 목록을 페이지네이션으로 조회합니다." + ) + ApiResponse list( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size + ); + + @Operation( + summary = "브랜드 상세 조회", + description = "특정 브랜드의 상세 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId + ); + + @Operation( + summary = "브랜드 수정", + description = "브랜드 정보를 수정합니다." + ) + ApiResponse update( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId, + @RequestBody(description = "브랜드 수정 요청 정보") + AdminBrandV1Dto.UpdateRequest request + ); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다." + ) + ApiResponse delete( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java new file mode 100644 index 000000000..e3bd22b77 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.dto.BrandResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse register( + @Valid @RequestBody AdminBrandV1Dto.RegisterRequest request + ) { + brandFacade.registerBrand(request.toCriteria()); + return ApiResponse.success(); + } + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page brandInfoPage = brandFacade.getBrands(PageRequest.of(page, size)); + return ApiResponse.success( + new AdminBrandV1Dto.ListResponse( + brandInfoPage.getNumber(), + brandInfoPage.getSize(), + brandInfoPage.getTotalElements(), + brandInfoPage.getTotalPages(), + brandInfoPage.getContent().stream() + .map(AdminBrandV1Dto.ListResponse.ListItem::from) + .toList())); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById( + @PathVariable Long brandId + ) { + BrandResult brandInfo = brandFacade.getBrand(brandId); + return ApiResponse.success(AdminBrandV1Dto.DetailResponse.from(brandInfo)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse update( + @PathVariable Long brandId, + @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request + ) { + brandFacade.updateBrand(brandId, request.toCriteria()); + return ApiResponse.success(); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete( + @PathVariable Long brandId + ) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..1dc0361c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 상세 조회", + description = "특정 브랜드의 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java new file mode 100644 index 000000000..76a8d051c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.dto.BrandResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById( + @PathVariable Long brandId + ) { + BrandResult brandInfo = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.DetailResponse.from(brandInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java new file mode 100644 index 000000000..b400827b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.brand.dto; + +import com.loopers.application.brand.dto.BrandCriteria; +import com.loopers.application.brand.dto.BrandResult; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminBrandV1Dto { + + public record RegisterRequest( + @NotBlank(message = "브랜드명은 필수 입력값입니다.") + @Size(max = 99, message = "브랜드명은 99자 이하여야 합니다.") + String name + ) { + public BrandCriteria.Register toCriteria() { + return new BrandCriteria.Register(name); + } + } + + public record UpdateRequest( + @NotBlank(message = "브랜드명은 필수 입력값입니다.") + @Size(max = 99, message = "브랜드명은 99자 이하여야 합니다.") + String name + ) { + public BrandCriteria.Update toCriteria() { + return new BrandCriteria.Update(name); + } + } + + public record DetailResponse( + Long id, + String name, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static DetailResponse from(BrandResult info) { + return new DetailResponse(info.id(), info.name(), info.createdAt(), info.updatedAt(), info.deletedAt()); + } + } + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + String name, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ListItem from(BrandResult info) { + return new ListItem(info.id(), info.name(), info.createdAt(), info.updatedAt(), info.deletedAt()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java new file mode 100644 index 000000000..70947943f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.brand.dto; + +import com.loopers.application.brand.dto.BrandResult; + +public class BrandV1Dto { + + public record DetailResponse( + Long id, + String name + ) { + public static DetailResponse from(BrandResult info) { + return new DetailResponse(info.id(), info.name()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..fee10a9ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like V1 API", description = "좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation( + summary = "상품 좋아요 등록", + description = "상품에 좋아요를 등록합니다." + ) + ApiResponse like( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "상품 ID", required = true, example = "1") Long productId + ); + + @Operation( + summary = "상품 좋아요 취소", + description = "상품의 좋아요를 취소합니다." + ) + ApiResponse unlike( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "상품 ID", required = true, example = "1") Long productId + ); + + @Operation( + summary = "내가 좋아요한 상품 목록 조회", + description = "로그인한 사용자가 좋아요한 상품 목록을 조회합니다." + ) + ApiResponse getMyLikes( + @Parameter(hidden = true) LoginUser loginUser + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java new file mode 100644 index 000000000..1a6485786 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.dto.LikeResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.Login; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse like( + @Login LoginUser loginUser, + @PathVariable Long productId + ) { + likeFacade.like(loginUser.id(), productId); + return ApiResponse.success(); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse unlike( + @Login LoginUser loginUser, + @PathVariable Long productId + ) { + likeFacade.unlike(loginUser.id(), productId); + return ApiResponse.success(); + } + + @GetMapping("/api/v1/users/me/likes") + @Override + public ApiResponse getMyLikes( + @Login LoginUser loginUser + ) { + List results = likeFacade.getMyLikedProducts(loginUser.id()); + + return ApiResponse.success( + new LikeV1Dto.ListResponse( + results.stream() + .map(LikeV1Dto.ListResponse.ListItem::from) + .toList())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java new file mode 100644 index 000000000..642b02dc0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.like.dto; + +import com.loopers.application.like.dto.LikeResult; +import java.time.ZonedDateTime; +import java.util.List; + +public class LikeV1Dto { + + public record LikeResponse( + Long productId, + ZonedDateTime createdAt + ) { + public static LikeResponse from(LikeResult result) { + return new LikeResponse(result.productId(), result.createdAt()); + } + } + + public record ListResponse( + List items + ) { + public record ListItem( + Long productId, + ZonedDateTime createdAt + ) { + public static ListItem from(LikeResult result) { + return new ListItem(result.productId(), result.createdAt()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java new file mode 100644 index 000000000..be1724db7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.order; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Order V1 API", description = "주문 관리자 API 입니다.") +public interface AdminOrderV1ApiSpec { + + @Operation( + summary = "주문 목록 조회", + description = "전체 주문을 페이지네이션으로 조회합니다." + ) + ApiResponse list( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "20") int size + ); + + @Operation( + summary = "주문 상세 조회", + description = "주문 상세를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "주문 ID", required = true) Long orderId + ); + + @Operation( + summary = "주문 아이템 취소", + description = "주문 아이템을 취소합니다. 취소 시 재고가 복구됩니다." + ) + ApiResponse cancelItem( + @Parameter(description = "주문 ID", required = true) Long orderId, + @Parameter(description = "주문 아이템 ID", required = true) Long orderItemId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java new file mode 100644 index 000000000..6f251d32b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class AdminOrderV1Controller implements AdminOrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orderPage = orderFacade.getAllOrders(PageRequest.of(page, size)); + return ApiResponse.success( + new OrderResponse.PageResponse( + orderPage.getNumber(), + orderPage.getSize(), + orderPage.getTotalElements(), + orderPage.getTotalPages(), + orderPage.getContent().stream() + .map(OrderResponse.OrderSummary::from) + .toList())); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @PathVariable Long orderId + ) { + return ApiResponse.success( + OrderResponse.OrderDetail.from( + orderFacade.getOrderDetail(orderId))); + } + + @PatchMapping("/{orderId}/items/{orderItemId}/cancel") + @Override + public ApiResponse cancelItem( + @PathVariable Long orderId, + @PathVariable Long orderItemId + ) { + orderFacade.cancelOrderItem(orderId, orderItemId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..41f4f4947 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.order; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.order.dto.OrderRequest; +import com.loopers.interfaces.order.dto.OrderResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.ZonedDateTime; + +@Tag(name = "Order V1 API", description = "주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 생성", + description = "새로운 주문을 생성합니다." + ) + ApiResponse create( + @Parameter(hidden = true) LoginUser loginUser, + @RequestBody(description = "주문 생성 요청 정보") OrderRequest.Create request + ); + + @Operation( + summary = "내 주문 목록 조회", + description = "기간 내 본인의 주문 목록을 조회합니다." + ) + ApiResponse list( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "시작일시") ZonedDateTime startAt, + @Parameter(description = "종료일시") ZonedDateTime endAt + ); + + @Operation( + summary = "내 주문 상세 조회", + description = "본인의 주문 상세를 조회합니다." + ) + ApiResponse getById( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "주문 ID", required = true) Long orderId + ); + + @Operation( + summary = "주문 아이템 취소", + description = "본인 주문의 아이템을 개별 취소합니다. 취소 시 재고가 복구됩니다." + ) + ApiResponse cancelItem( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "주문 ID", required = true) Long orderId, + @Parameter(description = "주문 아이템 ID", required = true) Long orderItemId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java new file mode 100644 index 000000000..4fe6403e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java @@ -0,0 +1,79 @@ +package com.loopers.interfaces.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.Login; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.order.dto.OrderRequest; +import com.loopers.interfaces.order.dto.OrderResponse; +import jakarta.validation.Valid; +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ApiResponse create( + @Login LoginUser loginUser, + @Valid @RequestBody OrderRequest.Create request + ) { + return ApiResponse.success( + OrderResponse.OrderSummary.from( + orderFacade.createOrder(loginUser.id(), request.toCriteria()))); + } + + @GetMapping + @Override + public ApiResponse list( + @Login LoginUser loginUser, + @RequestParam ZonedDateTime startAt, + @RequestParam ZonedDateTime endAt + ) { + List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderRequest.ListRequest(startAt, endAt).toCriteria()); + + return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @Login LoginUser loginUser, + @PathVariable Long orderId + ) { + return ApiResponse.success( + OrderResponse.OrderDetail.from( + orderFacade.getMyOrderDetail(loginUser.id(), orderId))); + } + + @PatchMapping("/{orderId}/items/{orderItemId}/cancel") + @Override + public ApiResponse cancelItem( + @Login LoginUser loginUser, + @PathVariable Long orderId, + @PathVariable Long orderItemId + ) { + orderFacade.cancelMyOrderItem(loginUser.id(), orderId, orderItemId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java new file mode 100644 index 000000000..29a0c8625 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.order.dto; + +import com.loopers.application.order.dto.OrderCriteria; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderRequest { + + public record ListRequest( + ZonedDateTime startAt, + ZonedDateTime endAt + ) { + public OrderCriteria.ListByDate toCriteria() { + return new OrderCriteria.ListByDate(startAt, endAt); + } + } + + public record Create( + @NotEmpty(message = "주문 항목은 필수 입력값입니다.") + @Valid + List items + ) { + public OrderCriteria.Create toCriteria() { + return new OrderCriteria.Create( + items.stream() + .map(item -> new OrderCriteria.Create.CreateItem( + item.productId(), item.quantity(), item.expectedPrice())) + .toList()); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수 입력값입니다.") + Long productId, + @NotNull(message = "수량은 필수 입력값입니다.") + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") + Integer quantity, + @NotNull(message = "예상 가격은 필수 입력값입니다.") + @Min(value = 0, message = "예상 가격은 0 이상이어야 합니다.") + Integer expectedPrice + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java new file mode 100644 index 000000000..c3e7901f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.order.dto; + +import com.loopers.application.order.dto.OrderResult; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderResponse { + + public record OrderSummary( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderSummary from(OrderResult.OrderSummary result) { + return new OrderSummary( + result.orderId(), + result.totalPrice(), + result.status(), + result.createdAt()); + } + } + + public record OrderDetail( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetail from(OrderResult.OrderDetail result) { + return new OrderDetail( + result.orderId(), + result.userId(), + result.totalPrice(), + result.status(), + result.createdAt(), + result.items().stream().map(OrderItemDetail::from).toList()); + } + } + + public record OrderItemDetail( + Long orderItemId, + Long productId, + String productName, + String brandName, + int orderPrice, + int quantity + ) { + public static OrderItemDetail from(OrderResult.OrderItemDetail result) { + return new OrderItemDetail( + result.orderItemId(), + result.productId(), + result.productName(), + result.brandName(), + result.orderPrice(), + result.quantity()); + } + } + + public record ListResponse( + List items + ) {} + + public record PageResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java new file mode 100644 index 000000000..d194528e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.AdminProductV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Product V1 API", description = "상품 관리자 API 입니다.") +public interface AdminProductV1ApiSpec { + + @Operation( + summary = "상품 등록", + description = "새로운 상품을 등록합니다." + ) + ApiResponse register( + @RequestBody(description = "상품 등록 요청 정보") + AdminProductV1Dto.RegisterRequest request + ); + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이지네이션으로 조회합니다." + ) + ApiResponse list( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size, + @Parameter(description = "브랜드 ID (선택)", example = "1") + Long brandId + ); + + @Operation( + summary = "상품 상세 조회", + description = "특정 상품의 상세 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId + ); + + @Operation( + summary = "상품 수정", + description = "상품 정보를 수정합니다." + ) + ApiResponse update( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId, + @RequestBody(description = "상품 수정 요청 정보") + AdminProductV1Dto.UpdateRequest request + ); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다." + ) + ApiResponse delete( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java new file mode 100644 index 000000000..d028994b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.dto.ProductResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.AdminProductV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductV1Controller implements AdminProductV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse register( + @Valid @RequestBody AdminProductV1Dto.RegisterRequest request + ) { + productFacade.registerProduct(request.toCriteria()); + return ApiResponse.success(); + } + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Long brandId + ) { + Page productInfoPage = brandId != null + ? productFacade.getProductsByBrandId(brandId, PageRequest.of(page, size)) + : productFacade.getProducts(PageRequest.of(page, size)); + + return ApiResponse.success( + new AdminProductV1Dto.ListResponse( + productInfoPage.getNumber(), + productInfoPage.getSize(), + productInfoPage.getTotalElements(), + productInfoPage.getTotalPages(), + productInfoPage.getContent().stream() + .map(AdminProductV1Dto.ListResponse.ListItem::from) + .toList())); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @PathVariable Long productId + ) { + ProductResult productInfo = productFacade.getProduct(productId); + return ApiResponse.success(AdminProductV1Dto.DetailResponse.from(productInfo)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse update( + @PathVariable Long productId, + @Valid @RequestBody AdminProductV1Dto.UpdateRequest request + ) { + productFacade.updateProduct(productId, request.toCriteria()); + return ApiResponse.success(); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete( + @PathVariable Long productId + ) { + productFacade.deleteProduct(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..4dafcf4ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.ProductV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 조회합니다. 브랜드 필터링, 정렬, 페이지네이션을 지원합니다." + ) + ApiResponse list( + @Parameter(description = "브랜드 ID (선택)", example = "1") + Long brandId, + @Parameter(description = "정렬 기준: latest / price_asc / likes_desc", example = "latest") + String sort, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size + ); + + @Operation( + summary = "상품 상세 조회", + description = "특정 상품의 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java new file mode 100644 index 000000000..3c89a43d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.dto.ProductResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.ProductV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page productPage = "likes_desc".equals(sort) + ? productFacade.getProductsWithActiveBrandSortedByLikes(brandId, page, size) + : brandId != null + ? productFacade.getProductsWithActiveBrandByBrandId( + brandId, PageRequest.of(page, size, toSort(sort))) + : productFacade.getProductsWithActiveBrand( + PageRequest.of(page, size, toSort(sort))); + + return ApiResponse.success( + new ProductV1Dto.ListResponse( + productPage.getNumber(), + productPage.getSize(), + productPage.getTotalElements(), + productPage.getTotalPages(), + productPage.getContent().stream() + .map(ProductV1Dto.ListResponse.ListItem::from) + .toList())); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @PathVariable Long productId + ) { + ProductResult result = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.DetailResponse.from(result)); + } + + private Sort toSort(String sort) { + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price.value"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java new file mode 100644 index 000000000..c904c71c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java @@ -0,0 +1,93 @@ +package com.loopers.interfaces.product.dto; + +import com.loopers.application.product.dto.ProductCriteria; +import com.loopers.application.product.dto.ProductResult; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminProductV1Dto { + + public record RegisterRequest( + @NotNull(message = "브랜드 ID는 필수 입력값입니다.") + Long brandId, + @NotBlank(message = "상품명은 필수 입력값입니다.") + @Size(max = 99, message = "상품명은 99자 이하여야 합니다.") + String name, + @NotNull(message = "가격은 필수 입력값입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + Integer price, + @NotNull(message = "재고는 필수 입력값입니다.") + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + Integer stock + ) { + public ProductCriteria.Register toCriteria() { + return new ProductCriteria.Register(brandId, name, price, stock); + } + } + + public record UpdateRequest( + @NotBlank(message = "상품명은 필수 입력값입니다.") + @Size(max = 99, message = "상품명은 99자 이하여야 합니다.") + String name, + @NotNull(message = "가격은 필수 입력값입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + Integer price, + @NotNull(message = "재고는 필수 입력값입니다.") + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + Integer stock + ) { + public ProductCriteria.Update toCriteria() { + return new ProductCriteria.Update(name, price, stock); + } + } + + public record DetailResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static DetailResponse from(ProductResult info) { + return new DetailResponse( + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), + info.createdAt(), info.updatedAt(), info.deletedAt()); + } + } + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ListItem from(ProductResult info) { + return new ListItem( + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), + info.createdAt(), info.updatedAt(), info.deletedAt()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java new file mode 100644 index 000000000..3fa370eac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.product.dto; + +import com.loopers.application.product.dto.ProductResult; +import java.util.List; + +public class ProductV1Dto { + + public record DetailResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + long likeCount + ) { + public static DetailResponse from(ProductResult info) { + return new DetailResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likeCount()); + } + } + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + Long brandId, + String brandName, + String name, + int price, + long likeCount + ) { + public static ListItem from(ProductResult info) { + return new ListItem( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.likeCount()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java similarity index 76% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java index b3d0743cf..9b1ff4b1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java @@ -1,6 +1,8 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.user; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.user.dto.UserV1Dto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.parameters.RequestBody; @@ -23,10 +25,8 @@ ApiResponse signup( description = "인증된 사용자의 정보를 조회합니다. 헤더에 X-Loopers-LoginId와 X-Loopers-LoginPw를 포함해야 합니다." ) ApiResponse getMyInfo( - @Parameter(description = "로그인 ID", required = true) - String loginId, - @Parameter(description = "비밀번호", required = true) - String password + @Parameter(hidden = true) + LoginUser loginUser ); @Operation( @@ -34,10 +34,8 @@ ApiResponse getMyInfo( description = "인증된 사용자의 비밀번호를 변경합니다. 헤더에 X-Loopers-LoginId와 X-Loopers-LoginPw를 포함해야 합니다." ) ApiResponse changePassword( - @Parameter(description = "로그인 ID", required = true) - String loginId, - @Parameter(description = "현재 비밀번호", required = true) - String currentPassword, + @Parameter(hidden = true) + LoginUser loginUser, @RequestBody(description = "비밀번호 변경 요청 정보") UserV1Dto.ChangePasswordRequest request ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java new file mode 100644 index 000000000..b84bbe192 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.dto.UserResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.Login; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.user.dto.UserV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping("/signup") + @Override + public ApiResponse signup( + @Valid @RequestBody UserV1Dto.SignupRequest request + ) { + UserResult userInfo = userFacade.signup(request.toCriteria()); + + return ApiResponse.success(UserV1Dto.SignupResponse.from(userInfo)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo(@Login LoginUser loginUser) { + UserResult userInfo = userFacade.getMyInfo(loginUser.loginId()); + + return ApiResponse.success(UserV1Dto.MyInfoResponse.from(userInfo)); + } + + @PatchMapping("/password") + @Override + public ApiResponse changePassword( + @Login LoginUser loginUser, + @Valid @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword(loginUser.loginId(), request.toCriteria()); + + return ApiResponse.success(UserV1Dto.ChangePasswordResponse.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/user/dto/UserV1Dto.java similarity index 71% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java index 7daae4809..5e36e9cae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java @@ -1,6 +1,7 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.user.dto; -import com.loopers.domain.UserModel; +import com.loopers.application.user.dto.UserCriteria; +import com.loopers.application.user.dto.UserResult; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -33,6 +34,9 @@ public record SignupRequest( @Email(message = "이메일 형식이 올바르지 않습니다.") String email ) { + public UserCriteria.Signup toCriteria() { + return new UserCriteria.Signup(loginId, password, name, birthDate, email); + } } public record SignupResponse( @@ -42,14 +46,13 @@ public record SignupResponse( String birthDate, String email ) { - public static SignupResponse from(UserModel model) { + public static SignupResponse from(UserResult info) { return new SignupResponse( - model.getId(), - model.getLoginId().getValue(), - model.getName().getMaskedName(), - model.getBirthDate().toDateString(), - model.getEmail().getMail() - ); + info.id(), + info.loginId(), + maskName(info.name()), + info.birthDate(), + info.email()); } } @@ -59,13 +62,12 @@ public record MyInfoResponse( String birthDate, String email ) { - public static MyInfoResponse from(UserModel model) { + public static MyInfoResponse from(UserResult info) { return new MyInfoResponse( - model.getLoginId().getValue(), - model.getName().getMaskedName(), - model.getBirthDate().toDateString(), - model.getEmail().getMail() - ); + info.loginId(), + maskName(info.name()), + info.birthDate(), + info.email()); } } @@ -80,6 +82,9 @@ public record ChangePasswordRequest( ) String newPassword ) { + public UserCriteria.ChangePassword toCriteria() { + return new UserCriteria.ChangePassword(currentPassword, newPassword); + } } public record ChangePasswordResponse( @@ -89,4 +94,9 @@ public static ChangePasswordResponse success() { return new ChangePasswordResponse("비밀번호가 성공적으로 변경되었습니다."); } } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) return name; + return name.substring(0, name.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java new file mode 100644 index 000000000..a53eb62f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.config; + +import com.loopers.interfaces.auth.LoginUserArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final LoginUserArgumentResolver loginUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index 0cc190b6b..be5b9f708 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -4,16 +4,16 @@ @Getter public class CoreException extends RuntimeException { - private final ErrorType errorType; + private final ErrorCode errorCode; private final String customMessage; - public CoreException(ErrorType errorType) { - this(errorType, null); + public CoreException(ErrorCode errorCode) { + this(errorCode, null); } - public CoreException(ErrorType errorType, String customMessage) { - super(customMessage != null ? customMessage : errorType.getMessage()); - this.errorType = errorType; + public CoreException(ErrorCode errorCode, String customMessage) { + super(customMessage != null ? customMessage : errorCode.getMessage()); + this.errorCode = errorCode; this.customMessage = customMessage; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java new file mode 100644 index 000000000..90f67b43c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java @@ -0,0 +1,9 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} 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 8d493491a..0f9f3657b 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 @@ -6,12 +6,13 @@ @Getter @RequiredArgsConstructor -public enum ErrorType { +public enum ErrorType implements ErrorCode { /** 범용 에러 */ 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(), "존재하지 않는 요청입니다."), + NOT_FOUND_DATA(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "해당 데이터를 찾을 수 없습니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); private final HttpStatus status; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..c4accb45a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,104 @@ +package com.loopers.application.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.brand.dto.BrandCriteria; +import com.loopers.application.brand.dto.BrandResult; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("BrandFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class BrandFacadeTest { + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + @InjectMocks + private BrandFacade brandFacade; + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @Test + @DisplayName("Command의 name을 BrandService.register에 전달한다") + void register_호출_검증() { + // arrange + BrandCriteria.Register criteria = new BrandCriteria.Register("나이키"); + + // act + brandFacade.registerBrand(criteria); + + // assert + verify(brandService).register("나이키"); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetById { + + @Test + @DisplayName("BrandService.getById 결과를 BrandResult로 변환하여 반환한다") + void getById_변환_검증() { + // arrange + BrandModel brandModel = BrandModel.create("나이키"); + when(brandService.getById(1L)).thenReturn(brandModel); + + // act + BrandResult result = brandFacade.getBrand(1L); + + // assert + assertThat(result.name()).isEqualTo("나이키"); + verify(brandService).getById(1L); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @Test + @DisplayName("id와 Command의 name을 BrandService.update에 전달한다") + void update_호출_검증() { + // arrange + BrandCriteria.Update criteria = new BrandCriteria.Update("아디다스"); + + // act + brandFacade.updateBrand(1L, criteria); + + // assert + verify(brandService).update(1L, "아디다스"); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @Test + @DisplayName("id를 BrandService.delete에 전달하고 해당 브랜드의 상품을 일괄 삭제한다") + void delete_호출_검증() { + // arrange & act + brandFacade.deleteBrand(1L); + + // assert + verify(brandService).delete(1L); + verify(productService).deleteAllByBrandId(1L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 000000000..cf46b51b9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,109 @@ +package com.loopers.application.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.like.dto.LikeResult; +import com.loopers.domain.like.ProductLikeModel; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("LikeFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeFacadeTest { + + @Mock + private ProductLikeService productLikeService; + + @Mock + private ProductService productService; + + @InjectMocks + private LikeFacade likeFacade; + + @DisplayName("좋아요를 등록할 때, ") + @Nested + class Like { + + @DisplayName("상품 존재를 검증하고 like를 호출한다.") + @Test + void like_validatesProductAndCallsLike() { + // act + likeFacade.like(1L, 2L); + + // assert + verify(productService).validateExists(2L); + verify(productLikeService).like(1L, 2L); + } + } + + @DisplayName("좋아요를 취소할 때, ") + @Nested + class Unlike { + + @DisplayName("unlike를 호출한다.") + @Test + void unlike_callsUnlike() { + // act + likeFacade.unlike(1L, 2L); + + // assert + verify(productLikeService).unlike(1L, 2L); + } + } + + @DisplayName("좋아요 목록을 조회할 때, ") + @Nested + class GetLikesByUserId { + + @DisplayName("사용자의 좋아요 목록을 조회하면, LikeResult 목록을 반환한다.") + @Test + void getLikesByUserId_returnsLikeResultList() { + // arrange + Long userId = 1L; + List likes = List.of( + ProductLikeModel.create(userId, 10L), + ProductLikeModel.create(userId, 20L) + ); + when(productLikeService.getLikesByUserId(userId)).thenReturn(likes); + when(productService.existsById(10L)).thenReturn(true); + when(productService.existsById(20L)).thenReturn(true); + + // act + List result = likeFacade.getMyLikedProducts(userId); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("삭제된 상품의 좋아요는 목록에서 제외된다.") + @Test + void getLikesByUserId_excludesDeletedProducts() { + // arrange + Long userId = 1L; + List likes = List.of( + ProductLikeModel.create(userId, 10L), + ProductLikeModel.create(userId, 20L) + ); + when(productLikeService.getLikesByUserId(userId)).thenReturn(likes); + when(productService.existsById(10L)).thenReturn(true); + when(productService.existsById(20L)).thenReturn(false); + + // act + List result = likeFacade.getMyLikedProducts(userId); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(10L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..3969302e4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,273 @@ +package com.loopers.application.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderErrorCode; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.ProductErrorCode; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.dto.ProductInfo; +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@DisplayName("OrderFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private OrderService orderService; + + @InjectMocks + private OrderFacade orderFacade; + + @DisplayName("주문을 생성할 때 (UC-O01), ") + @Nested + class CreateOrder { + + @DisplayName("검증+차감 → 브랜드 조회 → 주문 생성 순서를 수행한다") + @Test + void createOrder_success() { + // arrange + Long brandId = 1L; + when(productService.validateAndDeductStock(anyList())).thenReturn(List.of( + new ProductInfo.StockDeduction(10L, "상품A", 25000, 1, brandId))); + when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); + + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 25000, 1, "상품A", ("브랜드A")))); + when(orderService.createOrder(anyLong(), anyList())).thenReturn(order); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(10L, 1, 25000))); + + // act + OrderResult.OrderSummary result = orderFacade.createOrder(1L, criteria); + + // assert + assertAll( + () -> verify(productService).validateAndDeductStock(anyList()), + () -> verify(brandService).getNameMapByIds(List.of(brandId)), + () -> verify(orderService).createOrder(anyLong(), anyList()), + () -> assertThat(result.totalPrice()).isEqualTo(25000)); + } + + @DisplayName("상품이 존재하지 않으면 예외가 발생한다") + @Test + void createOrder_productNotFound_throwsException() { + // arrange + when(productService.validateAndDeductStock(anyList())) + .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(999L, 1, 25000))); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("expectedPrice와 현재 가격 불일치 시 예외가 발생한다") + @Test + void createOrder_priceMismatch_throwsException() { + // arrange + when(productService.validateAndDeductStock(anyList())) + .thenThrow(new CoreException(ProductErrorCode.PRICE_MISMATCH)); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(10L, 1, 30000))); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("재고 부족 시 예외가 발생한다") + @Test + void createOrder_insufficientStock_throwsException() { + // arrange + when(productService.validateAndDeductStock(anyList())) + .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(10L, 100, 25000))); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("회원 주문 목록을 조회할 때 (UC-O02), ") + @Nested + class GetMyOrders { + + @DisplayName("기간 내 본인 주문 목록을 반환한다") + @Test + void getMyOrders_returnsOrders() { + // arrange + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 50000, 1, "상품A", ("브랜드A")))); + ZonedDateTime startAt = ZonedDateTime.now().minusDays(7); + ZonedDateTime endAt = ZonedDateTime.now(); + + when(orderService.getOrdersByUserIdAndPeriod(1L, startAt, endAt)) + .thenReturn(List.of(order)); + + OrderCriteria.ListByDate criteria = new OrderCriteria.ListByDate(startAt, endAt); + + // act + List results = orderFacade.getMyOrders(1L, criteria); + + // assert + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).totalPrice()).isEqualTo(50000)); + } + } + + @DisplayName("회원 주문 상세를 조회할 때 (UC-O03), ") + @Nested + class GetMyOrderDetail { + + @DisplayName("주문 상세 + 주문 항목을 반환한다") + @Test + void getMyOrderDetail_returnsDetail() { + // arrange + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A")))); + + when(orderService.getByIdAndUserId(1L, 1L)).thenReturn(order); + + // act + OrderResult.OrderDetail result = orderFacade.getMyOrderDetail(1L, 1L); + + // assert + assertAll( + () -> assertThat(result.totalPrice()).isEqualTo(50000), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("상품A")); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void getMyOrderDetail_notOwner_throwsException() { + // arrange + when(orderService.getByIdAndUserId(1L, 1L)) + .thenThrow(new CoreException(OrderErrorCode.FORBIDDEN)); + + // act & assert + assertThatThrownBy(() -> orderFacade.getMyOrderDetail(1L, 1L)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("관리자 주문 조회할 때, ") + @Nested + class AdminOrders { + + @DisplayName("전체 주문 페이지네이션을 반환한다") + @Test + void getAllOrders_returnsPage() { + // arrange + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 50000, 1, "상품A", ("브랜드A")))); + Page page = new PageImpl<>(List.of(order), PageRequest.of(0, 10), 1); + + when(orderService.getAllOrders(any())).thenReturn(page); + + // act + Page result = orderFacade.getAllOrders(PageRequest.of(0, 10)); + + // assert + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(1), + () -> assertThat(result.getContent().get(0).totalPrice()).isEqualTo(50000)); + } + + @DisplayName("주문 상세를 반환한다 (소유권 검증 없음)") + @Test + void getOrderDetail_returnsDetail_withoutOwnerCheck() { + // arrange + OrderModel order = OrderModel.create(2L, List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A")))); + + when(orderService.getById(1L)).thenReturn(order); + + // act + OrderResult.OrderDetail result = orderFacade.getOrderDetail(1L); + + // assert + assertAll( + () -> assertThat(result.userId()).isEqualTo(2L), + () -> assertThat(result.items()).hasSize(1)); + } + } + + @DisplayName("회원 아이템 취소할 때 (UC-O04), ") + @Nested + class CancelMyOrderItem { + + @DisplayName("소유자 검증 + 아이템 취소 + 재고 복구가 수행된다") + @Test + void cancelMyOrderItem_success() { + // arrange + OrderItemModel cancelledItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", ("브랜드A")); + + when(orderService.getByIdAndUserId(1L, 1L)).thenReturn( + OrderModel.create(1L, List.of(cancelledItem))); + when(orderService.cancelItem(1L, 100L)).thenReturn(cancelledItem); + + // act + orderFacade.cancelMyOrderItem(1L, 1L, 100L); + + // assert + assertAll( + () -> verify(orderService).getByIdAndUserId(1L, 1L), + () -> verify(orderService).cancelItem(1L, 100L), + () -> verify(productService).increaseStock(10L, 2)); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void cancelMyOrderItem_notOwner_throwsException() { + // arrange + when(orderService.getByIdAndUserId(1L, 999L)) + .thenThrow(new CoreException(OrderErrorCode.FORBIDDEN)); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelMyOrderItem(999L, 1L, 100L)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..1f1665cb7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,120 @@ +package com.loopers.application.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +import com.loopers.application.product.dto.ProductResult; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@DisplayName("ProductFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private ProductLikeService productLikeService; + + @InjectMocks + private ProductFacade productFacade; + + @DisplayName("상품 상세를 조회할 때, ") + @Nested + class GetProduct { + + @DisplayName("좋아요 수가 포함된 ProductResult를 반환한다.") + @Test + void getProduct_returnsResultWithLikeCount() { + // arrange + ProductModel product = ProductModel.create(1L, "에어맥스", 150000, 100); + when(productService.getById(1L)).thenReturn(product); + when(brandService.getById(1L)).thenReturn(BrandModel.create("나이키")); + when(productLikeService.countLikes(1L)).thenReturn(5L); + + // act + ProductResult result = productFacade.getProduct(1L); + + // assert + assertThat(result.likeCount()).isEqualTo(5L); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetProductsWithActiveBrand { + + @DisplayName("각 상품에 좋아요 수가 포함된다.") + @Test + void getProductsWithActiveBrand_returnsResultsWithLikeCount() { + // arrange + ProductModel product1 = ProductModel.create(1L, "에어맥스", 150000, 100); + ProductModel product2 = ProductModel.create(1L, "에어포스", 120000, 50); + PageRequest pageable = PageRequest.of(0, 20); + + when(productService.getAll(pageable)) + .thenReturn(new PageImpl<>(List.of(product1, product2), pageable, 2)); + when(brandService.getActiveNameMapByIds(List.of(1L))) + .thenReturn(Map.of(1L, "나이키")); + when(productLikeService.countLikesByProductIds(List.of(0L, 0L))) + .thenReturn(Map.of(0L, 3L)); + + // act + Page result = productFacade.getProductsWithActiveBrand(pageable); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).likeCount()).isEqualTo(3L); + } + } + + @DisplayName("좋아요 수 정렬로 상품 목록을 조회할 때, ") + @Nested + class GetProductsWithActiveBrandSortedByLikes { + + @DisplayName("좋아요 수 내림차순으로 정렬되고 페이지네이션된다.") + @Test + void getProductsSortedByLikes_returnsSortedAndPaginated() { + // arrange + ProductModel product1 = ProductModel.create(1L, "에어맥스", 150000, 100); + ProductModel product2 = ProductModel.create(1L, "에어포스", 120000, 50); + ProductModel product3 = ProductModel.create(1L, "조던1", 200000, 30); + + when(productService.getAll()) + .thenReturn(List.of(product1, product2, product3)); + when(brandService.getActiveNameMapByIds(List.of(1L))) + .thenReturn(Map.of(1L, "나이키")); + when(productLikeService.countLikesByProductIds(anyList())) + .thenReturn(Map.of(0L, 1L)); + + // act + Page result = + productFacade.getProductsWithActiveBrandSortedByLikes(null, 0, 2); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java new file mode 100644 index 000000000..2317dc645 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java @@ -0,0 +1,67 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * domain 계층 순수성 ArchUnit 테스트. + * + * domain 패키지에 Spring Framework 의존이 침투하지 않는 것을 보장한다. + * Repository 인터페이스는 순수 Java, Entity에 Spring 어노테이션 금지. + * + * 출처: package-convention.md § 5, infrastructure-convention.md § 1 + * + * 복사 위치: apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class DomainPurityTest { + + // ======================================================================== + // domain에 Spring 스테레오타입 어노테이션 금지 + // 출처: package-convention.md § 5 — "domain은 순수 Java로만 구성한다" + // ======================================================================== + + @ArchTest + static final ArchRule domain에_Spring_Repository_어노테이션_금지 = + noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith( + org.springframework.stereotype.Repository.class) + .because("@Repository는 infrastructure의 RepositoryImpl에만 사용한다. " + + "domain Repository는 순수 Java 인터페이스여야 한다. " + + "(infrastructure-convention.md § 1)"); + + // 컨벤션: domain Service는 @Service 어노테이션 사용 가능. + // DI 등록을 위해 domain Service 클래스에 @Service를 허용한다. + // @Component, @Repository(Spring)는 여전히 금지 (위 규칙으로 검증). + + @ArchTest + static final ArchRule domain에_Spring_Component_어노테이션_금지 = + noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith( + org.springframework.stereotype.Component.class) + .because("domain 계층에 Spring @Component 금지. " + + "domain은 순수 Java로만 구성한다. " + + "(package-convention.md § 5)"); + + // ======================================================================== + // domain Service 허용 사항 (컨벤션) + // + // service-layer-convention.md § 3~4에 따라 domain Service는 다음을 사용할 수 있다: + // - @Service (DI 등록) + // - @Transactional (메서드 레벨, Facade와 REQUIRED 전파) + // - Page / Pageable (Spring Data 페이지네이션) + // + // 금지 대상은 Entity, VO, Repository 인터페이스이며, + // 해당 클래스에 대한 @Repository, @Component 금지는 위 규칙으로 검증한다. + // JpaRepository 상속은 infrastructure에서만 허용 (NamingConventionTest에서 검증). + // ======================================================================== +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java new file mode 100644 index 000000000..cf7c803de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -0,0 +1,90 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +/** + * 계층 간 의존 방향 ArchUnit 테스트. + * + * 출처: package-convention.md § 5. 의존 방향 규칙 + * 규칙: interfaces → application → domain ← infrastructure + * + * 복사 위치: apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class LayeredArchitectureTest { + + // ======================================================================== + // 계층 의존 방향: domain은 어떤 계층도 알지 못한다 + // 출처: package-convention.md § 5 + // ======================================================================== + + @ArchTest + static final ArchRule domain은_infrastructure를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .resideInAPackage("..infrastructure..") + .because("domain 계층은 순수 Java로만 구성한다. " + + "infrastructure 의존은 DIP 위반이다. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule domain은_application을_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .resideInAPackage("..application..") + .because("domain → application 역방향 의존 금지. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule domain은_interfaces를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .resideInAPackage("..interfaces..") + .because("domain → interfaces 역방향 의존 금지. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule interfaces는_infrastructure를_직접_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..interfaces..") + .should().dependOnClassesThat() + .resideInAPackage("..infrastructure..") + .because("Controller는 Facade를 통해 접근한다. " + + "Repository 직접 접근 금지. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule application은_interfaces를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..application..") + .should().dependOnClassesThat() + .resideInAPackage("..interfaces..") + .because("Facade가 Controller/Request/Response DTO를 알면 안 된다. " + + "(package-convention.md § 5)"); + + // ======================================================================== + // 순환 의존 금지: 도메인 간 순환 참조 방지 + // 출처: package-convention.md § 5. 의존 방향 규칙 + // ======================================================================== + + @ArchTest + static final ArchRule 도메인_간_순환_의존이_없다 = + slices() + .matching("..domain.(*)..") + .should().beFreeOfCycles() + .because("도메인 간 순환 의존은 결합도를 높인다. " + + "타 도메인 접근은 Facade에서 조율한다. " + + "(package-convention.md § 5)"); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java new file mode 100644 index 000000000..e72f2fb6c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java @@ -0,0 +1,107 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * 네이밍 및 배치 규칙 ArchUnit 테스트. + * + * 클래스 이름과 패키지 배치가 프로젝트 컨벤션을 따르는지 검증한다. + * + * 출처: package-convention.md § 4, infrastructure-convention.md § 1 + * + * 복사 위치: apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class NamingConventionTest { + + // ======================================================================== + // Controller 배치: interfaces 패키지에만 존재 + // 출처: package-convention.md § 4 + // ======================================================================== + + @ArchTest + static final ArchRule Controller는_interfaces_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideInAPackage("..interfaces..") + .because("Controller는 interfaces/{domain}/ 패키지에 배치한다. " + + "(package-convention.md § 4)"); + + // ======================================================================== + // Facade 배치: application 패키지에만 존재 + // 출처: package-convention.md § 4 + // ======================================================================== + + @ArchTest + static final ArchRule Facade는_application_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("Facade") + .should().resideInAPackage("..application..") + .because("Facade는 application/{domain}/ 패키지에 배치한다. " + + "(package-convention.md § 4)"); + + // ======================================================================== + // RepositoryImpl 배치: infrastructure 패키지에만 존재 + // 출처: infrastructure-convention.md § 1 — Repository 3-클래스 패턴 + // ======================================================================== + + @ArchTest + static final ArchRule RepositoryImpl은_infrastructure_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("RepositoryImpl") + .should().resideInAPackage("..infrastructure..") + .because("RepositoryImpl은 infrastructure/{domain}/ 패키지에 배치한다. " + + "(infrastructure-convention.md § 1. Repository 3-클래스 패턴)"); + + // ======================================================================== + // JpaRepository 배치: infrastructure 패키지에만 존재 + // 출처: infrastructure-convention.md § 1 — Repository 3-클래스 패턴 + // ======================================================================== + + @ArchTest + static final ArchRule JpaRepository는_infrastructure_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("JpaRepository") + .should().resideInAPackage("..infrastructure..") + .because("JpaRepository 인터페이스는 infrastructure/{domain}/ 패키지에 배치한다. " + + "(infrastructure-convention.md § 1. Repository 3-클래스 패턴)"); + + // ======================================================================== + // @Repository 어노테이션은 RepositoryImpl에만 + // 출처: infrastructure-convention.md § 1 + // ======================================================================== + + @ArchTest + static final ArchRule Repository_어노테이션은_Impl_클래스에만 = + classes() + .that().areAnnotatedWith( + org.springframework.stereotype.Repository.class) + .should().haveSimpleNameEndingWith("RepositoryImpl") + .orShould().haveSimpleNameEndingWith("QueryRepository") + .because("@Repository는 RepositoryImpl 또는 QueryRepository에만 붙인다. " + + "domain Repository 인터페이스는 순수 Java로 유지한다. " + + "(infrastructure-convention.md § 1)"); + + // ======================================================================== + // ErrorCode는 domain 패키지에 배치 + // 출처: exception-convention.md § 6 + // ======================================================================== + + @ArchTest + static final ArchRule ErrorCode는_domain_또는_support에_있다 = + classes() + .that().haveSimpleNameEndingWith("ErrorCode") + .should().resideInAPackage("..domain..") + .orShould().resideInAPackage("..support..") + .because("도메인 ErrorCode는 domain/{domain}/, " + + "공통 ErrorType은 support/error/ 에 배치한다. " + + "(exception-convention.md § 6)"); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java new file mode 100644 index 000000000..e0f02624e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java @@ -0,0 +1,97 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * 서비스 계층 호출 규칙 ArchUnit 테스트. + * + * Facade ↔ Service 간 호출 규칙을 바이트코드 수준에서 검증한다. + * + * 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class ServiceLayerTest { + + // ======================================================================== + // Facade → Facade 호출 금지 + // 출처: service-layer-convention.md § 5 + // "Facade → 타 Facade ❌ 순환 의존, 트랜잭션 경계 혼란" + // ======================================================================== + + @ArchTest + static final ArchRule Facade는_다른_Facade를_의존하지_않는다 = + noClasses() + .that().haveSimpleNameEndingWith("Facade") + .and().resideInAPackage("..application..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("Facade") + .because("Facade → Facade 호출 금지. " + + "순환 의존과 트랜잭션 경계 혼란을 방지한다. " + + "타 도메인 접근은 타 도메인의 Domain Service를 직접 호출한다. " + + "(service-layer-convention.md § 5)"); + + // ======================================================================== + // Controller → Domain Service 직접 호출 금지 + // 출처: service-layer-convention.md § 5 + // "Controller → Domain Service 직접 ❌ Facade 우회" + // ======================================================================== + + @ArchTest + static final ArchRule Controller는_domain_Service를_직접_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..interfaces..") + .and().haveSimpleNameEndingWith("Controller") + .should().dependOnClassesThat( + DescribedPredicate.describe( + "domain Service classes", + (JavaClass clazz) -> clazz.getPackageName().contains(".domain.") + && clazz.getSimpleName().endsWith("Service") + )) + .because("Controller는 Facade만 호출한다. " + + "Domain Service 직접 접근은 Facade 우회이다. " + + "Filter 등 인프라 클래스는 예외. " + + "(service-layer-convention.md § 5)"); + + // ======================================================================== + // Domain Service → Facade 역방향 호출 금지 + // 출처: service-layer-convention.md § 5 + // "Domain Service → Facade ❌ 하위 → 상위 역방향" + // ======================================================================== + + @ArchTest + static final ArchRule Domain_Service는_Facade를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("Facade") + .because("Domain Service → Facade 역방향 호출 금지. " + + "하위 계층은 상위 계층을 알 수 없다. " + + "(service-layer-convention.md § 5)"); + + // ======================================================================== + // Facade는 Repository를 직접 접근하지 않는다 + // 출처: service-layer-convention.md § 2 + // "Facade에 넣지 않는 것: Repository 직접 호출 → Domain Service" + // ======================================================================== + + @ArchTest + static final ArchRule Facade는_Repository를_직접_의존하지_않는다 = + noClasses() + .that().haveSimpleNameEndingWith("Facade") + .and().resideInAPackage("..application..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("Repository") + .because("Facade는 Repository를 직접 호출하지 않는다. " + + "데이터 접근은 Domain Service를 통해 한다. " + + "(service-layer-convention.md § 2)"); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java deleted file mode 100644 index cb3c0304e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.loopers.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import java.time.LocalDate; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class BirthDateTest { - - @DisplayName("생년월일 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("과거 혹은 현재의 날짜가 주어지면, 정상적으로 생성된다.") - @Test - void createBirthDate_whenValidDateProvided() { - // arrange - LocalDate validDate = LocalDate.of(1995, 5, 20); - - // act - BirthDate birthDate = new BirthDate(validDate); - - // assert - assertThat(birthDate.getDate()).isEqualTo(validDate); - } - - @DisplayName("날짜가 null이면 예외가 발생한다.") - @Test - void createBirthDate_whenDateIsNull() { - assertThatThrownBy(() -> new BirthDate(null)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("필수 입력값입니다."); - } - - @DisplayName("날짜가 미래의 날짜이면 예외가 발생한다.") - @Test - void createBirthDate_whenDateIsInFuture() { - // arrange - LocalDate futureDate = LocalDate.now().plusDays(1); - - // act & assert - assertThatThrownBy(() -> new BirthDate(futureDate)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("과거 날짜여야 합니다."); - } - } - - @DisplayName("날짜를 문자열로 변환할 때, ") - @Nested - class Conversion { - - @DisplayName("yyyyMMdd 형식의 문자열을 반환한다.") - @Test - void toDateString_returnsFormattedString() { - // arrange - BirthDate birthDate = new BirthDate(LocalDate.of(1988, 12, 5)); - - // act - String result = birthDate.toDateString(); - - // assert - assertThat(result).isEqualTo("19881205"); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java deleted file mode 100644 index 245c1f36a..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.loopers.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class EmailTest { - - @DisplayName("이메일 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("올바른 이메일 형식이 주어지면, 정상적으로 생성된다.") - @ParameterizedTest - @ValueSource(strings = { - "test@example.com", - "user.name+tag@domain.co.kr", - "12345@loopers.io", - "email@sub.domain.com" - }) - void createEmail_whenValidFormat(String validEmail) { - // act - Email email = new Email(validEmail); - - // assert - assertThat(email.getMail()).isEqualTo(validEmail); - } - - @DisplayName("이메일이 null이거나 비어있으면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"", " ", " "}) - void createEmail_whenNullOrBlank(String blankEmail) { - // null 케이스 별도 테스트 - assertThatThrownBy(() -> new Email(null)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("이메일은 비어있을 수 없습니다."); - - // 공백 케이스 - assertThatThrownBy(() -> new Email(blankEmail)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("이메일은 비어있을 수 없습니다."); - } - - @DisplayName("형식이 올바르지 않으면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = { - "plainaddress", // @ 없음 - "#@%^%#$@#$@#.com", // 특수문자 남발 - "@domain.com", // 로컬 파트 없음 - "Joe Smith ", // 이름 포함 - "email.domain.com", // @ 없음 - "email@domain@domain.com", // @ 중복 - "email@domain..com" // 도메인 마침표 중복 - }) - void createEmail_whenInvalidFormat(String invalidEmail) { - assertThatThrownBy(() -> new Email(invalidEmail)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("이메일 형식이 올바르지 않습니다."); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java deleted file mode 100644 index 36e3c8989..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class LoginIdTest { - - @DisplayName("로그인 ID 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("4~12자의 영문/숫자가 주어지면, 정상적으로 생성된다.") - @ParameterizedTest - @ValueSource(strings = {"user123", "loop", "loopers2026"}) - void createLoginId_whenValidValue(String validValue) { - LoginId loginId = new LoginId(validValue); - assertThat(loginId.getValue()).isEqualTo(validValue); - } - - @DisplayName("4자 미만이거나 12자를 초과하면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"abc", "longloginid123"}) - void createLoginId_whenLengthIsInvalid(String invalidLengthValue) { - assertThatThrownBy(() -> new LoginId(invalidLengthValue)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("영문/숫자가 아닌 문자가 포함되면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"user!", "로그인id", "user 12"}) - void createLoginId_whenContainsInvalidChars(String invalidCharValue) { - assertThatThrownBy(() -> new LoginId(invalidCharValue)) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java deleted file mode 100644 index eeb4796e3..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.loopers.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -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.ValueSource; - -class NameTest { - - @DisplayName("이름 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("2자 이상 10자 이하의 이름이 주어지면, 정상적으로 생성된다.") - @ParameterizedTest - @ValueSource(strings = {"홍길", "홍길동", "가나다라마바사아자차"}) - void createName_whenValidNameProvided(String validNameValue) { - // act - Name name = new Name(validNameValue); - - // assert - assertThat(name).isNotNull(); - } - - @DisplayName("이름이 null이면 예외가 발생한다.") - @Test - void createName_whenNameIsNull() { - assertThatThrownBy(() -> new Name(null)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("이름이 빈 문자열이거나 공백이면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"", " ", " "}) - void createName_whenNameIsBlank(String blankName) { - assertThatThrownBy(() -> new Name(blankName)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("이름이 2자 미만이면 예외가 발생한다.") - @Test - void createName_whenNameIsTooShort() { - String shortName = "가"; - - assertThatThrownBy(() -> new Name(shortName)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("이름이 10자를 초과하면 예외가 발생한다.") - @Test - void createName_whenNameIsTooLong() { - String longName = "가나다라마바사아자차카"; // 11자 - - assertThatThrownBy(() -> new Name(longName)) - .isInstanceOf(CoreException.class);} - } - - @DisplayName("이름을 마스킹할 때, ") - @Nested - class Masking { - - @DisplayName("이름의 마지막 글자가 '*'로 치환된다.") - @ParameterizedTest - @CsvSource({ - "홍길, 홍*", - "홍길동, 홍길*", - "가나다라마, 가나다라*" - }) - void getMaskedName_shouldMaskLastCharacter(String original, String expected) { - // arrange - Name name = new Name(original); - - // act - String maskedName = name.getMaskedName(); - - // assert - assertThat(maskedName).isEqualTo(expected); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java deleted file mode 100644 index 50d2220e8..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.loopers.domain; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -import com.loopers.support.error.CoreException; -import java.time.LocalDate; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class PasswordTest { - - @DisplayName("비밀번호 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("8~16자의 영문 대소문자, 숫자, 특수문자가 모두 포함되면 정상 생성된다.") - @Test - void createPassword_whenValidFormat() { - // 대문자(V), 소문자(alid), 숫자(123), 특수문자(!@#) 모두 포함 - assertDoesNotThrow(() -> new Password("Valid123!@#")); - } - - @DisplayName("규칙에 어긋나는 형식이면 예외가 발생한다.") - @Test - void createPassword_whenInvalidFormat() { - assertThatThrownBy(() -> new Password("invalid")) - .isInstanceOf(CoreException.class); - } - } - - @DisplayName("비밀번호 비즈니스 규칙을 검증할 때, ") - @Nested - class Validation { - - @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되어 있으면 예외가 발생한다.") - @Test - void validateNotContainBirthday_fail() { - // arrange: 정규식을 통과하기 위해 대문자 'P' 추가 - Password password = new Password("Pw19900115!"); - BirthDate birthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - - // act & assert - assertThatThrownBy(() -> password.validateNotContainBirthday(birthDate)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("수정하려는 비밀번호가 기존 비밀번호와 동일하면 예외가 발생한다.") - @Test - void validateNotSameAs_fail() { - // arrange - Password currentPassword = new Password("Current123!"); - Password newPassword = new Password("Current123!"); - - // act & assert - assertThatThrownBy(() -> newPassword.validateNotSameAs(currentPassword)) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..99ddb906e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,126 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class BrandModelTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("브랜드명이 주어지면, 정상적으로 생성된다.") + @Test + void createBrandModel_whenNameProvided() { + // act + BrandModel brand = BrandModel.create("Nike"); + + // assert + assertThat(brand.getName()).isEqualTo("Nike"); + } + + @DisplayName("브랜드명이 null이면 예외가 발생한다.") + @Test + void createBrandModel_whenNameIsNull() { + assertThatThrownBy(() -> BrandModel.create(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 빈 문자열이면 예외가 발생한다.") + @Test + void createBrandModel_whenNameIsBlank() { + assertThatThrownBy(() -> BrandModel.create(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 100자 이상이면 예외가 발생한다.") + @Test + void createBrandModel_whenNameTooLong() { + String longName = "a".repeat(100); + + assertThatThrownBy(() -> BrandModel.create(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 99자 이하여야 합니다."); + } + } + + @DisplayName("브랜드명을 변경할 때, ") + @Nested + class Update { + + @DisplayName("유효한 브랜드명이 주어지면, 정상적으로 변경된다.") + @Test + void updateBrandModel_whenNameProvided() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act + brand.update("Adidas"); + + // assert + assertThat(brand.getName()).isEqualTo("Adidas"); + } + + @DisplayName("브랜드명이 null이면 예외가 발생한다.") + @Test + void updateBrandModel_whenNameIsNull() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act & assert + assertThatThrownBy(() -> brand.update(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 빈 문자열이면 예외가 발생한다.") + @Test + void updateBrandModel_whenNameIsBlank() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act & assert + assertThatThrownBy(() -> brand.update(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 100자 이상이면 예외가 발생한다.") + @Test + void updateBrandModel_whenNameTooLong() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + String longName = "a".repeat(100); + + // act & assert + assertThatThrownBy(() -> brand.update(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 99자 이하여야 합니다."); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("delete() 호출 시 deletedAt이 설정된다.") + @Test + void delete_whenCalled() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act + brand.delete(); + + // assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..e3cc64a04 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,197 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +class BrandServiceTest { + + private BrandService brandService; + private FakeBrandRepository brandRepository; + + @BeforeEach + void setUp() { + brandRepository = new FakeBrandRepository(); + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드를 등록할 때, ") + @Nested + class Register { + + @DisplayName("유효한 브랜드명이 주어지면, 정상적으로 등록된다.") + @Test + void register_whenValidName() { + // act + brandService.register("Nike"); + + // assert + assertThat(brandRepository.findByName("Nike")).isPresent(); + } + + @DisplayName("이미 존재하는 브랜드명이면 CONFLICT 예외가 발생한다.") + @Test + void register_whenDuplicateName() { + // arrange + brandService.register("Nike"); + + // act & assert + assertThatThrownBy(() -> brandService.register("Nike")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.DUPLICATE_NAME)); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드 ID가 주어지면, 정상적으로 조회된다.") + @Test + void getById_whenExists() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + + // act + BrandModel found = brandService.getById(savedId); + + // assert + assertThat(found.getName()).isEqualTo("Nike"); + } + + @DisplayName("존재하지 않는 브랜드 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenNotExists() { + assertThatThrownBy(() -> brandService.getById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); + } + + @DisplayName("삭제된 브랜드 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenDeleted() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + brandService.delete(savedId); + + // act & assert + assertThatThrownBy(() -> brandService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 브랜드명이 주어지면, 정상적으로 수정된다.") + @Test + void update_whenValidName() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + + // act + brandService.update(savedId, "Adidas"); + + // assert + BrandModel updated = brandService.getById(savedId); + assertThat(updated.getName()).isEqualTo("Adidas"); + } + + @DisplayName("다른 브랜드가 사용 중인 이름이면 CONFLICT 예외가 발생한다.") + @Test + void update_whenDuplicateName() { + // arrange + brandService.register("Nike"); + brandService.register("Adidas"); + Long adidasId = brandRepository.findByName("Adidas").orElseThrow().getId(); + + // act & assert + assertThatThrownBy(() -> brandService.update(adidasId, "Nike")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.DUPLICATE_NAME)); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드 ID가 주어지면, 정상적으로 삭제된다.") + @Test + void delete_whenExists() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + + // act + brandService.delete(savedId); + + // assert + assertThatThrownBy(() -> brandService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); + } + } + + @DisplayName("브랜드 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("등록된 브랜드가 있으면, 페이지네이션된 목록을 반환한다.") + @Test + void getAll_whenBrandsExist() { + // arrange + brandService.register("Nike"); + brandService.register("Adidas"); + brandService.register("Puma"); + + // act + Page result = brandService.getAll(PageRequest.of(0, 2)); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + } + + @DisplayName("등록된 브랜드가 없으면, 빈 목록을 반환한다.") + @Test + void getAll_whenNoBrandsExist() { + // act + Page result = brandService.getAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @DisplayName("삭제된 브랜드는 목록에 포함되지 않는다.") + @Test + void getAll_excludesDeletedBrands() { + // arrange + brandService.register("Nike"); + brandService.register("Adidas"); + Long nikeId = brandRepository.findByName("Nike").orElseThrow().getId(); + brandService.delete(nikeId); + + // act + Page result = brandService.getAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("Adidas"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java new file mode 100644 index 000000000..6cf68cd6a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java @@ -0,0 +1,70 @@ +package com.loopers.domain.brand; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public BrandModel save(BrandModel brandModel) { + if (brandModel.getId() == 0L) { + try { + var idField = brandModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brandModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(brandModel.getId(), brandModel); + return brandModel; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(brand -> brand.getDeletedAt() == null); + } + + @Override + public Optional findByName(String name) { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .filter(brand -> brand.getName().equals(name)) + .findFirst(); + } + + @Override + public Page findAll(Pageable pageable) { + List activeModels = store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), activeModels.size()); + + List pageContent = start >= activeModels.size() + ? new ArrayList<>() + : activeModels.subList(start, end); + + return new PageImpl<>(pageContent, pageable, activeModels.size()); + } + + @Override + public List findAllByIdIn(List ids) { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .filter(brand -> ids.contains(brand.getId())) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -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.DisplayName; -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 static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java new file mode 100644 index 000000000..2defe2cdf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java @@ -0,0 +1,66 @@ +package com.loopers.domain.like; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +public class FakeProductLikeRepository implements ProductLikeRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public ProductLikeModel save(ProductLikeModel productLike) { + if (productLike.getId() == null) { + try { + var idField = productLike.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(productLike, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(productLike.getId(), productLike); + return productLike; + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.values().stream() + .filter(like -> like.getUserId().equals(userId) && like.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public void delete(ProductLikeModel productLike) { + store.remove(productLike.getId()); + } + + @Override + public List findAllByUserId(Long userId) { + return store.values().stream() + .filter(like -> like.getUserId().equals(userId)) + .toList(); + } + + @Override + public long countByProductId(Long productId) { + return store.values().stream() + .filter(like -> like.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(List productIds) { + Map countMap = store.values().stream() + .filter(like -> productIds.contains(like.getProductId())) + .collect(Collectors.groupingBy( + ProductLikeModel::getProductId, + Collectors.counting())); + productIds.forEach(id -> countMap.putIfAbsent(id, 0L)); + return countMap; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java new file mode 100644 index 000000000..5dfe0132f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductLikeModelTest { + + @DisplayName("좋아요를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 userId와 productId가 주어지면, 정상적으로 생성된다.") + @Test + void create_whenValidValues() { + // arrange + Long userId = 1L; + Long productId = 2L; + + // act + ProductLikeModel like = ProductLikeModel.create(userId, productId); + + // assert + assertAll( + () -> assertThat(like.getUserId()).isEqualTo(userId), + () -> assertThat(like.getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("userId가 null이면 예외가 발생한다.") + @Test + void create_whenUserIdIsNull() { + assertThatThrownBy(() -> ProductLikeModel.create(null, 2L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("productId가 null이면 예외가 발생한다.") + @Test + void create_whenProductIdIsNull() { + assertThatThrownBy(() -> ProductLikeModel.create(1L, null)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java new file mode 100644 index 000000000..e616f35ae --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -0,0 +1,173 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductLikeServiceTest { + + private ProductLikeService productLikeService; + private FakeProductLikeRepository productLikeRepository; + + @BeforeEach + void setUp() { + productLikeRepository = new FakeProductLikeRepository(); + productLikeService = new ProductLikeService(productLikeRepository); + } + + @DisplayName("좋아요를 등록할 때, ") + @Nested + class Like { + + @DisplayName("유효한 userId와 productId가 주어지면, 좋아요가 저장된다.") + @Test + void like_whenValidValues() { + // act + productLikeService.like(1L, 2L); + + // assert + assertThat(productLikeRepository.findByUserIdAndProductId(1L, 2L)).isPresent(); + } + + @DisplayName("이미 좋아요한 상품이면 CONFLICT 예외가 발생한다.") + @Test + void like_whenAlreadyLiked_throwsConflict() { + // arrange + productLikeService.like(1L, 2L); + + // act & assert + assertThatThrownBy(() -> productLikeService.like(1L, 2L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 좋아요한 상품입니다"); + } + } + + @DisplayName("좋아요를 취소할 때, ") + @Nested + class Unlike { + + @DisplayName("존재하는 좋아요가 주어지면, 좋아요가 삭제된다.") + @Test + void unlike_whenExists() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 2L)); + + // act + productLikeService.unlike(1L, 2L); + + // assert + assertThat(productLikeRepository.findByUserIdAndProductId(1L, 2L)).isEmpty(); + } + + @DisplayName("좋아요 기록이 없으면 NOT_FOUND 예외가 발생한다.") + @Test + void unlike_whenNotExists_throwsNotFound() { + // act & assert + assertThatThrownBy(() -> productLikeService.unlike(1L, 2L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("좋아요 기록이 없습니다"); + } + } + + @DisplayName("좋아요 존재 여부를 확인할 때, ") + @Nested + class ExistsByUserIdAndProductId { + + @DisplayName("좋아요가 존재하면, true를 반환한다.") + @Test + void exists_whenLikeExists() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 2L)); + + // act & assert + assertThat(productLikeService.existsByUserIdAndProductId(1L, 2L)).isTrue(); + } + + @DisplayName("좋아요가 없으면, false를 반환한다.") + @Test + void exists_whenLikeNotExists() { + // act & assert + assertThat(productLikeService.existsByUserIdAndProductId(1L, 2L)).isFalse(); + } + } + + @DisplayName("좋아요 목록을 조회할 때, ") + @Nested + class GetLikesByUserId { + + @DisplayName("사용자 ID로 조회하면, 해당 사용자의 좋아요 목록이 반환된다.") + @Test + void getLikesByUserId_whenLikesExist() { + // arrange + Long userId = 1L; + productLikeRepository.save(ProductLikeModel.create(userId, 10L)); + productLikeRepository.save(ProductLikeModel.create(userId, 20L)); + productLikeRepository.save(ProductLikeModel.create(2L, 30L)); + + // act + List result = productLikeService.getLikesByUserId(userId); + + // assert + assertThat(result).hasSize(2); + assertThat(result).allMatch(like -> like.getUserId().equals(userId)); + } + + @DisplayName("좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다.") + @Test + void getLikesByUserId_whenNoLikes() { + // act + List result = productLikeService.getLikesByUserId(999L); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("좋아요 수를 조회할 때, ") + @Nested + class CountLikes { + + @DisplayName("상품의 좋아요 수를 반환한다.") + @Test + void countLikes_returnsCount() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 10L)); + productLikeRepository.save(ProductLikeModel.create(2L, 10L)); + productLikeRepository.save(ProductLikeModel.create(3L, 20L)); + + // act & assert + assertThat(productLikeService.countLikes(10L)).isEqualTo(2); + assertThat(productLikeService.countLikes(20L)).isEqualTo(1); + assertThat(productLikeService.countLikes(99L)).isEqualTo(0); + } + } + + @DisplayName("상품별 좋아요 수를 일괄 조회할 때, ") + @Nested + class CountLikesByProductIds { + + @DisplayName("여러 상품 ID로 조회하면, 상품별 좋아요 수 Map을 반환한다.") + @Test + void countLikesByProductIds_returnsCountMap() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 10L)); + productLikeRepository.save(ProductLikeModel.create(2L, 10L)); + productLikeRepository.save(ProductLikeModel.create(3L, 20L)); + + // act + Map result = productLikeService.countLikesByProductIds(List.of(10L, 20L, 30L)); + + // assert + assertThat(result).containsEntry(10L, 2L); + assertThat(result).containsEntry(20L, 1L); + assertThat(result).containsEntry(30L, 0L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java new file mode 100644 index 000000000..a1ca79748 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java @@ -0,0 +1,35 @@ +package com.loopers.domain.order; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeOrderItemRepository implements OrderItemRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public OrderItemModel save(OrderItemModel orderItemModel) { + if (orderItemModel.getId() == 0L) { + try { + var idField = orderItemModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(orderItemModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(orderItemModel.getId(), orderItemModel); + return orderItemModel; + } + + @Override + public List findAllByOrderId(Long orderId) { + return store.values().stream() + .filter(item -> item.getOrder() != null + && item.getOrder().getId() == orderId) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java new file mode 100644 index 000000000..4d544216e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public OrderModel save(OrderModel orderModel) { + if (orderModel.getId() == 0L) { + try { + var idField = orderModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(orderModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(orderModel.getId(), orderModel); + return orderModel; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .filter(order -> { + ZonedDateTime createdAt = order.getCreatedAt(); + if (createdAt == null) return true; + return !createdAt.isBefore(startAt) && !createdAt.isAfter(endAt); + }) + .toList(); + } + + @Override + public Page findAll(Pageable pageable) { + List all = new ArrayList<>(store.values()); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), all.size()); + + List pageContent = start >= all.size() + ? new ArrayList<>() + : all.subList(start, end); + + return new PageImpl<>(pageContent, pageable, all.size()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..ee426e3a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("OrderItemModel 단위 테스트") +class OrderItemModelTest { + + @DisplayName("생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 값이면 생성에 성공한다 (orderId 없이, 스냅샷 포함)") + @Test + void create_withValidValues() { + // act + OrderItemModel orderItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", ("브랜드A")); + + // assert + assertAll( + () -> assertThat(orderItem.getProductId()).isEqualTo(10L), + () -> assertThat(orderItem.getOrderPrice()).isEqualTo(25000), + () -> assertThat(orderItem.getQuantity()).isEqualTo(2), + () -> assertThat(orderItem.getProductName()).isEqualTo("상품A"), + () -> assertThat(orderItem.getBrandName()).isEqualTo("브랜드A")); + } + } + + @DisplayName("취소할 때, ") + @Nested + class Cancel { + + @DisplayName("ORDERED 상태의 아이템이 CANCELLED로 변경된다") + @Test + void cancel_success() { + // arrange + OrderItemModel orderItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", ("브랜드A")); + + // act + orderItem.cancel(); + + // assert + assertThat(orderItem.getStatus()).isEqualTo(OrderItemStatus.CANCELLED); + } + + @DisplayName("이미 CANCELLED인 아이템을 취소하면 예외가 발생한다") + @Test + void cancel_alreadyCancelled_throwsException() { + // arrange + OrderItemModel orderItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", ("브랜드A")); + orderItem.cancel(); + + // act & assert + assertThatThrownBy(() -> orderItem.cancel()) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..e2679354b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,162 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("OrderModel 단위 테스트") +class OrderModelTest { + + private static final AtomicLong ID_GENERATOR = new AtomicLong(1); + + private static void setId(Object entity, long id) { + try { + var idField = entity.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private static OrderItemModel createItemWithId(Long productId, int orderPrice, int quantity, + String productName, String brandName) { + OrderItemModel item = OrderItemModel.create( + productId, orderPrice, quantity, productName, brandName); + setId(item, ID_GENERATOR.getAndIncrement()); + return item; + } + + @DisplayName("생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 값이면 ORDERED 상태로 생성되고 totalPrice가 items로부터 계산된다") + @Test + void create_withValidValues() { + // arrange + List items = List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A"))); + + // act + OrderModel order = OrderModel.create(1L, items); + + // assert + assertAll( + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(50000), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(order.getItems()).hasSize(1), + () -> assertThat(order.getItems().get(0).getOrder()).isSameAs(order)); + } + + @DisplayName("items가 비어있으면 예외가 발생한다") + @Test + void create_withEmptyItems_throwsException() { + assertThatThrownBy(() -> OrderModel.create(1L, List.of())) + .isInstanceOf(CoreException.class); + } + + @DisplayName("userId가 null이면 예외가 발생한다") + @Test + void create_withNullUserId_throwsException() { + // arrange + List items = List.of( + OrderItemModel.create(10L, 25000, 1, "상품A", ("브랜드A"))); + + // act & assert + assertThatThrownBy(() -> OrderModel.create(null, items)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("소유자 검증할 때, ") + @Nested + class ValidateOwner { + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void validateOwner_notOwner_throwsException() { + // arrange + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 25000, 1, "상품A", ("브랜드A")))); + + // act & assert + assertThatThrownBy(() -> order.validateOwner(999L)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("아이템을 취소할 때, ") + @Nested + class CancelItem { + + @DisplayName("취소된 아이템을 제외하고 totalPrice가 재계산된다") + @Test + void cancelItem_recalculatesTotalPrice() { + // arrange + OrderItemModel item1 = createItemWithId(10L, 25000, 2, "상품A", "브랜드A"); + OrderItemModel item2 = createItemWithId(20L, 30000, 1, "상품B", "브랜드B"); + OrderModel order = OrderModel.create(1L, List.of(item1, item2)); + + // act + order.cancelItem(item1.getId()); + + // assert + assertAll( + () -> assertThat(order.getTotalPrice()).isEqualTo(30000), + () -> assertThat(order.getOriginalTotalPrice()).isEqualTo(80000), + () -> assertThat(item1.getStatus()).isEqualTo(OrderItemStatus.CANCELLED)); + } + + @DisplayName("모든 아이템이 취소되면 주문 상태가 CANCELLED로 변경된다") + @Test + void cancelItem_allItemsCancelled_orderCancelled() { + // arrange + OrderItemModel item1 = createItemWithId(10L, 25000, 2, "상품A", "브랜드A"); + OrderItemModel item2 = createItemWithId(20L, 30000, 1, "상품B", "브랜드B"); + OrderModel order = OrderModel.create(1L, List.of(item1, item2)); + + // act + order.cancelItem(item1.getId()); + order.cancelItem(item2.getId()); + + // assert + assertAll( + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED), + () -> assertThat(order.getTotalPrice()).isEqualTo(0)); + } + + @DisplayName("존재하지 않는 아이템 ID로 취소하면 예외가 발생한다") + @Test + void cancelItem_itemNotFound_throwsException() { + // arrange + OrderItemModel item = createItemWithId(10L, 25000, 1, "상품A", "브랜드A"); + OrderModel order = OrderModel.create(1L, List.of(item)); + + // act & assert + assertThatThrownBy(() -> order.cancelItem(999L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("이미 CANCELLED인 주문에서 취소하면 예외가 발생한다") + @Test + void cancelItem_orderAlreadyCancelled_throwsException() { + // arrange + OrderItemModel item = createItemWithId(10L, 25000, 1, "상품A", "브랜드A"); + OrderModel order = OrderModel.create(1L, List.of(item)); + order.cancelItem(item.getId()); + + // act & assert + assertThatThrownBy(() -> order.cancelItem(item.getId())) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..a451d0f12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,175 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.support.error.CoreException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DisplayName("OrderService 단위 테스트") +class OrderServiceTest { + + private OrderService orderService; + private FakeOrderRepository fakeOrderRepository; + private FakeOrderItemRepository fakeOrderItemRepository; + + @BeforeEach + void setUp() { + fakeOrderRepository = new FakeOrderRepository(); + fakeOrderItemRepository = new FakeOrderItemRepository(); + orderService = new OrderService(fakeOrderRepository, fakeOrderItemRepository); + } + + private List createSampleItems() { + return List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A"))); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("주문이 저장되고 totalPrice가 계산된다") + @Test + void createOrder_savesOrder() { + // act + OrderModel order = orderService.createOrder(1L, createSampleItems()); + + // assert + assertAll( + () -> assertThat(order.getId()).isNotEqualTo(0L), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(50000), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(order.getItems()).hasSize(1)); + } + + @DisplayName("빈 항목이면 예외가 발생한다") + @Test + void createOrder_withEmptyItems_throwsException() { + assertThatThrownBy(() -> orderService.createOrder(1L, List.of())) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("주문을 조회할 때, ") + @Nested + class GetOrder { + + @DisplayName("ID로 주문을 조회한다") + @Test + void getById_returnsOrder() { + // arrange + OrderModel savedOrder = orderService.createOrder(1L, createSampleItems()); + + // act + OrderModel foundOrder = orderService.getById(savedOrder.getId()); + + // assert + assertThat(foundOrder.getId()).isEqualTo(savedOrder.getId()); + } + + @DisplayName("존재하지 않는 주문 조회 시 예외가 발생한다") + @Test + void getById_notFound_throwsException() { + assertThatThrownBy(() -> orderService.getById(999L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("ID + userId로 본인 주문을 조회한다") + @Test + void getByIdAndUserId_returnsOrder() { + // arrange + OrderModel savedOrder = orderService.createOrder(1L, createSampleItems()); + + // act + OrderModel foundOrder = orderService.getByIdAndUserId(savedOrder.getId(), 1L); + + // assert + assertThat(foundOrder.getId()).isEqualTo(savedOrder.getId()); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void getByIdAndUserId_notOwner_throwsException() { + // arrange + OrderModel savedOrder = orderService.createOrder(1L, createSampleItems()); + + // act & assert + assertThatThrownBy(() -> orderService.getByIdAndUserId(savedOrder.getId(), 999L)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("유저ID + 기간으로 주문 목록을 조회할 때, ") + @Nested + class GetOrdersByUserIdAndPeriod { + + @DisplayName("해당 유저의 주문 목록을 반환한다") + @Test + void getOrdersByUserIdAndPeriod_returnsOrders() { + // arrange + orderService.createOrder(1L, createSampleItems()); + + // act + List orders = orderService.getOrdersByUserIdAndPeriod( + 1L, + java.time.ZonedDateTime.now().minusDays(1), + java.time.ZonedDateTime.now().plusDays(1)); + + // assert + assertThat(orders).hasSize(1); + } + } + + @DisplayName("전체 주문을 페이지네이션으로 조회할 때, ") + @Nested + class GetAllOrders { + + @DisplayName("전체 주문을 페이지네이션으로 반환한다") + @Test + void getAllOrders_returnsPage() { + // arrange + orderService.createOrder(1L, createSampleItems()); + orderService.createOrder(2L, List.of( + OrderItemModel.create(20L, 30000, 1, "상품B", ("브랜드B")))); + + // act + Page page = orderService.getAllOrders(PageRequest.of(0, 10)); + + // assert + assertAll( + () -> assertThat(page.getTotalElements()).isEqualTo(2), + () -> assertThat(page.getContent()).hasSize(2)); + } + } + + @DisplayName("아이템을 취소할 때, ") + @Nested + class CancelItem { + + @DisplayName("주문을 조회하고 아이템을 취소한다") + @Test + void cancelItem_success() { + // arrange + OrderModel order = orderService.createOrder(1L, createSampleItems()); + Long orderItemId = order.getItems().get(0).getId(); + + // act + OrderItemModel cancelledItem = orderService.cancelItem(order.getId(), orderItemId); + + // assert + assertAll( + () -> assertThat(cancelledItem.getStatus()).isEqualTo(OrderItemStatus.CANCELLED), + () -> assertThat(order.getTotalPrice()).isEqualTo(0), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java new file mode 100644 index 000000000..747ee9b47 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -0,0 +1,94 @@ +package com.loopers.domain.product; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class FakeProductRepository implements ProductRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public ProductModel save(ProductModel productModel) { + if (productModel.getId() == 0L) { + try { + var idField = productModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(productModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(productModel.getId(), productModel); + return productModel; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(product -> product.getDeletedAt() == null); + } + + @Override + public Page findAll(Pageable pageable) { + List activeModels = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), activeModels.size()); + + List pageContent = start >= activeModels.size() + ? new ArrayList<>() + : activeModels.subList(start, end); + + return new PageImpl<>(pageContent, pageable, activeModels.size()); + } + + @Override + public List findAll() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + List filtered = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), filtered.size()); + + List pageContent = start >= filtered.size() + ? new ArrayList<>() + : filtered.subList(start, end); + + return new PageImpl<>(pageContent, pageable, filtered.size()); + } + + @Override + public List findAllByBrandId(Long brandId) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .toList(); + } + + @Override + public List findAllByIdIn(List ids) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> ids.contains(product.getId())) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..7178c3993 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,205 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductModelTest { + + private static final Long BRAND_ID = 1L; + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 값이 주어지면, 정상적으로 생성된다.") + @Test + void create_whenValidValues() { + // act + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // assert + assertThat(product.getBrandId()).isEqualTo(BRAND_ID); + assertThat(product.getName()).isEqualTo("에어맥스"); + assertThat(product.getPrice()).isEqualTo(150000); + assertThat(product.getStock()).isEqualTo(100); + } + + @DisplayName("브랜드 ID가 null이면 예외가 발생한다.") + @Test + void create_whenBrandIdIsNull() { + assertThatThrownBy(() -> ProductModel.create(null, "에어맥스", 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드 ID는 필수값입니다."); + } + + @DisplayName("상품명이 null이면 예외가 발생한다.") + @Test + void create_whenNameIsNull() { + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, null, 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 필수값입니다."); + } + + @DisplayName("상품명이 빈 문자열이면 예외가 발생한다.") + @Test + void create_whenNameIsBlank() { + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, " ", 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 필수값입니다."); + } + + @DisplayName("상품명이 100자 이상이면 예외가 발생한다.") + @Test + void create_whenNameTooLong() { + String longName = "a".repeat(100); + + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, longName, 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 99자 이하여야 합니다."); + } + + @DisplayName("가격이 음수이면 예외가 발생한다.") + @Test + void create_whenPriceIsNegative() { + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, "에어맥스", -1, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격은 0 이상이어야 합니다."); + } + + @DisplayName("재고가 음수이면 예외가 발생한다.") + @Test + void create_whenStockIsNegative() { + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, "에어맥스", 150000, -1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고는 0 이상이어야 합니다."); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 값이 주어지면, 정상적으로 수정된다.") + @Test + void update_whenValidValues() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // act + product.update("에어포스", 120000, 50); + + // assert + assertThat(product.getName()).isEqualTo("에어포스"); + assertThat(product.getPrice()).isEqualTo(120000); + assertThat(product.getStock()).isEqualTo(50); + } + + @DisplayName("상품명이 null이면 예외가 발생한다.") + @Test + void update_whenNameIsNull() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // act & assert + assertThatThrownBy(() -> product.update(null, 120000, 50)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 필수값입니다."); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class DecreaseStock { + + @DisplayName("충분한 재고가 있으면 정상적으로 차감된다.") + @Test + void decreaseStock_whenEnough() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 10); + + // act + product.decreaseStock(3); + + // assert + assertThat(product.getStock()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 예외가 발생한다.") + @Test + void decreaseStock_whenInsufficient() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 5); + + // act & assert + assertThatThrownBy(() -> product.decreaseStock(6)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다."); + } + } + + @DisplayName("재고를 복구할 때, ") + @Nested + class IncreaseStock { + + @DisplayName("수량만큼 재고가 증가한다") + @Test + void increaseStock_success() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 5); + + // act + product.increaseStock(3); + + // assert + assertThat(product.getStock()).isEqualTo(8); + } + } + + @DisplayName("품절 여부를 확인할 때, ") + @Nested + class IsSoldOut { + + @DisplayName("재고가 0이면 품절이다.") + @Test + void isSoldOut_whenStockIsZero() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 0); + + // act & assert + assertThat(product.isSoldOut()).isTrue(); + } + + @DisplayName("재고가 있으면 품절이 아니다.") + @Test + void isSoldOut_whenStockExists() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 1); + + // act & assert + assertThat(product.isSoldOut()).isFalse(); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("delete() 호출 시 deletedAt이 설정된다.") + @Test + void delete_whenCalled() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); + + // act + product.delete(); + + // assert + assertThat(product.getDeletedAt()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..2bbac2183 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,288 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.product.dto.ProductCommand; +import com.loopers.domain.product.dto.ProductInfo; +import com.loopers.support.error.CoreException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +class ProductServiceTest { + + private ProductService productService; + private FakeProductRepository productRepository; + + private static final Long BRAND_ID = 1L; + private static final Long BRAND_ID_2 = 2L; + + @BeforeEach + void setUp() { + productRepository = new FakeProductRepository(); + productService = new ProductService(productRepository); + } + + @DisplayName("상품을 등록할 때, ") + @Nested + class Register { + + @DisplayName("유효한 값이 주어지면, 정상적으로 등록된다.") + @Test + void register_whenValidValues() { + // act + productService.register(BRAND_ID, "에어맥스", 150000, 100); + + // assert + Page all = productRepository.findAll(PageRequest.of(0, 20)); + assertThat(all.getTotalElements()).isEqualTo(1); + assertThat(all.getContent().get(0).getName()).isEqualTo("에어맥스"); + } + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 상품 ID가 주어지면, 정상적으로 조회된다.") + @Test + void getById_whenExists() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + + // act + ProductModel found = productService.getById(savedId); + + // assert + assertThat(found.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 상품 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenNotExists() { + assertThatThrownBy(() -> productService.getById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + + @DisplayName("삭제된 상품 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenDeleted() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + productService.delete(savedId); + + // act & assert + assertThatThrownBy(() -> productService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 값이 주어지면, 정상적으로 수정된다.") + @Test + void update_whenValidValues() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + + // act + productService.update(savedId, "에어포스", 120000, 50); + + // assert + ProductModel updated = productService.getById(savedId); + assertThat(updated.getName()).isEqualTo("에어포스"); + assertThat(updated.getPrice()).isEqualTo(120000); + assertThat(updated.getStock()).isEqualTo(50); + } + + @DisplayName("존재하지 않는 상품이면 NOT_FOUND 예외가 발생한다.") + @Test + void update_whenNotExists() { + assertThatThrownBy(() -> productService.update(999L, "에어포스", 120000, 50)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 상품 ID가 주어지면, 정상적으로 삭제된다.") + @Test + void delete_whenExists() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + + // act + productService.delete(savedId); + + // assert + assertThatThrownBy(() -> productService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("등록된 상품이 있으면, 페이지네이션된 목록을 반환한다.") + @Test + void getAll_whenProductsExist() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어포스", 120000, 50); + productService.register(BRAND_ID, "조던1", 200000, 30); + + // act + Page result = productService.getAll(PageRequest.of(0, 2)); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void getAll_excludesDeletedProducts() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어포스", 120000, 50); + Long firstId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + productService.delete(firstId); + + // act + Page result = productService.getAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("브랜드별 상품 목록을 조회할 때, ") + @Nested + class GetAllByBrandId { + + @DisplayName("해당 브랜드의 상품만 반환한다.") + @Test + void getAllByBrandId_whenExists() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID_2, "울트라부스트", 180000, 80); + + // act + Page result = productService.getAllByBrandId(BRAND_ID, PageRequest.of(0, 20)); + + // assert + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("에어맥스"); + } + } + + @DisplayName("브랜드별 상품을 일괄 삭제할 때, ") + @Nested + class DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품이 삭제된다.") + @Test + void deleteAllByBrandId_whenProductsExist() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어포스", 120000, 50); + + // act + productService.deleteAllByBrandId(BRAND_ID); + + // assert + Page result = productService.getAll(PageRequest.of(0, 20)); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } + + @DisplayName("상품 검증 및 재고 차감을 할 때, ") + @Nested + class ValidateAndDeductStock { + + @DisplayName("유효한 커맨드가 주어지면, 스냅샷을 반환하고 재고가 차감된다.") + @Test + void validateAndDeductStock_success() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long productId = productRepository.findAll(PageRequest.of(0, 20)) + .getContent().get(0).getId(); + + List commands = List.of( + new ProductCommand.StockDeduction(productId, 2, 150000)); + + // act + List snapshots = productService.validateAndDeductStock(commands); + + // assert + assertAll( + () -> assertThat(snapshots).hasSize(1), + () -> assertThat(snapshots.get(0).productId()).isEqualTo(productId), + () -> assertThat(snapshots.get(0).name()).isEqualTo("에어맥스"), + () -> assertThat(snapshots.get(0).price()).isEqualTo(150000), + () -> assertThat(snapshots.get(0).quantity()).isEqualTo(2), + () -> assertThat(snapshots.get(0).brandId()).isEqualTo(BRAND_ID), + () -> assertThat(productService.getById(productId).getStock()).isEqualTo(98)); + } + + @DisplayName("존재하지 않는 상품 ID가 포함되면, NOT_FOUND 예외가 발생한다.") + @Test + void validateAndDeductStock_whenProductNotFound() { + assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( + new ProductCommand.StockDeduction(999L, 1, 50000)))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()) + .isEqualTo(ProductErrorCode.NOT_FOUND)); + } + + @DisplayName("expectedPrice와 현재 가격이 불일치하면, PRICE_MISMATCH 예외가 발생한다.") + @Test + void validateAndDeductStock_whenPriceMismatch() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long productId = productRepository.findAll(PageRequest.of(0, 20)) + .getContent().get(0).getId(); + + // act & assert + assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( + new ProductCommand.StockDeduction(productId, 1, 200000)))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()) + .isEqualTo(ProductErrorCode.PRICE_MISMATCH)); + } + + @DisplayName("재고가 부족하면, 예외가 발생한다.") + @Test + void validateAndDeductStock_whenInsufficientStock() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 5); + Long productId = productRepository.findAll(PageRequest.of(0, 20)) + .getContent().get(0).getId(); + + // act & assert + assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( + new ProductCommand.StockDeduction(productId, 10, 150000)))) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java similarity index 77% rename from apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java index 1cf99940c..124a0ee27 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java @@ -1,8 +1,8 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import com.loopers.support.error.CoreException; @@ -26,7 +26,7 @@ class AuthenticationServiceTest { private UserRepository userRepository; @Mock - private com.loopers.infrastructure.PasswordEncoder passwordEncoder; + private PasswordEncoder passwordEncoder; @InjectMocks private AuthenticationService authenticationService; @@ -42,12 +42,9 @@ void setUp() { validPassword = "Test1234!@#"; encodedPassword = "$2a$10$encodedPasswordHash"; - testUser = new UserModel( - new LoginId(validLoginId), - Password.fromEncoded(encodedPassword), - new Name("홍길동"), - new BirthDate(LocalDate.of(1990, 1, 15)), - new Email("test@example.com") + testUser = UserModel.create( + validLoginId, encodedPassword, + "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" ); } @@ -59,7 +56,7 @@ class Authenticate { @DisplayName("올바른 로그인 ID와 비밀번호로 인증하면 사용자 정보를 반환한다") void authenticate_should_return_user_when_credentials_are_correct() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(testUser)); when(passwordEncoder.matches(validPassword, encodedPassword)).thenReturn(true); // act @@ -67,19 +64,19 @@ void authenticate_should_return_user_when_credentials_are_correct() { // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId().getValue()).isEqualTo(validLoginId); + assertThat(result.getLoginId()).isEqualTo(validLoginId); } @Test @DisplayName("존재하지 않는 로그인 ID로 인증하면 UNAUTHORIZED 예외를 던진다") void authenticate_should_throw_exception_when_user_not_found() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.empty()); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, validPassword)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED) .hasMessageContaining("로그인 ID 또는 비밀번호가 일치하지 않습니다."); } @@ -87,14 +84,14 @@ void authenticate_should_throw_exception_when_user_not_found() { @DisplayName("잘못된 비밀번호로 인증하면 UNAUTHORIZED 예외를 던진다") void authenticate_should_throw_exception_when_password_is_incorrect() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(testUser)); String wrongPassword = "Wrong1234!@#"; when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, wrongPassword)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED) .hasMessageContaining("로그인 ID 또는 비밀번호가 일치하지 않습니다."); } @@ -102,13 +99,13 @@ void authenticate_should_throw_exception_when_password_is_incorrect() { @DisplayName("비밀번호가 null이면 UNAUTHORIZED 예외를 던진다") void authenticate_should_throw_exception_when_password_is_null() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(testUser)); when(passwordEncoder.matches(null, encodedPassword)).thenReturn(false); // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, null)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED); + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED); } } } 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 000000000..b0deac092 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java @@ -0,0 +1,14 @@ +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/FakeUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java new file mode 100644 index 000000000..bccd9d44f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.user; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class FakeUserRepository implements UserRepository { + + private final Map store = new HashMap<>(); + + @Override + public UserModel save(UserModel userModel) { + store.put(userModel.getLoginId(), userModel); + return userModel; + } + + @Override + public Optional findByLoginId(String loginId) { + return Optional.ofNullable(store.get(loginId)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java similarity index 55% rename from apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 6082a9efa..63f19f4b5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -13,19 +13,19 @@ class UserModelTest { - private LoginId validLoginId; - private Password validPassword; - private Name validName; - private BirthDate validBirthDate; - private Email validEmail; + private String validLoginId; + private String validEncryptedPassword; + private String validName; + private LocalDate validBirthDate; + private String validEmail; @BeforeEach void setUp() { - validLoginId = new LoginId("testuser123"); - validPassword = new Password("Test1234!@#"); - validName = new Name("홍길동"); - validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - validEmail = new Email("test@example.com"); + validLoginId = "testuser123"; + validEncryptedPassword = "ENCODED_Test1234!@#"; + validName = "홍길동"; + validBirthDate = LocalDate.of(1990, 1, 15); + validEmail = "test@example.com"; } @DisplayName("유저 모델을 생성할 때, ") @@ -36,12 +36,12 @@ class Create { @Test void createUserModel_whenAllDataProvided() { // act - UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validEncryptedPassword, validName, validBirthDate, validEmail); // assert assertAll( () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(user.getPassword()).isEqualTo(validPassword), + () -> assertThat(user.getPassword()).isEqualTo(validEncryptedPassword), () -> assertThat(user.getName()).isEqualTo(validName), () -> assertThat(user.getBirthDate()).isEqualTo(validBirthDate), () -> assertThat(user.getEmail()).isEqualTo(validEmail) @@ -51,36 +51,47 @@ void createUserModel_whenAllDataProvided() { @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") @Test void createUserModel_whenLoginIdIsNull() { - assertThatThrownBy(() -> new UserModel(null, validPassword, validName, validBirthDate, validEmail)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호가 누락되면 예외가 발생한다.") - @Test - void createUserModel_whenPasswordIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, null, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(null, validEncryptedPassword, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이름이 누락되면 예외가 발생한다.") @Test void createUserModel_whenNameIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, null, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validEncryptedPassword, null, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("생년월일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenBirthDateIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, null, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validEncryptedPassword, validName, null, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이메일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenEmailIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, validBirthDate, null)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validEncryptedPassword, validName, validBirthDate, null)) .isInstanceOf(CoreException.class); } } + + @DisplayName("비밀번호를 변경할 때, ") + @Nested + class ChangePassword { + + @DisplayName("새 암호화된 비밀번호가 주어지면 비밀번호가 변경된다.") + @Test + void changePassword_success() { + // arrange + UserModel user = UserModel.create(validLoginId, validEncryptedPassword, validName, validBirthDate, validEmail); + + // act + user.changePassword("ENCODED_NewPass123!@"); + + // assert + assertThat(user.getPassword()).isEqualTo("ENCODED_NewPass123!@"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java new file mode 100644 index 000000000..cd03a5a91 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java @@ -0,0 +1,146 @@ +package com.loopers.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("STEP3: UserService Fake 기반 단위 테스트 (After)") +class UserServiceFakeTest { + + private FakeUserRepository userRepository; + private FakePasswordEncoder passwordEncoder; + private UserService userService; + + private String loginId; + private String rawPassword; + private String name; + private String birthDate; + private String email; + + @BeforeEach + void setUp() { + userRepository = new FakeUserRepository(); + passwordEncoder = new FakePasswordEncoder(); + userService = new UserService(userRepository, passwordEncoder); + + loginId = "testuser1"; + rawPassword = "Test1234!@#"; + name = "홍길동"; + birthDate = "19900115"; + email = "test@example.com"; + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @Test + @DisplayName("성공 — when-then 0줄, 암호화 결과를 직접 검증") + void signup_성공() { + // act + UserModel result = userService.signup(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(loginId); + + // 암호화 검증은 repository를 통해 직접 확인 + UserModel saved = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(saved.getPassword()).isEqualTo("ENCODED_Test1234!@#"); + } + + @Test + @DisplayName("중복 아이디면 예외") + void signup_중복아이디_예외() { + // arrange + userService.signup(loginId, rawPassword, name, birthDate, email); + + // act & assert + assertThatThrownBy(() -> + userService.signup(loginId, "Other123!@#", name, birthDate, email) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 아이디입니다."); + } + + @Test + @DisplayName("비밀번호 형식이 올바르지 않으면 예외가 발생한다.") + void signup_비밀번호_형식_오류() { + assertThatThrownBy(() -> + userService.signup(loginId, "short", name, birthDate, email) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); + } + + @Test + @DisplayName("비밀번호에 생년월일이 포함되면 예외가 발생한다.") + void signup_비밀번호에_생년월일_포함() { + assertThatThrownBy(() -> + userService.signup(loginId, "Pw19900115!", name, birthDate, email) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @Test + @DisplayName("성공 — when-then 0줄, 변경된 비밀번호를 직접 검증") + void changePassword_성공() { + // arrange + userService.signup(loginId, rawPassword, name, birthDate, email); + + // act + userService.changePassword(loginId, rawPassword, "NewPass123!@"); + + // assert + UserModel updated = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(updated.getPassword()).isEqualTo("ENCODED_NewPass123!@"); + } + + @Test + @DisplayName("현재 비밀번호 불일치면 예외") + void changePassword_현재비밀번호_불일치() { + // arrange + userService.signup(loginId, rawPassword, name, birthDate, email); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword(loginId, "Wrong123!@#", "NewPass123!@") + ).isInstanceOf(CoreException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); + } + + @Test + @DisplayName("새 비밀번호가 현재와 같으면 예외") + void changePassword_새비밀번호가_현재와_같으면_예외() { + // arrange + userService.signup(loginId, rawPassword, name, birthDate, email); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword(loginId, rawPassword, rawPassword) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); + } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다.") + void changePassword_새비밀번호에_생년월일_포함() { + // arrange + userService.signup(loginId, rawPassword, name, birthDate, email); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword(loginId, rawPassword, "Pw19900115!") + ).isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java similarity index 61% rename from apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 670673094..ba0c1f208 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -1,15 +1,13 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -import com.loopers.infrastructure.PasswordEncoder; -import com.loopers.infrastructure.UserJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; -import java.time.LocalDate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,6 +18,7 @@ @SpringBootTest public class UserServiceIntegrationTest { + @Autowired private UserService userService; @@ -32,21 +31,19 @@ public class UserServiceIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; - private LoginId validLoginId; - private Password validPassword; + private String loginId; private String rawPassword; - private Name validName; - private BirthDate validBirthDate; - private Email validEmail; + private String name; + private String birthDate; + private String email; @BeforeEach void setUp() { - validLoginId = new LoginId("testuser123"); + loginId = "testuser123"; rawPassword = "Test1234!@#"; - validPassword = new Password(rawPassword); - validName = new Name("홍길동"); - validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - validEmail = new Email("test@example.com"); + name = "홍길동"; + birthDate = "19900115"; + email = "test@example.com"; } @AfterEach @@ -54,6 +51,10 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private UserModel doSignup() { + return userService.signup(loginId, rawPassword, name, birthDate, email); + } + @DisplayName("유저가 회원가입할 때") @Nested class SingUp{ @@ -61,15 +62,15 @@ class SingUp{ @Test void signup_whenAllInfoProvided() { // act - UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + UserModel result = doSignup(); // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(result.getName()).isEqualTo(validName), - () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), - () -> assertThat(result.getEmail()).isEqualTo(validEmail) + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDateString()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) ); } @@ -77,14 +78,15 @@ void signup_whenAllInfoProvided() { @Test void signup_should_encrypt_password() { // act - UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + UserModel result = doSignup(); // assert - String savedPassword = result.getPassword().getValue(); + UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); + String savedPassword = savedUser.getPassword(); assertAll( - () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 평문과 다름 - () -> assertThat(savedPassword).startsWith("$2a$"), // BCrypt 포맷 - () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() // 평문과 매칭됨 + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), + () -> assertThat(savedPassword).startsWith("$2a$"), + () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() ); } @@ -92,15 +94,15 @@ void signup_should_encrypt_password() { @Test void signup_should_save_encrypted_password_to_database() { // act - UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + UserModel result = doSignup(); // assert UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); - String savedPassword = savedUser.getPassword().getValue(); + String savedPassword = savedUser.getPassword(); assertAll( - () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 평문과 다름 - () -> assertThat(savedPassword).startsWith("$2a$"), // BCrypt 포맷 - () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() // 평문과 매칭됨 + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), + () -> assertThat(savedPassword).startsWith("$2a$"), + () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() ); } } @@ -112,31 +114,28 @@ class GetMyInfo { @Test void getMyInfo_whenValidLoginId() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + doSignup(); // act - UserModel result = userService.getMyInfo(validLoginId); + UserModel result = userService.getByLoginId(loginId); // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(result.getName()).isEqualTo(validName), - () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), - () -> assertThat(result.getEmail()).isEqualTo(validEmail) + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDateString()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) ); } @DisplayName("존재하지 않는 로그인 ID로 조회하면 예외가 발생한다") @Test void getMyInfo_whenInvalidLoginId() { - // arrange - LoginId invalidLoginId = new LoginId("invalid123"); - // act & assert - assertThatThrownBy(() -> userService.getMyInfo(invalidLoginId)) + assertThatThrownBy(() -> userService.getByLoginId("invalid123")) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); } } @@ -148,19 +147,20 @@ class ChangePassword { @Test void changePassword_whenValidPasswords() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password newPassword = new Password("NewPass123!@"); + doSignup(); + String newRawPassword = "NewPass123!@"; // act - userService.changePassword(validLoginId, validPassword, newPassword); + userService.changePassword(loginId, rawPassword, newRawPassword); // assert - UserModel updatedUser = userService.getMyInfo(validLoginId); - String savedPassword = updatedUser.getPassword().getValue(); + UserModel updatedUser = userService.getByLoginId(loginId); + UserModel savedUser = userJpaRepository.findById(updatedUser.getId()).orElseThrow(); + String savedPassword = savedUser.getPassword(); assertAll( - () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 이전 평문과 다름 - () -> assertThat(savedPassword).isNotEqualTo(newPassword.getValue()), // 새 평문과도 다름 (암호화됨) - () -> assertThat(passwordEncoder.matches(newPassword.getValue(), savedPassword)).isTrue() // 새 비밀번호와 매칭됨 + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), + () -> assertThat(savedPassword).isNotEqualTo(newRawPassword), + () -> assertThat(passwordEncoder.matches(newRawPassword, savedPassword)).isTrue() ); } @@ -168,12 +168,10 @@ void changePassword_whenValidPasswords() { @Test void changePassword_whenCurrentPasswordNotMatch() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password wrongPassword = new Password("Wrong123!@#"); - Password newPassword = new Password("NewPass123!@"); + doSignup(); // act & assert - assertThatThrownBy(() -> userService.changePassword(validLoginId, wrongPassword, newPassword)) + assertThatThrownBy(() -> userService.changePassword(loginId, "Wrong123!@#", "NewPass123!@")) .isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); } @@ -182,11 +180,10 @@ void changePassword_whenCurrentPasswordNotMatch() { @Test void changePassword_whenNewPasswordSameAsCurrent() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password samePassword = new Password("Test1234!@#"); + doSignup(); // act & assert - assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, samePassword)) + assertThatThrownBy(() -> userService.changePassword(loginId, rawPassword, rawPassword)) .isInstanceOf(CoreException.class) .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); } @@ -195,11 +192,10 @@ void changePassword_whenNewPasswordSameAsCurrent() { @Test void changePassword_whenNewPasswordContainsBirthDate() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password newPasswordWithBirthDate = new Password("Pw19900115!"); + doSignup(); // act & assert - assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, newPasswordWithBirthDate)) + assertThatThrownBy(() -> userService.changePassword(loginId, rawPassword, "Pw19900115!")) .isInstanceOf(CoreException.class) .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); } @@ -207,14 +203,10 @@ void changePassword_whenNewPasswordContainsBirthDate() { @DisplayName("존재하지 않는 사용자의 비밀번호 변경 시 예외가 발생한다") @Test void changePassword_whenUserNotFound() { - // arrange - LoginId invalidLoginId = new LoginId("invalid123"); - Password newPassword = new Password("NewPass123!@"); - // act & assert - assertThatThrownBy(() -> userService.changePassword(invalidLoginId, validPassword, newPassword)) + assertThatThrownBy(() -> userService.changePassword("invalid123", rawPassword, "NewPass123!@")) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java new file mode 100644 index 000000000..c49d51a0c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java @@ -0,0 +1,137 @@ +package com.loopers.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("STEP1: UserService Mock 기반 단위 테스트 (Before)") +@ExtendWith(MockitoExtension.class) +class UserServiceMockTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + private String loginId; + private String rawPassword; + private String name; + private String birthDate; + private String email; + + @BeforeEach + void setUp() { + loginId = "testuser1"; + rawPassword = "Test1234!@#"; + name = "홍길동"; + birthDate = "19900115"; + email = "test@example.com"; + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @Test + @DisplayName("성공") + void signup_성공() { + // arrange + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); + when(passwordEncoder.encode("Test1234!@#")).thenReturn("$2a$10$encodedHash"); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + // act + UserModel result = userService.signup(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(loginId); + } + + @Test + @DisplayName("중복 아이디면 예외") + void signup_중복아이디_예외() { + // arrange + UserModel existingUser = createTestUser("$2a$10$existingHash"); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(existingUser)); + + // act & assert + assertThatThrownBy(() -> + userService.signup(loginId, rawPassword, name, birthDate, email) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 아이디입니다."); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @Test + @DisplayName("성공") + void changePassword_성공() { + // arrange + UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); + + when(userRepository.findByLoginId(anyString())) + .thenReturn(Optional.of(existingUser)); + when(passwordEncoder.matches("Test1234!@#", "$2a$10$encodedOldHash")) + .thenReturn(true); + when(passwordEncoder.matches("NewPass123!@", "$2a$10$encodedOldHash")) + .thenReturn(false); + when(passwordEncoder.encode("NewPass123!@")) + .thenReturn("$2a$10$encodedNewHash"); + when(userRepository.save(any())) + .thenAnswer(inv -> inv.getArgument(0)); + + // act + userService.changePassword(loginId, "Test1234!@#", "NewPass123!@"); + + // assert + verify(userRepository).save(any()); + } + + @Test + @DisplayName("현재 비밀번호 불일치면 예외") + void changePassword_현재비밀번호_불일치() { + // arrange + UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); + + when(userRepository.findByLoginId(anyString())) + .thenReturn(Optional.of(existingUser)); + when(passwordEncoder.matches("Wrong123!@#", "$2a$10$encodedOldHash")) + .thenReturn(false); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword(loginId, "Wrong123!@#", "NewPass123!@") + ).isInstanceOf(CoreException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); + } + } + + // --- 헬퍼 --- + + private UserModel createTestUser(String encodedPassword) { + return UserModel.create(loginId, encodedPassword, name, LocalDate.of(1990, 1, 15), email); + } +} 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/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -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.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java new file mode 100644 index 000000000..461afcca0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java @@ -0,0 +1,124 @@ +package com.loopers.interfaces.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.user.AuthenticationService; +import com.loopers.domain.user.UserModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@DisplayName("AuthFilter 단위 테스트") +@ExtendWith(MockitoExtension.class) +class AuthFilterTest { + + @Mock + private AuthenticationService authenticationService; + + @Mock + private FilterChain filterChain; + + private AuthFilter authFilter; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + authFilter = new AuthFilter(authenticationService, objectMapper); + } + + @DisplayName("인증 필요 URL에 유효한 헤더가 있으면, LoginUser attribute를 설정하고 filterChain을 진행한다") + @Test + void setsLoginUserAttribute_whenValidHeadersOnAuthRequiredUrl() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); + request.addHeader("X-Loopers-LoginId", "testuser1"); + request.addHeader("X-Loopers-LoginPw", "Test1234!"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + UserModel userModel = UserModel.create( + "testuser1", "encoded", "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" + ); + when(authenticationService.authenticate("testuser1", "Test1234!")).thenReturn(userModel); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + LoginUser loginUser = (LoginUser) request.getAttribute("loginUser"); + assertThat(loginUser).isNotNull(); + assertThat(loginUser.loginId()).isEqualTo("testuser1"); + assertThat(loginUser.name()).isEqualTo("홍길동"); + verify(filterChain).doFilter(request, response); + } + + @DisplayName("인증 필요 URL에 헤더가 누락되면, 401 JSON 응답을 직접 반환한다") + @Test + void returns401_whenHeadersMissingOnAuthRequiredUrl() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("FAIL"); + verify(filterChain, never()).doFilter(request, response); + } + + @DisplayName("인증 필요 URL에 인증 실패하면, 401 JSON 응답을 직접 반환한다") + @Test + void returns401_whenAuthenticationFailsOnAuthRequiredUrl() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); + request.addHeader("X-Loopers-LoginId", "testuser1"); + request.addHeader("X-Loopers-LoginPw", "Wrong1234!"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(authenticationService.authenticate("testuser1", "Wrong1234!")) + .thenThrow(new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("로그인 ID 또는 비밀번호가 일치하지 않습니다."); + verify(filterChain, never()).doFilter(request, response); + } + + @DisplayName("인증 불필요 URL이면, 헤더 없이도 filterChain을 진행한다") + @Test + void proceedsFilterChain_whenUrlDoesNotRequireAuth() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/users/signup"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + verify(filterChain).doFilter(request, response); + verify(authenticationService, never()).authenticate(anyString(), anyString()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java new file mode 100644 index 000000000..1a66914a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java @@ -0,0 +1,109 @@ +package com.loopers.interfaces.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.ServletWebRequest; + +@DisplayName("LoginUserArgumentResolver 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LoginUserArgumentResolverTest { + + private LoginUserArgumentResolver resolver; + + @BeforeEach + void setUp() { + resolver = new LoginUserArgumentResolver(); + } + + @DisplayName("supportsParameter 메서드는") + @Nested + class SupportsParameter { + + @DisplayName("@Login 어노테이션과 LoginUser 타입이면 true를 반환한다") + @Test + void returnsTrue_whenLoginAnnotationAndLoginUserType() throws Exception { + // arrange + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("testMethod", LoginUser.class), 0 + ); + + // act & assert + assertThat(resolver.supportsParameter(parameter)).isTrue(); + } + + @DisplayName("@Login 어노테이션이 없으면 false를 반환한다") + @Test + void returnsFalse_whenNoLoginAnnotation() throws Exception { + // arrange + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("noAnnotationMethod", LoginUser.class), 0 + ); + + // act & assert + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + } + + @DisplayName("resolveArgument 메서드는") + @Nested + class ResolveArgument { + + @DisplayName("request attribute에 LoginUser가 있으면 반환한다") + @Test + void returnsLoginUser_whenAttributeExists() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest(); + LoginUser expectedLoginUser = new LoginUser(1L, "testuser1", "홍길동"); + request.setAttribute("loginUser", expectedLoginUser); + + NativeWebRequest webRequest = new ServletWebRequest(request); + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("testMethod", LoginUser.class), 0 + ); + + // act + Object result = resolver.resolveArgument(parameter, null, webRequest, null); + + // assert + assertThat(result).isInstanceOf(LoginUser.class); + LoginUser loginUser = (LoginUser) result; + assertThat(loginUser.id()).isEqualTo(1L); + assertThat(loginUser.loginId()).isEqualTo("testuser1"); + assertThat(loginUser.name()).isEqualTo("홍길동"); + } + + @DisplayName("request attribute에 LoginUser가 없으면 CoreException(UNAUTHORIZED)을 던진다") + @Test + void throwsUnauthorized_whenAttributeNotExists() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest(); + NativeWebRequest webRequest = new ServletWebRequest(request); + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("testMethod", LoginUser.class), 0 + ); + + // act & assert + assertThatThrownBy(() -> resolver.resolveArgument(parameter, null, webRequest, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED); + } + } + + // 테스트용 컨트롤러 + static class TestController { + public void testMethod(@Login LoginUser loginUser) {} + public void noAnnotationMethod(LoginUser loginUser) {} + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java new file mode 100644 index 000000000..363836e1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java @@ -0,0 +1,416 @@ +package com.loopers.interfaces.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminBrandV1ApiE2ETest { + + private static final String ENDPOINT_BRANDS = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminBrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + @DisplayName("LDAP 인증") + @Nested + class Authentication { + + @DisplayName("LDAP 헤더 없이 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenNoLdapHeader() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("나이키"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("잘못된 LDAP 헤더 값으로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenInvalidLdapHeader() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("나이키"); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong.value"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("GET 요청도 LDAP 헤더 없이는 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenGetWithoutLdapHeader() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.GET, null, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Register { + + @DisplayName("유효한 브랜드명을 주면, 브랜드 등록에 성공한다.") + @Test + void returnsSuccess_whenValidBrandNameIsProvided() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("나이키"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("브랜드명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest(""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("브랜드명이 99자를 초과하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandNameIsTooLong() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("a".repeat(100)); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("이미 존재하는 브랜드명이면, 409 CONFLICT 응답을 받는다.") + @Test + void throwsConflict_whenBrandNameAlreadyExists() { + // arrange + AdminBrandV1Dto.RegisterRequest firstRequest = new AdminBrandV1Dto.RegisterRequest("나이키"); + AdminBrandV1Dto.RegisterRequest secondRequest = new AdminBrandV1Dto.RegisterRequest("나이키"); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(firstRequest, adminHeaders()), responseType); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(secondRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(brandJpaRepository.count()).isEqualTo(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class List { + + @DisplayName("브랜드 목록을 조회하면, 페이지네이션된 목록을 반환한다.") + @Test + void returnsPaginatedList_whenBrandsExist() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("아디다스"), adminHeaders()), registerResponseType); + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("푸마"), adminHeaders()), registerResponseType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("브랜드가 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoBrandsExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "?page=0&size=20", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드를 조회하면, 상세 정보를 반환한다.") + @Test + void returnsBrandDetail_whenBrandExists() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull(), + () -> assertThat(response.getBody().data().deletedAt()).isNull() + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class Update { + + @DisplayName("유효한 수정 요청이면, 브랜드가 수정된다.") + @Test + void returnsSuccess_whenValidUpdateRequest() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스")).isPresent() + ); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @DisplayName("이미 존재하는 브랜드명으로 수정하면, 409 CONFLICT 응답을 받는다.") + @Test + void throwsConflict_whenBrandNameAlreadyExists() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("아디다스"), adminHeaders()), registerResponseType); + + Long nikeId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + nikeId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT) + ); + } + + @DisplayName("브랜드명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest(""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면, 성공한다.") + @Test + void returnsSuccess_whenBrandExists() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.findByNameAndDeletedAtIsNull("나이키")).isEmpty() + ); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..eb7410c5f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java @@ -0,0 +1,89 @@ +package com.loopers.interfaces.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT_BRANDS = "/api/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드를 조회하면, 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + brandJpaRepository.save(BrandModel.create("나이키")); + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java new file mode 100644 index 000000000..bbbdab78f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java @@ -0,0 +1,133 @@ +package com.loopers.interfaces.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Brand V1 API 시나리오 테스트") +class BrandV1ApiScenarioTest { + + private static final String ENDPOINT_BRANDS = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT_BRANDS = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiScenarioTest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + @DisplayName("브랜드 전체 플로우: 등록(Admin) -> 조회(Public) -> 수정(Admin) -> 조회(Public) -> 삭제(Admin) -> 조회(Public, 404)") + @Test + void fullBrandLifecycleScenario() { + // ===== 1단계: 브랜드 등록 (Admin API) ===== + AdminBrandV1Dto.RegisterRequest registerRequest = new AdminBrandV1Dto.RegisterRequest("나이키"); + + ParameterizedTypeReference> objectResponseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> registerResponse = + testRestTemplate.exchange(ADMIN_ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(registerRequest, adminHeaders()), objectResponseType); + + assertAll( + "브랜드 등록 성공 검증", + () -> assertTrue(registerResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // ===== 2단계: 브랜드 조회 (Public API) ===== + ParameterizedTypeReference> detailResponseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> getResponse = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, detailResponseType); + + assertAll( + "브랜드 조회 성공 검증", + () -> assertTrue(getResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(getResponse.getBody()).isNotNull(), + () -> assertThat(getResponse.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(getResponse.getBody().data().name()).isEqualTo("나이키") + ); + + // ===== 3단계: 브랜드 수정 (Admin API) ===== + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + ResponseEntity> updateResponse = + testRestTemplate.exchange(ADMIN_ENDPOINT_BRANDS + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), objectResponseType); + + assertAll( + "브랜드 수정 성공 검증", + () -> assertTrue(updateResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // ===== 4단계: 수정 후 조회 (Public API) ===== + ResponseEntity> getAfterUpdateResponse = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, detailResponseType); + + assertAll( + "수정 후 조회 검증", + () -> assertTrue(getAfterUpdateResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(getAfterUpdateResponse.getBody()).isNotNull(), + () -> assertThat(getAfterUpdateResponse.getBody().data().name()).isEqualTo("아디다스") + ); + + // ===== 5단계: 브랜드 삭제 (Admin API) ===== + ResponseEntity> deleteResponse = + testRestTemplate.exchange(ADMIN_ENDPOINT_BRANDS + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), objectResponseType); + + assertAll( + "브랜드 삭제 성공 검증", + () -> assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // ===== 6단계: 삭제 후 조회 (Public API, 404) ===== + ResponseEntity> getAfterDeleteResponse = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, objectResponseType); + + assertAll( + "삭제 후 조회 실패 검증", + () -> assertTrue(getAfterDeleteResponse.getStatusCode().is4xxClientError()), + () -> assertThat(getAfterDeleteResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..0c941c88f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java @@ -0,0 +1,366 @@ +package com.loopers.interfaces.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.ProductLikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import com.loopers.interfaces.user.dto.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductLikeJpaRepository productLikeJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private Long productId; + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductLikeJpaRepository productLikeJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productLikeJpaRepository = productLikeJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + // 사용자 등록 + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "홍길동", + "19900101", + "test@example.com" + ); + ParameterizedTypeReference> signupType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupType); + + // 브랜드 및 상품 등록 + BrandModel brand = brandJpaRepository.save(BrandModel.create("나이키")); + ProductModel product = productJpaRepository.save(ProductModel.create(brand.getId(), "에어맥스", 150000, 100)); + productId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + return headers; + } + + private String likeEndpoint(Long productId) { + return "/api/v1/products/" + productId + "/likes"; + } + + private void likeProduct(Long productId) { + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class Like { + + @DisplayName("유효한 인증 헤더로 좋아요를 등록하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidRequest() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면, 409 CONFLICT 응답을 받는다.") + @Test + void throwsConflict_whenAlreadyLiked() { + // arrange + likeProduct(productId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(999L), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("인증 헤더가 없으면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class Unlike { + + @DisplayName("좋아요한 상품의 좋아요를 취소하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenLikeExists() { + // arrange + likeProduct(productId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("좋아요 기록이 없는 상품의 좋아요를 취소하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenLikeDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @DisplayName("인증 헤더가 없으면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + } + + @DisplayName("GET /api/v1/users/me/likes") + @Nested + class GetMyLikes { + + private static final String ENDPOINT_MY_LIKES = "/api/v1/users/me/likes"; + + @DisplayName("좋아요한 상품이 있으면, 좋아요 목록을 반환한다.") + @Test + void returnsLikeList_whenLikesExist() { + // arrange + likeProduct(productId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productId()).isEqualTo(productId) + ); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikesExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + + @DisplayName("좋아요 후 취소하면, 목록에서 사라진다.") + @Test + void returnsEmptyList_afterUnlike() { + // arrange + likeProduct(productId); + + ParameterizedTypeReference> unlikeType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, authHeaders()), unlikeType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + + @DisplayName("인증 헤더가 없으면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java new file mode 100644 index 000000000..c9ef3b45b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java @@ -0,0 +1,136 @@ +package com.loopers.interfaces.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.dto.LikeResult; +import com.loopers.domain.product.ProductErrorCode; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("LikeV1Controller 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeV1ControllerTest { + + @Mock + private LikeFacade likeFacade; + + @InjectMocks + private LikeV1Controller likeV1Controller; + + private final LoginUser loginUser = new LoginUser(1L, "testuser", "테스터"); + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class Like { + + @DisplayName("좋아요 등록 요청이면, likeFacade.like를 호출하고 성공 응답을 반환한다.") + @Test + void like_callsFacadeLike() { + // arrange + Long productId = 10L; + doNothing().when(likeFacade).like(1L, 10L); + + // act + ApiResponse response = likeV1Controller.like(loginUser, productId); + + // assert + verify(likeFacade).like(1L, 10L); + assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + } + + @DisplayName("존재하지 않는 상품이면, 예외가 전파된다.") + @Test + void like_whenProductNotFound_throwsException() { + // arrange + Long productId = 999L; + doThrow(new CoreException(ProductErrorCode.NOT_FOUND)) + .when(likeFacade).like(1L, 999L); + + // act & assert + assertThatThrownBy( + () -> likeV1Controller.like(loginUser, productId) + ).isInstanceOf(CoreException.class); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class Unlike { + + @DisplayName("좋아요 취소 요청이면, likeFacade.unlike를 호출하고 성공 응답을 반환한다.") + @Test + void unlike_callsFacadeUnlike() { + // arrange + Long productId = 10L; + doNothing().when(likeFacade).unlike(1L, 10L); + + // act + ApiResponse response = likeV1Controller.unlike(loginUser, productId); + + // assert + verify(likeFacade).unlike(1L, 10L); + assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + } + } + + @DisplayName("GET /api/v1/users/me/likes") + @Nested + class GetMyLikes { + + @DisplayName("좋아요 목록을 조회하면, LikeV1Dto.ListResponse를 반환한다.") + @Test + void getMyLikes_returnsListResponse() { + // arrange + List results = List.of( + new LikeResult(1L, 1L, 10L, ZonedDateTime.now()), + new LikeResult(2L, 1L, 20L, ZonedDateTime.now()) + ); + when(likeFacade.getMyLikedProducts(1L)).thenReturn(results); + + // act + ApiResponse response = likeV1Controller.getMyLikes(loginUser); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().items()).hasSize(2), + () -> assertThat(response.data().items().get(0).productId()).isEqualTo(10L), + () -> assertThat(response.data().items().get(1).productId()).isEqualTo(20L) + ); + } + + @DisplayName("좋아요가 없으면, 빈 목록을 반환한다.") + @Test + void getMyLikes_returnsEmptyList_whenNoLikes() { + // arrange + when(likeFacade.getMyLikedProducts(1L)).thenReturn(List.of()); + + // act + ApiResponse response = likeV1Controller.getMyLikes(loginUser); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().items()).isEmpty() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java new file mode 100644 index 000000000..61a376b04 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java @@ -0,0 +1,190 @@ +package com.loopers.interfaces.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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; + +@DisplayName("Admin Order V1 API E2E 테스트") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminOrderV1ApiE2ETest { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final OrderJpaRepository orderJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private Long productId; + private Long orderId; + private Long orderItemId; + + @Autowired + public AdminOrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + OrderJpaRepository orderJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.orderJpaRepository = orderJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + // 사용자 등록 + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", "Test1234!", "홍길동", "19900101", "test@example.com"); + ResponseEntity> signupResponse = + testRestTemplate.exchange( + "/api/v1/users/signup", HttpMethod.POST, + new HttpEntity<>(signupRequest), + new ParameterizedTypeReference<>() {}); + Long userId = signupResponse.getBody().data().id(); + + // 브랜드 + 상품 + BrandModel brand = brandJpaRepository.save(BrandModel.create("ACNE STUDIOS")); + ProductModel product = productJpaRepository.save( + ProductModel.create(brand.getId(), "오버사이즈 코트", 50000, 100)); + productId = product.getId(); + + // 주문 직접 생성 (재고 차감 시뮬레이션) + product.decreaseStock(2); + productJpaRepository.save(product); + + OrderModel order = orderJpaRepository.save( + OrderModel.create(userId, List.of( + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", ("ACNE STUDIOS"))))); + orderId = order.getId(); + orderItemId = order.getItems().get(0).getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LDAP, "loopers.admin"); + return headers; + } + + private String cancelEndpoint(Long orderId, Long orderItemId) { + return "/api-admin/v1/orders/" + orderId + "/items/" + orderItemId + "/cancel"; + } + + @DisplayName("PATCH /api-admin/v1/orders/{orderId}/items/{orderItemId}/cancel") + @Nested + class CancelItem { + + @DisplayName("관리자가 주문 아이템을 취소하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidRequest() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK)); + } + + @DisplayName("취소 후 재고가 복구된다.") + @Test + void restoresStock_afterCancel() { + // act + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {}); + + // assert + ProductModel product = productJpaRepository.findById(productId).orElseThrow(); + assertThat(product.getStock()).isEqualTo(100); + } + + @DisplayName("타 사용자의 주문도 취소할 수 있다.") + @Test + void canCancelOtherUsersOrder() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("존재하지 않는 주문 ID로 취소하면, 404 응답을 반환한다.") + @Test + void throwsNotFound_whenOrderNotFound() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(999L, 999L), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("이미 취소된 아이템을 다시 취소하면, 400 응답을 반환한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + // arrange + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {}); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java new file mode 100644 index 000000000..f03665a4c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java @@ -0,0 +1,89 @@ +package com.loopers.interfaces.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@DisplayName("AdminOrderV1Controller 단위 테스트") +@ExtendWith(MockitoExtension.class) +class AdminOrderV1ControllerTest { + + @Mock + private OrderFacade orderFacade; + + @InjectMocks + private AdminOrderV1Controller adminOrderV1Controller; + + @DisplayName("GET /api-admin/v1/orders") + @Nested + class ListOrders { + + @DisplayName("전체 주문 목록을 200 응답으로 반환한다") + @Test + void list_returnsPageResponse() { + // arrange + OrderResult.OrderSummary summary = new OrderResult.OrderSummary( + 1L, 50000, "ORDERED", ZonedDateTime.now() + ); + Page page = new PageImpl<>( + List.of(summary), PageRequest.of(0, 20), 1 + ); + when(orderFacade.getAllOrders(any())).thenReturn(page); + + // act + ApiResponse response = adminOrderV1Controller.list(0, 20); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().totalElements()).isEqualTo(1), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + + @DisplayName("주문 상세를 200 응답으로 반환한다") + @Test + void getById_returnsOrderDetail() { + // arrange + OrderResult.OrderDetail result = new OrderResult.OrderDetail( + 1L, 2L, 50000, "ORDERED", ZonedDateTime.now(), + List.of(new OrderResult.OrderItemDetail(1L, 10L, "상품A", "브랜드A", 25000, 2)) + ); + when(orderFacade.getOrderDetail(1L)).thenReturn(result); + + // act + ApiResponse response = adminOrderV1Controller.getById(1L); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().orderId()).isEqualTo(1L), + () -> assertThat(response.data().userId()).isEqualTo(2L), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..f1cd0e510 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java @@ -0,0 +1,239 @@ +package com.loopers.interfaces.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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; + +@DisplayName("Order V1 API E2E 테스트") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final OrderJpaRepository orderJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private Long userId; + private Long productId; + private Long orderId; + private Long orderItemId; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + OrderJpaRepository orderJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.orderJpaRepository = orderJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + // 사용자 등록 + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", "Test1234!", "홍길동", "19900101", "test@example.com"); + ResponseEntity> signupResponse = + testRestTemplate.exchange( + "/api/v1/users/signup", HttpMethod.POST, + new HttpEntity<>(signupRequest), + new ParameterizedTypeReference<>() {}); + userId = signupResponse.getBody().data().id(); + + // 브랜드 + 상품 + BrandModel brand = brandJpaRepository.save(BrandModel.create("ACNE STUDIOS")); + ProductModel product = productJpaRepository.save( + ProductModel.create(brand.getId(), "오버사이즈 코트", 50000, 100)); + productId = product.getId(); + + // 주문 직접 생성 (재고 차감 시뮬레이션) + product.decreaseStock(2); + productJpaRepository.save(product); + + OrderModel order = orderJpaRepository.save( + OrderModel.create(userId, List.of( + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", ("ACNE STUDIOS"))))); + orderId = order.getId(); + orderItemId = order.getItems().get(0).getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + return headers; + } + + private String cancelEndpoint(Long orderId, Long orderItemId) { + return "/api/v1/orders/" + orderId + "/items/" + orderItemId + "/cancel"; + } + + @DisplayName("PATCH /api/v1/orders/{orderId}/items/{orderItemId}/cancel") + @Nested + class CancelItem { + + @DisplayName("본인 주문의 아이템을 취소하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidRequest() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK)); + } + + @DisplayName("취소 후 재고가 복구된다.") + @Test + void restoresStock_afterCancel() { + // act + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + // assert + ProductModel product = productJpaRepository.findById(productId).orElseThrow(); + assertThat(product.getStock()).isEqualTo(100); + } + + @DisplayName("취소 후 주문 상세에서 totalPrice가 재계산된다.") + @Test + void recalculatesTotalPrice_afterCancel() { + // act + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + // assert + ResponseEntity> detailResponse = + testRestTemplate.exchange( + "/api/v1/orders/" + orderId, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + assertThat(detailResponse.getBody().data().totalPrice()).isEqualTo(0); + } + + @DisplayName("이미 취소된 아이템을 다시 취소하면, 400 응답을 반환한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + // arrange + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 주문 ID로 취소하면, 404 응답을 반환한다.") + @Test + void throwsNotFound_whenOrderNotFound() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(999L, 999L), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("타인의 주문을 취소하면, 403 응답을 반환한다.") + @Test + void throwsForbidden_whenNotOwner() { + // arrange + UserV1Dto.SignupRequest otherUser = new UserV1Dto.SignupRequest( + "otheruser1", "Test1234!", "김철수", "19950101", "other@example.com"); + testRestTemplate.exchange( + "/api/v1/users/signup", HttpMethod.POST, + new HttpEntity<>(otherUser), + new ParameterizedTypeReference>() {}); + + HttpHeaders otherHeaders = new HttpHeaders(); + otherHeaders.set(HEADER_LOGIN_ID, "otheruser1"); + otherHeaders.set(HEADER_LOGIN_PW, "Test1234!"); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, otherHeaders), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @DisplayName("인증 헤더가 없으면, 401 응답을 반환한다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(1L, 1L), HttpMethod.PATCH, + null, + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java new file mode 100644 index 000000000..3cf7bfabf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.order.dto.OrderRequest; +import com.loopers.interfaces.order.dto.OrderResponse; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("OrderV1Controller 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OrderV1ControllerTest { + + @Mock + private OrderFacade orderFacade; + + @InjectMocks + private OrderV1Controller orderV1Controller; + + private final LoginUser loginUser = new LoginUser(1L, "testuser", "테스터"); + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + + @DisplayName("주문 생성 요청이면, 201 응답을 반환한다") + @Test + void create_returnsCreatedResponse() { + // arrange + OrderRequest.Create request = new OrderRequest.Create(List.of( + new OrderRequest.OrderItemRequest(10L, 2, 25000) + )); + + OrderResult.OrderSummary result = new OrderResult.OrderSummary( + 1L, 50000, "ORDERED", ZonedDateTime.now() + ); + when(orderFacade.createOrder(eq(1L), any(OrderCriteria.Create.class))).thenReturn(result); + + // act + ApiResponse response = orderV1Controller.create(loginUser, request); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().orderId()).isEqualTo(1L), + () -> assertThat(response.data().totalPrice()).isEqualTo(50000) + ); + } + } + + @DisplayName("GET /api/v1/orders") + @Nested + class ListOrders { + + @DisplayName("기간 내 주문 목록을 200 응답으로 반환한다") + @Test + void list_returnsOrderList() { + // arrange + ZonedDateTime startAt = ZonedDateTime.now().minusDays(7); + ZonedDateTime endAt = ZonedDateTime.now(); + + List results = List.of( + new OrderResult.OrderSummary(1L, 50000, "ORDERED", ZonedDateTime.now()) + ); + when(orderFacade.getMyOrders(eq(1L), any(OrderCriteria.ListByDate.class))).thenReturn(results); + + // act + ApiResponse response = orderV1Controller.list(loginUser, startAt, endAt); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + + @DisplayName("주문 상세를 200 응답으로 반환한다") + @Test + void getById_returnsOrderDetail() { + // arrange + OrderResult.OrderDetail result = new OrderResult.OrderDetail( + 1L, 1L, 50000, "ORDERED", ZonedDateTime.now(), + List.of(new OrderResult.OrderItemDetail(1L, 10L, "상품A", "브랜드A", 25000, 2)) + ); + when(orderFacade.getMyOrderDetail(1L, 1L)).thenReturn(result); + + // act + ApiResponse response = orderV1Controller.getById(loginUser, 1L); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().orderId()).isEqualTo(1L), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java new file mode 100644 index 000000000..5adcc251d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java @@ -0,0 +1,452 @@ +package com.loopers.interfaces.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.AdminProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminProductV1ApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api-admin/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private Long brandId; + + @Autowired + public AdminProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + BrandModel brand = brandJpaRepository.save(BrandModel.create("나이키")); + brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + private void registerProduct(String name, int price, int stock) { + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, name, price, stock); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + } + + @DisplayName("LDAP 인증") + @Nested + class Authentication { + + @DisplayName("LDAP 헤더 없이 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenNoLdapHeader() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("잘못된 LDAP 헤더 값으로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenInvalidLdapHeader() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", 150000, 100); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong.value"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Register { + + @DisplayName("유효한 상품 정보를 주면, 상품 등록에 성공한다.") + @Test + void returnsSuccess_whenValidProductInfoIsProvided() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("상품명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("가격이 음수이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenPriceIsNegative() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", -1, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("브랜드 ID가 null이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(null, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 브랜드 ID면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(999L, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class List { + + @DisplayName("상품 목록을 조회하면, 페이지네이션된 목록을 반환한다.") + @Test + void returnsPaginatedList_whenProductsExist() { + // arrange + registerProduct("에어맥스", 150000, 100); + registerProduct("에어포스", 120000, 50); + registerProduct("조던1", 200000, 30); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("브랜드 ID로 필터링하면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredList_whenBrandIdIsProvided() { + // arrange + BrandModel adidas = brandJpaRepository.save(BrandModel.create("아디다스")); + Long adidasId = brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스").get().getId(); + + registerProduct("에어맥스", 150000, 100); + + AdminProductV1Dto.RegisterRequest adidasProduct = new AdminProductV1Dto.RegisterRequest(adidasId, "울트라부스트", 180000, 80); + ParameterizedTypeReference> registerType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(adidasProduct, adminHeaders()), registerType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?brandId=" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoProductsExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=20", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품을 조회하면, 상세 정보를 반환한다.") + @Test + void returnsProductDetail_whenProductExists() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().price()).isEqualTo(150000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull(), + () -> assertThat(response.getBody().data().deletedAt()).isNull() + ); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class Update { + + @DisplayName("유효한 수정 요청이면, 상품이 수정된다.") + @Test + void returnsSuccess_whenValidUpdateRequest() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + AdminProductV1Dto.UpdateRequest updateRequest = new AdminProductV1Dto.UpdateRequest("에어포스", 120000, 50); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // verify updated + ParameterizedTypeReference> detailType = new ParameterizedTypeReference<>() {}; + ResponseEntity> detailResponse = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), detailType); + + assertAll( + () -> assertThat(detailResponse.getBody().data().name()).isEqualTo("에어포스"), + () -> assertThat(detailResponse.getBody().data().price()).isEqualTo(120000), + () -> assertThat(detailResponse.getBody().data().stock()).isEqualTo(50) + ); + } + + @DisplayName("존재하지 않는 상품을 수정하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + AdminProductV1Dto.UpdateRequest updateRequest = new AdminProductV1Dto.UpdateRequest("에어포스", 120000, 50); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @DisplayName("상품명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + AdminProductV1Dto.UpdateRequest updateRequest = new AdminProductV1Dto.UpdateRequest("", 120000, 50); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class Delete { + + @DisplayName("존재하는 상품을 삭제하면, 성공한다.") + @Test + void returnsSuccess_whenProductExists() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productJpaRepository.findByIdAndDeletedAtIsNull(productId)).isEmpty() + ); + } + + @DisplayName("존재하지 않는 상품을 삭제하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..57f54096e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java @@ -0,0 +1,266 @@ +package com.loopers.interfaces.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.like.ProductLikeModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.ProductLikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductLikeJpaRepository productLikeJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private BrandModel savedBrand; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductLikeJpaRepository productLikeJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productLikeJpaRepository = productLikeJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + brandJpaRepository.save(BrandModel.create("나이키")); + savedBrand = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ProductModel saveProduct(String name, int price, int stock) { + ProductModel product = ProductModel.create(savedBrand.getId(), name, price, stock); + return productJpaRepository.save(product); + } + + @DisplayName("GET /api/v1/products") + @Nested + class List { + + @DisplayName("상품 목록을 조회하면, 페이지네이션된 목록을 반환한다.") + @Test + void returnsPaginatedList_whenProductsExist() { + // arrange + saveProduct("에어맥스", 150000, 100); + saveProduct("에어포스", 120000, 50); + saveProduct("조던1", 200000, 30); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=2", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("브랜드 ID로 필터링하면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredList_whenBrandIdIsProvided() { + // arrange + brandJpaRepository.save(BrandModel.create("아디다스")); + BrandModel adidas = brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스").get(); + + saveProduct("에어맥스", 150000, 100); + productJpaRepository.save(ProductModel.create(adidas.getId(), "울트라부스트", 180000, 80)); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?brandId=" + savedBrand.getId(), HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void excludesDeletedProducts() { + // arrange + saveProduct("에어맥스", 150000, 100); + ProductModel toDelete = saveProduct("에어포스", 120000, 50); + Long deleteId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 20)) + .getContent().stream() + .filter(p -> p.getName().equals("에어포스")) + .findFirst().get().getId(); + ProductModel found = productJpaRepository.findByIdAndDeletedAtIsNull(deleteId).get(); + found.delete(); + productJpaRepository.save(found); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoProductsExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=20", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + + @DisplayName("목록 조회 시 각 상품에 좋아요 수가 포함된다.") + @Test + void returnsList_withLikeCount() { + // arrange + ProductModel product1 = saveProduct("에어맥스", 150000, 100); + saveProduct("에어포스", 120000, 50); + productLikeJpaRepository.save(ProductLikeModel.create(1L, product1.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(2L, product1.getId())); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).anyMatch(item -> item.likeCount() == 2L), + () -> assertThat(response.getBody().data().items()).anyMatch(item -> item.likeCount() == 0L) + ); + } + + @DisplayName("likes_desc 정렬로 조회하면, 좋아요 수 내림차순으로 정렬된다.") + @Test + void returnsSortedByLikesDesc_whenSortIsLikesDesc() { + // arrange + ProductModel product1 = saveProduct("에어맥스", 150000, 100); + ProductModel product2 = saveProduct("에어포스", 120000, 50); + ProductModel product3 = saveProduct("조던1", 200000, 30); + productLikeJpaRepository.save(ProductLikeModel.create(1L, product2.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(2L, product2.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(3L, product2.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(1L, product3.getId())); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=likes_desc&page=0&size=2", + HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어포스"), + () -> assertThat(response.getBody().data().items().get(0).likeCount()).isEqualTo(3L), + () -> assertThat(response.getBody().data().items().get(1).name()).isEqualTo("조던1"), + () -> assertThat(response.getBody().data().items().get(1).likeCount()).isEqualTo(1L) + ); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품을 조회하면, 좋아요 수가 포함된 상세 정보를 반환한다.") + @Test + void returnsProductDetail_whenProductExists() { + // arrange + ProductModel product = saveProduct("에어맥스", 150000, 100); + Long productId = product.getId(); + productLikeJpaRepository.save(ProductLikeModel.create(1L, productId)); + productLikeJpaRepository.save(ProductLikeModel.create(2L, productId)); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(savedBrand.getId()), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().price()).isEqualTo(150000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(2L) + ); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiE2ETest.java similarity index 93% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiE2ETest.java index c81b0f5b3..71c71138d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiE2ETest.java @@ -1,11 +1,12 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.user; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.loopers.infrastructure.UserJpaRepository; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -91,8 +92,8 @@ void throwsBadRequest_whenLoginIdIsTooShort() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -116,8 +117,8 @@ void throwsBadRequest_whenLoginIdIsTooLong() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -141,8 +142,8 @@ void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -166,8 +167,8 @@ void throwsBadRequest_whenPasswordIsTooShort() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -191,8 +192,8 @@ void throwsBadRequest_whenPasswordIsTooLong() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -241,8 +242,8 @@ void throwsBadRequest_whenEmailFormatIsInvalid() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -300,8 +301,8 @@ void throwsBadRequest_whenNameIsTooShort() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -325,8 +326,8 @@ void throwsBadRequest_whenNameIsTooLong() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -665,8 +666,8 @@ void changePassword_whenInvalidPasswordFormat() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiScenarioTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiScenarioTest.java index e15c7e65d..e376f9a6e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiScenarioTest.java @@ -1,10 +1,11 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.user; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..fa298b2b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,8 @@ subprojects { testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}") testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}") testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}") + // ArchUnit + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") // Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers") diff --git a/docs/design/_shared/CONVENTIONS.md b/docs/design/_shared/CONVENTIONS.md new file mode 100644 index 000000000..c4a6cf6c6 --- /dev/null +++ b/docs/design/_shared/CONVENTIONS.md @@ -0,0 +1,92 @@ +# 공통 설계 원칙 + +## 프로젝트 개요 + +SSENSE와 같은 하이패션 이커머스 플랫폼. Spring Boot 3.4.4, Java 21. + +### 액터 + +| 액터 | 설명 | 식별 방식 | +|------|------|----------| +| 비회원 (Guest) | 로그인하지 않은 사용자 | 헤더 없음 | +| 회원 (User) | 로그인한 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| 관리자 (Admin) | 사내 관리자 | `X-Loopers-Ldap: loopers.admin` | + +### API Prefix + +- 대고객 API: `/api/v1` +- 어드민 API: `/api-admin/v1` + +--- + +## 도메인 참조 원칙 + +- **DB FK 제약 미사용** — 테이블 간 외래키 제약조건을 사용하지 않는다. 무결성은 애플리케이션 레벨에서 보장. + - FK의 문제: 잠금 전파(데드락 위험), 삭제 순서 강제, 테이블 간 결합 +- **DB 유니크 제약 사용** — 테이블 내부 제약은 사용한다. 동시성(더블클릭 등) 시 중복 방지. +- **참조 방식** + - 도메인 간: ID 참조 (`private Long brandId`, `private Long userId` 등). 도메인 패키지 간 격벽 유지 + - 같은 도메인 내부 (Order ↔ OrderItem): 양방향 매핑 허용 기준에 따라 결정 +- **Aggregate** — 각 도메인은 독립 Aggregate Root. `@OneToMany` 사용하지 않음. Aggregate 규칙은 Service에서 `@Transactional`로 관리. + +### 도메인 간 의존 규칙 + +도메인 패키지 간 의존은 기본적으로 **단방향**만 허용한다. 허용된 방향은 아래 표에 명시한다. + +| From → To | 방향 | 허용 레이어 | 사유 | +|-----------|------|------------|------| +| Product → Brand | 단방향 | Domain (ID 참조), Application (Service 호출) | 상품은 브랜드에 생명주기 종속 | +| Brand → Product | 역방향 금지 | Application Facade에서만 조율 | BrandFacade가 ProductService를 호출하여 연쇄 삭제 처리 | +| Order → Product | 단방향 | Application (Service 호출, 스냅샷 조회) | 주문 시 상품 정보 스냅샷. Domain에서는 ID 참조만 | + +**원칙:** +- Domain 레이어에서 다른 도메인을 참조할 때는 ID 참조만 허용 (객체참조 금지) +- 역방향이 필요한 조율(브랜드 삭제 시 상품 연쇄 삭제 등)은 Application 레이어(Facade)에서 처리 +- 순환 의존이 발생하면 이벤트 기반 분리를 검토 + +**양방향 매핑 허용 기준:** + +같은 도메인 내에서 트랜잭션 일관성·생명주기 종속·독립 변경 가능성을 따졌을 때 양방향이 더 자연스러운 경우 `@OneToMany` 양방향 매핑을 허용한다. 예: Order ↔ OrderItem처럼 루트 엔티티를 기준으로 비즈니스가 동작하고, 항상 루트를 통해 하위 엔티티에 접근하는 구조. + +JPA 양방향 매핑에 따르는 추가 UPDATE 쿼리 등 성능 오버헤드는 객체 그래프 탐색의 편리함과 트레이드오프로 감안한다. 단, 쿼리 최적화(fetch join, batch size 등)는 별도로 고려한다. + +--- + +## Soft Delete 전략 + +- **Soft Delete 대상**: brands, products, orders, order_items → `deleted_at` 컬럼으로 논리 삭제 +- **Hard Delete 대상**: likes, cart_items → 이력이 필요 없는 토글/임시 데이터. UNIQUE 제약조건과의 충돌 방지. + +--- + +## 공통 엔티티 구조 + +- **BaseEntity**: 공통 컬럼 (id, created_at, updated_at, deleted_at) +- **Enum 저장**: VARCHAR로 저장 +- **Rich Domain Model**: 비즈니스 로직은 엔티티와 VO 메서드에 포함. Facade는 오케스트레이션만 담당. + +--- + +## 도메인 용어집 + +| 한글 | 영문 | 설명 | +|------|------|------| +| 회원 | User | 서비스에 가입한 사용자. 구현 완료 (범위 제외) | +| 브랜드 | Brand | 상품을 판매하는 브랜드. Admin이 등록/관리 | +| 상품 | Product | 브랜드에 속한 판매 상품. 재고(stock) 포함 | +| 좋아요 | Like | 회원이 상품에 대해 표현하는 선호. 회원당 상품당 1개 | +| 장바구니 항목 | CartItem | 장바구니에 담긴 개별 상품과 수량 | +| 주문 | Order | 회원이 상품을 구매하기 위한 요청 | +| 주문 항목 | OrderItem | 주문에 포함된 개별 상품의 스냅샷 | +| 스냅샷 | Snapshot | 주문 시점의 상품 정보를 복사하여 저장하는 것 | + +--- + +## 범위 제외 사항 + +| 제외 항목 | 사유 | +|---|---| +| 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | +| 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | +| 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | +| 주문 상태 전이 (결제 연동) | 결제 기능과 함께 추가. 현재는 ORDERED / CANCELLED만 | diff --git a/docs/design/_shared/OVERVIEW.md b/docs/design/_shared/OVERVIEW.md new file mode 100644 index 000000000..d7482ea64 --- /dev/null +++ b/docs/design/_shared/OVERVIEW.md @@ -0,0 +1,253 @@ +# 전체 설계 조감도 + +> 전체 구조 파악용. 각 도메인의 상세 스펙은 `{domain}/DESIGN.md` 참조. + +--- + +## 전체 ERD + +> FK 제약조건은 사용하지 않는다. 관계선은 논리적 참조 관계를 나타내며, 실제 DB에서는 ID 컬럼으로만 참조한다. + +```mermaid +erDiagram + users { + bigint id PK + varchar login_id UK + varchar password + varchar name + date birth_date + varchar email + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands { + bigint id PK + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + products { + bigint id PK + bigint brand_id + varchar name + int price + int stock + int like_count + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + likes { + bigint id PK + bigint user_id + bigint product_id + timestamp created_at + } + + cart_items { + bigint id PK + bigint user_id + bigint product_id + int quantity + timestamp created_at + timestamp updated_at + } + + orders { + bigint id PK + bigint user_id + int total_price + int original_total_price + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + order_items { + bigint id PK + bigint order_id + bigint product_id + varchar product_name + varchar brand_name + varchar image_url + int order_price + int quantity + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands ||--o{ products : "" + users ||--o{ likes : "" + products ||--o{ likes : "" + users ||--o{ cart_items : "" + products ||--o{ cart_items : "" + users ||--o{ orders : "" + orders ||--|{ order_items : "" +``` + +--- + +## 전체 클래스 다이어그램 + +```mermaid +classDiagram + class User { + String loginId + String password + String name + LocalDate birthDate + String email + } + + class Brand { + String name + +update(String) void + +softDelete() void + } + + class Product { + Long brandId + String name + int price + int stock + int likeCount + +decreaseStock(int) void + +isSoldOut() boolean + +addLikeCount() void + +subtractLikeCount() void + +softDelete() void + } + + class ProductLike { + Long userId + Long productId + } + + class CartItem { + Long userId + Long productId + int quantity + +addQuantity(int) void + +updateQuantity(int) void + } + + class Cart { + <<일급 컬렉션>> + List~CartItem~ items + +getTotalPrice() int + +selectItems(List~Long~) List~CartItem~ + } + + class Order { + Long userId + List~OrderItem~ items + int totalPrice + int originalTotalPrice + OrderStatus status + +create(Long userId, List~OrderItem~ items) Order + +addItem(OrderItem item) void + +cancelItem(Long orderItemId) OrderItem + +recalculateTotalPrice() void + +validateOwner(Long userId) void + } + + class OrderItem { + Order order + Long productId + ProductSnapshot productSnapshot + int orderPrice + int quantity + OrderItemStatus status + +create(Long productId, int orderPrice, int quantity, ProductSnapshot snapshot) OrderItem + +cancel() void + +assignOrder(Order order) void + } + + class ProductSnapshot { + <> + String productName + String brandName + String imageUrl + } + + class OrderStatus { + <> + ORDERED + CANCELLED + } + + class OrderItemStatus { + <> + ORDERED + CANCELLED + } + + Product "*" --> "1" Brand : ID 참조 (brandId) + ProductLike "*" --> "1" User : userId + ProductLike "*" --> "1" Product : productId + CartItem "*" --> "1" User : userId + CartItem "*" --> "1" Product : productId + Cart o-- CartItem : 일급 컬렉션 + Order "*" --> "1" User : userId + Order "1" *-- "*" OrderItem : @OneToMany (양방향) + Order --> OrderStatus + OrderItem *-- ProductSnapshot : @Embedded + OrderItem --> OrderItemStatus +``` + +--- + +## 도메인 간 관계 요약 + +| 관계 | 카디널리티 | 참조 방식 | 비고 | +|---|---|---|---| +| Brand → Product | 1 : N | ID 참조 (brandId) | 도메인 간 패키지 격벽 유지 | +| User → ProductLike | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | +| Product → ProductLike | 1 : N | ID 참조 (productId) | 교차 테이블 | +| User → CartItem | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | +| Product → CartItem | 1 : N | ID 참조 (productId) | 가격 저장 안 함 | +| User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | +| Order ↔ OrderItem | 1 : N | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade=PERSIST, @BatchSize(100) | +| OrderItem → ProductSnapshot | - | `@Embedded` (`@Embeddable` VO) | 주문 시점 스냅샷을 개념 단위로 그룹핑 | + +--- + +## 제약조건 전체 + +| 테이블 | 제약조건 | 설명 | +|---|---|---| +| users | UNIQUE(login_id) | 로그인 ID 중복 방지 | +| brands | UNIQUE(name) | 브랜드명 중복 방지 (409 Conflict) | +| likes | UNIQUE(user_id, product_id) | 1인 1좋아요 보장 | +| cart_items | UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 | + +--- + +## 인덱스 전체 + +| 테이블 | 인덱스 컬럼 | 용도 | +|---|---|---| +| products | brand_id | 브랜드별 상품 필터링 | +| likes | user_id | 유저의 좋아요 목록 조회 | +| cart_items | user_id | 유저의 장바구니 조회 | +| orders | (user_id, created_at) | 유저의 주문 목록 조회 | +| order_items | order_id | 주문의 상세 항목 조회 | + +--- + +## 동시성 제어 전체 + +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 재고 음수 방지 | +| products.like_count | 원자적 UPDATE | 경합 낮음 | +| likes | DB UNIQUE 제약 | 더블클릭 중복 방지 | +| cart_items | DB UNIQUE 제약 | 더블클릭 중복 방지 | diff --git a/docs/design/brand/DESIGN.md b/docs/design/brand/DESIGN.md new file mode 100644 index 000000000..0235847f3 --- /dev/null +++ b/docs/design/brand/DESIGN.md @@ -0,0 +1,170 @@ +# Brand 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **비회원으로서**, 브랜드 정보를 조회할 수 있다. +> **관리자로서**, 브랜드를 등록/수정/삭제하여 입점 브랜드를 관리할 수 있다. + +### 예외 및 정책 + +- **Soft Delete** — `deleted_at` 컬럼으로 논리 삭제. 복구 가능성을 열어둔다. +- **브랜드 삭제 연쇄 처리** — 브랜드 soft delete 시 해당 브랜드의 상품도 전체 soft delete. 장바구니/좋아요는 즉시 삭제하지 않고 조회 시점에 필터링. + ``` + 브랜드 soft delete + └→ 해당 브랜드의 상품 전체 soft delete + └→ 장바구니 항목: 조회 시 필터링 + └→ 좋아요: 조회 시 필터링 + ``` +- **브랜드명 중복 불가** — 동일한 브랜드명이 이미 존재하면 등록/수정 실패 (409 Conflict) +- **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 등록일/수정일/삭제 여부 등 관리 정보 추가 제공 +- **soft delete된 브랜드** — 고객 조회 불가 (404 반환) +- **Brand → Product 참조** — ID 참조 (`Long brandId`). 도메인 간 패키지 격벽 유지를 위해 객체참조 대신 ID 참조 +- **독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. 브랜드 삭제 → 상품 soft delete는 Facade에서 조율. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 브랜드 정보 조회 | 비회원/회원 | GET | `/api/v1/brands/{brandId}` | X | +| 브랜드 목록 조회 | Admin | GET | `/api-admin/v1/brands?page=0&size=20` | LDAP | +| 브랜드 상세 조회 | Admin | GET | `/api-admin/v1/brands/{brandId}` | LDAP | +| 브랜드 등록 | Admin | POST | `/api-admin/v1/brands` | LDAP | +| 브랜드 정보 수정 | Admin | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | +| 브랜드 삭제 | Admin | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | + +--- + +## 유즈케이스 + +**UC-B01: 브랜드 정보 조회 (비회원)** + +``` +[기능 흐름] +1. 비회원이 brandId로 브랜드 정보를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 브랜드 기본 정보를 반환한다 + +[예외] +- brandId에 해당하는 브랜드가 없으면 404 반환 +- soft delete된 브랜드는 조회 불가 (404 반환) +``` + +**UC-B02: 브랜드 등록 (Admin)** + +``` +[기능 흐름] +1. Admin이 브랜드 정보(이름 등)를 입력한다 +2. 동일한 브랜드명이 이미 존재하는지 확인한다 +3. 브랜드를 저장한다 +4. 생성된 브랜드 정보를 반환한다 + +[예외] +- 이미 존재하는 브랜드명이면 등록 실패 (409 Conflict) + +[조건] +- 브랜드명은 필수값이며 중복 불가 +``` + +**UC-B03: 브랜드 정보 수정 (Admin)** + +``` +[기능 흐름] +1. Admin이 brandId와 수정할 정보를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 브랜드 정보를 업데이트한다 + +[예외] +- brandId에 해당하는 브랜드가 없거나 삭제된 경우 404 반환 +- 수정하려는 브랜드명이 다른 브랜드와 중복되면 409 Conflict +``` + +**UC-B04: 브랜드 삭제 (Admin)** + +``` +[기능 흐름] +1. Admin이 brandId로 삭제를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 해당 브랜드를 soft delete 한다 +4. 해당 브랜드의 모든 상품도 soft delete 한다 + +[예외] +- brandId에 해당하는 브랜드가 없으면 404 반환 +- 이미 삭제된 브랜드이면 404 반환 +``` + +--- + +## 시퀀스 다이어그램: 브랜드 삭제 (연쇄 처리) + +> 브랜드 삭제는 **Brand 삭제 + Product 연쇄 삭제**를 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + participant BC as BrandAdminController + participant BF as BrandFacade + participant BS as BrandService + participant PS as ProductService + + Note left of BC: DELETE /api-admin/v1/brands/{id} + BC->>BF: 브랜드 삭제 요청 + BF->>BS: 브랜드 조회 및 검증 + BS-->>BF: 검증 완료 + + Note over BS: 브랜드 예외 처리
(없음, 이미 삭제됨) + + BF->>BS: 브랜드 soft delete + BS-->>BF: 완료 + + BF->>PS: 해당 브랜드 상품 전체 삭제 + PS-->>BF: 완료 + + Note over BF: 장바구니/좋아요는
조회 시 필터링 + + BF-->>BC: 삭제 완료 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class Brand { + String name + +update(String) void + +softDelete() void + +isDeleted() boolean + } +``` + +### 비즈니스 규칙 + +| 메서드 | 비즈니스 규칙 | +|---|---| +| update(String) | 브랜드명 변경 | +| softDelete() / isDeleted() | deleted_at 설정. "삭제"의 정의가 바뀌어도 한 곳만 수정 | + +--- + +## ERD + +```mermaid +erDiagram + brands { + bigint id PK + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } +``` + +### 제약조건 + +| 제약조건 | 설명 | +|---|---| +| UNIQUE(name) | 브랜드명 중복 방지 (409 Conflict) | diff --git a/docs/design/cart/DESIGN.md b/docs/design/cart/DESIGN.md new file mode 100644 index 000000000..ebab12f4e --- /dev/null +++ b/docs/design/cart/DESIGN.md @@ -0,0 +1,205 @@ +# Cart 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **회원으로서**, 구매하고 싶은 상품을 장바구니에 담아두고 나중에 한 번에 확인할 수 있다. +> 담은 상품의 수량을 변경하거나 제거할 수 있다. +> 장바구니에 품절 상품이 있으면 품절 상태로, 삭제된 상품은 판매 종료 상태로 보여준다. + +### 예외 및 정책 + +- **Cart 엔티티 미사용** — DB에 Cart 테이블 없음. CartItem만 DB 엔티티. Cart는 코드에서 일급 컬렉션(First-Class Collection)으로 표현하여 "전체 가격 계산", "선택 항목 추출" 등 장바구니 단위 행위를 응집. +- **가격: 현재 가격 기준** — CartItem에 가격을 저장하지 않음 (가격의 원천은 항상 Product). 조회 시 항상 현재 상품 가격 사용. 하이패션 시즌 세일 시 자동 반영. +- **재고: 담기 시 미확인** — 장바구니에 담을 때 재고는 확인하지 않음. 주문 시점에만 확인. 장바구니는 "보관함" 성격. +- **주문과 독립** — Cart 도메인과 Order 도메인은 서로를 모른다. Facade가 경로를 조율. + ``` + [장바구니 → 주문 흐름] + 장바구니 → CartItem 조회 → OrderItemCommand 변환 → OrderService 호출 → CartItem 삭제 + + [바로구매 흐름] + 상품 페이지 → OrderItemCommand 직접 생성 → OrderService 호출 + ``` +- **품절 상품** — 자동 제거하지 않음. 품절 표시하고 유저가 직접 제거. 하이패션에서 신중하게 골라 담은 상품이 자동으로 사라지면 UX 저하. +- **삭제된 상품(SoftDelete)** — 판매 종료 표시 + 주문 불가. +- **CartItem 유니크 제약** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. +- **참조 방식** — ID 참조 (userId, productId). 스냅샷 불필요. +- **물리 삭제(Hard Delete)** — 임시 데이터. UNIQUE 제약과 충돌 방지. +- **제약 조건** + + | 제약 | 값 | 근거 | + |------|-----|------| + | 상품당 최대 수량 | 99개 | 비정상 요청 방어 | + | 장바구니 최대 종류 | 100종 | 합리적 상한 + 페이지네이션 적용 | + | 최소 수량 | 1개 | 수량 0은 불가. 제거는 DELETE API로 명확히 분리 | + | quantity | 필수값 | 기본값 없음. 클라이언트가 명시적으로 전달 | + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 장바구니에 상품 담기 | 회원 | POST | `/api/v1/carts` | O | +| 장바구니 목록 조회 | 회원 | GET | `/api/v1/carts?page=0&size=20` | O | +| 장바구니 수량 변경 | 회원 | PUT | `/api/v1/carts/{cartItemId}` | O | +| 장바구니 항목 제거 | 회원 | DELETE | `/api/v1/carts/{cartItemId}` | O | + +--- + +## 유즈케이스 + +**UC-C01: 장바구니에 상품 담기** + +``` +[기능 흐름] +1. 회원이 productId와 quantity(필수)로 담기를 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 장바구니에 같은 상품이 이미 있는지 확인한다 +4-a. 없으면: 새 CartItem을 저장한다 +4-b. 있으면: 기존 수량에 요청 수량을 합산한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 실패 +- 합산 후 수량이 99를 초과하면 실패 +- 장바구니에 이미 100종류가 담겨있으면 신규 상품 담기 실패 + +[조건] +- quantity는 필수값 (기본값 없음), 1 이상 +- 담을 때 재고는 확인하지 않음 (주문 시점에 확인) +- 가격은 저장하지 않음 (조회 시 현재 가격 사용) +- 로그인한 회원만 가능 +``` + +**UC-C02: 장바구니 목록 조회** + +``` +[기능 흐름] +1. 회원이 장바구니 목록을 요청한다 (page, size) +2. 해당 회원의 장바구니 항목을 조회한다 +3. 각 항목의 상품/브랜드 상태를 확인한다 +4. 품절(stock=0) 상품은 품절 상태를 표시한다 +5. 삭제된(SoftDelete) 상품은 판매 종료 상태를 표시한다 +6. 상품의 현재 정보(이름, 가격, 재고 상태)와 함께 반환한다 + +[조건] +- 가격은 항상 현재 상품 가격 기준 +- 페이지네이션 적용 (장바구니 최대 100종류) +- 본인의 장바구니만 조회 가능 +- 로그인한 회원만 가능 +``` + +**UC-C03: 장바구니 수량 변경** + +``` +[기능 흐름] +1. 회원이 cartItemId와 변경할 quantity를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 수량을 업데이트한다 + +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 +- 수량이 1 미만이면 실패 (최소 1) +- 수량이 99 초과이면 실패 (최대 99) + +[조건] +- 수량 0으로 변경 불가 → 제거는 DELETE API로만 가능 +- 본인의 장바구니 항목만 수정 가능 +- 로그인한 회원만 가능 +``` + +**UC-C04: 장바구니 항목 제거** + +``` +[기능 흐름] +1. 회원이 cartItemId로 제거를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 해당 항목을 삭제한다 + +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 + +[조건] +- 본인의 장바구니 항목만 제거 가능 +- 로그인한 회원만 가능 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class CartItem { + Long userId + Long productId + int quantity + +addQuantity(int) void + +updateQuantity(int) void + } + + class Cart { + <<일급 컬렉션>> + List~CartItem~ items + +getTotalPrice() int + +getItemCount() int + +selectItems(List~Long~) List~CartItem~ + } + + CartItem "*" --> "1" User : userId + CartItem "*" --> "1" Product : productId + Cart o-- CartItem : 일급 컬렉션 +``` + +### 비즈니스 규칙 + +| 엔티티 | 메서드 | 비즈니스 규칙 | +|---|---|---| +| CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산. 99 초과 시 예외. Entity 내부에서 범위 검증 | +| CartItem | updateQuantity(int) | 수량 변경. 1~99 범위를 Entity 내부에서 검증 | +| Cart | getTotalPrice() | 일급 컬렉션. 장바구니 전체 가격 계산 (Product 현재 가격 기준) | +| Cart | selectItems(List) | 선택한 항목만 추출 (장바구니에서 부분 주문 시) | + +--- + +## ERD + +```mermaid +erDiagram + cart_items { + bigint id PK + bigint user_id + bigint product_id + int quantity + timestamp created_at + timestamp updated_at + } + + users ||--o{ cart_items : "" + products ||--o{ cart_items : "" +``` + +### 제약조건 + +| 제약조건 | 설명 | +|---|---| +| UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 (수량 합산으로 처리) | + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| cart_items.user_id | 유저의 장바구니 조회 | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| cart_items | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지 | + +### 참조 무결성 검증 (애플리케이션 레벨) + +- 장바구니 담기 시 — product_id가 유효한(삭제되지 않은) 상품인지 확인 (재고는 확인하지 않음) diff --git a/docs/design/like/DESIGN.md b/docs/design/like/DESIGN.md new file mode 100644 index 000000000..34d4fe0db --- /dev/null +++ b/docs/design/like/DESIGN.md @@ -0,0 +1,169 @@ +# Like 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **회원으로서**, 마음에 드는 상품에 좋아요를 눌러 선호를 표현하고, 나중에 다시 찾아볼 수 있다. +> 이미 좋아요한 상품은 취소할 수 있다. + +### 예외 및 정책 + +- **좋아요 수: COUNT(*) 실시간 쿼리** — likes 테이블에서 COUNT(*)로 조회. Product 엔티티에 캐시 필드를 두지 않는다. +- **API 방식: 엔드포인트 분리** — POST/DELETE 엔드포인트를 분리하고, Facade도 like/unlike 메서드를 각각 제공. +- **중복 방어: 이중 방어** — 애플리케이션 레벨 중복 체크(1차) + DB UNIQUE 제약(2차). 중복 시 CONFLICT 예외. +- **회원당 상품당 1개** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. +- **삭제된 상품/브랜드의 좋아요** — 목록 조회 시 필터링으로 제외. +- **상품 검증** — 등록 시에만 ProductService로 상품 존재 확인. 취소 시에는 Like 도메인 내에서 처리. +- **참조 방식** — 모두 ID 참조 (userId, productId). +- **물리 삭제(Hard Delete)** — 이력 불필요. UNIQUE 제약과 충돌 방지. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 좋아요 등록 | 회원 | POST | `/api/v1/products/{productId}/likes` | O | +| 상품 좋아요 취소 | 회원 | DELETE | `/api/v1/products/{productId}/likes` | O | +| 내가 좋아요한 상품 목록 조회 | 회원 | GET | `/api/v1/users/{userId}/likes` | O | + +--- + +## 유즈케이스 + +**UC-L01: 상품 좋아요 등록/취소** + +``` +[기능 흐름 - 등록 (POST)] +1. 회원이 productId로 좋아요 등록을 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 중복 좋아요인지 확인한다 +4. 좋아요를 저장한다 + +[기능 흐름 - 취소 (DELETE)] +1. 회원이 productId로 좋아요 취소를 요청한다 +2. 좋아요 기록을 조회한다 +3. 좋아요를 삭제한다 + +[예외] +- 등록 시: 상품이 없거나 삭제된 경우 404, 이미 좋아요한 경우 409 +- 취소 시: 좋아요 기록이 없는 경우 404 + +[조건] +- 로그인한 회원만 가능 +- 회원당 상품당 1개만 저장 (애플리케이션 체크 + DB 유니크 제약) +``` + +**UC-L02: 내가 좋아요한 상품 목록 조회** + +``` +[기능 흐름] +1. 회원이 자신의 좋아요 목록을 요청한다 +2. likes 테이블에서 해당 회원의 좋아요 목록을 조회한다 +3. 상품/브랜드가 삭제되지 않은 항목만 필터링한다 +4. 상품 정보와 함께 반환한다 + +[조건] +- 로그인한 회원만 가능 +- soft delete된 상품/브랜드는 목록에서 제외 (조회 시 필터링) +- 본인의 좋아요 목록만 조회 가능 (타 유저 접근 불가) +``` + +--- + +## 시퀀스 다이어그램: 좋아요 등록/취소 + +> 좋아요 등록은 **Product 존재 확인 + Like 등록**을 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + autonumber + + participant LC as LikeController + participant LF as LikeFacade + participant PS as ProductService + participant LS as LikeService + + Note left of LC: POST /products/{id}/likes + + LC->>LF: like(userId, productId) + + Note over LF: @Transactional + LF->>PS: validateExists(productId) + LF->>LS: like(userId, productId) + LS-->>LS: 중복 체크 후 저장 +``` + +```mermaid +sequenceDiagram + autonumber + + participant LC as LikeController + participant LF as LikeFacade + participant LS as LikeService + + Note left of LC: DELETE /products/{id}/likes + + LC->>LF: unlike(userId, productId) + + Note over LF: @Transactional + LF->>LS: unlike(userId, productId) + LS-->>LS: 조회 후 삭제 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class ProductLike { + Long userId + Long productId + } + + ProductLike "*" --> "1" User : userId + ProductLike "*" --> "1" Product : productId +``` + +> ProductLike는 created_at만 사용 (BaseEntity의 updated_at, deleted_at 불필요). + +--- + +## ERD + +```mermaid +erDiagram + likes { + bigint id PK + bigint user_id + bigint product_id + timestamp created_at + } + + users ||--o{ likes : "" + products ||--o{ likes : "" +``` + +### 제약조건 + +| 제약조건 | 설명 | +|---|---| +| UNIQUE(user_id, product_id) | 1인 1좋아요 보장. 동시성(더블클릭) 방지 | + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| likes.user_id | 유저의 좋아요 목록 조회 | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| likes | 애플리케이션 중복 체크(1차) + DB UNIQUE 제약(2차) | 이중 방어. 더블클릭 시에도 중복 INSERT 방지 | + +### 참조 무결성 검증 (애플리케이션 레벨) + +- 좋아요 등록 시 — product_id가 유효한(삭제되지 않은) 상품인지 확인 diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md new file mode 100644 index 000000000..45e025fa1 --- /dev/null +++ b/docs/design/order/DESIGN.md @@ -0,0 +1,371 @@ +# Order 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **회원으로서**, 여러 상품을 한 번에 주문할 수 있다. +> 주문 시 상품 재고가 확인되고 차감된다. +> 주문 후에도 당시 상품 정보(가격, 이름 등)를 확인할 수 있다. +> +> **회원으로서**, 주문한 아이템을 개별적으로 취소할 수 있다. +> 취소 시 해당 상품의 재고가 복구된다. +> +> **관리자로서**, 전체 주문 내역을 조회할 수 있다. +> **관리자로서**, 주문 아이템을 취소할 수 있다. + +### 예외 및 정책 + +- **재고 확인 + 차감 원자적 처리** — 재고 확인과 차감은 하나의 트랜잭션 안에서 원자적으로 수행. 일괄 처리 방식(IN 쿼리). +- **스냅샷 저장** — 주문 시점의 상품 정보(상품명, 가격, 브랜드명)를 OrderItem에 복사. 이후 상품이 변경/삭제되어도 주문 내역은 보존. +- **재고 부족 시 주문 전체 실패** — 하나의 상품이라도 재고 부족이면 주문 전체가 롤백. 부분 성공 없음. +- **items 비어있으면 실패** — 주문 항목이 없는 요청은 거부. +- **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. +- **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. +- **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. +- **스냅샷 구조** — `ProductSnapshot` `@Embeddable` VO로 그룹핑 (productName, brandName, imageUrl). 도메인 성장에 따라 스냅샷 필드가 늘어날 수 있으므로 개념 단위로 묶는다. productId는 스냅샷 외부에 별도 유지 (재구매, 통계용, FK 아님). +- **Order ↔ OrderItem** — 양방향 `@OneToMany` / `@ManyToOne` 매핑. 같은 Aggregate 내부이므로 Aggregate Root(Order)가 OrderItem의 생명주기를 직접 관리한다. + - `cascade = {CascadeType.PERSIST, CascadeType.MERGE}` — Order 저장/병합 시 OrderItem 함께 처리. REMOVE는 Soft Delete와 충돌하므로 미사용. + - `orphanRemoval = false` — 주문 항목 제거 요구사항 없음 + Soft Delete 정책과 충돌 방지. + - `@BatchSize(size = 100)` — LAZY 기본, 목록 조회 시 N+1 방지. + - **totalPrice / originalTotalPrice** — Order.create() 시점에 items로부터 직접 계산. originalTotalPrice는 생성 시점의 금액을 보존(불변). totalPrice는 아이템 취소 시 남은 ORDERED 아이템 기준으로 재계산. + - **OrderItemRepository 유지** — Aggregate Root 통한 접근은 생성/변경에 적용. 조회(통계, 배치, 검색)는 OrderItemRepository 직접 사용 허용. +- **주문 아이템 취소** — 아이템 단위로 개별 취소. 한 번에 하나씩만 취소 가능. + - **취소 가능 조건** — Order가 ORDERED 상태이고, 해당 OrderItem이 ORDERED 상태일 때만 취소 가능. + - **OrderItemStatus** — ORDERED, CANCELLED. 아이템별 개별 상태 관리. + - **전체 아이템 취소 시** — 모든 아이템이 CANCELLED이면 Order도 자동으로 CANCELLED 전이. + - **재고 복구** — 취소된 아이템의 quantity만큼 Product 재고 복구. Facade에서 Order↔Product 조율. + - **취소 액터** — 회원 본인(소유자 검증) + Admin(소유자 검증 없음). + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 주문 요청 | 회원 | POST | `/api/v1/orders` | O | +| 주문 목록 조회 | 회원 | GET | `/api/v1/orders?startAt={date}&endAt={date}` | O | +| 주문 상세 조회 | 회원 | GET | `/api/v1/orders/{orderId}` | O | +| 주문 목록 조회 | Admin | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | +| 주문 상세 조회 | Admin | GET | `/api-admin/v1/orders/{orderId}` | LDAP | +| 아이템 취소 | 회원 | PATCH | `/api/v1/orders/{orderId}/items/{orderItemId}/cancel` | O | +| 아이템 취소 | Admin | PATCH | `/api-admin/v1/orders/{orderId}/items/{orderItemId}/cancel` | LDAP | + +### 주문 요청 본문 예시 + +```json +{ + "items": [ + { "productId": 1, "quantity": 2, "expectedPrice": 50000 }, + { "productId": 3, "quantity": 1, "expectedPrice": 120000 } + ] +} +``` + +--- + +## 유즈케이스 + +**UC-O01: 주문 요청** + +``` +[기능 흐름] +1. 회원이 상품 목록(productId, quantity, expectedPrice)으로 주문을 요청한다 +2. 상품 ID 목록을 추출하여 IN절로 일괄 조회한다 (개별 루프 조회 금지) +3. 조회된 상품 수와 요청 상품 수가 일치하는지 확인한다 (삭제된 상품 불가) +4. 각 상품의 expectedPrice와 현재 가격을 비교한다 (불일치 시 실패) +5. 각 상품의 재고가 충분한지 확인하고 차감한다 (원자적 처리) +6. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (ProductSnapshot: 상품명, 브랜드명, 이미지 등) +7. 주문을 생성한다 + +[예외] +- 상품이 존재하지 않거나 삭제된 경우 주문 실패 +- expectedPrice와 현재 가격이 불일치하면 주문 실패 +- 재고가 부족한 상품이 하나라도 있으면 주문 전체 실패 +- items가 비어있으면 주문 실패 + +[조건] +- 로그인한 회원만 가능 +- 바로구매/장바구니 주문 모두 같은 API 사용 (Order 도메인은 출처를 모름) +- 재고 확인과 차감은 원자적으로 처리되어야 함 +- 동시성 이슈는 추후 해결 (비관적 락 또는 낙관적 락) +``` + +**UC-O02: 주문 목록 조회 (회원)** + +``` +[기능 흐름] +1. 회원이 기간(startAt, endAt)을 지정하여 주문 목록을 요청한다 +2. 해당 기간 내 본인의 주문 목록을 반환한다 + +[조건] +- 본인의 주문만 조회 가능 +- startAt, endAt은 필수값 (기간 지정 필수) +``` + +**UC-O03: 주문 상세 조회 (회원)** + +``` +[기능 흐름] +1. 회원이 orderId로 주문 상세를 요청한다 +2. 해당 주문이 존재하는지 확인한다 +3. 본인의 주문인지 확인한다 +4. 주문 정보와 스냅샷된 상품 정보를 반환한다 + +[예외] +- orderId에 해당하는 주문이 없으면 404 반환 +- 본인의 주문이 아니면 접근 불가 + +[조건] +- 본인의 주문만 조회 가능 +- 상품 정보는 스냅샷 기준 (현재 상품 상태와 무관) +``` + +**UC-O04: 주문 아이템 취소 (회원)** + +``` +[기능 흐름] +1. 회원이 orderId, orderItemId로 아이템 취소를 요청한다 +2. 주문이 존재하는지 확인한다 +3. 본인의 주문인지 확인한다 +4. 주문이 ORDERED 상태인지 확인한다 +5. 해당 아이템이 ORDERED 상태인지 확인한다 +6. 아이템을 CANCELLED로 변경한다 +7. totalPrice를 남은 ORDERED 아이템 기준으로 재계산한다 +8. 모든 아이템이 CANCELLED이면 Order도 CANCELLED로 변경한다 +9. 해당 상품의 재고를 복구한다 (quantity만큼) + +[예외] +- 주문이 존재하지 않으면 404 +- 본인의 주문이 아니면 접근 불가 +- 주문이 이미 CANCELLED이면 취소 불가 +- 아이템이 이미 CANCELLED이면 취소 불가 + +[조건] +- 로그인한 회원만 가능 +- 한 번에 아이템 하나만 취소 +``` + +**UC-O05: 주문 아이템 취소 (Admin)** + +``` +[기능 흐름] +1. Admin이 orderId, orderItemId로 아이템 취소를 요청한다 +2. 주문이 존재하는지 확인한다 +3. 주문이 ORDERED 상태인지 확인한다 +4. 해당 아이템이 ORDERED 상태인지 확인한다 +5. 아이템을 CANCELLED로 변경한다 +6. totalPrice를 남은 ORDERED 아이템 기준으로 재계산한다 +7. 모든 아이템이 CANCELLED이면 Order도 CANCELLED로 변경한다 +8. 해당 상품의 재고를 복구한다 (quantity만큼) + +[예외] +- 주문이 존재하지 않으면 404 +- 주문이 이미 CANCELLED이면 취소 불가 +- 아이템이 이미 CANCELLED이면 취소 불가 + +[조건] +- LDAP 인증 필요 +- 소유자 검증 없음 +``` + +--- + +## 시퀀스 다이어그램 + +### 주문 요청 + +> 주문은 **Product 도메인 (상품 검증 + 재고 차감) + Order 도메인 (주문 생성 + 스냅샷)**을 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + participant OC as OrderController + participant OF as OrderFacade + participant PS as ProductService + participant OS as OrderService + participant OR as OrderRepository + + Note left of OC: POST /api/v1/orders + OC->>OF: 주문 요청 + OF->>PS: 상품 조회 및 검증 + PS-->>OF: 상품 + + Note over PS: 상품 예외 처리
(없음, 삭제됨, 재고 부족) + + OF->>PS: 재고 차감 + PS-->>OF: 완료 + + OF->>OS: 주문 생성 (스냅샷 포함) + OS->>OR: 주문 저장 + OR-->>OS: 주문 + OS-->>OF: 주문 + OF-->>OC: 주문 생성 완료 +``` + +### 주문 아이템 취소 + +> 취소는 **Order 도메인 (상태 전이 + totalPrice 재계산) + Product 도메인 (재고 복구)**을 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + participant OC as OrderController + participant OF as OrderFacade + participant OS as OrderService + participant PS as ProductService + + Note left of OC: PATCH /api/v1/orders/{orderId}
/items/{orderItemId}/cancel + OC->>OF: 아이템 취소 요청 (orderId, orderItemId, userId) + OF->>OS: 주문 조회 + 소유자 검증 + OS-->>OF: Order (with items) + + Note over OS: 주문 상태 검증 (ORDERED인지)
아이템 상태 검증 (ORDERED인지) + + OF->>OS: 아이템 취소 (cancelItem) + Note over OS: item.cancel()
totalPrice 재계산
전체 CANCELLED 시 Order도 CANCELLED + OS-->>OF: 취소된 OrderItem 정보 (productId, quantity) + + OF->>PS: 재고 복구 (productId, quantity) + PS-->>OF: 완료 + + OF-->>OC: 취소 완료 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class Order { + Long userId + List~OrderItem~ items + int totalPrice + int originalTotalPrice + OrderStatus status + +create(Long userId, List~OrderItem~ items) Order + +addItem(OrderItem item) void + +cancelItem(Long orderItemId) OrderItem + +recalculateTotalPrice() void + +validateOwner(Long userId) void + } + + class OrderItem { + Order order + Long productId + ProductSnapshot productSnapshot + int orderPrice + int quantity + OrderItemStatus status + +create(Long productId, int orderPrice, int quantity, ProductSnapshot snapshot) OrderItem + +cancel() void + +assignOrder(Order order) void + } + + class ProductSnapshot { + <> + String productName + String brandName + String imageUrl + } + + class OrderStatus { + <> + ORDERED + CANCELLED + } + + class OrderItemStatus { + <> + ORDERED + CANCELLED + } + + Order "*" --> "1" User : userId + Order "1" *-- "*" OrderItem : @OneToMany (양방향) + Order --> OrderStatus + OrderItem *-- ProductSnapshot : @Embedded + OrderItem --> OrderItemStatus +``` + +### 비즈니스 규칙 + +| 엔티티 | 메서드 | 비즈니스 규칙 | +|---|---|---| +| Order | create(userId, items) | 정적 팩토리. items를 받아 totalPrice와 originalTotalPrice를 직접 계산하고, 양방향 연관관계를 세팅 | +| Order | addItem(item) | 편의 메서드. items 컬렉션에 추가 + item.assignOrder(this)로 양방향 동기화 | +| Order | cancelItem(orderItemId) | 아이템을 찾아 cancel() 호출. totalPrice 재계산. 전체 CANCELLED 시 Order도 CANCELLED. 취소된 OrderItem 반환 (재고 복구용) | +| Order | recalculateTotalPrice() | ORDERED 상태인 items의 orderPrice × quantity 합산으로 totalPrice 갱신 | +| Order | validateOwner(userId) | 본인 주문인지 검증 | +| OrderItem | create(productId, orderPrice, quantity, snapshot) | 정적 팩토리. 주문 시점 상품 정보를 ProductSnapshot으로 저장. status는 ORDERED | +| OrderItem | cancel() | status를 CANCELLED로 변경. 이미 CANCELLED이면 예외 | +| OrderItem | assignOrder(order) | Order 참조 세팅. Order.addItem()에서 호출 | + +### 관계 정리 + +| 관계 | 참조 방식 | 설명 | +|---|---|---| +| User → Order | ID 참조 (userId) | UserSnapshot 불필요 | +| Order ↔ OrderItem | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade={PERSIST,MERGE}, orphanRemoval=false, @BatchSize(100) | +| OrderItem → ProductSnapshot | `@Embedded` (`@Embeddable` VO) | 주문 시점 상품 정보를 개념 단위로 그룹핑. 테이블은 order_items에 그대로 저장 | +| OrderItem.productId | ID 유지 (FK 아님) | 재구매, 통계 분석을 위한 데이터 연결용 | + +--- + +## ERD + +```mermaid +erDiagram + orders { + bigint id PK + bigint user_id + int total_price + int original_total_price + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + order_items { + bigint id PK + bigint order_id + bigint product_id + varchar product_name + varchar brand_name + varchar image_url + int order_price + int quantity + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + users ||--o{ orders : "" + orders ||--|{ order_items : "" +``` + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| orders (user_id, created_at) | 유저의 주문 목록 조회 (날짜 범위 필터링) | +| order_items.order_id | 주문의 상세 항목 조회 | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | + +### 참조 무결성 검증 (애플리케이션 레벨) + +- 주문 생성 시 — 모든 product_id가 유효하고, expectedPrice와 현재 가격이 일치하며, 재고가 충분한지 확인 + +### order_items.product_id 포함 이유 + +스냅샷은 **조회 편의용**이고, product_id는 **데이터 연결용**으로 역할이 다르다. + +- 재구매 기능: "이 상품을 다시 구매" 시 원본 상품으로 이동 +- 통계 분석: "어떤 상품이 얼마나 팔렸나" 집계 시 product_id 기준으로 GROUP BY +- FK가 아님: 상품이 삭제되어도 주문 내역은 스냅샷으로 보존 diff --git a/docs/design/product/DESIGN.md b/docs/design/product/DESIGN.md new file mode 100644 index 000000000..e4e18b30c --- /dev/null +++ b/docs/design/product/DESIGN.md @@ -0,0 +1,197 @@ +# Product 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **비회원으로서**, 상품 목록을 둘러보고 상세 정보를 확인할 수 있다. +> **관리자로서**, 상품을 등록/수정/삭제하여 판매 상품을 관리할 수 있다. + +### 예외 및 정책 + +- **Soft Delete** — `deleted_at` 컬럼으로 논리 삭제 +- **재고: Product 필드로 관리** — 별도 Stock 도메인 분리 없이 Product 엔티티의 stock 필드로 관리. 등록/수정 시 재고 설정, 주문 시 차감. +- **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 관리 정보 추가 제공 +- **soft delete된 상품** — 고객 조회 불가 (404 반환) +- **Brand → Product 참조** — ID 참조 (`Long brandId`). 도메인 간 패키지 격벽을 위해 객체참조 대신 ID 참조. Brand 정보가 필요할 때는 Application 계층(Facade)에서 BrandService를 통해 조합. +- **Product.likeCount 캐시 필드** — 찜 수 조회 성능을 위해 Product에 likeCount 캐싱. 찜/취소 시 원자적 증감. +- **독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 목록 조회 | 비회원/회원 | GET | `/api/v1/products` | X | +| 상품 정보 조회 | 비회원/회원 | GET | `/api/v1/products/{productId}` | X | +| 상품 목록 조회 | Admin | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | +| 상품 상세 조회 | Admin | GET | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 등록 | Admin | POST | `/api-admin/v1/products` | LDAP | +| 상품 정보 수정 | Admin | PUT | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 삭제 | Admin | DELETE | `/api-admin/v1/products/{productId}` | LDAP | + +### 상품 목록 조회 쿼리 파라미터 + +| 파라미터 | 설명 | 기본값 | +|----------|------|--------| +| `brandId` | 특정 브랜드 상품 필터링 | - (선택) | +| `sort` | 정렬 기준: `latest` / `price_asc` / `likes_desc` | `latest` | +| `page` | 페이지 번호 | 0 | +| `size` | 페이지당 상품 수 | 20 | + +> `likes_desc` 정렬 시 좋아요 수는 Product.likeCount 필드로 정렬. + +--- + +## 유즈케이스 + +**UC-P01: 상품 목록 조회 (비회원)** + +``` +[기능 흐름] +1. 비회원이 상품 목록을 요청한다 (선택: brandId, sort, page, size) +2. soft delete된 상품/브랜드를 제외한다 +3. 정렬 조건에 맞게 정렬한다 +4. 페이지네이션하여 상품 목록을 반환한다 +5. 각 상품의 좋아요 수를 Product.likeCount로 함께 반환한다 + +[대안 흐름] +- brandId가 없으면 전체 상품 조회 +- sort가 없으면 latest(최신순) 기본 적용 +``` + +**UC-P02: 상품 정보 조회 (비회원)** + +``` +[기능 흐름] +1. 비회원이 productId로 상품 정보를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 상품 정보와 함께 좋아요 수(Product.likeCount)를 반환한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 +``` + +**UC-P03: 상품 등록 (Admin)** + +``` +[기능 흐름] +1. Admin이 상품 정보를 입력한다 (brandId, 상품명, 가격, 재고 등) +2. brandId에 해당하는 브랜드가 존재하는지 확인한다 +3. 상품을 저장한다 +4. 생성된 상품 정보를 반환한다 + +[예외] +- brandId에 해당하는 브랜드가 없거나 삭제된 경우 등록 실패 + +[조건] +- 상품의 브랜드는 반드시 이미 등록된(삭제되지 않은) 브랜드여야 함 +- 재고(stock)는 상품 등록 시 초기값 설정 (0 이상) +``` + +**UC-P04: 상품 정보 수정 (Admin)** + +``` +[기능 흐름] +1. Admin이 productId와 수정할 정보를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 상품 정보를 업데이트한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 + +[조건] +- 상품의 브랜드(brandId)는 수정할 수 없음 +- 재고(stock) 수정 가능 +``` + +**UC-P05: 상품 삭제 (Admin)** + +``` +[기능 흐름] +1. Admin이 productId로 삭제를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 해당 상품을 soft delete 한다 + +[예외] +- productId에 해당하는 상품이 없거나 이미 삭제된 경우 404 반환 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class Product { + Long brandId + String name + int price + int stock + int likeCount + +update(String, int, int) void + +decreaseStock(int) void + +validateExpectedPrice(int) void + +isSoldOut() boolean + +addLikeCount() void + +subtractLikeCount() void + +softDelete() void + +isDeleted() boolean + } + + Product "*" --> "1" Brand : ID 참조 (brandId) +``` + +### 비즈니스 규칙 + +| 메서드 | 비즈니스 규칙 | +|---|---| +| create(brandId, name, price, stock) | 가격 0 이상, 재고 0 이상 검증 (Entity 내부 validatePriceRange, validateStockRange) | +| decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Entity 도메인 메서드에서 직접 처리 | +| validateExpectedPrice(int) | 주문 시 기대 가격과 현재 가격 비교. 불일치 시 예외 | +| isSoldOut() | stock이 0인지 확인. "품절"의 정의를 캡슐화 | +| addLikeCount() / subtractLikeCount() | 찜 등록/취소 시 likeCount 원자적 증감 | +| softDelete() / isDeleted() | deleted_at 설정 | + +--- + +## ERD + +```mermaid +erDiagram + brands { + bigint id PK + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + products { + bigint id PK + bigint brand_id + varchar name + int price + int stock + int like_count + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands ||--o{ products : "" +``` + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| products.brand_id | 브랜드별 상품 필터링, 브랜드 삭제 시 연쇄 soft delete | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | +| products.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 경합이 심하지 않으므로 비관적 락은 과도함 | diff --git "a/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" "b/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" new file mode 100644 index 000000000..3f1118bec --- /dev/null +++ "b/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" @@ -0,0 +1,740 @@ +# 하이패션 이커머스 도메인 관계 설계 — 의사결정 기록 + +> 기술적 사고력이란, Trade-Off를 내 상황에 맞게 실무 관점에서 생각하고 결정하는 것이다. +> 이 글은 하이패션 이커머스 프로젝트의 도메인 관계를 설계하면서 어떤 고민을 했고, 어떤 선택지가 있었고, 왜 그렇게 결정했는지를 기록한 것이다. + +--- + +## 프로젝트 맥락 + +SSENSE와 같은 하이패션 이커머스 플랫폼을 설계하고 있다. 관리자가 브랜드의 상품을 올리는 구조이며, 고가 브랜드를 다루기 때문에 소비자들이 신중하게 구매하고, 장바구니에 상품이 많이 담기지 않는 특성이 있다. 현재 샵이나 카테고리는 없지만 나중에 추가할 수 있다. + +초기 도메인은 Brand, Product, Order, Like, CartItem으로 시작했고, 이 관계들을 하나씩 구체화해나갔다. + +## 최종 도메인 관계 — 먼저 결론부터 + +이 글에서 다루는 모든 고민의 최종 결과다. 왜 이렇게 됐는지는 이후의 Part 1~3에서 하나씩 따라가면 된다. + +```mermaid +graph TB + User["유저 (User)"] + Brand["브랜드 (Brand)"] + Product["상품 (Product)"] + Like["찜 (Like)"] + CartItem["장바구니 (CartItem)"] + Order["주문 (Order)"] + OrderItem["주문상품 (OrderItem)"] + + Brand -->|"객체참조, FK없음"| Product + User --- Like + User --- CartItem + User --- Order + Product --- Like + Product --- CartItem + Product -->|"스냅샷"| OrderItem + Order --- OrderItem +``` + +``` +모든 영역 간 참조는 ID 참조 (Brand → Product만 객체참조 + FK 없음) +Cart ↔ Order 사이에 직접 관계 없음 (Facade가 조율) +삭제 전략은 기본적으로 SoftDelete +DB FK 제약 사용하지 않음, 무결성은 애플리케이션에서 보장 +DB 유니크 제약은 사용 (테이블 내부 제약, FK와 성격이 다름) +``` + +--- + +# Part 1. 주문 흐름 설계 + +> **이 파트에서 다루는 것**: 상품이 장바구니에 담기고, 주문으로 이어지는 흐름을 설계한다. Cart 엔티티가 정말 필요한지, 주문 경로가 2개인 게 맞는지, 품절/삭제/가격 변동을 어떻게 검증할지를 결정한다. + +## 1-1. Cart 엔티티가 필요한가? + +### 상황 + +User와 CartItem을 연결하는 방식을 설계하면서, "현실 세계에 장바구니라는 개념이 있으니까 Cart 엔티티를 만들어야 하는 거 아닌가?"라는 의문이 들었다. + +### 스스로에게 던진 질문들 + +**"장바구니 단위로 어떤 행위를 하고 싶은가?"** — 딱히 없었다. 장바구니라는 현실 개념이 있으니까 고민이 된 것이지, Cart 엔티티가 해야 할 구체적인 행위가 떠오르지 않았다. 이 시점에서 이미 경고 신호였다. + +**"장바구니에서 주문할 때, 전체를 주문하나? 선택한 항목만?"** — 둘 다 지원해야 한다. 그런데 이건 Cart 엔티티 유무와 관계없이 가능하다. 전체 주문은 `WHERE user_id = ?`로, 선택 주문은 `WHERE id IN (?)`로 CartItem을 조회하면 된다. 이건 요청 파라미터의 문제이지 도메인 모델의 문제가 아니다. + +**"한 유저가 여러 장바구니를 가질 수 있나?"** — 없다. 1인 1장바구니다. 이게 결정적이었다. User : Cart가 1:1이면, Cart의 식별자는 사실상 userId와 동일하다. 별도의 cartId가 존재할 이유가 없다. 만약 1:N이었다면 (위시리스트, "나중에 살 것" 등) Cart에 이름이나 타입 같은 고유 속성이 생기니까 엔티티가 정당화되지만, 1:1에서는 그냥 User의 연장선이다. + +### 선택지 + +| | User → CartItem 직접 | User → Cart → CartItem | +|---|---|---| +| 구조 | CartItem에 userId만 | Cart 엔티티 + CartItem | +| Cart 고유 상태 | 없음 | 만료일, 쿠폰, 공유 URL 등 가능 | +| Cart 고유 행위 | 없음 | 잠금, 만료 등 가능 | +| 복잡도 | 낮음 | 중간 | + +Cart 엔티티가 정당화되려면 장바구니 만료일(30일 후 자동 비우기), 장바구니 단위 쿠폰 적용 상태, 장바구니 공유 기능(공유 URL 생성), 장바구니 잠금(결제 진행 중 변경 방지) 같은 것이 필요하다. 현재 이런 요구사항이 없다. + +- **내 상황에서의 판단**: "N개의 상품을 담는다"는 Cart의 고유한 역할이 아니라 CartItem 집합의 성질이다. `SELECT * FROM cart_item WHERE user_id = ?` 한 줄로 "이 유저의 장바구니"가 완성된다. + +### 결정: User → CartItem 직접 + Cart는 일급 컬렉션으로 + +DB 엔티티로서의 Cart는 만들지 않되, 코드에서 **일급 컬렉션(First-Class Collection)**으로 Cart 객체를 둔다. + +```java +public class Cart { + private final List items; + + public Cart(List items) { + this.items = items; + } + + public int getTotalPrice() { ... } + public int getItemCount() { ... } + public List selectItems(List cartItemIds) { ... } +} +``` + +이렇게 하면 "장바구니 전체 가격 계산", "선택한 항목만 추출" 같은 장바구니 단위 행위를 Cart 객체 안에 응집시킬 수 있다. 테이블은 없으니 불필요한 엔티티 없이, 장바구니라는 현실 개념을 코드에서 표현하는 절충안이다. + +> **이 결정에서 얻은 것**: "현실의 개념 = 엔티티"가 아니다. "도메인에서 고유한 상태와 행위가 있는 개념 = 엔티티"다. + +--- + +## 1-2. 주문 경로가 2개인 문제 + +### 상황 + +이 프로젝트에서 가장 깊이 고민한 설계 결정이다. + +주문으로 이어지는 경로가 두 가지다. "상품 페이지에서 바로구매"와 "장바구니에서 주문". 그런데 정말 2가지 경로가 필요한 것인지 의심이 들었다. 주문할 때 Product가 CartItem으로 담기는 구조라면, 결국 모든 주문이 CartItem을 거치는 것 아닌가? + +### 스스로에게 던진 질문들 + +**"주문의 진짜 원천은 뭐지?"** — CartItem은 Product를 "담아둔 포인터"일 뿐이다. 장바구니 주문이든 바로구매든, 결국 주문에 필요한 정보(상품, 가격, 재고)는 Product가 가지고 있다. 그러면 "모든 주문은 결국 Product를 거친다"고 볼 수 있지 않을까? + +**"CartItem에 가격을 저장해야 하나?"** — 하이패션에서 시즌 세일이 핵심 구매 패턴이다. CartItem에 담은 시점 가격을 저장하면, 세일이 시작돼도 장바구니에 정가가 보인다. 유저가 빼고 다시 담아야 세일 가격이 적용되는 최악의 UX가 된다. **가격의 원천(source of truth)은 항상 Product여야 한다.** + +**"그러면 CartItem은 뭐지?"** — CartItem은 `productId + quantity`만 가지는 포인터다. + +### 선택지 + +**접근법 A: 두 개의 독립 경로.** 바로구매는 Product → Order 직접, 장바구니 주문은 CartItem → Order로 별도 로직. + +- 장점: 바로구매 시 CartItem을 만들 필요 없음, 각 경로 독립 최적화 가능 +- 단점: 주문 생성 로직이 두 벌, 쿠폰/할인 추가 시 양쪽 다 수정 필요 +- **내 상황에서의 판단**: 쿠폰/할인 기능을 계획하고 있기 때문에, 주문 로직이 두 벌이면 유지보수 비용이 배로 늘어난다. **탈락.** + +**접근법 B: 모든 주문이 CartItem을 거침.** 바로구매를 눌러도 내부적으로 임시 CartItem 생성 → 주문 → CartItem 삭제. 항상 단일 흐름. + +- 장점: 주문 로직이 하나, 쿠폰/할인 한 곳에서 관리 +- 단점: 바로구매 시 임시 CartItem이 생기는 게 도메인적으로 부자연스러움. CartItem은 "담아두기"라는 의미인데, 바로구매하는 사람은 "담아둔" 적이 없음 +- **내 상황에서의 판단**: 하이패션 특성상 고가 상품이라 바로구매 비율이 높을 수 있다. 매번 임시 CartItem을 만들었다 지우는 건 의미적으로도, 데이터적으로도 낭비다. + +**접근법 C: Cart와 Order가 서로를 모르는 구조.** 핵심 발상의 전환 — OrderItem 입장에서 자기가 장바구니에서 왔는지, 바로구매에서 왔는지는 전혀 중요하지 않다. Order 도메인은 "어떤 상품을, 몇 개, 얼마에"만 알면 된다. + +``` +// 핵심 원칙: OrderService는 출처를 모른다 +OrderService.createOrder(userId, List items, CouponInfo coupon) + +// Facade에서 분기 +바로구매 → OrderItemCommand 직접 생성 → OrderService 호출 +장바구니 → CartItem 조회 → OrderItemCommand 변환 → OrderService 호출 → CartItem 삭제 +``` + +- 장점: 도메인 간 결합도 최소, 주문 로직 단일화, Cart를 나중에 완전히 바꿔도 Order에 영향 없음 +- 단점: Facade 계층을 잘 설계해야 함 +- **내 상황에서의 판단**: Facade 패턴은 현재 프로젝트에서 이미 사용하고 있는 구조여서 추가 비용이 크지 않다. + +**접근법 D: "모든 주문은 Product를 거친다"는 관점.** 접근법 C를 한 단계 더 구체화한다. + +- 바로구매: Product → Order +- 장바구니 주문: CartItem → **Product** → Order + +CartItem은 Product를 가리키는 포인터일 뿐이고, 주문의 진짜 원천은 항상 Product다. 이 관점이 C에서 Facade가 `OrderItemCommand(productId, quantity, price)`를 만들 때 **price를 어디서 가져오느냐**를 확정해준다 — 항상 Product에서 가져온다. + +### 결정: 접근법 C + Product 중심 원칙 + +```mermaid +graph TB + subgraph A["접근법 A: 독립 경로"] + A1["바로구매"] --> A2["주문 로직 #1"] + A3["장바구니"] --> A4["주문 로직 #2"] + end + + subgraph B["접근법 B: 전부 CartItem 경유"] + B1["바로구매"] --> B2["임시 CartItem"] + B3["장바구니"] --> B4["CartItem"] + B2 --> B4 --> B5["주문 로직"] + end + + subgraph C["접근법 C: Facade 조율 ✅"] + C1["바로구매"] --> C3["Facade"] + C2["장바구니"] --> C3 + C3 --> C4["OrderItemCommand"] + C4 --> C5["OrderService"] + end +``` + +"2가지 경로"라는 문제의 해결책은 "경로를 하나로 합치는 것(B)"이 아니라, **"Order 도메인이 경로를 신경 쓰지 않게 만드는 것(C)"**이었다. + +이 결정의 핵심 근거: + +1. **쿠폰/할인 계획이 있다** → 주문 로직이 단일해야 유지보수 가능 (A 탈락) +2. **하이패션 특성상 바로구매 비율이 높을 수 있다** → 임시 CartItem 강제는 비효율 (B 약점) +3. **DDD 관점에서 Cart와 Order는 서로 다른 Bounded Context** → 직접 의존 없는 것이 자연스러움 (C의 강점) +4. **Facade 패턴을 이미 사용 중** → C의 조율 비용이 추가 부담이 아님 +5. **하이패션은 시즌 세일이 핵심 패턴** → 가격의 원천은 항상 Product (D의 보강) + +--- + +## 1-3. 품절, 삭제, 가격 변동 — 주문 시점에 무엇을 검증해야 하는가? + +### 상황 + +1-2에서 "가격의 원천은 항상 Product"라는 원칙을 세웠다. 그런데 이 원칙을 따르면, 장바구니에 담긴 상품의 상태가 바뀌었을 때 어떻게 처리해야 하는지라는 문제가 자연스럽게 따라온다. + +### 스스로에게 던진 질문들 + +#### "CartItem이 가격을 저장하는 이커머스는 어떤 상황인가?" + +이 질문은 "왜 최신 가격이 맞는지"를 반대 사례로 검증하기 위해 던졌다. + +CartItem에 가격을 저장하는 대표적인 사례는 B2B 이커머스(고객별 협상 가격)나 타임세일/플래시딜("지금 담으면 이 가격 보장") 같은 비즈니스 규칙이 있는 경우다. 하이패션에서는 고객별 차등 가격이 없고, "담으면 가격 보장" 정책도 없다. 오히려 시즌 세일 시 장바구니에 담아둔 상품 가격이 자동으로 바뀌어야 자연스러운 UX다. + +#### "장바구니에 담긴 상품이 품절되면?" + +하이패션에서 한정 수량이 많으니 이건 빈번한 시나리오다. + +**선택지:** +1. 품절되면 장바구니에서 자동 제거 +2. 품절 표시하고 유저가 직접 제거 +3. 품절 표시 + 재입고 시 알림 + +**결정: 품절 표시하고 유저가 직접 제거.** 자동 제거는 유저 입장에서 "내가 담아둔 게 사라졌다"는 혼란을 준다. 하이패션에서 비싼 상품을 신중하게 골라 담았는데 자동으로 없어지면 불쾌하다. + +#### "상품이 아예 삭제(SoftDelete)되면?" + +시즌이 끝나면 상품 자체가 내려가는 경우다. + +**선택지:** +1. 판매 종료 표시 + 주문 불가 +2. 장바구니에서 자동 제거 +3. 일정 기간 후 자동 제거 + +**결정: 판매 종료 표시 + 주문 불가.** 품절은 재입고 가능성이 있지만, 삭제된 상품은 돌아오지 않는다. SoftDelete의 가치가 여기서 드러난다 — HardDelete였으면 CartItem이 참조하는 Product가 사라져서 FK 제약 위반이 되지만, SoftDelete이면 Product 데이터는 남아있으니 "판매 종료" 표시가 가능하다. + +#### "장바구니에 품절 상품이 섞여있을 때 주문하면?" + +**선택지:** +1. 품절 상품 포함 시 주문 전체 차단 +2. 품절 상품만 빼고 나머지만 주문 가능 +3. 유저에게 선택하게 + +**결정: 품절 상품 포함 시 주문 전체 차단.** 하이패션에서 유저는 "전체 코디를 맞춰서 사는" 패턴이 강하다. 자켓 + 팬츠를 함께 담았는데 팬츠만 빠진 채로 주문되면 오히려 불만이다. + +#### "품절 차단을 어느 시점에 하는가?" + +**결정: 장바구니 화면에서 미리 차단 + 백엔드 이중 검증.** 프론트에서 주문 버튼을 비활성화하는 건 UX 가이드일 뿐이다. 유저가 장바구니 화면을 보는 시점과 주문 버튼을 누르는 시점 사이에 시간차가 있고, 그 사이에 품절될 수 있다. **프론트는 UX를 위한 가이드이고, 백엔드가 실제 안전장치다.** + +#### "결제 직전에 가격이 바뀌는 동시성 문제는?" + +이게 가장 실무적으로 중요한 문제다. 유저가 장바구니 화면에서 50만원을 보고 주문 버튼을 눌렀는데, 그 사이 관리자가 가격을 변경했다면? + +**결정: 주문 시점에 유저가 본 가격(expectedPrice)과 Product의 현재 가격을 비교해서, 다르면 주문을 막는다.** 하이패션에서 고가 상품이라 가격 차이가 크기 때문에, 일반 이커머스에서 100원 차이는 무시할 수 있지만 여기서 50,000원 차이는 분쟁이 된다. + +#### "가격 변동 시 유저에게 알림을 줘야 하나?" + +**결정: 지금은 아니지만, 설계에 영향을 주지 않게 분리한다.** 알림은 CartItem의 책임이 아니라 알림 도메인의 책임이다. Product 가격이 바뀌면 이벤트를 발행하고, 해당 Product를 CartItem에 담고 있는 유저들에게 알림을 보내는 이벤트 기반 구조로 나중에 추가할 수 있다. 중요한 건 이 기능 때문에 CartItem에 price 필드를 넣을 필요가 없다는 점이다. + +### Facade 검증 흐름 — 세 가지가 한 곳에서 수렴 + +위 질문들의 결론을 종합하면, 주문 생성 직전에 Facade가 해야 할 검증이 하나로 모인다. + +```mermaid +flowchart TB + Start(["주문 요청"]) --> Facade["Facade 검증"] + Facade --> Check1{"상품 삭제?"} + Check1 -->|"삭제됨"| E1["판매 종료된 상품입니다"] + Check1 -->|"정상"| Check2{"품절?"} + Check2 -->|"품절"| E2["품절된 상품입니다"] + Check2 -->|"재고 있음"| Check3{"가격 변동?"} + Check3 -->|"불일치"| E3["가격이 변경되었습니다"] + Check3 -->|"일치"| Pass["검증 통과"] + Pass --> Order(["OrderService.createOrder()"]) +``` + +```java +// Facade에서 주문 생성 전 검증 흐름 +for (OrderItemCommand item : items) { + Product product = productService.findById(item.getProductId()); + + // 1. 상품 삭제 여부 + if (product.isDeleted()) → "판매 종료된 상품입니다" 에러 + + // 2. 품절 여부 + if (product.isSoldOut()) → "품절된 상품입니다" 에러 + + // 3. 가격 변동 여부 + if (product.getPrice() != item.getExpectedPrice()) → "가격이 변경되었습니다" 에러 +} + +// 모든 검증 통과 시에만 주문 생성 +orderService.createOrder(userId, validatedItems); +``` + +상품 삭제, 품절, 가격 변동 — 이 세 가지 검증이 모두 같은 시점(주문 생성 직전)에, 같은 위치(Facade)에서 일어난다. Order 도메인은 이 검증을 몰라도 된다. + +### Part 1에서 세운 원칙 + +> - **가격의 원천은 항상 Product** — CartItem은 가격을 저장하지 않는다 +> - **Cart와 Order는 서로를 모른다** — Facade가 경로를 조율하고, Order 도메인은 출처를 모른다 +> - **Facade가 안전한 주문만 통과시키는 게이트** — 품절/삭제/가격 검증은 Facade에서 수렴 + +--- + +# Part 2. 도메인 경계 설계 + +> **이 파트에서 다루는 것**: Part 1에서 주문 흐름을 설계했으니, 이제 도메인을 어떤 영역으로 나누고, 영역 간에 어떻게 연결하며, Aggregate 경계를 어디에 두는지를 결정한다. + +## 2-1. 도메인 영역은 어떻게 나누는가? + +### 상황 + +도메인 관계가 구체화되면서, "이 도메인들을 어떤 영역(Bounded Context)으로 묶을 것인가?"라는 질문이 자연스럽게 떠올랐다. 처음에는 "상품 영역에 Brand, Product, Like, CartItem이 있고, 주문 영역에 Order가 있다"고 생각했다. + +### 영역 판단 기준의 발전 + +이 고민을 풀면서 **영역을 나누는 기준 자체가 3단계로 발전**했다. 처음에 세운 기준이 반례에 부딪히면서 더 정교한 기준으로 진화한 과정이 핵심이다. + +#### 첫 번째 기준: 생명주기 종속 → 실패 + +"Like의 생명주기가 Product에 종속되니까 상품 영역"이라고 판단했다. Product가 삭제되면 Like도 존재 이유가 없으니까. + +하지만 CartItem도 Product가 삭제되면 "판매 종료"가 되는데, Part 1에서 CartItem은 독자적인 품절/가격 검증 규칙이 있어서 독립 영역이 맞다고 결정했다. **생명주기 종속만으로는 영역을 결정할 수 없었다.** + +#### 두 번째 기준: 독자적 행위 유무 → 필요하지만 불충분 + +Like의 행위를 구체적으로 나열해보니: + +- Like한 상품이 품절/삭제 시 유저 알림 필요 +- 상품의 인기수에 반영 (Product 방향) +- 유저의 찜 리스트 조회 (User 방향) + +분명히 독자적 행위가 있었다. 만약 Like가 Product 하위 도메인이면, "마이페이지 찜 리스트"를 조회할 때 ProductService에 `findLikedProductsByUserId` 같은 메서드가 생긴다. Product 도메인이 User의 찜 행위까지 책임지게 되는 것이다. + +**→ Like도 독립 영역.** + +하지만 이 기준만으로는 "그러면 Like와 CartItem을 하나로 묶을 수 있는가?"에 답할 수 없었다. 둘 다 독자적 행위가 있으니까. + +#### 세 번째 기준: "함께 변경되는가" → 결정적 + +Like와 CartItem을 나란히 비교해봤다. + +| | Like | CartItem | +|---|---|---| +| 구조 | userId + productId | userId + productId + quantity | +| Order와 관계 | 없음 | Facade 통해 주문으로 변환 | +| 변경 빈도 | 낮음 (한번 찜하면 유지) | 높음 (수량 변경, 추가/제거) | + +구조가 비슷하니까 "유저 활동 영역"으로 묶을 수 있지 않을까? 하지만 핵심 차이는 **CartItem은 주문 흐름에 참여하고, Like는 참여하지 않는다**는 것이다. CartItem의 품절/가격 검증 로직이 변경될 때 Like 쪽 코드는 건드릴 필요가 없다. + +"구조가 비슷하다"는 묶는 이유가 안 된다. **"함께 변경되는가"**가 영역을 나누는 진짜 기준이다. + +#### 세 기준의 발전 과정 정리 + +| 기준 | 내용 | 한계 | +|---|---|---| +| ① 생명주기 종속 | "Product 삭제 시 같이 사라지면 같은 영역" | CartItem도 종속인데 독립 영역 → 반례 | +| ② 독자적 행위 유무 | "자기만의 비즈니스 규칙이 있으면 독립" | Like, CartItem 둘 다 있음 → 묶을지 판단 불가 | +| ③ 함께 변경되는가 | "한쪽 로직이 바뀔 때 다른쪽도 바꿔야 하면 같은 영역" | **결정적 기준** | + +기준 ①이 실패해서 ②로, ②가 부족해서 ③으로 발전한 것이지, 처음부터 ③을 알고 있었던 건 아니다. + +### 결정: 6개 독립 영역 + +```mermaid +graph LR + subgraph brand_ctx["브랜드"] + Brand["Brand"] + end + subgraph product_ctx["상품"] + Product["Product"] + end + subgraph like_ctx["찜"] + Like["Like"] + end + subgraph cart_ctx["장바구니"] + CartItem["CartItem"] + Cart["Cart (일급컬렉션)"] + end + subgraph order_ctx["주문"] + Order["Order"] + OrderItem["OrderItem"] + end + subgraph user_ctx["유저"] + User["User"] + end + + brand_ctx -->|"객체참조"| product_ctx + product_ctx ---|"ID 참조"| like_ctx + product_ctx ---|"ID 참조"| cart_ctx + product_ctx ---|"ID 참조"| order_ctx + user_ctx ---|"ID 참조"| like_ctx + user_ctx ---|"ID 참조"| cart_ctx + user_ctx ---|"ID 참조"| order_ctx +``` + +실선 화살표(→)는 같은 도메인 내 객체 참조, 실선(─)은 영역 간 ID 참조다. + +- **브랜드 영역**: Brand +- **상품 영역**: Product +- **찜 영역**: Like +- **장바구니 영역**: CartItem (+ 일급 컬렉션 Cart) +- **주문 영역**: Order, OrderItem +- **유저 영역**: User + +--- + +## 2-2. Brand ↔ Product — 참조 방식, Aggregate 경계, 패키지 구조 + +### 상황 + +Brand 1:N Product 관계에서, Brand가 삭제되면 해당 Product도 전부 판매 중단하기로 결정했고, Product는 반드시 Brand에 속해야 한다. 이 결정들이 참조 방식과 Aggregate 경계에 어떤 영향을 주는지 따져봐야 했다. + +### 스스로에게 던진 질문들 + +#### "JPA에서 참조 방식이 뭐가 있고, 뭘 써야 하나?" + +멘토님이 "JPA에서는 키(인덱스, 유니크키, 외래키)에 대한 기능을 직접적으로 사용하지 않습니다"라고 했는데, 정확히 어떤 방식인지 몰랐다. 알고 보니 세 가지 방식이 있었다. + +**방식 1: 객체 참조 + DB FK 제약.** `@ManyToOne` + `@JoinColumn`으로 DB에 FK 제약조건이 생긴다. + +**방식 2: ID 참조.** `private Long brandId`로 ID만 가진다. Brand 정보가 필요하면 별도로 조회해야 한다. + +**방식 3: 객체 참조 + FK 없음.** `@ManyToOne` + `@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))`로 코드에서는 객체 참조를 쓰지만 DB에는 FK 제약을 안 만든다. + +| | 방식 1 (객체 + FK) | 방식 2 (ID만) | 방식 3 (객체 + FK없음) | +|---|---|---|---| +| 코드 접근 | `product.getBrand()` | `brandService.findById(brandId)` | `product.getBrand()` | +| DB FK 제약 | 있음 | 없음 | 없음 | +| 무결성 보장 | DB가 보장 | 앱이 보장 | 앱이 보장 | +| 결합도 | DB + 코드 둘 다 | 완전 분리 | 코드만 결합 | +| 데드락 위험 | FK 잠금 전파 | 없음 | 없음 | + +- **내 상황에서의 판단**: Brand와 Product는 같은 상품 도메인이고, `product.getBrand().getName()` 같은 접근이 빈번하다. 방식 2(ID만)는 매번 별도 조회가 필요해서 번거롭다. 방식 1(FK)은 데드락 위험과 결합도가 문제다. 방식 3은 코드 편의성과 DB 유연성의 절충안이다. + +#### "데드락 가능성은?" + +FK 제약이 있으면 Product를 INSERT/UPDATE할 때 DB가 Brand 행에 공유 잠금을 건다. 같은 Brand의 상품을 여러 트랜잭션이 동시에 수정하면 데드락이 가능하다. 하이패션에서 시즌 세일 때 한 브랜드의 상품 가격을 일괄 변경하는 상황을 생각하면 현실적 위험이다. FK가 없는 방식 3에서는 이 위험이 사라진다. + +#### "Brand와 Product의 Aggregate 경계는?" + +| | Brand가 Root + Product 하위 | 각각 독립 Root | +|---|---|---| +| Brand 조회 시 | Product 전부 로딩 | Brand만 로딩 | +| Product 수정 시 | Brand Aggregate 잠금 | Product만 잠금 | +| 동시성 | 같은 Brand 상품 동시 수정 어려움 | 자유로움 | +| Brand 삭제 → Product | Aggregate 내부 자동 처리 | Facade에서 조율 필요 | + +- **내 상황에서의 판단**: 하이패션에서 브랜드당 상품이 100개 이상일 수 있다. Brand를 조회할 때마다 Product 100개가 로딩되고, 시즌 세일 때 같은 브랜드의 상품을 동시에 수정하면 충돌이 생긴다. Aggregate 내부 자동 처리라는 편의보다 성능과 동시성이 더 중요하다. + +#### "패키지 구조는?" + +**접근 A: 영역별 완전 분리.** brand/, product/, like/, cart/, order/, user/ 각각 독립 패키지. +**접근 B: 핵심 도메인 그룹핑.** product/ 안에 brand, like 등을 서브패키지로. + +- **내 상황에서의 판단**: 2-1에서 6개 독립 영역으로 결정했다. 패키지 구조가 이 결정을 반영해야 한다. "브랜드를 검색했는데 상품이 없을 수도 있다"는 점을 생각하면, Brand가 Product 하위 패키지에 있으면 Brand 조회가 상품 영역의 하위 기능처럼 보인다. **접근 A 채택.** + +### 결정 + +- **참조 방식**: 방식 3 (객체 참조 + FK 없음) +- **Aggregate 경계**: 각각 독립 Aggregate Root +- **패키지 구조**: 영역별 완전 분리 + +> **이 결정에서 얻은 것**: 참조 방식은 "편하냐"가 아니라 "FK가 주는 제약(데드락, 결합도)을 감수할 수 있느냐"로, Aggregate 경계는 "같은 영역이냐"가 아니라 "같은 트랜잭션에서 다뤄져야 하느냐"로 판단해야 한다. + +--- + +## 2-3. Product ↔ OrderItem — 주문 내역과 스냅샷 + +### 상황 + +CartItem은 "아직 안 산 것"이지만 OrderItem은 "이미 산 것"이다. 이 차이가 설계에 큰 영향을 준다. 핵심 질문은 "주문된 상품이 나중에 삭제되면, 과거 주문 내역은 어떻게 되는가?"였다. + +### 스스로에게 던진 질문들 + +#### "Product가 삭제된 후에도 주문 내역에서 상품 정보를 보여줘야 하나?" + +**결정: 주문 시 스냅샷을 같이 생성한다.** Part 1의 "가격의 원천은 Product" 원칙과 연결된다. 주문 시점에 Product에서 최신 정보를 가져와서 OrderItem에 스냅샷으로 찍는다. + +여기서 **CartItem과 OrderItem의 스냅샷 필요성 차이**가 명확해진다. + +```mermaid +graph LR + subgraph now["CartItem = 지금"] + CI["CartItem"] -->|"항상 최신 조회"| P1["Product\n현재 가격"] + end + + subgraph then["OrderItem = 그때"] + OI["OrderItem"] --- SS["ProductSnapshot\n주문 시점 기록"] + OI -.->|"연결용"| P2["Product"] + end +``` + +| | CartItem | OrderItem | +|---|---|---| +| 성격 | "아직 안 산 것" | "이미 산 것" | +| 필요한 정보 | 항상 최신 (세일 가격 반영) | 주문 시점 기록 보존 | +| 스냅샷 | **불필요** | **필수** | + +같은 Product 정보를 참조하지만 "시간의 관점"이 다르다. CartItem은 "지금"이 중요하고, OrderItem은 "그때"가 중요하다. + +#### "OrderItem에서 productId를 계속 가지고 있어야 하나?" + +스냅샷이 있으면 productId 없이도 주문 내역을 보여줄 수 있다. 하지만 "이 상품을 다시 구매" 기능이나 "어떤 상품이 얼마나 팔렸나" 통계 분석에 productId가 필요하다. + +**결정: productId 유지.** 스냅샷은 조회 편의용이고, productId는 데이터 연결용으로 역할이 다르다. + +#### "스냅샷을 어떤 구조로 저장할 것인가?" + +| | 접근 1: 직접 넣기 | 접근 2: @Embedded | 접근 3: 별도 테이블 | +|---|---|---|---| +| DB | OrderItem 테이블 하나 | OrderItem 테이블 하나 | OrderItem + Snapshot 2개 | +| 코드 | 필드 뒤섞임 | 역할별 분리 | 완전 분리 | +| 스냅샷 필드 추가 시 | OrderItem이 비대해짐 | ProductSnapshot만 수정 | JOIN 필요 | +| JPA 마찰 | 없음 | @AttributeOverride, null 전파 | JOIN 관리 | + +- **내 상황에서의 판단**: 현재 스냅샷 필드가 2개(productName, brandName)로 소수이므로 접근 1(직접 필드)이 가장 단순하다. 접근 2(@Embedded)는 `@Embeddable`의 JPA 마찰(같은 타입 2개 시 `@AttributeOverride`, 내부 필드 전부 null 시 객체 자체 null)이 있고, 프로젝트 전체에서 VO를 만들지 않는 원칙과도 일관성이 맞지 않는다. 접근 3은 항상 같이 생성/조회되는 데이터를 굳이 테이블로 나누는 과도한 분리다. 스냅샷 필드가 많아지면 그때 @Embedded 또는 별도 테이블을 재검토한다. + +### 결정: Entity 직접 필드로 저장 + +> **리팩토링 기록**: 초기에는 `@Embedded ProductSnapshot`으로 설계했으나, VO 전면 제거 리팩토링에서 Entity 직접 필드 방식으로 변경. `@Embeddable`의 JPA 마찰(같은 타입 2개 시 `@AttributeOverride` 보일러플레이트, 내부 필드 전부 null 시 객체 자체 null 등)을 고려하여, 스냅샷 필드가 소수일 때는 직접 필드가 더 단순하다고 판단. + +```java +@Entity +public class OrderItem { + private Long orderId; + private Long productId; // 데이터 연결용 (재구매, 통계) + private int orderPrice; + private int quantity; + private String productName; // 주문 시점 스냅샷 + private String brandName; // 주문 시점 스냅샷 +} +``` + +### Part 2에서 세운 원칙 + +> - **영역을 나누는 기준은 "함께 변경되는가"** — 생명주기 종속이나 구조 유사성이 아니라, 변경 이유가 같은가 +> - **FK는 편의가 아니라 제약** — 데드락, 결합도, 삭제 순서 문제를 감수할 수 있을 때만 +> - **같은 데이터라도 역할이 다르면 분리** — productId(연결), orderPrice(기록), productName/brandName(스냅샷 조회) + +--- + +# Part 3. 나머지 관계 확정 + +> **이 파트에서 다루는 것**: Part 1~2에서 핵심 구조와 원칙을 세웠다. 이제 남은 관계들(User ↔ Order, User ↔ Like, User ↔ CartItem, Product ↔ Like, Product ↔ CartItem, Order ↔ OrderItem)에 같은 원칙을 적용해서 일괄 확정한다. + +## 3-1. User ↔ Order + +### "참조 방식은?" + +다른 영역이므로 ID 참조가 원칙이다. 하지만 "Order에서 User 정보가 자주 필요하면 방식 3(객체 참조)이 편하지 않을까?"라는 의문이 있었다. + +이걸 판단하려면 **"주문 내역을 누가 조회하는가?"**부터 따져야 한다. 유저 본인만 자기 주문을 본다. 관리자 화면은 현재 범위에 없다. 그러면 Order 도메인 로직에서 User 객체가 필요한 순간이 없다. + +- **내 상황에서의 판단**: Brand ↔ Product에서 방식 3을 택한 건 "같은 상품 도메인이고 `product.getBrand().getName()`이 빈번하기 때문"이었는데, User ↔ Order에는 그런 이유가 없다. **ID 참조.** + +### "UserSnapshot이 필요한가?" + +| | ProductSnapshot | UserSnapshot | +|---|---|---| +| 삭제 후 조회 상황 | 유저가 "뭘 샀는지" 봄 | 탈퇴한 유저는 로그인 불가 → 조회 자체가 없음 | +| 스냅샷 필요성 | **필수** | **불필요** | + +- **내 상황에서의 판단**: ProductSnapshot이 필요한 이유는 "상품이 삭제돼도 유저가 주문 내역에서 뭘 샀는지 봐야 하기 때문"이다. UserSnapshot은? 유저 본인이 탈퇴하면 조회 주체 자체가 사라진다. 보여줄 대상이 없다. + +### 결정 + +- 참조 방식: ID 참조 (userId만) +- UserSnapshot: 불필요 +- User 탈퇴 시: 주문 데이터 DB 유지 (비즈니스 기록) +- 주문 취소만 지원 (반품은 나중에 추가) + +--- + +## 3-2. User ↔ Like, User ↔ CartItem + +### User ↔ Like + +Like 도메인 로직에서 `like.getUser().getName()` 같은 호출이 필요한 시점이 없다. **ID 참조.** + +User 탈퇴 시 Like 데이터를 유지할 이유도 없다. 탈퇴한 유저의 찜은 허수 데이터일 뿐이다. 삭제하면 상품의 찜 수(likeCount)도 줄어드는데, 이것이 맞다 — 실제로 활동하는 유저의 찜만 의미 있는 수치다. + +### User ↔ CartItem + 동시성 문제 + +CartItem도 같은 논리로 **ID 참조.** + +여기서 중요한 설계 결정이 나왔다. **"동일 상품을 장바구니에 또 담으면?"** → 수량 +1로 결정했는데, 이건 `userId + productId` 조합이 유일해야 한다는 뜻이다. + +#### "유니크 보장을 어디서 할 것인가?" + +애플리케이션에서만 보장하면 동시성 문제가 생긴다. 같은 상품에 "장바구니 담기" 버튼을 빠르게 두 번 클릭하면, 두 요청이 동시에 "CartItem 없음"을 확인하고 둘 다 INSERT해서 중복 데이터가 생긴다. + +| | 비관적 락 | 분산 락 | DB 유니크 제약 | +|---|---|---|---| +| 경합 빈도 낮을 때 | 과도함 | 과도함 | **적절** | +| 정합성 보장 | 확실 | Redis 정상 시 | **확실** | +| 인프라 추가 | 없음 | Redis 필요 | **없음** | +| 코드 복잡도 | 중간 (잠금 범위 관리) | 높음 (락 타임아웃, 갱신) | **낮음** (예외 처리만) | + +- **내 상황에서의 판단**: 하이패션에서 장바구니 담기의 동시성 경합은 "더블클릭" 수준이지, 쿠팡 타임세일처럼 수만 명이 동시에 담는 상황이 아니다. 경합 빈도는 매우 낮지만, 발생하면 반드시 막아야 한다(중복 CartItem → 주문 시 같은 상품이 OrderItem 2개로 생김). Redis 인프라도 없다. **DB 유니크 제약이 가장 합리적인 비용으로 가장 확실한 보장을 준다.** + +비관적 락이나 분산 락은 "수만 명이 동시에 같은 자원을 경합하는 상황"에서 빛을 발한다. 예를 들어 한정판 스니커즈 100개를 1만 명이 동시에 주문하는 시나리오 — 이건 재고 차감의 동시성 문제이고, 장바구니 중복 방지와는 다른 문제다. + +#### "멘토님이 키를 안 쓴다고 했는데, 유니크 제약도 해당되나?" + +FK와 유니크 제약은 같은 "키"라는 이름이지만 성격이 다르다. + +| | FK (외래키) | 유니크 제약 | +|---|---|---| +| 제약 범위 | **테이블 간** (Product → Brand) | **테이블 내부** (CartItem 자체) | +| 다른 테이블 영향 | 있음 (잠금 전파, 삭제 순서) | **없음** | +| 데드락 위험 | 있음 | **없음** | + +멘토님이 피하려는 건 FK의 **테이블 간 결합**이지, 테이블 내부 제약까지 포함한 게 아니다. + +### 결정 + +| | User ↔ Like | User ↔ CartItem | +|---|---|---| +| 참조 방식 | ID 참조 (userId) | ID 참조 (userId, productId) | +| 유니크 제약 | userId + productId | userId + productId | +| User 탈퇴 시 | 삭제 + likeCount 감소 | 삭제 | + +--- + +## 3-3. Product ↔ Like — likeCount 집계 방식 + +### "인기수를 어떻게 집계할 것인가?" + +상품 상세, 상품 목록, 브랜드별 상품, 검색 결과 — 상품이 나오는 모든 곳에서 찜 수가 표현돼야 한다. + +| 기준 | COUNT (조회 시 집계) | likeCount 캐시 (Product 필드) | +|---|---|---| +| 데이터 정합성 | ✅ 항상 정확 | 관리 필요 (탈퇴/삭제 시 동기화) | +| 조회 성능 | 모든 API에 서브쿼리 | ✅ Product만 읽으면 끝 | +| 영역 간 결합도 | ✅ 완전 독립 | Like → Product 단방향 결합 | +| 동시성 | - | `likeCount + 1` 원자적 처리 | + +처음에는 "영역 결합도"를 중시해서 COUNT 방식을 선호했다. 하지만 결합의 실체를 따져보니 — **원자적 UPDATE 한 줄(`SET likeCount = likeCount + 1`)이 전부**다. 이 정도 결합으로 모든 조회 API가 깔끔해진다면? + +- **내 상황에서의 판단**: 영역 결합은 원칙이고, 조회 편의성은 매일 마주치는 현실이다. 원칙을 어기는 대가가 UPDATE 한 줄이고, 지키는 대가가 모든 상품 조회 API의 서브쿼리라면, **충분히 합리적인 타협**이다. + +### "likeCount가 있으면 Like 엔티티가 필요 없지 않나?" + +| 기능 | likeCount만으로 가능? | Like 엔티티 필요? | +|---|---|---| +| "이 상품 찜 수" 표시 | ✅ | - | +| "내가 이 상품을 찜했나?" | ❌ | ✅ | +| "내 찜 리스트" 조회 | ❌ | ✅ | +| "찜 취소" | ❌ | ✅ | +| "찜한 상품 세일 알림" | ❌ | ✅ | + +**likeCount는 Like 데이터의 파생값(derived data)**이다. Like 엔티티가 원본이고 likeCount는 조회 성능을 위한 캐시다. + +```mermaid +graph TB + subgraph origin["찜 영역 — 원본"] + Like["Like"] + end + + subgraph cache["상품 영역 — 캐시"] + Product["Product.likeCount"] + end + + Like -->|"찜하기: +1 / 취소: -1"| Product + + subgraph need_like["Like 엔티티가 필요한 기능"] + F1["내가 찜했나?"] + F2["내 찜 리스트"] + F3["찜 취소"] + F4["찜한 상품 알림"] + end + + subgraph only_count["likeCount만으로 충분한 기능"] + C1["찜 수 표시"] + end + + Like -.-> need_like + Product -.-> only_count +``` + +### 결정 + +- 참조 방식: ID 참조 (productId만) +- 유니크 제약: DB 유니크 (`userId + productId`) +- 인기수 집계: Product에 likeCount 캐시 (원자적 증감) +- Like 엔티티 유지 (원본 데이터) +- Product 삭제 시: Like 삭제 + +--- + +## 3-4. Product ↔ CartItem + +Part 1-3에서 품절/삭제 시 처리 규칙은 다뤘다. 참조 방식과 스냅샷 여부만 확정하면 된다. + +Part 2-3에서 정리한 대비가 여기서도 적용된다 — **CartItem은 "아직 안 산 것"이라 항상 Product의 최신 정보를 보여줘야 한다.** 하이패션에서 시즌 세일이 시작되면 장바구니에 담아둔 상품의 세일 가격이 바로 반영돼야 한다. + +### 결정 + +- 참조 방식: ID 참조 (productId만) +- 스냅샷: 불필요 (장바구니 조회 시 항상 Product에서 현재 정보 조회) + +--- + +## 3-5. Order ↔ OrderItem — @OneToMany vs ID 참조 + +### 상황 + +같은 주문 영역이고 같은 Aggregate(Order가 Root)다. 같은 Aggregate이면 `@OneToMany + Cascade`가 자연스러운 선택인데, 정말 그래야 하나? + +### Trade-Off + +| | @OneToMany + Cascade | ID 참조 + Service 관리 | +|---|---|---| +| Aggregate 보장 | JPA 자동 (cascade, orphanRemoval) | Service 수동 (@Transactional) | +| 조회 | `order.getOrderItems()` | `orderItemRepository.findByOrderId()` | +| 예측 가능성 | JPA 내부 동작에 의존 | **쿼리가 명시적** | +| N+1 위험 | 있음 (LAZY 로딩 시) | **없음** | +| 프로젝트 일관성 | 이 관계만 다른 패턴 | **전체 ID 참조와 동일** | + +`orderItem.getOrder()`가 필요한 상황을 점검했는데, OrderItem을 조회하는 상황은 항상 "주문 #123의 상품 목록" 같이 **Order를 먼저 알고 있는 상황**이다. 역추적이 필요 없으니 객체 참조의 실질적 이점이 없다. + +- **내 상황에서의 판단**: 세 가지 이유로 ID 참조. 첫째, 프로젝트 전체가 ID 참조 패턴이므로 **일관성**. 한 군데만 `@OneToMany`를 쓰면 코드를 읽을 때 "여기는 왜 이 방식이지?"라는 의문이 생긴다. 둘째, 멘토님의 JPA 암묵적 동작 지양 방침과의 **정합성**. 셋째, `orderItem.getOrder()`가 필요한 상황이 없으므로 **잃는 게 없음**. + +### 결정 + +- 참조 방식: ID 참조 (orderId만) +- Aggregate 규칙: Service에서 관리 (@Transactional로 함께 생성/삭제 보장) +- @OneToMany 사용하지 않음 + +### Part 3에서 도출된 판단 프레임워크 + +> 모든 관계를 따져보니 네 가지 질문으로 참조 방식을 판단할 수 있었다. +> +> 1. **"도메인 로직에서 상대 객체가 필요한가?"** — 필요 없으면 ID 참조 +> 2. **"삭제 시 데이터를 유지해야 하는가?"** — 유지 필요하면 스냅샷 검토 +> 3. **"동시성으로 데이터가 깨질 수 있는가?"** — 유니크 필요하면 DB 제약 +> 4. **"프로젝트 전체와 일관적인가?"** — 일관성이 예측 가능성을 높인다 + +--- + +## 돌아보며 + +도메인 관계를 설계할 때, 가장 먼저 "정답"을 찾으려 했다. 하지만 실제로 중요했던 것은 **내 상황을 정확히 파악하는 것**이었다. + +"하이패션이라 장바구니에 많이 안 담긴다", "바로구매 비율이 높을 수 있다", "쿠폰/할인이 나중에 들어온다", "시즌 세일이 핵심 구매 패턴이다" — 이런 맥락이 선택지의 장단점 무게를 완전히 바꿨다. 같은 접근법이라도 쿠팡 같은 대량 구매 이커머스였다면 다른 결정을 내렸을 것이다. + +그리고 하나의 좋은 질문이 연쇄적으로 다음 질문을 낳았다. "주문 경로가 2개인 게 맞나?"라는 의심이 "가격의 원천은 어디인가?"로, 그리고 "품절/삭제/가격 변동을 어떻게 검증하는가?"로 자연스럽게 이어졌다. 영역을 나누다 보니 "Like는 정말 상품 영역인가?"라는 의문이 생겼고, 참조 방식을 결정하다 보니 "데드락은?"이라는 질문이 Aggregate 경계 결정으로 이어졌다. + +기술적 의사결정은 "가장 좋은 방법"을 고르는 게 아니라, "내 상황에서 가장 합리적인 타협"을 찾는 과정이다. 그리고 그 과정에서 **스스로에게 좋은 질문을 던지는 것**이 생각하는 힘의 핵심이다. diff --git a/gradle.properties b/gradle.properties index 142d7120f..a1af0cfb3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,4 @@ mockitoVersion=5.14.0 instancioJUnitVersion=5.0.2 slackAppenderVersion=1.6.1 kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m +org.gradle.java.home=/Users/jins/Library/Java/JavaVirtualMachines/temurin-21.0.7/Contents/Home diff --git a/http/commerce-api/admin-brand-v1.http b/http/commerce-api/admin-brand-v1.http new file mode 100644 index 000000000..39bc78995 --- /dev/null +++ b/http/commerce-api/admin-brand-v1.http @@ -0,0 +1,76 @@ +### 브랜드 등록 - 성공 케이스 +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "나이키" +} + +### 브랜드 등록 - 브랜드명 빈값 (실패) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "" +} + +### 브랜드 등록 - LDAP 헤더 누락 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "나이키" +} + +### 브랜드 등록 - 잘못된 LDAP 헤더 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: wrong.value + +{ + "name": "나이키" +} + +### 브랜드 목록 조회 - 기본 페이지 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 브랜드 목록 조회 - 페이지 크기 지정 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=2 +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 - 존재하지 않는 브랜드 (실패) +GET {{commerce-api}}/api-admin/v1/brands/999 +X-Loopers-Ldap: loopers.admin + +### 브랜드 수정 - 성공 케이스 +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "아디다스" +} + +### 브랜드 수정 - 브랜드명 빈값 (실패) +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "" +} + +### 브랜드 삭제 - 성공 케이스 +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 삭제 - 존재하지 않는 브랜드 (실패) +DELETE {{commerce-api}}/api-admin/v1/brands/999 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/admin-product-v1.http b/http/commerce-api/admin-product-v1.http new file mode 100644 index 000000000..761fb5c87 --- /dev/null +++ b/http/commerce-api/admin-product-v1.http @@ -0,0 +1,96 @@ +### 상품 등록 - 성공 케이스 +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "에어맥스", + "price": 150000, + "stock": 100 +} + +### 상품 등록 - 상품명 빈값 (실패) +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "", + "price": 150000, + "stock": 100 +} + +### 상품 등록 - LDAP 헤더 누락 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스", + "price": 150000, + "stock": 100 +} + +### 상품 등록 - 잘못된 LDAP 헤더 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: wrong.value + +{ + "brandId": 1, + "name": "에어맥스", + "price": 150000, + "stock": 100 +} + +### 상품 목록 조회 - 기본 페이지 +GET {{commerce-api}}/api-admin/v1/products?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - 페이지 크기 지정 +GET {{commerce-api}}/api-admin/v1/products?page=0&size=2 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - 브랜드 필터링 +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 - 존재하지 않는 상품 (실패) +GET {{commerce-api}}/api-admin/v1/products/999 +X-Loopers-Ldap: loopers.admin + +### 상품 수정 - 성공 케이스 +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "에어포스", + "price": 120000, + "stock": 50 +} + +### 상품 수정 - 상품명 빈값 (실패) +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "", + "price": 120000, + "stock": 50 +} + +### 상품 삭제 - 성공 케이스 +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin + +### 상품 삭제 - 존재하지 않는 상품 (실패) +DELETE {{commerce-api}}/api-admin/v1/products/999 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..3d6b70e97 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,5 @@ +### 브랜드 상세 조회 - 성공 케이스 +GET {{commerce-api}}/api/v1/brands/1 + +### 브랜드 상세 조회 - 존재하지 않는 브랜드 (실패) +GET {{commerce-api}}/api/v1/brands/999 diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..c89d000c4 --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,117 @@ +### 좋아요 등록 - 성공 케이스 (사전: 회원가입 + 상품 등록 필요) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 등록 - 이미 좋아요한 상품 (실패, 409 CONFLICT) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 등록 - 존재하지 않는 상품 (실패, 404 NOT_FOUND) +POST {{commerce-api}}/api/v1/products/999/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 등록 - 인증 헤더 누락 (실패, 401 UNAUTHORIZED) +POST {{commerce-api}}/api/v1/products/1/likes + +### 좋아요 등록 - 잘못된 비밀번호 (실패, 401 UNAUTHORIZED) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +############################################### +# 좋아요 취소 API +############################################### + +### 좋아요 취소 - 성공 케이스 +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 - 좋아요 기록이 없는 상품 (실패, 404 NOT_FOUND) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 - 인증 헤더 누락 (실패, 401 UNAUTHORIZED) +DELETE {{commerce-api}}/api/v1/products/1/likes + +### 좋아요 취소 - 잘못된 비밀번호 (실패, 401 UNAUTHORIZED) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +############################################### +# 내 좋아요 목록 조회 API +############################################### + +### 내 좋아요 목록 조회 - 성공 케이스 +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 내 좋아요 목록 조회 - 좋아요 없을 때 빈 목록 +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 내 좋아요 목록 조회 - 인증 헤더 누락 (실패, 401 UNAUTHORIZED) +GET {{commerce-api}}/api/v1/users/me/likes + +### 내 좋아요 목록 조회 - 잘못된 비밀번호 (실패, 401 UNAUTHORIZED) +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +############################################### +# 시나리오 테스트 (전체 플로우) +############################################### + +### [STEP 1] 회원가입 +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "likeuser1", + "password": "Test1234!", + "name": "좋아요테스터", + "birthDate": "19950505", + "email": "like@example.com" +} + +### [STEP 2] 좋아요 등록 (상품 1) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 3] 좋아요 등록 (상품 2) +POST {{commerce-api}}/api/v1/products/2/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 4] 내 좋아요 목록 조회 (2개) +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 5] 좋아요 취소 (상품 1) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 6] 내 좋아요 목록 조회 (1개) +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 7] 중복 좋아요 등록 시도 (실패, 409 CONFLICT) +POST {{commerce-api}}/api/v1/products/2/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 8] 이미 취소한 좋아요 다시 취소 시도 (실패, 404 NOT_FOUND) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..66e182c6e --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,20 @@ +### 상품 목록 조회 - 기본 +GET {{commerce-api}}/api/v1/products?page=0&size=20 + +### 상품 목록 조회 - 브랜드 필터링 +GET {{commerce-api}}/api/v1/products?brandId=1&page=0&size=20 + +### 상품 목록 조회 - 가격 낮은순 정렬 +GET {{commerce-api}}/api/v1/products?sort=price_asc&page=0&size=20 + +### 상품 목록 조회 - 좋아요 많은순 정렬 +GET {{commerce-api}}/api/v1/products?sort=likes_desc&page=0&size=20 + +### 상품 목록 조회 - 페이지 크기 지정 +GET {{commerce-api}}/api/v1/products?page=0&size=2 + +### 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1 + +### 상품 상세 조회 - 존재하지 않는 상품 (실패) +GET {{commerce-api}}/api/v1/products/999