From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/66] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e5..000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. From c7b0383e6b3eceeae249727cfa92bccc0f9766e8 Mon Sep 17 00:00:00 2001 From: praesentia Date: Wed, 4 Feb 2026 06:11:14 +0900 Subject: [PATCH 02/66] =?UTF-8?q?chore:=20Claude=20Code=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20=EB=B0=8F=20=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .claude/hooks/tdd-notion-logger.py | 470 +++++++++++++++++++++++++++++ .claude/settings.json | 16 + .claude/settings.local.json | 27 ++ .gitignore | 42 +-- CLAUDE.md | 152 ++++++++++ docs/member-implementation-plan.md | 455 ++++++++++++++++++++++++++++ 6 files changed, 1123 insertions(+), 39 deletions(-) create mode 100755 .claude/hooks/tdd-notion-logger.py create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 docs/member-implementation-plan.md diff --git a/.claude/hooks/tdd-notion-logger.py b/.claude/hooks/tdd-notion-logger.py new file mode 100755 index 000000000..04ffbfcb4 --- /dev/null +++ b/.claude/hooks/tdd-notion-logger.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +""" +TDD Notion Logger Hook for Claude Code. + +PostToolUse hook that logs TDD Red-Green-Refactor cycles to a Notion page +with AI reasoning extracted from the conversation transcript. +""" + +import json +import os +import re +import sys +import urllib.request +import urllib.error +from datetime import datetime, timezone, timedelta + +NOTION_PAGE_ID = "2fc2e1bd53b2809cbd5ed9009dc775bd" +NOTION_API_VERSION = "2022-06-28" +KST = timezone(timedelta(hours=9)) + +TEST_FILE_PATTERN = re.compile(r".*Test\.java$") +JAVA_FILE_PATTERN = re.compile(r".*\.java$") + + +def main(): + hook_input = json.loads(sys.stdin.read()) + + tool_name = hook_input.get("tool_name", "") + if tool_name != "Bash": + return + + command = hook_input.get("tool_input", {}).get("command", "") + if not re.search(r"gradlew.*test", command): + return + + tool_response = hook_input.get("tool_response", {}) + stdout = extract_stdout(tool_response) + + if "BUILD SUCCESSFUL" not in stdout: + return + + notion_api_key = os.environ.get("NOTION_API_KEY") + if not notion_api_key: + sys.stderr.write("NOTION_API_KEY environment variable not set\n") + return + + transcript_path = hook_input.get("transcript_path", "") + if not transcript_path or not os.path.exists(transcript_path): + sys.stderr.write(f"Transcript not found: {transcript_path}\n") + return + + phases = parse_tdd_phases(transcript_path) + + test_class = extract_test_class(command) + test_methods = extract_test_methods(stdout) + timestamp = datetime.now(KST).strftime("%Y-%m-%d %H:%M") + + blocks = build_notion_blocks(test_class, timestamp, phases, test_methods) + append_blocks_to_notion(notion_api_key, blocks) + + +# --------------------------------------------------------------------------- +# Stdout / metadata extraction (unchanged) +# --------------------------------------------------------------------------- + +def extract_stdout(tool_response): + """Extract stdout text from tool_response, handling various formats.""" + if isinstance(tool_response, str): + return tool_response + if isinstance(tool_response, dict): + if "stdout" in tool_response: + return tool_response["stdout"] + if "content" in tool_response: + return str(tool_response["content"]) + if isinstance(tool_response, list): + parts = [] + for item in tool_response: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(item.get("text", "")) + return "\n".join(parts) + return str(tool_response) + + +def extract_test_class(command): + """Extract test class name from gradlew test command.""" + match = re.search(r'--tests\s+"?\*?([A-Za-z0-9_.]+)"?', command) + if match: + name = match.group(1) + return name.rsplit(".", 1)[-1] if "." in name else name + return "UnknownTest" + + +def extract_test_methods(stdout): + """Extract executed test method names from test output.""" + methods = [] + for line in stdout.split("\n"): + match = re.search(r">\s+(\w+)\(\)\s+PASSED", line) + if match: + methods.append(match.group(1)) + return methods + + +# --------------------------------------------------------------------------- +# Transcript parsing — TDD phase extraction with AI reasoning +# --------------------------------------------------------------------------- + +def read_recent_entries(transcript_path, max_entries=500): + """Read the most recent entries from a JSONL transcript file.""" + entries = [] + try: + with open(transcript_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + except (OSError, IOError): + return [] + return entries[-max_entries:] + + +def find_tdd_cycle_entries(entries): + """Return entries between the last two gradlew test Bash commands. + + This scopes the parsing to only the current TDD cycle. + """ + test_run_indices = [] + for i, entry in enumerate(entries): + if entry.get("type") != "assistant": + continue + for content in entry.get("message", {}).get("content", []): + if (content.get("type") == "tool_use" + and content.get("name") == "Bash" + and re.search(r"gradlew.*test", + content.get("input", {}).get("command", ""))): + test_run_indices.append(i) + break + + if not test_run_indices: + return entries + + if len(test_run_indices) < 2: + return entries[: test_run_indices[-1]] + + start = test_run_indices[-2] + 1 + end = test_run_indices[-1] + return entries[start:end] + + +def extract_reasoning_from_entry(entry): + """Extract visible reasoning text from an assistant message. + + Prefers ``text`` blocks (visible to user). Falls back to a truncated + ``thinking`` block summary when no text is available. + """ + content_list = entry.get("message", {}).get("content", []) + if not isinstance(content_list, list): + return "" + + text_parts = [] + thinking_parts = [] + + for content in content_list: + if content.get("type") == "text": + t = content.get("text", "").strip() + if t: + text_parts.append(t) + elif content.get("type") == "thinking": + t = content.get("thinking", "").strip() + if t: + thinking_parts.append(t) + + if text_parts: + return "\n".join(text_parts) + if thinking_parts: + combined = "\n".join(thinking_parts) + return combined[:800] + ("..." if len(combined) > 800 else "") + return "" + + +def extract_test_names(tool_input): + """Extract test method names from Write/Edit content.""" + names = [] + content = tool_input.get("content", "") or tool_input.get("new_string", "") + if not content: + return names + + for match in re.finditer( + r"(?:@Test|@DisplayName)\s*(?:\(\"([^\"]+)\"\))?\s*\n\s*(?:void\s+(\w+))?", + content, + ): + display_name = match.group(1) + method_name = match.group(2) + name = display_name or method_name + if name and name not in names: + names.append(name) + + if not names: + for match in re.finditer(r"void\s+(\w+)\s*\(", content): + name = match.group(1) + if name not in names: + names.append(name) + + return names + + +def parse_tdd_phases(transcript_path): + """Parse transcript JSONL into TDD phases with AI reasoning. + + Returns ``{"red": [...], "green": [...], "refactor": [...]}``. + Each entry: ``{"reasoning": str, "files": [str], "test_names": [str]}``. + """ + entries = read_recent_entries(transcript_path, max_entries=500) + cycle_entries = find_tdd_cycle_entries(entries) + + phases = {"red": [], "green": [], "refactor": []} + green_files_seen = set() + + for entry in cycle_entries: + if entry.get("type") != "assistant": + continue + + content_list = entry.get("message", {}).get("content", []) + if not isinstance(content_list, list): + continue + + test_files = [] + source_files = [] + test_names = [] + + for content in content_list: + if content.get("type") != "tool_use": + continue + if content.get("name") not in ("Write", "Edit"): + continue + + file_path = content.get("input", {}).get("file_path", "") + if not file_path: + continue + + filename = os.path.basename(file_path) + + if TEST_FILE_PATTERN.match(filename): + if filename not in test_files: + test_files.append(filename) + test_names.extend( + n for n in extract_test_names(content.get("input", {})) + if n not in test_names + ) + elif JAVA_FILE_PATTERN.match(filename): + if filename not in source_files: + source_files.append(filename) + + if not test_files and not source_files: + continue + + reasoning = extract_reasoning_from_entry(entry) + + if test_files: + phases["red"].append({ + "reasoning": reasoning, + "files": test_files, + "test_names": test_names, + }) + + if source_files: + all_seen = all(f in green_files_seen for f in source_files) + if green_files_seen and all_seen: + phases["refactor"].append({ + "reasoning": reasoning, + "files": source_files, + "test_names": [], + }) + else: + phases["green"].append({ + "reasoning": reasoning, + "files": source_files, + "test_names": [], + }) + green_files_seen.update(source_files) + + return phases + + +# --------------------------------------------------------------------------- +# Notion block builders +# --------------------------------------------------------------------------- + +def truncate_text(text, max_len=1900): + """Truncate text to fit Notion rich_text limit (2000 chars).""" + if len(text) <= max_len: + return text + return text[:max_len] + "..." + + +def make_heading2(text): + return { + "object": "block", + "type": "heading_2", + "heading_2": { + "rich_text": [{"type": "text", "text": {"content": truncate_text(text)}}] + }, + } + + +def make_paragraph(text, bold_prefix=None): + rich_text = [] + if bold_prefix: + rich_text.append({ + "type": "text", + "text": {"content": bold_prefix}, + "annotations": {"bold": True}, + }) + + parts = re.split(r"(`[^`]+`)", text) + for part in parts: + if part.startswith("`") and part.endswith("`"): + rich_text.append({ + "type": "text", + "text": {"content": part[1:-1]}, + "annotations": {"code": True}, + }) + elif part: + rich_text.append({ + "type": "text", + "text": {"content": truncate_text(part)}, + }) + + return { + "object": "block", + "type": "paragraph", + "paragraph": {"rich_text": rich_text}, + } + + +def make_bulleted_list(text): + return { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": { + "rich_text": [{"type": "text", "text": {"content": text}}] + }, + } + + +def make_toggle(title, children_blocks, color="default"): + """Create a Notion toggle block with nested children.""" + return { + "object": "block", + "type": "toggle", + "toggle": { + "rich_text": [{"type": "text", "text": {"content": title}}], + "color": color, + "children": children_blocks, + }, + } + + +def make_divider(): + return {"object": "block", "type": "divider", "divider": {}} + + +# --------------------------------------------------------------------------- +# Notion block assembly +# --------------------------------------------------------------------------- + +def build_notion_blocks(test_class, timestamp, phases, test_methods): + """Build Notion API block children payload with AI reasoning per phase.""" + blocks = [] + + blocks.append(make_heading2(f"{test_class} ({timestamp})")) + + # --- Red phase --- + red_summary = _format_red_summary(phases["red"], test_methods) + blocks.append(make_paragraph(red_summary, bold_prefix="Red: ")) + for entry in phases["red"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="red_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"파일: {f}")) + + # --- Green phase --- + green_summary = "테스트 통과를 위한 구현" if phases["green"] else "테스트 통과 확인" + blocks.append(make_paragraph(green_summary, bold_prefix="Green: ")) + for entry in phases["green"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="green_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"파일: {f}")) + + # --- Refactor phase --- + if phases["refactor"]: + blocks.append(make_paragraph("코드 품질 개선", bold_prefix="Refactor: ")) + for entry in phases["refactor"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="blue_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"파일: {f}")) + + # --- Result --- + blocks.append(make_paragraph("BUILD SUCCESSFUL", bold_prefix="Result: ")) + blocks.append(make_divider()) + + # Safety: Notion allows max 100 blocks per request + return blocks[:100] + + +def _format_red_summary(red_entries, test_methods): + """Format the Red phase summary line.""" + all_names = [] + for entry in red_entries: + all_names.extend(entry["test_names"]) + + if all_names: + names = ", ".join(f"`{n}`" for n in all_names[:5]) + return f"{names} 테스트 작성" + if test_methods: + names = ", ".join(f"`{m}`" for m in test_methods[:5]) + return f"{names} 테스트 작성" + return "테스트 작성" + + +# --------------------------------------------------------------------------- +# Notion API call (unchanged) +# --------------------------------------------------------------------------- + +def append_blocks_to_notion(api_key, blocks): + """Append blocks to the Notion page using urllib (no external deps).""" + url = f"https://api.notion.com/v1/blocks/{NOTION_PAGE_ID}/children" + payload = json.dumps({"children": blocks}).encode("utf-8") + + req = urllib.request.Request( + url, + data=payload, + method="PATCH", + headers={ + "Authorization": f"Bearer {api_key}", + "Notion-Version": NOTION_API_VERSION, + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status != 200: + sys.stderr.write(f"Notion API returned status {resp.status}\n") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + sys.stderr.write(f"Notion API error {e.code}: {body}\n") + except urllib.error.URLError as e: + sys.stderr.write(f"Notion API connection error: {e.reason}\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..61fadfa1c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "source ~/.zshrc 2>/dev/null; python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-notion-logger.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..5d525720f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew build:*)", + "Bash(java -version:*)", + "Bash(echo:*)", + "Bash(/usr/libexec/java_home:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home ./gradlew build:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home ./gradlew:*)", + "Bash(docker info:*)", + "Bash(docker context inspect:*)", + "Bash(docker run:*)", + "Bash(docker context:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home DOCKER_HOST=unix:///Users/praesentia/.docker/run/docker.sock ./gradlew:*)", + "Bash(~/.testcontainers.properties)", + "Bash(curl:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock ./gradlew:*)", + "Bash(./gradlew:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home DOCKER_API_VERSION=1.44 ./gradlew:*)", + "Bash(python3:*)", + "WebSearch", + "WebFetch(domain:github.com)", + "Bash(osascript:*)", + "Bash(docker:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index 5a979af6f..f9bd50072 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,4 @@ -HELP.md -.gradle +.DS_Store +.idea/ build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ - -### Kotlin ### -.kotlin +.gradle/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d547a18a5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multi-module Spring Boot 3.4.4 / Java 21 template project with clean architecture. The actual codebase is in `loop-pack-be-l2-vol3-java/`. + +## Build & Test Commands + +```bash +# Build +./gradlew build + +# Run all tests +./gradlew test + +# Run single test class +./gradlew test --tests "ExampleV1ApiE2ETest" + +# Run tests matching pattern +./gradlew test --tests "*ModelTest" + +# Generate coverage report +./gradlew jacocoTestReport +``` + +## Local Development Infrastructure + +```bash +# Start MySQL, Redis (master/replica), Kafka +docker-compose -f ./docker/infra-compose.yml up + +# Start Prometheus + Grafana (localhost:3000, admin/admin) +docker-compose -f ./docker/monitoring-compose.yml up +``` + +## Module Structure + +``` +loop-pack-be-l2-vol3-java/ +├── apps/ # Executable Spring Boot applications +│ ├── commerce-api # REST API service +│ ├── commerce-batch # Batch processing +│ └── commerce-streamer # Kafka event streaming +├── modules/ # Reusable configuration modules +│ ├── jpa # JPA + QueryDSL config +│ ├── redis # Redis cache config +│ └── kafka # Kafka config +└── supports/ # Add-on utilities + ├── jackson # JSON serialization + ├── logging # Structured logging + └── monitoring # Prometheus/Grafana metrics +``` + +## Architecture Layers (per app) + +- `interfaces/api/` - REST Controllers + DTOs (request/response records) +- `application/` - Facades/Use Cases (business orchestration) +- `domain/` - Business logic, entities, domain services +- `infrastructure/` - JPA repositories, external integrations + +## Key Conventions + +### Entity Design +All entities extend `BaseEntity` (`modules/jpa/.../domain/BaseEntity.java`): +- Auto-managed: `id`, `createdAt`, `updatedAt`, `deletedAt` +- Soft-delete via idempotent `delete()` / `restore()` methods +- Override `guard()` for validation (called on PrePersist/PreUpdate) + +### Error Handling +Use `CoreException` with `ErrorType` enum: +```java +throw new CoreException(ErrorType.NOT_FOUND); +throw new CoreException(ErrorType.BAD_REQUEST, "Custom message"); +``` +Available: `BAD_REQUEST`, `NOT_FOUND`, `CONFLICT`, `INTERNAL_ERROR` + +### API Response Format +All responses wrapped in `ApiResponse`: +```json +{ + "meta": { "result": "SUCCESS|FAIL", "errorCode": null, "message": null }, + "data": { ... } +} +``` + +### DTO Pattern +Use Java records with nested response classes and static `from()` factories: +```java +public class ExampleV1Dto { + public record ExampleResponse(Long id, String name) { + public static ExampleResponse from(ExampleModel model) { ... } + } +} +``` + +## Testing Strategy + +Three test tiers with naming conventions: +1. **Unit tests** (`*ModelTest`) - Domain logic, no Spring context +2. **Integration tests** (`*IntegrationTest`) - `@SpringBootTest`, uses `DatabaseCleanUp.truncateAllTables()` in `@AfterEach` +3. **E2E tests** (`*E2ETest`) - `@SpringBootTest(webEnvironment=RANDOM_PORT)`, uses `TestRestTemplate` + +Test configuration: +- Profile: `spring.profiles.active=test` +- Timezone: `Asia/Seoul` +- TestContainers for MySQL and Redis + +## Tech Stack + +- Java 21, Spring Boot 3.4.4, Spring Cloud 2024.0.1 +- MySQL 8.0 + JPA + QueryDSL +- Redis 7.0 (master-replica), Kafka 3.5.1 (KRaft mode) +- JUnit 5, Mockito, SpringMockK, Instancio, TestContainers + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 given-when-then 원칙으로 작성할 것 +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 +- ## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file diff --git a/docs/member-implementation-plan.md b/docs/member-implementation-plan.md new file mode 100644 index 000000000..0ace6419d --- /dev/null +++ b/docs/member-implementation-plan.md @@ -0,0 +1,455 @@ +# Member 기능 구현 계획 + +## 요구사항 상세 + +### 회원가입 +- **필요 정보**: loginId, password, name, birthDate, email +- 이미 가입된 loginId로는 가입 불가 +- 각 정보 포맷 검증 필요: + - **loginId**: 영문과 숫자만 허용 + - **name**: 필수값 + - **email**: 이메일 형식 + - **birthDate**: 날짜 형식 +- 비밀번호는 암호화해 저장 + +### 비밀번호 규칙 +- 8~16자 +- 영문 대소문자, 숫자, 특수문자만 허용 +- 생년월일 포함 불가 (YYYYMMDD, YYMMDD 형식 모두) +- (비밀번호 변경 시) 현재 비밀번호 재사용 불가 + +### 내 정보 조회 +- **인증 방식**: 헤더로 전달 + - `X-Loopers-LoginId`: 로그인 ID + - `X-Loopers-LoginPw`: 비밀번호 +- **반환 정보**: loginId, name, birthDate, email +- **마스킹**: 이름의 마지막 글자를 `*`로 마스킹 + - 예: "홍길동" → "홍길*" + +### 비밀번호 수정 +- **필요 정보**: 기존 비밀번호, 새 비밀번호 +- 기존 비밀번호 일치 확인 필수 +- 비밀번호 규칙 적용 + 현재 비밀번호 재사용 불가 + +--- + +## API 설계 + +| 기능 | Method | Endpoint | 인증 | +|------|--------|----------|------| +| 회원가입 | POST | `/api/v1/members` | 불필요 | +| 내정보조회 | GET | `/api/v1/members/me` | 헤더 인증 | +| 비밀번호변경 | PATCH | `/api/v1/members/me/password` | 헤더 인증 | + +### Request/Response 예시 + +#### 회원가입 +```http +POST /api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test@example.com" +} +``` + +#### 내 정보 조회 +```http +GET /api/v1/members/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +``` +```json +{ + "meta": { "result": "SUCCESS" }, + "data": { + "loginId": "testuser", + "name": "홍길*", + "birthDate": "1990-01-15", + "email": "test@example.com" + } +} +``` + +#### 비밀번호 변경 +```http +PATCH /api/v1/members/me/password +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass5678!" +} +``` + +--- + +## TDD 테스트 작성 전략 + +### 원칙: "가장 단순한 것" 또는 "가장 예외적인 것"부터 + +TDD에서 테스트 순서를 정하는 두 가지 접근법: + +| 접근법 | 설명 | 장점 | +|--------|------|------| +| **Simplest First** | 가장 단순한 성공 케이스부터 | 빠르게 동작하는 코드 확보 | +| **Edge First** | 가장 예외적인/경계 케이스부터 | 견고한 검증 로직 먼저 확보 | + +### 권장: 혼합 전략 (Zombie 방법론) + +``` +Z - Zero (빈 값, null) +O - One (단일 값, 정상 케이스 하나) +M - Many (여러 값, 경계값) +B - Boundary (경계 조건) +I - Interface (입출력 형식) +E - Exception (예외 상황) +``` + +**실제 적용 순서:** +1. **Zero/Null** → 가장 단순한 예외 (null, 빈 값) +2. **One** → 정상 동작 하나 +3. **Boundary** → 경계값 (8자, 16자 등) +4. **Exception** → 비즈니스 예외 (중복, 규칙 위반) + +--- + +## TDD 구현 순서 (상세) + +### Phase 1: PasswordValidator (단위 테스트) - 순수 Java + +**테스트 파일**: `PasswordValidatorTest.java` + +**작성 순서 (권장):** + +``` +1. [Zero] null 또는 빈 문자열 → BAD_REQUEST +2. [Boundary] 정확히 8자 → 성공 +3. [Boundary] 7자 (경계-1) → BAD_REQUEST +4. [Boundary] 정확히 16자 → 성공 +5. [Boundary] 17자 (경계+1) → BAD_REQUEST +6. [Exception] 허용되지 않는 문자 (한글) → BAD_REQUEST +7. [Exception] 생년월일 YYYYMMDD 포함 → BAD_REQUEST +8. [Exception] 생년월일 YYMMDD 포함 → BAD_REQUEST +9. [One] 모든 규칙 통과 → 성공 +``` + +| # | 테스트 메서드명 | 입력 예시 | 기대 결과 | +|---|----------------|----------|----------| +| 1 | `validate_WithNull_ThrowsBadRequest` | `null` | BAD_REQUEST | +| 2 | `validate_WithExactly8Chars_Succeeds` | `"Abcd123!"` | 성공 | +| 3 | `validate_With7Chars_ThrowsBadRequest` | `"Abc123!"` | BAD_REQUEST | +| 4 | `validate_WithExactly16Chars_Succeeds` | `"Abcd1234!@#$Efgh"` | 성공 | +| 5 | `validate_With17Chars_ThrowsBadRequest` | `"Abcd1234!@#$Efghi"` | BAD_REQUEST | +| 6 | `validate_WithKorean_ThrowsBadRequest` | `"Abcd123한글"` | BAD_REQUEST | +| 7 | `validate_ContainsBirthYYYYMMDD_ThrowsBadRequest` | `"Pass19900115!"` (생년월일: 1990-01-15) | BAD_REQUEST | +| 8 | `validate_ContainsBirthYYMMDD_ThrowsBadRequest` | `"Pass900115!!"` (생년월일: 1990-01-15) | BAD_REQUEST | +| 9 | `validate_WithValidPassword_Succeeds` | `"ValidPass1!"` | 성공 | + +### Phase 2: NameMasker (단위 테스트) - 순수 Java + +**테스트 파일**: `NameMaskerTest.java` + +**작성 순서:** + +``` +1. [Zero] null → null 반환 또는 예외 +2. [Zero] 빈 문자열 → 빈 문자열 +3. [Boundary] 1글자 → "*" +4. [Boundary] 2글자 → "홍*" +5. [One] 3글자 이상 → "홍길*" +``` + +| # | 테스트 메서드명 | 입력 | 기대 결과 | +|---|----------------|------|----------| +| 1 | `mask_WithNull_ReturnsNull` | `null` | `null` | +| 2 | `mask_WithEmpty_ReturnsEmpty` | `""` | `""` | +| 3 | `mask_With1Char_ReturnsMasked` | `"홍"` | `"*"` | +| 4 | `mask_With2Chars_ReturnsMasked` | `"홍길"` | `"홍*"` | +| 5 | `mask_With3Chars_ReturnsMasked` | `"홍길동"` | `"홍길*"` | + +### Phase 3: LoginIdValidator (단위 테스트) - 순수 Java + +**테스트 파일**: `LoginIdValidatorTest.java` + +**작성 순서:** + +``` +1. [Zero] null → BAD_REQUEST +2. [Zero] 빈 문자열 → BAD_REQUEST +3. [Exception] 특수문자 포함 → BAD_REQUEST +4. [Exception] 한글 포함 → BAD_REQUEST +5. [One] 영문+숫자 → 성공 +6. [One] 영문만 → 성공 +7. [One] 숫자만 → 성공 +``` + +### Phase 4: MemberModel (단위 테스트) + +**테스트 파일**: `MemberModelTest.java` + +**작성 순서:** + +``` +1. [Zero] loginId null → BAD_REQUEST +2. [Zero] name null → BAD_REQUEST +3. [One] 정상 생성 → 성공 +4. [One] changePassword 호출 → 비밀번호 변경됨 +``` + +| # | 테스트 메서드명 | 기대 결과 | +|---|----------------|----------| +| 1 | `create_WithNullLoginId_ThrowsBadRequest` | BAD_REQUEST | +| 2 | `create_WithNullName_ThrowsBadRequest` | BAD_REQUEST | +| 3 | `create_WithValidInput_Succeeds` | 성공 | +| 4 | `changePassword_UpdatesPassword` | 비밀번호 변경됨 | + +### Phase 5: MemberService (통합 테스트) + +**테스트 파일**: `MemberServiceIntegrationTest.java` + +**작성 순서:** + +``` +1. [One] 정상 회원가입 → 성공, 비밀번호 암호화됨 +2. [Exception] 중복 loginId → CONFLICT +3. [One] 존재하는 회원 조회 → 회원 반환 +4. [Exception] 존재하지 않는 회원 조회 → NOT_FOUND +5. [One] 헤더 인증 성공 → 회원 반환 +6. [Exception] 헤더 인증 실패 (비밀번호 불일치) → UNAUTHORIZED +7. [One] 정상 비밀번호 변경 → 성공 +8. [Exception] 현재 비밀번호 불일치 → BAD_REQUEST +9. [Exception] 새 비밀번호 규칙 위반 → BAD_REQUEST +``` + +### Phase 6: API E2E 테스트 + +**테스트 파일**: `MemberV1ApiE2ETest.java` + +**작성 순서:** + +``` +1. [One] POST 회원가입 성공 → 200 +2. [Exception] POST 중복 loginId → 409 +3. [Exception] POST 잘못된 loginId 형식 → 400 +4. [One] GET 내 정보 조회 성공 → 200, 이름 마스킹됨 +5. [Exception] GET 인증 실패 → 401 +6. [One] PATCH 비밀번호 변경 성공 → 200 +7. [Exception] PATCH 현재 비밀번호 불일치 → 400 +8. [Exception] PATCH 비밀번호 규칙 위반 → 400 +``` + +--- + +## 구현 파일 목록 + +### 1. 의존성 추가 +``` +apps/commerce-api/build.gradle.kts # spring-security-crypto 추가 +``` + +### 2. 설정 +``` +apps/commerce-api/src/main/java/com/loopers/config/ +└── PasswordEncoderConfig.java # BCryptPasswordEncoder Bean +``` + +### 3. Domain Layer +``` +apps/commerce-api/src/main/java/com/loopers/domain/member/ +├── MemberModel.java # 엔티티 (BaseEntity 확장) +├── MemberRepository.java # 저장소 인터페이스 +├── MemberService.java # 도메인 서비스 +├── PasswordValidator.java # 비밀번호 검증 +├── LoginIdValidator.java # 로그인ID 검증 +└── NameMasker.java # 이름 마스킹 +``` + +### 4. Application Layer +``` +apps/commerce-api/src/main/java/com/loopers/application/member/ +├── MemberFacade.java # 유스케이스 조율 +└── MemberInfo.java # DTO (record) +``` + +### 5. Infrastructure Layer +``` +apps/commerce-api/src/main/java/com/loopers/infrastructure/member/ +├── MemberJpaRepository.java # Spring Data JPA +└── MemberRepositoryImpl.java # Repository 구현체 +``` + +### 6. Interfaces Layer +``` +apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/ +├── MemberV1ApiSpec.java # Swagger 명세 +├── MemberV1Controller.java # REST Controller +└── MemberV1Dto.java # Request/Response DTO +``` + +### 7. HTTP 테스트 +``` +http/commerce-api/member-v1.http # API 테스트용 +``` + +--- + +## 핵심 구현 사항 + +### PasswordValidator.java +```java +public class PasswordValidator { + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{}|;':\",./<>?]+$"); + + public static void validate(String password, LocalDate birthDate) { + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + if (password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!ALLOWED_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + if (containsBirthDate(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + public static void validateForChange(String newPassword, LocalDate birthDate, + String currentEncodedPassword, PasswordEncoder encoder) { + validate(newPassword, birthDate); + if (encoder.matches(newPassword, currentEncodedPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호는 사용할 수 없습니다."); + } + } + + private static boolean containsBirthDate(String password, LocalDate birthDate) { + if (birthDate == null) return false; + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); // 19900115 + String yymmdd = yyyymmdd.substring(2); // 900115 + return password.contains(yyyymmdd) || password.contains(yymmdd); + } +} +``` + +### NameMasker.java +```java +public class NameMasker { + private static final char MASK_CHAR = '*'; + + public static String mask(String name) { + if (name == null) return null; + if (name.isEmpty()) return ""; + if (name.length() == 1) return String.valueOf(MASK_CHAR); + return name.substring(0, name.length() - 1) + MASK_CHAR; + } +} +``` + +### LoginIdValidator.java +```java +public class LoginIdValidator { + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + public static void validate(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (!PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 가능합니다."); + } + } +} +``` + +### MemberModel.java +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true) + private String loginId; + + @Column(nullable = false) + private String password; // BCrypt 암호화 + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, + LocalDate birthDate, String email) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + // getters... +} +``` + +--- + +## 참조 파일 (기존 패턴) +- `domain/example/ExampleModel.java` - Entity 패턴 +- `domain/example/ExampleService.java` - Service 패턴 +- `interfaces/api/ExampleV1ApiE2ETest.java` - E2E 테스트 패턴 +- `modules/jpa/.../BaseEntity.java` - BaseEntity 구조 + +--- + +## 검증 방법 + +### 1. 단위 테스트 +```bash +./gradlew test --tests "*PasswordValidatorTest" +./gradlew test --tests "*NameMaskerTest" +./gradlew test --tests "*LoginIdValidatorTest" +./gradlew test --tests "*MemberModelTest" +``` + +### 2. 통합 테스트 +```bash +./gradlew test --tests "*MemberServiceIntegrationTest" +``` + +### 3. E2E 테스트 +```bash +./gradlew test --tests "*MemberV1ApiE2ETest" +``` + +### 4. HTTP 파일로 수동 테스트 +```bash +# 인프라 실행 +docker-compose -f ./docker/infra-compose.yml up + +# 앱 실행 후 http/commerce-api/member-v1.http 실행 +``` From 95da7027b8497f5a01189abb71c71c815fa108b7 Mon Sep 17 00:00:00 2001 From: praesentia Date: Wed, 4 Feb 2026 06:08:24 +0900 Subject: [PATCH 03/66] =?UTF-8?q?chore:=20Member=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EA=B8=B0=EB=B0=98=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spring-security-crypto 의존성 추가 (BCryptPasswordEncoder) - PasswordEncoderConfig 빈 등록 - ErrorType에 UNAUTHORIZED 추가 Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/build.gradle.kts | 3 +++ .../com/loopers/config/PasswordEncoderConfig.java | 15 +++++++++++++++ .../java/com/loopers/support/error/ErrorType.java | 1 + 3 files changed, 19 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..9ad4d8ea9 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..d42feb176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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 5d142efbf..8d493491a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); From 0335bc1789e95f67d8172dbdfe1bbe9824abda61 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:16:49 +0900 Subject: [PATCH 04/66] =?UTF-8?q?feat:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20Value=20Object=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4개 VO 구현 (LoginId, Password, MemberName, Email) - MemberModel 엔티티 (@Embedded VO, matchesPassword 행위 메서드) - MemberRepository 인터페이스 및 JPA 구현 - ErrorType 도메인 에러 코드 추가 (10개) - 단위 테스트: VO 검증 + MemberModel + Repository 통합테스트 Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/member/Email.java | 44 +++++ .../com/loopers/domain/member/LoginId.java | 44 +++++ .../loopers/domain/member/MemberModel.java | 69 +++++++ .../com/loopers/domain/member/MemberName.java | 50 +++++ .../domain/member/MemberRepository.java | 10 + .../com/loopers/domain/member/Password.java | 52 ++++++ .../member/MemberJpaRepository.java | 11 ++ .../member/MemberRepositoryImpl.java | 25 +++ .../com/loopers/support/error/ErrorType.java | 14 +- .../com/loopers/domain/member/EmailTest.java | 72 ++++++++ .../loopers/domain/member/LoginIdTest.java | 83 +++++++++ .../domain/member/MemberModelTest.java | 92 ++++++++++ .../loopers/domain/member/MemberNameTest.java | 94 ++++++++++ .../domain/member/MemberRepositoryTest.java | 90 +++++++++ .../loopers/domain/member/PasswordTest.java | 173 ++++++++++++++++++ 15 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java new file mode 100644 index 000000000..948d99b1e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java @@ -0,0 +1,44 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + private static final Pattern PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + + @Column(name = "email") + private String value; + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_EMAIL); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java new file mode 100644 index 000000000..0f7da8a33 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java @@ -0,0 +1,44 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + @Column(name = "login_id", nullable = false, unique = true) + private String value; + + public LoginId(String value) { + if (value == null || value.isBlank() || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_LOGIN_ID); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 000000000..e0ffb37cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Column(nullable = false) + private String password; + + @Embedded + private MemberName name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Embedded + private Email email; + + protected MemberModel() {} + + public MemberModel(LoginId loginId, String encodedPassword, MemberName name, + LocalDate birthDate, Email email) { + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public boolean matchesPassword(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.password); + } + + public String encodedPassword() { + return password; + } + + public LoginId loginId() { + return loginId; + } + + public MemberName name() { + return name; + } + + public LocalDate birthDate() { + return birthDate; + } + + public Email email() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java new file mode 100644 index 000000000..7c850b274 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java @@ -0,0 +1,50 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberName { + + private static final char MASK_CHAR = '*'; + + @Column(name = "name", nullable = false) + private String value; + + public MemberName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.INVALID_NAME); + } + this.value = value; + } + + public String value() { + return value; + } + + public String masked() { + if (value.length() == 1) { + return String.valueOf(MASK_CHAR); + } + return value.substring(0, value.length() - 1) + MASK_CHAR; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..8ed51f87a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + MemberModel save(MemberModel member); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java new file mode 100644 index 000000000..dbb4ab6dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java @@ -0,0 +1,52 @@ +package com.loopers.domain.member; + +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; + +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{}|;':\",./<>?]+$"); + + private final String value; + + public static Password of(String value, LocalDate birthDate) { + Password password = new Password(value); + password.validateAgainst(birthDate); + return password; + } + + public Password(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + if (!ALLOWED_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + this.value = value; + } + + public void validateAgainst(LocalDate birthDate) { + if (birthDate == null) { + return; + } + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String yymmdd = yyyymmdd.substring(2); + if (value.contains(yyyymmdd) || value.contains(yymmdd)) { + throw new CoreException(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + public String value() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..2eb5cfe87 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginIdValue(String value); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..5f7f6e00d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginIdValue(loginId); + } +} 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..2c55138ef 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 @@ -12,7 +12,19 @@ public enum ErrorType { BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + /** Member 도메인 에러 */ + INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "Invalid Login Id", "로그인 ID는 영문과 숫자만 가능합니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Invalid Password", "비밀번호가 규칙에 맞지 않습니다."), + INVALID_EMAIL(HttpStatus.BAD_REQUEST, "Invalid Email", "이메일 형식이 올바르지 않습니다."), + INVALID_NAME(HttpStatus.BAD_REQUEST, "Invalid Name", "이름은 필수입니다."), + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate Login Id", "이미 존재하는 로그인 ID입니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "Password Mismatch", "현재 비밀번호가 일치하지 않습니다."), + PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST, "Password Same As Old", "현재 비밀번호는 사용할 수 없습니다."), + PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "Password Contains Birth Date", "비밀번호에 생년월일을 포함할 수 없습니다."), + MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member Not Found", "회원을 찾을 수 없습니다."), + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "Authentication Failed", "비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java new file mode 100644 index 000000000..6030b304a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.member; + +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailTest { + + @DisplayName("Email 생성") + @Nested + class Create { + + @DisplayName("유효한 이메일로 생성할 수 있다") + @Test + void validEmailCreatesSuccessfully() { + // given + String value = "kwonmo@example.com"; + + // when + Email email = new Email(value); + + // then + assertThat(email.value()).isEqualTo(value); + } + + @DisplayName("이메일 형식이 올바르지 않으면 생성할 수 없다") + @ParameterizedTest + @ValueSource(strings = {"", "testexample.com", "test@", "@example.com"}) + void rejectsInvalidEmailFormats(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Email(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_EMAIL); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + Email one = new Email("kwonmo@example.com"); + Email another = new Email("kwonmo@example.com"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + Email one = new Email("kwonmo@example.com"); + Email another = new Email("jihun@example.com"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java new file mode 100644 index 000000000..98ca41492 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.member; + +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LoginIdTest { + + @DisplayName("LoginId 생성") + @Nested + class Create { + + @DisplayName("null, 빈 문자열, 공백은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new LoginId(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("특수문자나 한글이 포함되면 생성할 수 없다") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test유저", "hello world!", "user#1"}) + void rejectsInvalidCharacters(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new LoginId(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("영문, 숫자 조합으로 생성할 수 있다") + @ParameterizedTest + @ValueSource(strings = {"testuser", "12345", "user123"}) + void acceptsAlphanumericValues(String value) { + // given & when + LoginId loginId = new LoginId(value); + + // then + assertThat(loginId.value()).isEqualTo(value); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + LoginId one = new LoginId("kwonmo"); + LoginId another = new LoginId("kwonmo"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + LoginId one = new LoginId("kwonmo"); + LoginId another = new LoginId("jihun"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 000000000..ff9a0e1be --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.member; + +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.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberModelTest { + + @Mock + private PasswordEncoder passwordEncoder; + + @DisplayName("회원 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + LoginId loginId = new LoginId("kwonmo"); + String encodedPassword = "encodedPassword"; + MemberName name = new MemberName("양권모"); + LocalDate birthDate = LocalDate.of(1998, 9, 16); + Email email = new Email("kwonmo@example.com"); + when(passwordEncoder.matches("rawPassword", "encodedPassword")).thenReturn(true); + + // when + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, email); + + // then + assertAll( + () -> assertThat(member.loginId()).isEqualTo(loginId), + () -> assertThat(member.matchesPassword("rawPassword", passwordEncoder)).isTrue(), + () -> assertThat(member.name()).isEqualTo(name), + () -> assertThat(member.birthDate()).isEqualTo(birthDate), + () -> assertThat(member.email()).isEqualTo(email) + ); + } + + @DisplayName("email이 null이어도 생성할 수 있다") + @Test + void createsWithNullEmail() { + // given + LoginId loginId = new LoginId("kwonmo"); + String encodedPassword = "encodedPassword"; + MemberName name = new MemberName("양권모"); + LocalDate birthDate = LocalDate.of(1998, 9, 16); + + // when + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, null); + + // then + assertThat(member.email()).isNull(); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("새 비밀번호로 변경하면 이전 비밀번호는 매칭되지 않는다") + @Test + void newPasswordReplacesOld() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "oldEncodedPassword", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com") + ); + String newEncodedPassword = "newEncodedPassword"; + when(passwordEncoder.matches("newRaw", "newEncodedPassword")).thenReturn(true); + when(passwordEncoder.matches("oldRaw", "newEncodedPassword")).thenReturn(false); + + // when + member.changePassword(newEncodedPassword); + + // then + assertThat(member.matchesPassword("newRaw", passwordEncoder)).isTrue(); + assertThat(member.matchesPassword("oldRaw", passwordEncoder)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java new file mode 100644 index 000000000..d72086b0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.member; + +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberNameTest { + + @DisplayName("MemberName 생성") + @Nested + class Create { + + @DisplayName("null, 빈 문자열, 공백은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new MemberName(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_NAME); + } + + @DisplayName("유효한 이름으로 생성할 수 있다") + @Test + void validNameCreatesSuccessfully() { + // given + String value = "양권모"; + + // when + MemberName name = new MemberName(value); + + // then + assertThat(name.value()).isEqualTo(value); + } + } + + @DisplayName("이름 마스킹") + @Nested + class Masked { + + @DisplayName("글자 수에 따라 마지막 글자를 마스킹한다") + @ParameterizedTest + @CsvSource({"양, *", "양권, 양*", "양권모, 양권*"}) + void masksLastCharacter(String input, String expected) { + // given + MemberName name = new MemberName(input); + + // when + String result = name.masked(); + + // then + assertThat(result).isEqualTo(expected); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + MemberName one = new MemberName("양권모"); + MemberName another = new MemberName("양권모"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + MemberName one = new MemberName("양권모"); + MemberName another = new MemberName("박지훈"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java new file mode 100644 index 000000000..02c2fea85 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java @@ -0,0 +1,90 @@ +package com.loopers.domain.member; + +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 java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryTest { + + @Autowired + MemberRepository memberRepository; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("loginId로 회원 조회") + @Nested + class FindByLoginId { + + @DisplayName("존재하지 않는 loginId면 빈 Optional을 반환한다") + @Test + void returnsEmptyForNonExistentLoginId() { + // given + String loginId = "nonexistent"; + + // when + Optional result = memberRepository.findByLoginId(loginId); + + // then + assertThat(result).isEmpty(); + } + + @DisplayName("존재하는 loginId면 저장된 회원을 반환한다") + @Test + void returnsMemberForExistingLoginId() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "Test1234!", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + memberRepository.save(member); + + // when + Optional result = memberRepository.findByLoginId("kwonmo"); + + // then + assertThat(result).isPresent(); + assertAll( + () -> assertThat(result.get().loginId().value()).isEqualTo("kwonmo"), + () -> assertThat(result.get().name().value()).isEqualTo("양권모"), + () -> assertThat(result.get().birthDate()).isEqualTo(LocalDate.of(1998, 9, 16)), + () -> assertThat(result.get().email().value()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("회원 저장") + @Nested + class Save { + + @DisplayName("유효한 회원 정보를 저장하면 ID가 생성된다") + @Test + void generatesIdOnSave() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "Test1234!", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + // when + MemberModel saved = memberRepository.save(member); + + // then + assertThat(saved.getId()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java new file mode 100644 index 000000000..8caedef1b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java @@ -0,0 +1,173 @@ +package com.loopers.domain.member; + +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PasswordTest { + + @DisplayName("Password 생성") + @Nested + class Create { + + @DisplayName("null이나 빈 문자열은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + void rejectsNullOrEmpty(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("유효한 비밀번호로 생성할 수 있다") + @Test + void validPasswordCreatesSuccessfully() { + // given + String value = "ValidPass1!"; + + // when + Password password = new Password(value); + + // then + assertThat(password.value()).isEqualTo(value); + } + + @DisplayName("8~16자 범위 내의 비밀번호는 허용된다") + @ParameterizedTest + @ValueSource(strings = {"Abcd123!", "Abcd1234!@#$Efgh"}) + void acceptsPasswordsWithinLengthRange(String value) { + // given & when & then + assertDoesNotThrow(() -> new Password(value)); + } + + @DisplayName("8~16자 범위를 벗어나면 생성할 수 없다") + @ParameterizedTest + @ValueSource(strings = {"Abc123!", "Abcd1234!@#$Efghi"}) + void rejectsPasswordsOutsideLengthRange(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("한글이 포함되면 생성할 수 없다") + @Test + void rejectsKoreanCharacters() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password("Abcd123한글")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + } + + @DisplayName("생년월일 포함 여부 검증") + @Nested + class ValidateAgainst { + + private static final LocalDate BIRTH_DATE = LocalDate.of(1998, 9, 16); + + @DisplayName("YYYYMMDD 형식의 생년월일이 포함되면 예외가 발생한다") + @Test + void rejectsPasswordContainingFullBirthDate() { + // given + Password password = new Password("Pass19980916!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + password.validateAgainst(BIRTH_DATE)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("YYMMDD 형식의 생년월일이 포함되어도 예외가 발생한다") + @Test + void rejectsPasswordContainingShortBirthDate() { + // given + Password password = new Password("Pass980916!!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + password.validateAgainst(BIRTH_DATE)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("생년월일이 포함되지 않으면 통과한다") + @Test + void passesWhenBirthDateNotIncluded() { + // given + Password password = new Password("ValidPass1!"); + + // when & then + assertDoesNotThrow(() -> password.validateAgainst(BIRTH_DATE)); + } + + @DisplayName("birthDate가 null이면 검증을 건너뛴다") + @Test + void skipsValidationWhenBirthDateIsNull() { + // given + Password password = new Password("ValidPass1!"); + + // when & then + assertDoesNotThrow(() -> password.validateAgainst(null)); + } + } + + @DisplayName("of 팩토리 메서드") + @Nested + class OfFactory { + + @DisplayName("유효한 비밀번호와 생년월일로 생성할 수 있다") + @Test + void createsWithValidPasswordAndBirthDate() { + // given & when & then + assertDoesNotThrow(() -> Password.of("ValidPass1!", LocalDate.of(1998, 9, 16))); + } + + @DisplayName("형식이 잘못되면 INVALID_PASSWORD 예외가 발생한다") + @Test + void rejectsInvalidFormat() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + Password.of("short", LocalDate.of(1998, 9, 16))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("생년월일이 포함된 비밀번호는 거부한다") + @Test + void rejectsPasswordWithBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + Password.of("Pass19980916!", LocalDate.of(1998, 9, 16))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("생년월일이 null이면 형식만 검증한다") + @Test + void onlyValidatesFormatWhenBirthDateIsNull() { + // given & when & then + assertDoesNotThrow(() -> Password.of("ValidPass1!", null)); + } + } +} From 3a9f4184540cd67d81ffdf0964ec1730263e8939 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:17:09 +0900 Subject: [PATCH 05/66] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberSignupService (중복 체크, 비밀번호 암호화, 저장) - 단위 테스트: Mock을 활용한 동작 검증 - 통합 테스트: 실제 DB 연동 검증 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberSignupService.java | 35 ++++++ .../MemberSignupServiceIntegrationTest.java | 117 ++++++++++++++++++ .../member/MemberSignupServiceTest.java | 114 +++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java new file mode 100644 index 000000000..89505937d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java @@ -0,0 +1,35 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberSignupService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public MemberModel signup(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + LoginId loginIdVo = new LoginId(loginId); + MemberName nameVo = new MemberName(name); + Email emailVo = email != null ? new Email(email) : null; + Password password = Password.of(rawPassword, birthDate); + + memberRepository.findByLoginId(loginId).ifPresent(m -> { + throw new CoreException(ErrorType.DUPLICATE_LOGIN_ID); + }); + + String encodedPassword = passwordEncoder.encode(rawPassword); + MemberModel member = new MemberModel(loginIdVo, encodedPassword, nameVo, birthDate, emailVo); + return memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java new file mode 100644 index 000000000..8fc4fa9ba --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.member; + +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 org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +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 MemberSignupServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면 회원이 생성되고 비밀번호가 암호화된다") + @Test + void createsMemberWithEncodedPassword() { + // given + String loginId = "kwonmo"; + String password = "Test1234!"; + String name = "양권모"; + LocalDate birthDate = LocalDate.of(1998, 9, 16); + String email = "kwonmo@example.com"; + + // when + MemberModel result = memberSignupService.signup(loginId, password, name, birthDate, email); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.loginId().value()).isEqualTo(loginId), + () -> assertThat(result.name().value()).isEqualTo(name), + () -> assertThat(result.birthDate()).isEqualTo(birthDate), + () -> assertThat(result.email().value()).isEqualTo(email), + () -> assertThat(result.matchesPassword(password, passwordEncoder)).isTrue() + ); + } + + @DisplayName("이미 존재하는 loginId로 가입하면 DUPLICATE_LOGIN_ID 예외가 발생한다") + @Test + void throwsOnDuplicateLoginId() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "Other1234!", "박지훈", + LocalDate.of(1995, 5, 20), "jihun@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.DUPLICATE_LOGIN_ID); + } + + @DisplayName("loginId 형식이 잘못되면 INVALID_LOGIN_ID 예외가 발생한다") + @Test + void throwsOnInvalidLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("test@user", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("비밀번호 규칙을 위반하면 INVALID_PASSWORD 예외가 발생한다") + @Test + void throwsOnInvalidPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "short", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("생년월일이 포함된 비밀번호는 거부한다") + @Test + void rejectsPasswordContainingBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "Pass19980916!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java new file mode 100644 index 000000000..a2441df86 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java @@ -0,0 +1,114 @@ +package com.loopers.domain.member; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberSignupServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberSignupService memberSignupService; + + @BeforeEach + void setUp() { + memberSignupService = new MemberSignupService(memberRepository, passwordEncoder); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면 비밀번호를 암호화하고 저장한다") + @Test + void encodesPasswordAndSaves() { + // given + String loginId = "kwonmo"; + String rawPassword = "Test1234!"; + String encodedPassword = "encoded_password"; + LocalDate birthDate = LocalDate.of(1998, 9, 16); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when + MemberModel result = memberSignupService.signup(loginId, rawPassword, "양권모", birthDate, "kwonmo@example.com"); + + // then + assertThat(result.matchesPassword(rawPassword, passwordEncoder)).isTrue(); + verify(memberRepository).save(any(MemberModel.class)); + verify(passwordEncoder).encode(rawPassword); + } + + @DisplayName("이미 사용 중인 loginId면 저장하지 않고 예외를 던진다") + @Test + void throwsOnDuplicateLoginId() { + // given + String loginId = "kwonmo"; + MemberModel existing = new MemberModel( + new LoginId(loginId), "encoded", new MemberName("기존회원"), + LocalDate.of(1998, 9, 16), new Email("exist@example.com")); + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(existing)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup(loginId, "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.DUPLICATE_LOGIN_ID); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("loginId 형식이 잘못되면 repository를 조회하지 않는다") + @Test + void skipsRepositoryOnInvalidLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("test@user!", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + verify(memberRepository, never()).findByLoginId(any()); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("비밀번호 규칙 위반이면 repository를 조회하지 않는다") + @Test + void skipsRepositoryOnInvalidPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "short", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + verify(memberRepository, never()).findByLoginId(any()); + } + } +} From 337aba516ea3ddeb4c8a8931bee9bc4d9fddc6c6 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:08 +0900 Subject: [PATCH 06/66] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberAuthService (loginId/password 검증, 회원 조회) - 단위 테스트: Mock을 활용한 동작 검증 - 통합 테스트: 실제 DB 연동 검증 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberAuthService.java | 27 +++++ .../MemberAuthServiceIntegrationTest.java | 79 +++++++++++++++ .../domain/member/MemberAuthServiceTest.java | 98 +++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java new file mode 100644 index 000000000..f90f86e5a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberAuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public MemberModel authenticate(String loginId, String password) { + MemberModel member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.MEMBER_NOT_FOUND)); + + if (!member.matchesPassword(password, passwordEncoder)) { + throw new CoreException(ErrorType.AUTHENTICATION_FAILED); + } + return member; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java new file mode 100644 index 000000000..7700520a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java @@ -0,0 +1,79 @@ +package com.loopers.domain.member; + +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberAuthServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private MemberAuthService memberAuthService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 인증") + @Nested + class Authenticate { + + @DisplayName("올바른 loginId와 비밀번호면 회원을 반환한다") + @Test + void returnsMemberOnValidCredentials() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + MemberModel result = memberAuthService.authenticate("kwonmo", "Test1234!"); + + // then + assertThat(result.loginId().value()).isEqualTo("kwonmo"); + } + + @DisplayName("비밀번호가 틀리면 인증 실패 예외가 발생한다") + @Test + void throwsOnWrongPassword() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("kwonmo", "WrongPass1!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + + @DisplayName("존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("nobody", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.MEMBER_NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java new file mode 100644 index 000000000..42ebd3a1a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.member; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberAuthServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberAuthService memberAuthService; + + @BeforeEach + void setUp() { + memberAuthService = new MemberAuthService(memberRepository, passwordEncoder); + } + + @DisplayName("회원 인증") + @Nested + class Authenticate { + + @DisplayName("올바른 자격 증명이면 회원을 반환한다") + @Test + void returnsMemberOnValidCredentials() { + // given + String loginId = "kwonmo"; + String rawPassword = "Test1234!"; + MemberModel member = new MemberModel( + new LoginId(loginId), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches(rawPassword, "encoded")).thenReturn(true); + + // when + MemberModel result = memberAuthService.authenticate(loginId, rawPassword); + + // then + assertThat(result.loginId().value()).isEqualTo(loginId); + } + + @DisplayName("존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외를 던진다") + @Test + void throwsOnNonExistentLoginId() { + // given + when(memberRepository.findByLoginId("nobody")).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("nobody", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.MEMBER_NOT_FOUND); + verify(passwordEncoder, never()).matches(any(), any()); + } + + @DisplayName("비밀번호가 틀리면 인증 실패 예외를 던진다") + @Test + void throwsOnWrongPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(memberRepository.findByLoginId("kwonmo")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("WrongPass1!", "encoded")).thenReturn(false); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("kwonmo", "WrongPass1!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + } +} From d1159edf5fe5339cea4b198eb5de345e2e0a50bc Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:23 +0900 Subject: [PATCH 07/66] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberPasswordService (현재 비밀번호 검증, 새 비밀번호 암호화 저장) - 단위 테스트: Mock을 활용한 동작 검증 - 통합 테스트: 실제 DB 연동 검증 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberPasswordService.java | 33 +++++ .../MemberPasswordServiceIntegrationTest.java | 117 +++++++++++++++++ .../member/MemberPasswordServiceTest.java | 123 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java new file mode 100644 index 000000000..76e9d93f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberPasswordService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void changePassword(MemberModel member, String currentPassword, String newRawPassword) { + if (!member.matchesPassword(currentPassword, passwordEncoder)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH); + } + + Password newPassword = new Password(newRawPassword); + + if (member.matchesPassword(newRawPassword, passwordEncoder)) { + throw new CoreException(ErrorType.PASSWORD_SAME_AS_OLD); + } + newPassword.validateAgainst(member.birthDate()); + + member.changePassword(passwordEncoder.encode(newRawPassword)); + memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java new file mode 100644 index 000000000..f09cb43f0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.member; + +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberPasswordServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private MemberAuthService memberAuthService; + + @Autowired + private MemberPasswordService memberPasswordService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호면 변경에 성공한다") + @Test + void changesPasswordSuccessfully() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + memberPasswordService.changePassword(member, "Test1234!", "NewPass5678!"); + + // then + MemberModel updated = memberAuthService.authenticate("kwonmo", "NewPass5678!"); + assertThat(updated.loginId().value()).isEqualTo("kwonmo"); + } + + @DisplayName("현재 비밀번호가 틀리면 PASSWORD_MISMATCH 예외가 발생한다") + @Test + void throwsOnWrongCurrentPassword() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "WrongPass1!", "NewPass5678!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + + @DisplayName("새 비밀번호가 현재와 같으면 PASSWORD_SAME_AS_OLD 예외가 발생한다") + @Test + void throwsWhenNewPasswordSameAsOld() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_SAME_AS_OLD); + } + + @DisplayName("새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외가 발생한다") + @Test + void throwsOnInvalidNewPassword() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "short")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면 거부한다") + @Test + void rejectsNewPasswordContainingBirthDate() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Pass19980916!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java new file mode 100644 index 000000000..04703f1ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java @@ -0,0 +1,123 @@ +package com.loopers.domain.member; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberPasswordServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberPasswordService memberPasswordService; + + @BeforeEach + void setUp() { + memberPasswordService = new MemberPasswordService(memberRepository, passwordEncoder); + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면 새 비밀번호로 암호화해서 저장한다") + @Test + void encodesAndSavesNewPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Current1234!", "currentEncoded")).thenReturn(true); + when(passwordEncoder.matches("NewPass5678!", "currentEncoded")).thenReturn(false); + when(passwordEncoder.encode("NewPass5678!")).thenReturn("newEncoded"); + when(passwordEncoder.matches("NewPass5678!", "newEncoded")).thenReturn(true); + + // when + memberPasswordService.changePassword(member, "Current1234!", "NewPass5678!"); + + // then + assertThat(member.matchesPassword("NewPass5678!", passwordEncoder)).isTrue(); + verify(memberRepository).save(member); + verify(passwordEncoder).encode("NewPass5678!"); + } + + @DisplayName("현재 비밀번호가 틀리면 저장하지 않고 예외를 던진다") + @Test + void throwsOnWrongCurrentPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("WrongPass1!", "currentEncoded")).thenReturn(false); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "WrongPass1!", "NewPass5678!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + verify(memberRepository, never()).save(any()); + verify(passwordEncoder, never()).encode(any()); + } + + @DisplayName("새 비밀번호가 현재와 같으면 저장하지 않는다") + @Test + void throwsWhenNewPasswordSameAsOld() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Test1234!", "currentEncoded")).thenReturn(true); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_SAME_AS_OLD); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외를 던진다") + @Test + void throwsOnInvalidNewPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Current1234!", "currentEncoded")).thenReturn(true); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Current1234!", "short")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + verify(memberRepository, never()).save(any()); + } + } +} From e3aa8944c61a2eef385463d1da334243df346d8e Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:57 +0900 Subject: [PATCH 08/66] =?UTF-8?q?feat:=20Member=20API=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberFacade (signup, getMyInfo, changePassword) - MemberInfo 응답 DTO (이름 마스킹 포함) - MemberV1Controller (POST /members, GET /me, PATCH /me/password) - MemberV1Dto (SignupRequest, MemberResponse, ChangePasswordRequest) - E2E 테스트: MemberV1ApiE2ETest - MemberFacadeTest 단위 테스트 Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 36 +++ .../application/member/MemberInfo.java | 26 ++ .../api/member/MemberV1ApiSpec.java | 19 ++ .../api/member/MemberV1Controller.java | 55 ++++ .../interfaces/api/member/MemberV1Dto.java | 32 +++ .../application/member/MemberFacadeTest.java | 110 ++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 253 ++++++++++++++++++ 7 files changed, 531 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 000000000..75658e7e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,36 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberPasswordService; +import com.loopers.domain.member.MemberSignupService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberSignupService memberSignupService; + private final MemberAuthService memberAuthService; + private final MemberPasswordService memberPasswordService; + + public MemberInfo signup(String loginId, String password, String name, + LocalDate birthDate, String email) { + MemberModel member = memberSignupService.signup(loginId, password, name, birthDate, email); + return MemberInfo.from(member); + } + + public MemberInfo getMyInfo(String loginId, String password) { + MemberModel member = memberAuthService.authenticate(loginId, password); + return MemberInfo.fromWithMaskedName(member); + } + + public void changePassword(String loginId, String password, + String currentPassword, String newPassword) { + MemberModel member = memberAuthService.authenticate(loginId, password); + memberPasswordService.changePassword(member, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 000000000..c20d1c9dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +import java.time.LocalDate; + +public record MemberInfo(String loginId, String name, LocalDate birthDate, String email) { + + public static MemberInfo from(MemberModel model) { + return new MemberInfo( + model.loginId().value(), + model.name().value(), + model.birthDate(), + model.email() != null ? model.email().value() : null + ); + } + + public static MemberInfo fromWithMaskedName(MemberModel model) { + return new MemberInfo( + model.loginId().value(), + model.name().masked(), + model.birthDate(), + model.email() != null ? model.email().value() : null + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..9cca8fd0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Member V1 API", description = "회원 API 입니다.") +public interface MemberV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + ApiResponse signup(MemberV1Dto.SignupRequest request); + + @Operation(summary = "내 정보 조회", description = "헤더 인증을 통해 내 정보를 조회합니다.") + ApiResponse getMe(String loginId, String password); + + @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") + ApiResponse changePassword(String loginId, String password, + MemberV1Dto.ChangePasswordRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..a3ee3eb2f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberFacade memberFacade; + + @PostMapping + @Override + public ApiResponse signup( + @RequestBody MemberV1Dto.SignupRequest request + ) { + MemberInfo info = memberFacade.signup( + request.loginId(), request.password(), request.name(), + request.birthDate(), request.email() + ); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, password); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberFacade.changePassword(loginId, password, + request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..9ae0821d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignupRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record MemberResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MemberResponse from(MemberInfo info) { + return new MemberResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 000000000..75353325b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,110 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberFacadeTest { + + @Mock + private MemberSignupService memberSignupService; + + @Mock + private MemberAuthService memberAuthService; + + @Mock + private MemberPasswordService memberPasswordService; + + private MemberFacade memberFacade; + + @BeforeEach + void setUp() { + memberFacade = new MemberFacade(memberSignupService, memberAuthService, memberPasswordService); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("SignupService에 위임하고 MemberInfo를 반환한다") + @Test + void delegatesToSignupService() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + when(memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")).thenReturn(member); + + // when + MemberInfo result = memberFacade.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // then + assertAll( + () -> assertThat(result.loginId()).isEqualTo("kwonmo"), + () -> assertThat(result.name()).isEqualTo("양권모"), + () -> assertThat(result.email()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("내 정보 조회") + @Nested + class GetMyInfo { + + @DisplayName("인증 후 마스킹된 이름으로 반환한다") + @Test + void returnsWithMaskedName() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + when(memberAuthService.authenticate("kwonmo", "Test1234!")).thenReturn(member); + + // when + MemberInfo result = memberFacade.getMyInfo("kwonmo", "Test1234!"); + + // then + assertAll( + () -> assertThat(result.loginId()).isEqualTo("kwonmo"), + () -> assertThat(result.name()).isEqualTo("양권*"), + () -> assertThat(result.email()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("인증 후 PasswordService에 위임한다") + @Test + void delegatesToPasswordService() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + when(memberAuthService.authenticate("kwonmo", "Test1234!")).thenReturn(member); + + // when + memberFacade.changePassword("kwonmo", "Test1234!", "Current1!", "NewPass5678!"); + + // then + verify(memberPasswordService).changePassword(member, "Current1!", "NewPass5678!"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 000000000..b79abda30 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,253 @@ +package com.loopers.interfaces.api; + +import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.member.MemberV1Dto; +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; + +import java.time.LocalDate; + +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 MemberV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/members"; + private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/members/me/password"; + + private final TestRestTemplate testRestTemplate; + private final MemberJpaRepository memberJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberJpaRepository memberJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberJpaRepository = memberJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private MemberV1Dto.SignupRequest signupRequest() { + return new MemberV1Dto.SignupRequest( + "kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com" + ); + } + + private void signupMember() { + testRestTemplate.exchange( + ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(signupRequest()), + new ParameterizedTypeReference>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + @DisplayName("POST /api/v1/members") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면 200과 회원 정보를 반환한다") + @Test + void returns200WithMemberInfo() { + // given + MemberV1Dto.SignupRequest request = signupRequest(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(request), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("kwonmo"), + () -> assertThat(response.getBody().data().name()).isEqualTo("양권모"), + () -> assertThat(response.getBody().data().email()).isEqualTo("kwonmo@example.com") + ); + } + + @DisplayName("중복 loginId로 가입하면 409를 반환한다") + @Test + void returns409OnDuplicateLoginId() { + // given + signupMember(); + MemberV1Dto.SignupRequest duplicateRequest = new MemberV1Dto.SignupRequest( + "kwonmo", "Other1234!", "박지훈", + LocalDate.of(1995, 5, 20), "jihun@example.com" + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(duplicateRequest), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("잘못된 loginId 형식이면 400을 반환한다") + @Test + void returns400OnInvalidLoginId() { + // given + MemberV1Dto.SignupRequest request = new MemberV1Dto.SignupRequest( + "test@user", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com" + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/members/me") + @Nested + class GetMe { + + @DisplayName("인증 성공 시 마스킹된 이름으로 반환한다") + @Test + void returns200WithMaskedName() { + // given + signupMember(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, + new HttpEntity<>(null, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("kwonmo"), + () -> assertThat(response.getBody().data().name()).isEqualTo("양권*"), + () -> assertThat(response.getBody().data().email()).isEqualTo("kwonmo@example.com") + ); + } + + @DisplayName("비밀번호가 틀리면 401을 반환한다") + @Test + void returns401OnWrongPassword() { + // given + signupMember(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, + new HttpEntity<>(null, authHeaders("kwonmo", "WrongPass1!")), + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/v1/members/me/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면 200을 반환한다") + @Test + void returns200OnValidRequest() { + // given + signupMember(); + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("Test1234!", "NewPass5678!"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("현재 비밀번호가 틀리면 400을 반환한다") + @Test + void returns400OnWrongCurrentPassword() { + // given + signupMember(); + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass5678!"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호 규칙을 위반하면 400을 반환한다") + @Test + void returns400OnInvalidNewPassword() { + // given + signupMember(); + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("Test1234!", "short"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} From 3156d07f57c4270bc90c49b15862d5e1a720ae8d Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:19:12 +0900 Subject: [PATCH 09/66] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Example/Core 테스트 DisplayName 자연스럽게 개선 - TEST-README.md 테스트 체크리스트 추가 Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/TEST-README.md | 150 ++++++++++++++++++ .../domain/example/ExampleModelTest.java | 14 +- .../ExampleServiceIntegrationTest.java | 12 +- .../interfaces/api/ExampleV1ApiE2ETest.java | 6 +- .../support/error/CoreExceptionTest.java | 8 +- 5 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 apps/commerce-api/TEST-README.md diff --git a/apps/commerce-api/TEST-README.md b/apps/commerce-api/TEST-README.md new file mode 100644 index 000000000..c426b1fcd --- /dev/null +++ b/apps/commerce-api/TEST-README.md @@ -0,0 +1,150 @@ +# Commerce API Test Checklist + +--- + +## Domain - Member + +### LoginIdTest (5) +- [ ] null, 빈 문자열, 공백은 허용하지 않는다 +- [ ] 특수문자나 한글이 포함되면 생성할 수 없다 +- [ ] 영문, 숫자 조합으로 생성할 수 있다 +- [ ] 같은 값이면 동일하다 (equals) +- [ ] 다른 값이면 다르다 (equals) + +### PasswordTest (13) +- [ ] null이나 빈 문자열은 허용하지 않는다 +- [ ] 유효한 비밀번호로 생성할 수 있다 +- [ ] 8~16자 범위 내의 비밀번호는 허용된다 +- [ ] 8~16자 범위를 벗어나면 생성할 수 없다 +- [ ] 한글이 포함되면 생성할 수 없다 +- [ ] YYYYMMDD 형식의 생년월일이 포함되면 예외가 발생한다 +- [ ] YYMMDD 형식의 생년월일이 포함되어도 예외가 발생한다 +- [ ] 생년월일이 포함되지 않으면 통과한다 +- [ ] birthDate가 null이면 검증을 건너뛴다 +- [ ] of 팩토리: 유효한 비밀번호와 생년월일로 생성할 수 있다 +- [ ] of 팩토리: 형식이 잘못되면 INVALID_PASSWORD 예외가 발생한다 +- [ ] of 팩토리: 생년월일이 포함된 비밀번호는 거부한다 +- [ ] of 팩토리: 생년월일이 null이면 형식만 검증한다 + +### MemberNameTest (5) +- [ ] null, 빈 문자열, 공백은 허용하지 않는다 +- [ ] 유효한 이름으로 생성할 수 있다 +- [ ] 글자 수에 따라 마지막 글자를 마스킹한다 (@CsvSource) +- [ ] 같은 값이면 동일하다 (equals) +- [ ] 다른 값이면 다르다 (equals) + +### EmailTest (4) +- [ ] 유효한 이메일로 생성할 수 있다 +- [ ] 이메일 형식이 올바르지 않으면 생성할 수 없다 +- [ ] 같은 값이면 동일하다 (equals) +- [ ] 다른 값이면 다르다 (equals) + +### MemberModelTest (3) +- [ ] 유효한 정보로 생성할 수 있다 +- [ ] email이 null이어도 생성할 수 있다 +- [ ] 새 비밀번호로 변경하면 이전 비밀번호는 매칭되지 않는다 + +### MemberRepositoryTest (3) - Integration +- [ ] 존재하지 않는 loginId면 빈 Optional을 반환한다 +- [ ] 존재하는 loginId면 저장된 회원을 반환한다 +- [ ] 유효한 회원 정보를 저장하면 ID가 생성된다 + +--- + +## Domain - Member Service (Unit) + +### MemberSignupServiceTest (4) +- [ ] 유효한 정보로 가입하면 비밀번호를 암호화하고 저장한다 +- [ ] 이미 사용 중인 loginId면 저장하지 않고 예외를 던진다 +- [ ] loginId 형식이 잘못되면 repository를 조회하지 않는다 +- [ ] 비밀번호 규칙 위반이면 repository를 조회하지 않는다 + +### MemberAuthServiceTest (3) +- [ ] 올바른 자격 증명이면 회원을 반환한다 +- [ ] 존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외를 던진다 +- [ ] 비밀번호가 틀리면 인증 실패 예외를 던진다 + +### MemberPasswordServiceTest (4) +- [ ] 유효한 요청이면 새 비밀번호로 암호화해서 저장한다 +- [ ] 현재 비밀번호가 틀리면 저장하지 않고 예외를 던진다 +- [ ] 새 비밀번호가 현재와 같으면 저장하지 않는다 +- [ ] 새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외를 던진다 + +--- + +## Domain - Member Service (Integration) + +### MemberSignupServiceIntegrationTest (5) +- [ ] 유효한 정보로 가입하면 회원이 생성되고 비밀번호가 암호화된다 +- [ ] 이미 존재하는 loginId로 가입하면 DUPLICATE_LOGIN_ID 예외가 발생한다 +- [ ] loginId 형식이 잘못되면 INVALID_LOGIN_ID 예외가 발생한다 +- [ ] 비밀번호 규칙을 위반하면 INVALID_PASSWORD 예외가 발생한다 +- [ ] 생년월일이 포함된 비밀번호는 거부한다 + +### MemberAuthServiceIntegrationTest (3) +- [ ] 올바른 loginId와 비밀번호면 회원을 반환한다 +- [ ] 비밀번호가 틀리면 인증 실패 예외가 발생한다 +- [ ] 존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외가 발생한다 + +### MemberPasswordServiceIntegrationTest (5) +- [ ] 올바른 현재 비밀번호와 유효한 새 비밀번호면 변경에 성공한다 +- [ ] 현재 비밀번호가 틀리면 PASSWORD_MISMATCH 예외가 발생한다 +- [ ] 새 비밀번호가 현재와 같으면 PASSWORD_SAME_AS_OLD 예외가 발생한다 +- [ ] 새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외가 발생한다 +- [ ] 새 비밀번호에 생년월일이 포함되면 거부한다 + +--- + +## Application + +### MemberFacadeTest (3) +- [ ] 회원가입: SignupService에 위임하고 MemberInfo를 반환한다 +- [ ] 내 정보 조회: 인증 후 마스킹된 이름으로 반환한다 +- [ ] 비밀번호 변경: 인증 후 PasswordService에 위임한다 + +--- + +## E2E (API) + +### MemberV1ApiE2ETest (8) +- [ ] POST /api/v1/members - 유효한 정보로 가입하면 200과 회원 정보를 반환한다 +- [ ] POST /api/v1/members - 중복 loginId로 가입하면 409를 반환한다 +- [ ] POST /api/v1/members - 잘못된 loginId 형식이면 400을 반환한다 +- [ ] GET /api/v1/members/me - 인증 성공 시 마스킹된 이름으로 반환한다 +- [ ] GET /api/v1/members/me - 비밀번호가 틀리면 401을 반환한다 +- [ ] PATCH /api/v1/members/me/password - 유효한 요청이면 200을 반환한다 +- [ ] PATCH /api/v1/members/me/password - 현재 비밀번호가 틀리면 400을 반환한다 +- [ ] PATCH /api/v1/members/me/password - 비밀번호 규칙을 위반하면 400을 반환한다 + +### ExampleV1ApiE2ETest (3) +- [ ] GET /api/v1/examples/{id} - 존재하는 ID면 해당 예시 정보를 반환한다 +- [ ] GET /api/v1/examples/{id} - 숫자가 아닌 ID면 400을 반환한다 +- [ ] GET /api/v1/examples/{id} - 존재하지 않는 ID면 404를 반환한다 + +--- + +## Domain - Example + +### ExampleModelTest (3) +- [ ] 제목과 설명이 모두 주어지면 정상 생성된다 +- [ ] 제목이 공백이면 BAD_REQUEST 예외가 발생한다 +- [ ] 설명이 비어있으면 BAD_REQUEST 예외가 발생한다 + +### ExampleServiceIntegrationTest (2) +- [ ] 존재하는 ID면 해당 예시 정보를 반환한다 +- [ ] 존재하지 않는 ID면 NOT_FOUND 예외가 발생한다 + +--- + +## Support + +### CoreExceptionTest (2) +- [ ] 커스텀 메시지가 없으면 ErrorType의 메시지를 사용한다 +- [ ] 커스텀 메시지가 주어지면 해당 메시지를 사용한다 + +--- + +## Context + +### CommerceApiContextTest (1) +- [ ] Spring Boot 애플리케이션 컨텍스트가 정상적으로 로드된다 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 index 44ca7576e..5a94cb896 100644 --- 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 @@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") + @DisplayName("예시 모델 생성") @Nested class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") + @DisplayName("제목과 설명이 모두 주어지면 정상 생성된다") @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { + void createsWithNameAndDescription() { // arrange String name = "제목"; String description = "설명"; @@ -32,9 +32,9 @@ void createsExampleModel_whenNameAndDescriptionAreProvided() { ); } - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") + @DisplayName("제목이 공백이면 BAD_REQUEST 예외가 발생한다") @Test - void throwsBadRequestException_whenTitleIsBlank() { + void throwsOnBlankTitle() { // arrange String name = " "; @@ -47,9 +47,9 @@ void throwsBadRequestException_whenTitleIsBlank() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @DisplayName("설명이 비어있으면 BAD_REQUEST 예외가 발생한다") @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { + void throwsOnEmptyDescription() { // arrange String description = ""; 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 index bbd5fdbe1..7a74d1076 100644 --- 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 @@ -31,12 +31,12 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("예시를 조회할 때,") + @DisplayName("예시 조회") @Nested class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") + @DisplayName("존재하는 ID면 해당 예시 정보를 반환한다") @Test - void returnsExampleInfo_whenValidIdIsProvided() { + void returnsExampleForExistingId() { // arrange ExampleModel exampleModel = exampleJpaRepository.save( new ExampleModel("예시 제목", "예시 설명") @@ -54,11 +54,11 @@ void returnsExampleInfo_whenValidIdIsProvided() { ); } - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") @Test - void throwsException_whenInvalidIdIsProvided() { + void throwsOnNonExistentId() { // arrange - Long invalidId = 999L; // Assuming this ID does not exist + Long invalidId = 999L; // act CoreException exception = assertThrows(CoreException.class, () -> { 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 index 1bb3dba65..bee78db56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -51,7 +51,7 @@ void tearDown() { @DisplayName("GET /api/v1/examples/{id}") @Nested class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") + @DisplayName("존재하는 ID면 해당 예시 정보를 반환한다") @Test void returnsExampleInfo_whenValidIdIsProvided() { // arrange @@ -74,7 +74,7 @@ void returnsExampleInfo_whenValidIdIsProvided() { ); } - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") + @DisplayName("숫자가 아닌 ID면 400을 반환한다") @Test void throwsBadRequest_whenIdIsNotProvided() { // arrange @@ -92,7 +92,7 @@ void throwsBadRequest_whenIdIsNotProvided() { ); } - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") + @DisplayName("존재하지 않는 ID면 404를 반환한다") @Test void throwsException_whenInvalidIdIsProvided() { // arrange diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e6..aff2274cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -6,9 +6,9 @@ import static org.assertj.core.api.Assertions.assertThat; class CoreExceptionTest { - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") + @DisplayName("커스텀 메시지가 없으면 ErrorType의 메시지를 사용한다") @Test - void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + void usesErrorTypeMessageByDefault() { // arrange ErrorType[] errorTypes = ErrorType.values(); @@ -19,9 +19,9 @@ void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { } } - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.") + @DisplayName("커스텀 메시지가 주어지면 해당 메시지를 사용한다") @Test - void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + void usesCustomMessageWhenProvided() { // arrange String customMessage = "custom message"; From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 10/66] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 From 22bb7c9b033e348500325483bf55d38c0c269053 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 13 Feb 2026 17:08:50 +0900 Subject: [PATCH 11/66] =?UTF-8?q?docs:=20=EC=9D=B4=EC=BB=A4=EB=A8=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1=20-=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9A=94=EA=B5=AC=EC=A0=95=EC=9D=98=EC=84=9C=20-=20?= =?UTF-8?q?=EC=9C=A0=EB=B9=84=EC=BF=BC=ED=84=B0=EC=8A=A4=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=20=EC=A0=95=EC=9D=98=EC=84=9C=20-=20=EC=8B=9C?= =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20-=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20-=20erd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/mermaid/01-requirements.md | 335 ++++++++++++++++++ docs/design/mermaid/02-ubiquitous-language.md | 110 ++++++ .../mermaid/03-sequence-brand-delete.mmd | 23 ++ .../mermaid/03-sequence-like-toggle.mmd | 47 +++ .../mermaid/03-sequence-order-creation.mmd | 31 ++ .../mermaid/03-sequence-product-list.mmd | 28 ++ docs/design/mermaid/04-class-diagram.mmd | 98 +++++ docs/design/mermaid/05-erd.mmd | 59 +++ 8 files changed, 731 insertions(+) create mode 100644 docs/design/mermaid/01-requirements.md create mode 100644 docs/design/mermaid/02-ubiquitous-language.md create mode 100644 docs/design/mermaid/03-sequence-brand-delete.mmd create mode 100644 docs/design/mermaid/03-sequence-like-toggle.mmd create mode 100644 docs/design/mermaid/03-sequence-order-creation.mmd create mode 100644 docs/design/mermaid/03-sequence-product-list.mmd create mode 100644 docs/design/mermaid/04-class-diagram.mmd create mode 100644 docs/design/mermaid/05-erd.mmd diff --git a/docs/design/mermaid/01-requirements.md b/docs/design/mermaid/01-requirements.md new file mode 100644 index 000000000..0a7cd3bae --- /dev/null +++ b/docs/design/mermaid/01-requirements.md @@ -0,0 +1,335 @@ +# 이커머스 요구사항 정의서 + +--- + +## 1. Brand (브랜드) + +### 유저 스토리 + +- 고객은 브랜드 정보를 조회할 수 있다. +- 어드민은 새로운 브랜드를 등록할 수 있다. +- 어드민은 브랜드 정보(이름, 설명)를 수정할 수 있다. +- 어드민은 브랜드를 삭제할 수 있다. + +### 기능 흐름 + +**브랜드 등록** + +1. 어드민 인증 확인 (@AdminUser) +2. 브랜드명 유효성 검증 (빈값 불가) +3. 브랜드명 중복 체크 (Q17: 중복 불가 → 409 Conflict) +4. 브랜드 생성 및 저장 +5. 등록 결과 반환 + +**브랜드 수정** + +1. 어드민 인증 확인 +2. 브랜드 존재 여부 확인 (없으면 404) +3. 브랜드명 변경 시 중복 체크 +4. name, description 수정 및 저장 +5. 소속 상품에는 별도 업데이트 불필요 (Q31: brandId 참조 방식으로 자동 반영) + +**브랜드 삭제 (연쇄)** + +1. 어드민 인증 확인 +2. 브랜드 존재 여부 확인 (없으면 404) +3. 해당 브랜드 소속 상품 전체 soft delete (Q1: Soft Delete 연쇄) +4. 브랜드 soft delete +5. 트랜잭션 커밋 (전체 하나의 트랜잭션) + +### 비즈니스 규칙 + +- 브랜드명은 유니크해야 한다 (Q17) +- 브랜드 삭제 시 소속 상품도 함께 soft delete (Q1) +- 브랜드-상품은 ID 참조만 사용, JPA 연관관계 없음 (ADR-008) +- 브랜드 restore는 설계 범위에서 제외 (Q2) +- 삭제된 브랜드에 새 상품 등록 불가 (Q27) +- 브랜드명 수정 시 소속 상품에 자동 반영됨 — brandId 참조 방식 (Q31) +- 브랜드 필드: name(필수, BrandName VO), description(선택) (Q14) + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| GET | `/api/v1/brands/{brandId}` | 고객 브랜드 정보 조회 | 불필요 | +| POST | `/api-admin/v1/brands` | 브랜드 등록 | @AdminUser | +| GET | `/api-admin/v1/brands?page=0&size=20` | 어드민 브랜드 목록 (삭제 포함) | @AdminUser | +| GET | `/api-admin/v1/brands/{brandId}` | 어드민 브랜드 상세 | @AdminUser | +| PUT | `/api-admin/v1/brands/{brandId}` | 브랜드 수정 | @AdminUser | +| DELETE | `/api-admin/v1/brands/{brandId}` | 브랜드 삭제 | @AdminUser | + +--- + +## 2. Product + Stock (상품 + 재고) + +### 유저 스토리 + +- 고객은 상품 목록을 조회할 수 있다. +- 고객은 상품 상세 정보를 볼 수 있다. +- 어드민은 새로운 상품을 등록할 수 있다. +- 어드민은 상품 정보를 수정할 수 있다. +- 어드민은 상품을 삭제할 수 있다. + +### 기능 흐름 + +**상품 등록** + +1. 어드민 인증 확인 (@AdminUser) +2. 브랜드 존재 여부 확인 (삭제된 브랜드 불가, Q27) +3. 상품 정보 검증 (name 필수, price >= 0) +4. 상품 생성 및 저장 +5. Stock 생성 (initialStock 수량, Product와 1:1, Q4) +6. 하나의 트랜잭션으로 처리 + +**상품 목록 조회 (Customer)** + +1. 정렬 파라미터 파싱 (Q24: created_desc / price_asc / price_desc / likes_desc) +2. 페이지네이션 적용 (Q15: Spring Pageable) +3. 삭제된 상품 제외 (deletedAt IS NULL) +4. 브랜드 필터 적용 (선택) +5. Stock 정보 조합 +6. 재고 상태 변환 (Q6: >10 → IN_STOCK, 1~10 → LOW_STOCK, 0 → OUT_OF_STOCK) + +**상품 목록 조회 (Admin)** + +1. 어드민 인증 확인 +2. 삭제된 상품도 포함하여 조회 가능 +3. 정확한 재고 수량 표시 (Q6) +4. createdAt, updatedAt, deletedAt 포함 + +**상품 수정 (Admin)** + +1. 어드민 인증 확인 +2. 상품 존재 여부 확인 +3. 수정 가능 필드만 변경: name, description, price (Q29) +4. 수정 불가 필드: brandId(브랜드 이동 불가), likeCount(시스템 관리) (Q29) +5. 저장 + +### 비즈니스 규칙 + +- 상품 필드: name, description, price(Money VO), brandId 필수 (Q3) +- 재고는 별도 Stock 엔티티로 분리 — Product와 1:1 (Q4) +- 가격은 Money VO (int 내부 타입, 원화, 음수 불가, 0원 허용) (Q5) +- 고객에게는 재고 상태(IN_STOCK/LOW_STOCK/OUT_OF_STOCK)만 표시 (Q6) +- 어드민에게는 정확한 재고 수량 표시 (Q6) +- 상품명 중복 허용 (Q18) +- 수정 가능: name, description, price / 수정 불가: brandId, likeCount (Q29) +- 삭제된 브랜드에 상품 등록 불가 (Q27) + +### 정렬 옵션 (Q24) + +| 정렬 값 | 동작 | +|---------|------| +| `created_desc` (기본) | 상품 등록일 최신순 | +| `price_asc` | 가격 낮은순 | +| `price_desc` | 가격 높은순 | +| `likes_desc` | 좋아요 많은순 | + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| GET | `/api/v1/products?brandId={brandId}&sort={sort}&page=0&size=20` | 고객 상품 목록 | 불필요 | +| GET | `/api/v1/products/{productId}` | 고객 상품 상세 | 불필요 | +| POST | `/api-admin/v1/products` | 상품 등록 (initialStock 포함) | @AdminUser | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | 어드민 상품 목록 (삭제 포함) | @AdminUser | +| GET | `/api-admin/v1/products/{productId}` | 어드민 상품 상세 | @AdminUser | +| PUT | `/api-admin/v1/products/{productId}` | 상품 수정 | @AdminUser | +| DELETE | `/api-admin/v1/products/{productId}` | 상품 삭제 | @AdminUser | + +--- + +## 3. Like (좋아요) + +### 유저 스토리 + +- 사용자는 상품을 찜할 수 있다. +- 이미 찜한 상품을 다시 누르면 무시된다 (멱등성). +- 사용자는 찜을 취소할 수 있다. +- 이미 취소한 찜을 다시 취소하면 무시된다 (멱등성). +- 사용자는 찜한 상품 목록을 볼 수 있다. + +### 기능 흐름 + +**좋아요 추가** + +1. 로그인 사용자만 가능 (@LoginMember) +2. 상품 존재 여부 확인 (삭제된 상품 불가, Q16) +3. 기존 좋아요 존재 여부 판단 +4. 없으면 좋아요 저장 + likeCount 증가 +5. 있으면 아무 동작 없이 200 OK 반환 (Q7: 멱등성) + +**좋아요 취소** + +1. 로그인 사용자만 가능 +2. 기존 좋아요 존재 여부 판단 +3. 있으면 삭제 + likeCount 감소 (음수 방지, Q28) +4. 없으면 아무 동작 없이 200 OK 반환 (Q7: 멱등성) + +**좋아요 목록 조회** + +1. 로그인 사용자만 가능 +2. 본인의 좋아요 목록만 조회 +3. 삭제된 상품은 목록에서 제외 (Q16) +4. 좋아요 누른 시간 최신순 정렬 (Q23) +5. 페이지네이션 적용 + +### 비즈니스 규칙 + +- 좋아요 등록/취소 모두 멱등성 보장, 200 OK 반환 (Q7) +- Product 테이블에 likeCount 비정규화 유지 (Q8) +- likeCount는 0 미만이 될 수 없다 — 애플리케이션 가드 + DB CHECK (Q28) +- 삭제된 상품에는 좋아요 불가 (Q16) +- 삭제된 상품은 좋아요 목록에서 제외 (Q16) +- 좋아요 목록은 좋아요 누른 시간 최신순 (Q23) +- 좋아요 데이터 자체는 삭제하지 않음 (상품 복구 시 다시 보임) + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| POST | `/api/v1/products/{productId}/likes` | 좋아요 추가 | @LoginMember | +| DELETE | `/api/v1/products/{productId}/likes` | 좋아요 취소 | @LoginMember | +| GET | `/api/v1/users/{userId}/likes` | 내 좋아요 목록 | @LoginMember | + +--- + +## 4. Order (주문) + +### 유저 스토리 + +- 고객은 여러 상품을 한 번에 주문할 수 있다. +- 주문하면 재고가 즉시 차감된다. +- 주문 정보에는 당시의 상품 정보가 스냅샷으로 저장된다. +- 고객은 자신의 주문 내역을 조회할 수 있다. +- 고객은 주문 상세(상품별 수량, 금액)를 볼 수 있다. +- 어드민은 전체 주문을 조회할 수 있다. + +### 기능 흐름 + +**주문 생성** + +1. 로그인 사용자만 가능 (@LoginMember) +2. 각 주문 상품에 대해: + a. 상품 존재 여부 확인 — 삭제된 상품 포함 시 전체 실패 (Q20) + b. 수량 검증 — 1 이상 (Q26) + c. 재고 확인 및 차감 — 부족 시 전체 실패 (Q19) +3. Order 생성 (status = CREATED, Q11) +4. OrderItem 생성 — 상품명, 가격 스냅샷 저장 (Q9) +5. 총액은 서버에서 계산하여 Order에 저장 (Q12) +6. 전체 하나의 트랜잭션 — 실패 시 전체 롤백 (Q19: All or Nothing) + +**주문 목록 조회** + +1. 고객: 본인 주문만 조회 (Q25: userId 필터) +2. 날짜 범위 필터 적용 (startAt, endAt 쿼리 파라미터) +3. 어드민: 전체 주문 조회 가능 (Q25) +4. 최신순 정렬, 페이지네이션 적용 + +**주문 상세 조회** + +1. 고객: 본인 주문만 상세 조회 가능 +2. OrderItem 목록 포함 (스냅샷된 상품명, 가격, 수량, 소계) +3. 상품이 삭제된 후에도 스냅샷으로 조회 가능 + +### 비즈니스 규칙 + +- Order + OrderItem 분리 (Q10: 1:N 관계) +- OrderItem에 주문 시점의 상품명, 가격, 수량 스냅샷 저장 (Q9) +- OrderStatus: CREATED만 사용, 나머지(CONFIRMED, SHIPPING, DELIVERED, CANCELLED)는 미래 확장용 (Q11) +- 총액은 서버에서 계산, 클라이언트 전송값 무시 (Q12) +- 재고 부족 시 주문 전체 실패 — All or Nothing (Q19) +- 삭제된 상품 포함 시 전체 실패 + 상세 에러 메시지 (Q20) +- 주문 생성과 동시에 재고 차감, 하나의 트랜잭션 (Q21) +- 자기 자신 주문 제한 없음 (Q22: 어드민/고객 인증 체계 분리) +- 고객은 본인 주문만, 어드민은 전체 조회 가능 (Q25) +- 수량은 최소 1, 최대 해당 상품 재고 이하 (Q26) + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| POST | `/api/v1/orders` | 주문 생성 | @LoginMember | +| GET | `/api/v1/orders?startAt={date}&endAt={date}` | 내 주문 목록 (날짜 범위 필터) | @LoginMember | +| GET | `/api/v1/orders/{orderId}` | 주문 상세 | @LoginMember | +| GET | `/api-admin/v1/orders?page=0&size=20` | 전체 주문 목록 | @AdminUser | +| GET | `/api-admin/v1/orders/{orderId}` | 주문 상세 | @AdminUser | + +--- + +## 5. 공통 + +### 인증 (Q13: ArgumentResolver) + +- `@LoginMember` — 고객 인증 (X-Loopers-LoginId + X-Loopers-LoginPw → MemberModel 주입) +- `@AdminUser` — 어드민 인증 (X-Loopers-Ldap → AdminInfo 주입) + +### 페이지네이션 (Q15) + +- Spring Pageable 사용 +- 공통 파라미터: page, size +- JPA Repository와 자연스럽게 연동 + +### 에러 처리 (Q32) + +기존 ErrorType 4종(NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_ERROR)을 활용하고, 메시지로 상황을 구분한다. + +| 상황 | ErrorType | 메시지 예시 | +|------|-----------|------------| +| 존재하지 않는 상품/브랜드/주문 | NOT_FOUND | "상품을 찾을 수 없습니다" | +| 브랜드명 중복 | CONFLICT | "이미 존재하는 브랜드명입니다" | +| 재고 부족 | BAD_REQUEST | "재고가 부족합니다: [상품명]" | +| 주문 수량 0 이하 | BAD_REQUEST | "주문 수량은 1 이상이어야 합니다" | +| 삭제된 상품에 좋아요 | NOT_FOUND | "상품을 찾을 수 없습니다" | +| 삭제된 상품이 주문에 포함 | NOT_FOUND | "삭제된 상품이 포함되어 있습니다: [상품명]" | + +--- + +## 6. API 엔드포인트 요약 + +| 구분 | Customer | Admin | 합계 | +|------|----------|-------|------| +| Brand | 1 | 5 | 6 | +| Product | 2 | 5 | 7 | +| Like | 3 | 0 | 3 | +| Order | 3 | 2 | 5 | +| **합계** | **9** | **12** | **21** | + +--- + +## 7. Q&A 트레이드오프 추적표 + +| Q# | 결정 사항 | 반영 위치 | +|----|-----------|-----------| +| Q1 | Soft Delete 연쇄 | Brand 비즈니스 규칙, 기능 흐름 | +| Q2 | 브랜드 restore 제외 | Brand 비즈니스 규칙 | +| Q3 | 상품 필드 (name, desc, price, brandId) | Product 비즈니스 규칙 | +| Q4 | Stock 별도 엔티티 분리 | Product 비즈니스 규칙, 기능 흐름 | +| Q5 | Money VO (int) | Product 비즈니스 규칙 | +| Q6 | 고객/어드민 재고 표시 차이 | Product 기능 흐름 | +| Q7 | 좋아요 멱등성 | Like 기능 흐름, 비즈니스 규칙 | +| Q8 | likeCount 비정규화 | Like 비즈니스 규칙 | +| Q9 | 스냅샷 (상품명 + 가격 + 수량) | Order 비즈니스 규칙 | +| Q10 | Order + OrderItem 분리 | Order 비즈니스 규칙 | +| Q11 | OrderStatus enum 미래 확장 | Order 비즈니스 규칙 | +| Q12 | 서버 계산 totalAmount | Order 기능 흐름, 비즈니스 규칙 | +| Q13 | ArgumentResolver | 공통 인증 | +| Q14 | 브랜드 필드 (name + description) | Brand 비즈니스 규칙 | +| Q15 | Spring Pageable | 공통 페이지네이션 | +| Q16 | 삭제된 상품 좋아요 불가 + 목록 제외 | Like 기능 흐름, 비즈니스 규칙 | +| Q17 | 브랜드명 중복 불가 | Brand 기능 흐름, 비즈니스 규칙 | +| Q18 | 상품명 중복 허용 | Product 비즈니스 규칙 | +| Q19 | 재고 부족 전체 실패 | Order 기능 흐름, 비즈니스 규칙 | +| Q20 | 삭제 상품 포함 시 전체 실패 | Order 기능 흐름, 비즈니스 규칙 | +| Q21 | 주문 생성 시 재고 즉시 차감 | Order 기능 흐름, 비즈니스 규칙 | +| Q22 | 자기 자신 주문 제한 없음 | Order 비즈니스 규칙 | +| Q23 | 좋아요 목록 최신순 | Like 기능 흐름 | +| Q24 | 커스텀 sort 4종 | Product 정렬 옵션 | +| Q25 | 주문 조회 범위 (고객: 본인, 어드민: 전체) | Order 기능 흐름, 비즈니스 규칙 | +| Q26 | 수량 최소 1, 최대 재고 이하 | Order 비즈니스 규칙 | +| Q27 | 삭제된 브랜드에 상품 등록 불가 | Brand/Product 비즈니스 규칙 | +| Q28 | likeCount 음수 방지 | Like 비즈니스 규칙 | +| Q29 | 상품 수정 범위 (name, desc, price만) | Product 기능 흐름, 비즈니스 규칙 | +| Q30 | ~~재고 절대값 세팅~~ (구현 범위 제외) | - | +| Q31 | 브랜드명 변경 자동 반영 | Brand 기능 흐름, 비즈니스 규칙 | +| Q32 | 기존 ErrorType 4종 활용 | 공통 에러 처리 | diff --git a/docs/design/mermaid/02-ubiquitous-language.md b/docs/design/mermaid/02-ubiquitous-language.md new file mode 100644 index 000000000..c7138535e --- /dev/null +++ b/docs/design/mermaid/02-ubiquitous-language.md @@ -0,0 +1,110 @@ +# 유비쿼터스 언어 + +프로젝트 전반에서 통일하여 사용하는 도메인 용어를 정의합니다. +코드, 문서, 커뮤니케이션에서 동일한 의미로 사용합니다. + +--- + +## 1. Actor (행위자) + +| 용어 | 설명 | 인증 방식 | +|------|------|-----------| +| **Customer** | 로그인한 일반 사용자. 상품 조회, 좋아요, 주문 가능 | `@LoginMember` (X-Loopers-LoginId + X-Loopers-LoginPw) | +| **Admin** | 관리자. 브랜드/상품/주문 관리 | `@AdminUser` (X-Loopers-Ldap) | + +--- + +## 2. Brand 도메인 + +| 용어 | 타입 | 설명 | +|------|------|------| +| **BrandModel** | Entity | 브랜드 엔티티. BaseEntity 상속. name(BrandName VO) + description | +| **BrandName** | @Embeddable VO | 브랜드명 값 객체. 유니크 제약, 빈값 불가, `value()` 접근자 | +| **BrandService** | Domain Service | 단일 도메인 로직. CRUD, 브랜드명 유니크 검증 | +| **BrandFacade** | Application Facade | 유스케이스 조합. 삭제 시 소속 상품 연쇄 soft delete | +| **브랜드 삭제 연쇄** | 비즈니스 규칙 | 브랜드 삭제 → 소속 상품 전체 soft delete. 하나의 트랜잭션 (Q1) | + +--- + +## 3. Product 도메인 + +| 용어 | 타입 | 설명 | +|------|------|------| +| **ProductModel** | Entity | 상품 엔티티. name, description, price(Money), brandId(ID 참조), likeCount(비정규화) | +| **Money** | @Embeddable VO | 금액 값 객체. int 내부 타입(원화), 음수 불가, 0원 허용. `add()`, `multiply()` 행위 메서드 | +| **StockModel** | Entity | 재고 엔티티. Product와 1:1 관계. `decrease()`, `increase()`, `hasEnough()` 행위 메서드 | +| **StockStatus** | 표시 상태 | 고객에게 보여주는 재고 상태. IN_STOCK(>10), LOW_STOCK(1~10), OUT_OF_STOCK(0) | +| **ProductService** | Domain Service | 상품 CRUD, likeCount 증감, soft delete | +| **StockService** | Domain Service | 재고 생성, 조회, 차감(`checkAndDecrease`) | +| **ProductFacade** | Application Facade | 상품 + Stock 동시 생성, 브랜드 존재 확인 | +| **initialStock** | 요청 파라미터 | 상품 등록 시 초기 재고 수량 | + +--- + +## 4. Like 도메인 + +| 용어 | 타입 | 설명 | +|------|------|------| +| **LikeModel** | Entity | 좋아요 엔티티. userId + productId 유니크 제약 | +| **멱등성 (Idempotency)** | 비즈니스 규칙 | 좋아요 중복 등록 → 무시 + 200 OK. 취소 중복 → 무시 + 200 OK (Q7) | +| **likeCount 동기화** | 비즈니스 규칙 | 좋아요 추가 → `incrementLikeCount()`, 취소 → `decrementLikeCount()`. 음수 방지 가드 포함 (Q28) | +| **LikeService** | Domain Service | 좋아요 등록/취소, 존재 여부 조회, 목록 조회 | +| **LikeFacade** | Application Facade | 삭제된 상품 체크, 트랜잭션 내 likeCount 동기화 | + +--- + +## 5. Order 도메인 + +| 용어 | 타입 | 설명 | +|------|------|------| +| **OrderModel** | Entity | 주문 엔티티. userId, status(OrderStatus), totalAmount(Money) | +| **OrderItemModel** | Entity | 주문 상세 엔티티. orderId, productId, 스냅샷(productName, productPrice), quantity. `subtotal()` 행위 메서드 | +| **OrderStatus** | Enum | 주문 상태. CREATED(현재 사용) → CONFIRMED → SHIPPING → DELIVERED → CANCELLED (미래 확장용) | +| **스냅샷 (Snapshot)** | 비즈니스 개념 | 주문 시점의 상품명, 가격을 OrderItem에 복사 저장. 상품 삭제/변경 후에도 주문 내역 조회 가능 (Q9) | +| **All or Nothing** | 비즈니스 규칙 | 재고 부족 또는 삭제된 상품 포함 시 주문 전체 실패. 부분 성공 없음 (Q19) | +| **OrderService** | Domain Service | 주문 생성(총액 계산 포함), 조회 | +| **OrderFacade** | Application Facade | 상품 조회 → 재고 차감 → 주문 생성. 하나의 트랜잭션 | + +--- + +## 6. 공통 패턴 + +### 6.1 엔티티 기반 + +| 용어 | 설명 | +|------|------| +| **BaseEntity** | 모든 엔티티의 부모 클래스. id, createdAt, updatedAt, deletedAt 자동 관리 | +| **Soft Delete** | `deletedAt`을 세팅하여 논리 삭제. `delete()` / `restore()` 메서드. 조회 시 `findByIdAndDeletedAtIsNull` | +| **guard()** | BaseEntity의 검증 메서드. `@PrePersist` / `@PreUpdate` 시 호출 | + +### 6.2 값 객체 (Value Object) + +| 용어 | 설명 | +|------|------| +| **@Embeddable VO** | JPA 임베딩 가능한 값 객체 패턴. 생성 시 검증, `value()` 접근자, equals/hashCode | +| **value()** | VO의 내부 값 접근 메서드. getter 대신 사용 (`loginId.value()`, `price.value()`) | + +### 6.3 아키텍처 레이어 + +| 용어 | 설명 | +|------|------| +| **Controller** | HTTP 요청/응답 처리, 인증 어노테이션 적용. `interfaces/api/` 패키지 | +| **Facade** | 유스케이스 조합, `@Transactional` 경계 관리, 여러 Service 조합. `application/` 패키지 | +| **Service** | 단일 도메인 비즈니스 로직. `domain/` 패키지 | +| **Repository** | 데이터 접근 인터페이스(domain) + JPA 구현체(infrastructure) | + +### 6.4 에러 처리 + +| 용어 | 설명 | +|------|------| +| **CoreException** | 비즈니스 예외. ErrorType enum 기반으로 생성 | +| **ErrorType** | 에러 유형 enum. NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_ERROR (Q32) | +| **ApiResponse** | 공통 응답 래퍼. `meta`(result, errorCode, message) + `data`(응답 본문) | + +### 6.5 관계 설계 + +| 용어 | 설명 | +|------|------| +| **ID 참조** | JPA 연관관계(@ManyToOne 등) 없이 `Long brandId`, `Long productId`로만 참조 (ADR-008) | +| **1:1 분리** | Product-Stock 관계. 변경 이유가 다르므로 별도 엔티티/테이블 (ADR-001) | +| **비정규화** | Product.likeCount. 조회 성능을 위해 집계값을 저장. 쓰기 시 동기화 필요 (ADR-002) | diff --git a/docs/design/mermaid/03-sequence-brand-delete.mmd b/docs/design/mermaid/03-sequence-brand-delete.mmd new file mode 100644 index 000000000..4510846c6 --- /dev/null +++ b/docs/design/mermaid/03-sequence-brand-delete.mmd @@ -0,0 +1,23 @@ +sequenceDiagram + participant A as Admin + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + + A->>F: 브랜드 삭제 요청 (DELETE /api-admin/v1/brands/{brandId}) + Note over F: @AdminUser 인증 + + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>BS: 브랜드 조회 + alt 브랜드 없음 + BS-->>F: NOT_FOUND + end + F->>PS: 소속 상품 전체 조회 + loop 각 소속 상품 + F->>PS: 상품 soft delete + end + F->>BS: 브랜드 soft delete + end + + F-->>A: 200 OK \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-like-toggle.mmd b/docs/design/mermaid/03-sequence-like-toggle.mmd new file mode 100644 index 000000000..77f372b12 --- /dev/null +++ b/docs/design/mermaid/03-sequence-like-toggle.mmd @@ -0,0 +1,47 @@ +sequenceDiagram + participant C as Customer + participant F as LikeFacade + participant PS as ProductService + participant LS as LikeService + + rect rgb(232, 245, 233) + Note over C,LS: 좋아요 추가 + C->>F: 좋아요 요청 (POST /api/v1/products/{productId}/likes) + Note over F: @LoginMember 인증 + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>PS: 상품 존재 확인 + alt 삭제된 상품 + PS-->>F: NOT_FOUND + end + F->>LS: 좋아요 존재 여부 확인 + alt 이미 좋아요 존재 + Note over F: 무시 (멱등성) + else 좋아요 없음 + F->>LS: 좋아요 저장 + F->>PS: likeCount 증가 + end + end + deactivate F + F-->>C: 200 OK (liked: true) + end + + rect rgb(255, 235, 238) + Note over C,LS: 좋아요 취소 + C->>F: 취소 요청 (DELETE /api/v1/products/{productId}/likes) + Note over F: @LoginMember 인증 + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>LS: 좋아요 존재 여부 확인 + alt 좋아요 존재 + F->>LS: 좋아요 삭제 + F->>PS: likeCount 감소 (음수 방지) + else 좋아요 없음 + Note over F: 무시 (멱등성) + end + end + deactivate F + F-->>C: 200 OK (liked: false) + end \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-order-creation.mmd b/docs/design/mermaid/03-sequence-order-creation.mmd new file mode 100644 index 000000000..39836c193 --- /dev/null +++ b/docs/design/mermaid/03-sequence-order-creation.mmd @@ -0,0 +1,31 @@ +sequenceDiagram + participant C as Customer + participant F as OrderFacade + participant PS as ProductService + participant SS as StockService + participant OS as OrderService + + C->>F: 주문 요청 (POST /api/v1/orders) + Note over F: @LoginMember 인증 + + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + loop 각 주문 상품 + F->>PS: 상품 조회 + alt 삭제된 상품 + PS-->>F: NOT_FOUND + end + F->>SS: 재고 확인 및 차감 + alt 재고 부족 + SS-->>F: BAD_REQUEST + Note over F: 전체 롤백 (All or Nothing) + end + end + F->>OS: 주문 생성 (총액 서버 계산) + Note over OS: Order + OrderItem 저장 + Note over OS: 스냅샷: 상품명, 가격, 수량 + end + deactivate F + + F-->>C: 200 OK (주문 정보) \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-product-list.mmd b/docs/design/mermaid/03-sequence-product-list.mmd new file mode 100644 index 000000000..cb907a35d --- /dev/null +++ b/docs/design/mermaid/03-sequence-product-list.mmd @@ -0,0 +1,28 @@ +sequenceDiagram + participant C as Customer + participant A as Admin + participant F as ProductFacade + participant PS as ProductService + participant SS as StockService + + rect rgb(232, 245, 233) + Note over C,SS: Customer 상품 목록 조회 + C->>F: GET /api/v1/products?sort=likes_desc&page=0 + F->>PS: 상품 목록 조회 (삭제 제외, 정렬, 페이지네이션) + F->>SS: 재고 정보 조합 + Note over F: 재고 상태 변환 + Note over F: >10 → IN_STOCK + Note over F: 1~10 → LOW_STOCK + Note over F: 0 → OUT_OF_STOCK + F-->>C: 200 OK (상품 목록 + 재고 상태) + end + + rect rgb(227, 242, 253) + Note over A,SS: Admin 상품 목록 조회 + A->>F: GET /api-admin/v1/products?page=0 + Note over F: @AdminUser 인증 + F->>PS: 상품 목록 조회 (삭제 포함 가능) + F->>SS: 재고 정보 조합 + Note over F: 정확한 재고 수량 표시 + F-->>A: 200 OK (상품 목록 + 재고 수량) + end \ No newline at end of file diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd new file mode 100644 index 000000000..dafa7ac6b --- /dev/null +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -0,0 +1,98 @@ +classDiagram + direction LR + + namespace Brand { + class BrandName { + <> + -String value + } + class BrandModel { + -BrandName name + -String description + } + class BrandFacade { + 삭제 시 상품 연쇄 soft delete + } + } + + namespace Product { + class Money { + <> + -int value + +add(Money) Money + +multiply(int) Money + } + class ProductModel { + -String name + -String description + -Money price + -Long brandId + -int likeCount + +incrementLikeCount() + +decrementLikeCount() + } + class StockModel { + -Long productId + -int quantity + +hasEnough(int) boolean + +decrease(int) + +increase(int) + } + class ProductFacade { + 상품 + Stock 동시 생성 + } + } + + namespace Like { + class LikeModel { + -Long userId + -Long productId + } + class LikeFacade { + 멱등성 + likeCount 동기화 + } + } + + namespace Order { + class OrderStatus { + <> + CREATED + CONFIRMED + SHIPPING + DELIVERED + CANCELLED + } + class OrderModel { + -Long userId + -OrderStatus status + -Money totalAmount + } + class OrderItemModel { + -Long orderId + -Long productId + -String productName + -Money productPrice + -int quantity + +subtotal() Money + } + class OrderFacade { + 재고 차감 + 스냅샷 + 주문 생성 + } + } + + BrandModel *-- BrandName + ProductModel *-- Money + OrderModel *-- OrderStatus + OrderModel *-- Money + OrderItemModel *-- Money + + BrandFacade --> BrandModel + ProductFacade --> ProductModel + ProductFacade --> StockModel + LikeFacade --> LikeModel + OrderFacade --> OrderModel + OrderFacade --> OrderItemModel + + BrandFacade --> ProductModel + LikeFacade --> ProductModel + OrderFacade --> StockModel \ No newline at end of file diff --git a/docs/design/mermaid/05-erd.mmd b/docs/design/mermaid/05-erd.mmd new file mode 100644 index 000000000..8baf2ac4d --- /dev/null +++ b/docs/design/mermaid/05-erd.mmd @@ -0,0 +1,59 @@ +erDiagram + member ||--o{ member_like : "has" + member ||--o{ orders : "places" + brand ||--o{ product : "has" + product ||--|| stock : "has" + product ||--o{ member_like : "receives" + product ||--o{ order_item : "ordered_as" + orders ||--|{ order_item : "contains" + + member { + bigint id PK + varchar login_id UK "LoginId VO" + varchar password "BCrypt encoded" + varchar name "MemberName VO" + varchar email "Email VO, nullable" + date birth_date + timestamp deleted_at "soft delete" + } + brand { + bigint id PK + varchar name UK "BrandName VO" + varchar description + timestamp deleted_at "soft delete" + } + product { + bigint id PK + varchar name + text description + int price "Money VO" + bigint brand_id "refs brand" + int like_count "비정규화" + timestamp deleted_at "soft delete" + } + stock { + bigint id PK + bigint product_id UK "1:1" + int quantity + } + member_like { + bigint id PK + bigint user_id "refs member" + bigint product_id "refs product" + timestamp created_at "좋아요 시간, 정렬 기준(Q23)" + } + orders { + bigint id PK + bigint user_id "refs member" + varchar status "OrderStatus" + int total_amount "Money VO" + timestamp created_at "주문일시, 날짜필터 기준" + } + order_item { + bigint id PK + bigint order_id "refs orders" + bigint product_id "refs product" + varchar product_name "snapshot" + int product_price "snapshot" + int quantity + } \ No newline at end of file From ea209d5db7fca02cb11eab115e34b98e1b7db53c Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:25:56 +0900 Subject: [PATCH 12/66] =?UTF-8?q?docs:=20CLAUDE.md=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EB=A7=81=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EA=B7=9C=EC=B9=99=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d547a18a5..e8a0f9510 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,46 @@ Test configuration: - Redis 7.0 (master-replica), Kafka 3.5.1 (KRaft mode) - JUnit 5, Mockito, SpringMockK, Instancio, TestContainers +## 도메인 & 객체 설계 전략 + +### Entity / VO / Domain Service 책임 분리 +- **Entity** (extends `BaseEntity`): 식별자를 가진 도메인 객체. 생성자에서 불변식 검증, `guard()`는 JPA persist/update 시점 재검증 +- **Value Object** (`@Embeddable`): 값으로 비교되는 불변 객체. `equals/hashCode` 필수 구현, `@NoArgsConstructor(access = PROTECTED)` +- **Domain Service** (`@Component`): 단일 Aggregate 내 비즈니스 규칙 수행. Repository 인터페이스에 의존 (DIP) +- **Facade** (`@Component`, application 레이어): 여러 Domain Service 조합, Info DTO 변환. 트랜잭션은 Domain Service에 위임 + +### 네이밍 규칙 +- Entity: `{Domain}Model` (e.g., `ProductModel`, `BrandModel`) +- Repository Interface: `{Domain}Repository` (domain 패키지) +- Repository Impl: `{Domain}RepositoryImpl` (infrastructure 패키지) +- JPA Repository: `{Domain}JpaRepository` (infrastructure 패키지) +- Service: `{Domain}Service` (domain 패키지, `@Component`) +- Facade: `{Domain}Facade` (application 패키지, `@Component`) +- Info DTO: `{Domain}Info` (application 패키지, record) +- API DTO: `{Domain}V1Dto` (interfaces 패키지, 외부 class + 내부 record) +- API Spec: `{Domain}V1ApiSpec` / `{Domain}AdminV1ApiSpec` (Swagger interface) + +## 아키텍처 & 패키지 구성 전략 + +### 레이어드 아키텍처 + DIP +``` +interfaces/api/ → application/ → domain/ ← infrastructure/ +``` +- domain 레이어는 외부 의존 없음 (Repository는 interface만 정의) +- infrastructure가 domain의 Repository interface를 구현 +- application은 domain service를 조합하여 유스케이스 수행 + +### 패키지 구조 (apps/commerce-api 기준) +``` +com.loopers/ +├── interfaces/api/{domain}/ # Controller, ApiSpec, Dto +├── application/{domain}/ # Facade, Info, Command +├── domain/{domain}/ # Model, Repository(interface), Service, VO, Enum +├── infrastructure/{domain}/ # JpaRepository, RepositoryImpl +├── config/ # Spring Configuration +└── support/ # Error, Util +``` + ## 개발 규칙 ### 진행 Workflow - 증강 코딩 - **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. From d6a77d54cf015d0165ea2d1e4e35177bd1e29c66 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:26:16 +0900 Subject: [PATCH 13/66] =?UTF-8?q?feat:=20Money=20VO=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/product/Money.java | 47 ++++++++ .../com/loopers/domain/product/MoneyTest.java | 110 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..7023595af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,47 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + private int value; + + public Money(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + this.value = value; + } + + public int value() { + return value; + } + + public Money multiply(int quantity) { + return new Money(this.value * quantity); + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money money)) return false; + return value == money.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..17a0a62a2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.product; + +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 MoneyTest { + + @DisplayName("Money 생성") + @Nested + class Create { + + @DisplayName("0원으로 생성할 수 있다") + @Test + void createsWithZero() { + // arrange & act + Money money = new Money(0); + // assert + assertThat(money.value()).isEqualTo(0); + } + + @DisplayName("양수 금액으로 생성할 수 있다") + @Test + void createsWithPositiveValue() { + // arrange & act + Money money = new Money(10000); + // assert + assertThat(money.value()).isEqualTo(10000); + } + + @DisplayName("음수 금액이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNegativeValue() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new Money(-1); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Money 연산") + @Nested + class Operations { + + @DisplayName("multiply: 금액에 수량을 곱한 새 Money를 반환한다") + @Test + void multipliesValue() { + // arrange + Money money = new Money(1000); + // act + Money result = money.multiply(3); + // assert + assertAll( + () -> assertThat(result.value()).isEqualTo(3000), + () -> assertThat(result).isNotSameAs(money) + ); + } + + @DisplayName("add: 두 Money를 더한 새 Money를 반환한다") + @Test + void addsValues() { + // arrange + Money a = new Money(1000); + Money b = new Money(2000); + // act + Money result = a.add(b); + // assert + assertAll( + () -> assertThat(result.value()).isEqualTo(3000), + () -> assertThat(result).isNotSameAs(a) + ); + } + } + + @DisplayName("Money 동등성") + @Nested + class Equality { + + @DisplayName("같은 금액의 Money는 동등하다") + @Test + void equalForSameValue() { + // arrange + Money a = new Money(1000); + Money b = new Money(1000); + // assert + assertAll( + () -> assertThat(a).isEqualTo(b), + () -> assertThat(a.hashCode()).isEqualTo(b.hashCode()) + ); + } + + @DisplayName("다른 금액의 Money는 동등하지 않다") + @Test + void notEqualForDifferentValue() { + // arrange + Money a = new Money(1000); + Money b = new Money(2000); + // assert + assertThat(a).isNotEqualTo(b); + } + } +} From 25647ec8bedff960a473a306736c5ab829f32a37 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:26:34 +0900 Subject: [PATCH 14/66] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20Enu?= =?UTF-8?q?m=20=EC=A0=95=EC=9D=98=20(ProductSortType,=20OrderStatus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../main/java/com/loopers/domain/order/OrderStatus.java | 9 +++++++++ .../java/com/loopers/domain/product/ProductSortType.java | 8 ++++++++ 2 files changed, 17 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java 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..0ffc53f2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + CONFIRMED, + SHIPPING, + DELIVERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..d2dc834b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + CREATED_DESC, + PRICE_ASC, + PRICE_DESC, + LIKES_DESC +} From 95b246d812b8e72fd8139d906ddf22de70006f05 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:26:57 +0900 Subject: [PATCH 15/66] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20ArgumentReso?= =?UTF-8?q?lver=20=EA=B5=AC=ED=98=84=20(@LoginMember,=20@AdminUser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../java/com/loopers/config/WebMvcConfig.java | 24 ++++++++++ .../interfaces/api/auth/AdminUser.java | 11 +++++ .../api/auth/AdminUserArgumentResolver.java | 38 +++++++++++++++ .../interfaces/api/auth/LoginMember.java | 11 +++++ .../api/auth/LoginMemberArgumentResolver.java | 46 +++++++++++++++++++ 5 files changed, 130 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..32bc4edca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.api.auth.LoginMemberArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AdminUserArgumentResolver adminUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + resolvers.add(adminUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java new file mode 100644 index 000000000..742e59d0c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.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 AdminUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java new file mode 100644 index 000000000..3ebfb8b43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 +public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_ADMIN_LDAP = "X-Loopers-AdminLdap"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AdminUser.class) + && parameter.getParameterType().equals(String.class); + } + + @Override + public String resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String adminLdap = webRequest.getHeader(HEADER_ADMIN_LDAP); + + if (adminLdap == null || adminLdap.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 누락되었습니다."); + } + + return adminLdap; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java new file mode 100644 index 000000000..0533de4d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.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 LoginMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..d746e275d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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; + +@RequiredArgsConstructor +@Component +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberAuthService memberAuthService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class) + && parameter.getParameterType().equals(MemberModel.class); + } + + @Override + public MemberModel resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String loginId = webRequest.getHeader(HEADER_LOGIN_ID); + String loginPw = webRequest.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); + } + + return memberAuthService.authenticate(loginId, loginPw); + } +} From 81e65013738781a1cb84508d8272222b92ead55a Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:39:08 +0900 Subject: [PATCH 16/66] =?UTF-8?q?test:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(Red)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/brand/BrandModelTest.java | 113 ++++++++++++ .../domain/brand/BrandServiceTest.java | 161 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java 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..c2cc82d52 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,113 @@ +package com.loopers.domain.brand; + +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 BrandModelTest { + + @DisplayName("브랜드 생성") + @Nested + class Create { + + @DisplayName("이름과 설명이 주어지면 정상 생성된다") + @Test + void createsWithNameAndDescription() { + // arrange + String name = "나이키"; + String description = "스포츠 브랜드"; + // act + BrandModel brand = new BrandModel(name, description); + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name), + () -> assertThat(brand.getDescription()).isEqualTo(description) + ); + } + + @DisplayName("설명 없이 이름만으로 생성할 수 있다") + @Test + void createsWithNameOnly() { + // arrange & act + BrandModel brand = new BrandModel("나이키", null); + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("나이키"), + () -> assertThat(brand.getDescription()).isNull() + ); + } + + @DisplayName("이름이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new BrandModel(null, "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new BrandModel(" ", "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("이름과 설명을 수정할 수 있다") + @Test + void updatesNameAndDescription() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); + // act + brand.update("아디다스", "독일 스포츠 브랜드"); + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("아디다스"), + () -> assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드") + ); + } + + @DisplayName("수정 시 이름이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullNameUpdate() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brand.update(null, "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수정 시 이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankNameUpdate() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brand.update(" ", "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..4f5b97c29 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,161 @@ +package com.loopers.domain.brand; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @InjectMocks + private BrandService brandService; + + @Mock + private BrandRepository brandRepository; + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("이름이 중복되지 않으면 정상 등록된다") + @Test + void registersSuccessfully() { + // arrange + String name = "나이키"; + String description = "스포츠 브랜드"; + BrandModel brand = new BrandModel(name, description); + given(brandRepository.findByName(name)).willReturn(Optional.empty()); + given(brandRepository.save(any(BrandModel.class))).willReturn(brand); + // act + BrandModel result = brandService.register(name, description); + // assert + assertAll( + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getDescription()).isEqualTo(description) + ); + then(brandRepository).should().save(any(BrandModel.class)); + } + + @DisplayName("이름이 중복되면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateName() { + // arrange + String name = "나이키"; + given(brandRepository.findByName(name)).willReturn(Optional.of(new BrandModel(name, "기존"))); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.register(name, "신규"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + then(brandRepository).should(never()).save(any()); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetById { + + @DisplayName("존재하는 ID면 브랜드를 반환한다") + @Test + void returnsForExistingId() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + // act + BrandModel result = brandService.getById(id); + // assert + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentId() { + // arrange + Long id = 999L; + given(brandRepository.findById(id)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("자기 자신과 같은 이름이면 정상 수정된다") + @Test + void updatesWhenSameNameAsSelf() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + ReflectionTestUtils.setField(brand, "id", id); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + given(brandRepository.findByName("나이키")).willReturn(Optional.of(brand)); + // act + BrandModel result = brandService.update(id, "나이키", "새 설명"); + // assert + assertThat(result.getDescription()).isEqualTo("새 설명"); + } + + @DisplayName("다른 브랜드와 이름이 중복되면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateNameWithOther() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + ReflectionTestUtils.setField(brand, "id", id); + BrandModel other = new BrandModel("아디다스", "독일"); + ReflectionTestUtils.setField(other, "id", 2L); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + given(brandRepository.findByName("아디다스")).willReturn(Optional.of(other)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.update(id, "아디다스", "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 soft delete 한다") + @Test + void softDeletesExistingBrand() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + // act + brandService.delete(id); + // assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + } +} From 6a299dc59a33bbff8d97ce365e9202175d21c3ae Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:39:28 +0900 Subject: [PATCH 17/66] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(Green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/brand/BrandModel.java | 41 ++++++++++++++ .../loopers/domain/brand/BrandRepository.java | 13 +++++ .../loopers/domain/brand/BrandService.java | 53 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java 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..26fbef7bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,41 @@ +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; + +@Entity +@Table(name = "brand") +public class BrandModel extends BaseEntity { + + @Column(nullable = false, unique = true) + private String name; + + private String description; + + protected BrandModel() {} + + public BrandModel(String name, String description) { + validateName(name); + this.name = name; + this.description = description; + } + + public String getName() { return name; } + public String getDescription() { return description; } + + public void update(String name, String description) { + validateName(name); + this.name = name; + this.description = description; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + } +} 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..42aa38398 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface BrandRepository { + Optional findById(Long id); + Optional findByName(String name); + Page findAll(Pageable pageable); + BrandModel save(BrandModel brand); +} 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..aa49ae63d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,53 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); + }); + return brandRepository.save(new BrandModel(name, description)); + } + + @Transactional(readOnly = true) + public BrandModel getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다. [id = " + id + "]")); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + @Transactional + public BrandModel update(Long id, String name, String description) { + BrandModel brand = getById(id); + brandRepository.findByName(name) + .filter(existing -> !existing.getId().equals(brand.getId())) + .ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); + }); + brand.update(name, description); + return brand; + } + + @Transactional + public void delete(Long id) { + BrandModel brand = getById(id); + brand.delete(); + } +} From be76eaf656deadd056238ffe641eeb69acddc515 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:39:46 +0900 Subject: [PATCH 18/66] =?UTF-8?q?test:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(Red)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/product/ProductModelTest.java | 158 ++++++++++++++ .../domain/product/ProductServiceTest.java | 192 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java 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..9539e033f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,158 @@ +package com.loopers.domain.product; + +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 ProductModelTest { + + @DisplayName("상품 생성") + @Nested + class Create { + + @DisplayName("이름, 설명, 가격, 브랜드ID가 주어지면 정상 생성된다") + @Test + void createsSuccessfully() { + // arrange + String name = "에어맥스"; + String description = "러닝화"; + Money price = new Money(129000); + Long brandId = 1L; + // act + ProductModel product = new ProductModel(name, description, price, brandId); + // assert + assertAll( + () -> assertThat(product.getName()).isEqualTo(name), + () -> assertThat(product.getDescription()).isEqualTo(description), + () -> assertThat(product.getPrice().value()).isEqualTo(129000), + () -> assertThat(product.getBrandId()).isEqualTo(brandId), + () -> assertThat(product.getLikeCount()).isEqualTo(0) + ); + } + + @DisplayName("이름이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new ProductModel(null, "설명", new Money(10000), 1L); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new ProductModel(" ", "설명", new Money(10000), 1L); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullPrice() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new ProductModel("상품", "설명", null, 1L); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("이름, 설명, 가격을 수정할 수 있다") + @Test + void updatesSuccessfully() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + product.update("에어포스", "캐주얼", new Money(109000)); + // assert + assertAll( + () -> assertThat(product.getName()).isEqualTo("에어포스"), + () -> assertThat(product.getDescription()).isEqualTo("캐주얼"), + () -> assertThat(product.getPrice().value()).isEqualTo(109000) + ); + } + + @DisplayName("수정 시 이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankNameUpdate() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + product.update(" ", "설명", new Money(10000)); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수정 시 가격이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullPriceUpdate() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + product.update("에어포스", "설명", null); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("좋아요 수 관리") + @Nested + class LikeCount { + + @DisplayName("좋아요 수를 증가시킨다") + @Test + void increasesLikeCount() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + product.increaseLikeCount(); + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수를 감소시킨다") + @Test + void decreasesLikeCount() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.increaseLikeCount(); + product.increaseLikeCount(); + // act + product.decreaseLikeCount(); + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수가 0 미만으로 감소하지 않는다") + @Test + void doesNotDecreaseBelowZero() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + product.decreaseLikeCount(); + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} 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..23bc997fe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,192 @@ +package com.loopers.domain.product; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @InjectMocks + private ProductService productService; + + @Mock + private ProductRepository productRepository; + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("정상적으로 등록된다") + @Test + void registersSuccessfully() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.save(any(ProductModel.class))).willReturn(product); + // act + ProductModel result = productService.register("에어맥스", "러닝화", new Money(129000), 1L); + // assert + assertAll( + () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getPrice().value()).isEqualTo(129000) + ); + } + } + + @DisplayName("상품 조회") + @Nested + class GetById { + + @DisplayName("존재하는 상품을 반환한다") + @Test + void returnsForExistingId() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + ProductModel result = productService.getById(id); + // assert + assertThat(result.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentId() { + // arrange + Long id = 999L; + given(productRepository.findById(id)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + productService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnDeletedProduct() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.delete(); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + productService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("상품 정보를 수정한다") + @Test + void updatesSuccessfully() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + ProductModel result = productService.update(id, "에어포스", "캐주얼", new Money(109000)); + // assert + assertAll( + () -> assertThat(result.getName()).isEqualTo("에어포스"), + () -> assertThat(result.getPrice().value()).isEqualTo(109000) + ); + } + } + + @DisplayName("상품 삭제") + @Nested + class Delete { + + @DisplayName("상품을 soft delete 한다") + @Test + void softDeletesProduct() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + productService.delete(id); + // assert + assertThat(product.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("브랜드별 상품 일괄 삭제") + @Nested + class SoftDeleteByBrandId { + + @DisplayName("브랜드에 속한 모든 상품을 soft delete 한다") + @Test + void softDeletesAllByBrandId() { + // arrange + Long brandId = 1L; + ProductModel product1 = new ProductModel("에어맥스", "러닝", new Money(129000), brandId); + ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), brandId); + given(productRepository.findAllByBrandId(brandId)).willReturn(List.of(product1, product2)); + // act + productService.softDeleteByBrandId(brandId); + // assert + assertAll( + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + } + + @DisplayName("좋아요 수 관리") + @Nested + class LikeCount { + + @DisplayName("좋아요 수를 증가시킨다") + @Test + void increasesLikeCount() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + productService.increaseLikeCount(id); + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수를 감소시킨다") + @Test + void decreasesLikeCount() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.increaseLikeCount(); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + productService.decreaseLikeCount(id); + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} From a988a8adddba98e8b95b3c048c9779e93e38973c Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:40:07 +0900 Subject: [PATCH 19/66] =?UTF-8?q?feat:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(Green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/product/ProductModel.java | 78 +++++++++++++++++++ .../domain/product/ProductRepository.java | 14 ++++ .../domain/product/ProductService.java | 69 ++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java 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..4083c2b0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,78 @@ +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.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + @Column(nullable = false) + private String name; + + private String description; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + protected ProductModel() {} + + public ProductModel(String name, String description, Money price, Long brandId) { + validateName(name); + validatePrice(price); + this.name = name; + this.description = description; + this.price = price; + this.brandId = brandId; + this.likeCount = 0; + } + + public String getName() { return name; } + public String getDescription() { return description; } + public Money getPrice() { return price; } + public Long getBrandId() { return brandId; } + public int getLikeCount() { return likeCount; } + + public void update(String name, String description, Money price) { + validateName(name); + validatePrice(price); + this.name = name; + this.description = description; + this.price = price; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + } + + private void validatePrice(Money price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + } +} 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..7f39941f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Optional findById(Long id); + List findAllByBrandId(Long brandId); + Page findAll(Pageable pageable, ProductSortType sortType); + ProductModel save(ProductModel product); +} 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..974d724ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,69 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId) { + return productRepository.save(new ProductModel(name, description, price, brandId)); + } + + @Transactional(readOnly = true) + public ProductModel getById(Long id) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. [id = " + id + "]")); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 상품입니다. [id = " + id + "]"); + } + return product; + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable, ProductSortType sortType) { + return productRepository.findAll(pageable, sortType); + } + + @Transactional + public ProductModel update(Long id, String name, String description, Money price) { + ProductModel product = getById(id); + product.update(name, description, price); + return product; + } + + @Transactional + public void delete(Long id) { + ProductModel product = getById(id); + product.delete(); + } + + @Transactional + public void softDeleteByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); + } + + @Transactional + public void increaseLikeCount(Long productId) { + ProductModel product = getById(productId); + product.increaseLikeCount(); + } + + @Transactional + public void decreaseLikeCount(Long productId) { + ProductModel product = getById(productId); + product.decreaseLikeCount(); + } +} From d39a115c16b0b27d841f7154be0552b965272bf3 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:40:24 +0900 Subject: [PATCH 20/66] =?UTF-8?q?test:=20Stock=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(Red)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/stock/StockModelTest.java | 143 ++++++++++++++++++ .../domain/stock/StockServiceTest.java | 83 ++++++++++ 2 files changed, 226 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java new file mode 100644 index 000000000..bb6a6f73f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java @@ -0,0 +1,143 @@ +package com.loopers.domain.stock; + +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 StockModelTest { + + @DisplayName("재고 생성") + @Nested + class Create { + + @DisplayName("상품ID와 수량으로 정상 생성된다") + @Test + void createsSuccessfully() { + // arrange & act + StockModel stock = new StockModel(1L, 100); + // assert + assertAll( + () -> assertThat(stock.getProductId()).isEqualTo(1L), + () -> assertThat(stock.getQuantity()).isEqualTo(100) + ); + } + + @DisplayName("수량이 0이면 정상 생성된다") + @Test + void createsWithZeroQuantity() { + // arrange & act + StockModel stock = new StockModel(1L, 0); + // assert + assertThat(stock.getQuantity()).isEqualTo(0); + } + + @DisplayName("수량이 음수면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNegativeQuantity() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new StockModel(1L, -1); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 차감") + @Nested + class Decrease { + + @DisplayName("충분한 재고가 있으면 차감된다") + @Test + void decreasesSuccessfully() { + // arrange + StockModel stock = new StockModel(1L, 100); + // act + stock.decrease(30); + // assert + assertThat(stock.getQuantity()).isEqualTo(70); + } + + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnInsufficientStock() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.decrease(11); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 정확히 0까지 차감할 수 있다") + @Test + void decreasesToZero() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act + stock.decrease(10); + // assert + assertThat(stock.getQuantity()).isEqualTo(0); + } + } + + @DisplayName("재고 수정") + @Nested + class Update { + + @DisplayName("수량을 수정할 수 있다") + @Test + void updatesQuantity() { + // arrange + StockModel stock = new StockModel(1L, 100); + // act + stock.update(50); + // assert + assertThat(stock.getQuantity()).isEqualTo(50); + } + + @DisplayName("음수로 수정하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNegativeQuantityUpdate() { + // arrange + StockModel stock = new StockModel(1L, 100); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.update(-1); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 충분 여부 확인") + @Nested + class HasEnough { + + @DisplayName("재고가 충분하면 true를 반환한다") + @Test + void returnsTrueWhenEnough() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act & assert + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("재고가 부족하면 false를 반환한다") + @Test + void returnsFalseWhenNotEnough() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act & assert + assertThat(stock.hasEnough(11)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java new file mode 100644 index 000000000..83dc033d6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.stock; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class StockServiceTest { + + @InjectMocks + private StockService stockService; + + @Mock + private StockRepository stockRepository; + + @DisplayName("재고 조회") + @Nested + class GetByProductId { + + @DisplayName("상품ID로 재고를 조회한다") + @Test + void returnsStockForProductId() { + // arrange + Long productId = 1L; + StockModel stock = new StockModel(productId, 100); + given(stockRepository.findByProductId(productId)).willReturn(Optional.of(stock)); + // act + StockModel result = stockService.getByProductId(productId); + // assert + assertAll( + () -> assertThat(result.getProductId()).isEqualTo(productId), + () -> assertThat(result.getQuantity()).isEqualTo(100) + ); + } + + @DisplayName("재고가 없으면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentStock() { + // arrange + Long productId = 999L; + given(stockRepository.findByProductId(productId)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stockService.getByProductId(productId); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("재고 저장") + @Nested + class Save { + + @DisplayName("재고를 저장한다") + @Test + void savesStock() { + // arrange + StockModel stock = new StockModel(1L, 100); + given(stockRepository.save(any(StockModel.class))).willReturn(stock); + // act + StockModel result = stockService.save(1L, 100); + // assert + assertThat(result.getQuantity()).isEqualTo(100); + then(stockRepository).should().save(any(StockModel.class)); + } + } +} From eb48f8e01a115e1412ee1115be418d5495e1cdcb Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:40:42 +0900 Subject: [PATCH 21/66] =?UTF-8?q?feat:=20Stock=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(Green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/stock/StockModel.java | 53 +++++++++++++++++++ .../loopers/domain/stock/StockRepository.java | 8 +++ .../loopers/domain/stock/StockService.java | 25 +++++++++ 3 files changed, 86 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java new file mode 100644 index 000000000..d47bc4a6e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain.stock; + +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 jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "stock", uniqueConstraints = @UniqueConstraint(columnNames = "product_id")) +public class StockModel extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = false) + private int quantity; + + protected StockModel() {} + + public StockModel(Long productId, int quantity) { + validateQuantity(quantity); + this.productId = productId; + this.quantity = quantity; + } + + public Long getProductId() { return productId; } + public int getQuantity() { return quantity; } + + public void decrease(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.quantity -= amount; + } + + public void update(int quantity) { + validateQuantity(quantity); + this.quantity = quantity; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + private void validateQuantity(int quantity) { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java new file mode 100644 index 000000000..4b4609a12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.stock; + +import java.util.Optional; + +public interface StockRepository { + Optional findByProductId(Long productId); + StockModel save(StockModel stock); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java new file mode 100644 index 000000000..f0db2959a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.stock; + +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 StockService { + + private final StockRepository stockRepository; + + @Transactional(readOnly = true) + public StockModel getByProductId(Long productId) { + return stockRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다. [productId = " + productId + "]")); + } + + @Transactional + public StockModel save(Long productId, int quantity) { + return stockRepository.save(new StockModel(productId, quantity)); + } +} From 7f56c34711096ef7b941b38926bd54063a232b22 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:53:06 +0900 Subject: [PATCH 22/66] =?UTF-8?q?feat:=20Brand/Product/Stock=20JPA=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../brand/BrandJpaRepository.java | 14 +++++ .../brand/BrandRepositoryImpl.java | 37 +++++++++++++ .../product/ProductJpaRepository.java | 15 ++++++ .../product/ProductRepositoryImpl.java | 52 +++++++++++++++++++ .../stock/StockJpaRepository.java | 10 ++++ .../stock/StockRepositoryImpl.java | 25 +++++++++ 6 files changed, 153 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java 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..d3dbbcebe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByNameAndDeletedAtIsNull(String name); + Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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..457d7c242 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @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 BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } +} 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..da4f18d4b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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..ba58e363c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public Page findAll(Pageable pageable, ProductSortType sortType) { + Sort sort = toSort(sortType); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); + } + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + private Sort toSort(ProductSortType sortType) { + return switch (sortType) { + case CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); + case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "price.value"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java new file mode 100644 index 000000000..32a6e4347 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StockJpaRepository extends JpaRepository { + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java new file mode 100644 index 000000000..09bf1dc3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class StockRepositoryImpl implements StockRepository { + + private final StockJpaRepository stockJpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return stockJpaRepository.findByProductId(productId); + } + + @Override + public StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } +} From af9e37567d7038626b68ad304155c98e3c588d7b Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:53:27 +0900 Subject: [PATCH 23/66] =?UTF-8?q?feat:=20Brand/Product=20=EC=95=A0?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 43 +++++++++ .../loopers/application/brand/BrandInfo.java | 9 ++ .../application/product/ProductFacade.java | 91 +++++++++++++++++++ .../application/product/ProductInfo.java | 81 +++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java 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..1e87dfcb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.brand; + +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.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo register(String name, String description) { + BrandModel brand = brandService.register(name, description); + return BrandInfo.from(brand); + } + + public BrandInfo getById(Long id) { + BrandModel brand = brandService.getById(id); + return BrandInfo.from(brand); + } + + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } + + public BrandInfo update(Long id, String name, String description) { + BrandModel brand = brandService.update(id, name, description); + return BrandInfo.from(brand); + } + + @Transactional + public void delete(Long id) { + brandService.delete(id); + productService.softDeleteByBrandId(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..8cee1eac4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,9 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +public record BrandInfo(Long id, String name, String description) { + public static BrandInfo from(BrandModel model) { + return new BrandInfo(model.getId(), model.getName(), model.getDescription()); + } +} 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..2b365ed53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,91 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final StockService stockService; + + @Transactional + public ProductInfo.AdminDetail register(String name, String description, Money price, Long brandId, int stockQuantity) { + BrandModel brand = brandService.getById(brandId); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.BAD_REQUEST, "삭제된 브랜드에는 상품을 등록할 수 없습니다."); + } + ProductModel product = productService.register(name, description, price, brandId); + StockModel stock = stockService.save(product.getId(), stockQuantity); + return ProductInfo.AdminDetail.from(product, brand, stock); + } + + public Page getAllForCustomer(Pageable pageable, ProductSortType sortType) { + Page products = productService.getAll(pageable, sortType); + return products.map(product -> { + BrandModel brand = brandService.getById(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductInfo.Summary.from(product, brand, stock); + }); + } + + public ProductInfo.Detail getDetailForCustomer(Long id) { + ProductModel product = productService.getById(id); + BrandModel brand = brandService.getById(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductInfo.Detail.from(product, brand, stock); + } + + public Page getAllForAdmin(Pageable pageable, ProductSortType sortType) { + Page products = productService.getAll(pageable, sortType); + return products.map(product -> { + BrandModel brand = brandService.getById(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductInfo.AdminSummary.from(product, brand, stock); + }); + } + + public ProductInfo.AdminDetail getDetailForAdmin(Long id) { + ProductModel product = productService.getById(id); + BrandModel brand = brandService.getById(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductInfo.AdminDetail.from(product, brand, stock); + } + + @Transactional + public ProductInfo.AdminDetail update(Long id, String name, String description, Money price) { + ProductModel product = productService.update(id, name, description, price); + BrandModel brand = brandService.getById(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductInfo.AdminDetail.from(product, brand, stock); + } + + @Transactional + public void delete(Long id) { + productService.delete(id); + } + + @Transactional + public ProductInfo.AdminDetail updateStock(Long productId, int quantity) { + ProductModel product = productService.getById(productId); + StockModel stock = stockService.getByProductId(productId); + stock.update(quantity); + BrandModel brand = brandService.getById(product.getBrandId()); + return ProductInfo.AdminDetail.from(product, brand, stock); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..57204d519 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,81 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; + +public class ProductInfo { + + public enum StockStatus { + IN_STOCK, LOW_STOCK, OUT_OF_STOCK; + + public static StockStatus from(int quantity) { + if (quantity <= 0) return OUT_OF_STOCK; + if (quantity <= 5) return LOW_STOCK; + return IN_STOCK; + } + } + + public record Summary( + Long id, String name, int price, String brandName, StockStatus stockStatus + ) { + public static Summary from(ProductModel product, BrandModel brand, StockModel stock) { + return new Summary( + product.getId(), + product.getName(), + product.getPrice().value(), + brand.getName(), + StockStatus.from(stock.getQuantity()) + ); + } + } + + public record Detail( + Long id, String name, String description, int price, + String brandName, int likeCount, StockStatus stockStatus + ) { + public static Detail from(ProductModel product, BrandModel brand, StockModel stock) { + return new Detail( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice().value(), + brand.getName(), + product.getLikeCount(), + StockStatus.from(stock.getQuantity()) + ); + } + } + + public record AdminSummary( + Long id, String name, int price, String brandName, int stockQuantity + ) { + public static AdminSummary from(ProductModel product, BrandModel brand, StockModel stock) { + return new AdminSummary( + product.getId(), + product.getName(), + product.getPrice().value(), + brand.getName(), + stock.getQuantity() + ); + } + } + + public record AdminDetail( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, int stockQuantity + ) { + public static AdminDetail from(ProductModel product, BrandModel brand, StockModel stock) { + return new AdminDetail( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice().value(), + brand.getId(), + brand.getName(), + product.getLikeCount(), + stock.getQuantity() + ); + } + } +} From 1298dd1bd8f186930be748047ef35c2c36310819 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:53:52 +0900 Subject: [PATCH 24/66] =?UTF-8?q?feat:=20Brand=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20/=20Product=20=EA=B3=A0=EA=B0=9D=C2=B7=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../api/brand/BrandAdminV1ApiSpec.java | 26 +++++ .../api/brand/BrandAdminV1Controller.java | 77 +++++++++++++++ .../interfaces/api/brand/BrandAdminV1Dto.java | 16 +++ .../api/product/ProductAdminV1ApiSpec.java | 29 ++++++ .../api/product/ProductAdminV1Controller.java | 99 +++++++++++++++++++ .../api/product/ProductAdminV1Dto.java | 34 +++++++ .../api/product/ProductV1ApiSpec.java | 17 ++++ .../api/product/ProductV1Controller.java | 43 ++++++++ .../interfaces/api/product/ProductV1Dto.java | 28 ++++++ 9 files changed, 369 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java new file mode 100644 index 000000000..bc887e320 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Brand Admin V1 API", description = "브랜드 관리 API") +public interface BrandAdminV1ApiSpec { + + @Operation(summary = "브랜드 등록", description = "새로운 브랜드를 등록합니다.") + ApiResponse create(String adminLdap, BrandAdminV1Dto.CreateRequest request); + + @Operation(summary = "브랜드 목록 조회", description = "브랜드 목록을 페이징하여 조회합니다.") + ApiResponse> getAll(String adminLdap, Pageable pageable); + + @Operation(summary = "브랜드 상세 조회", description = "브랜드 상세 정보를 조회합니다.") + ApiResponse getById(String adminLdap, Long brandId); + + @Operation(summary = "브랜드 수정", description = "브랜드 정보를 수정합니다.") + ApiResponse update(String adminLdap, Long brandId, BrandAdminV1Dto.UpdateRequest request); + + @Operation(summary = "브랜드 삭제", description = "브랜드를 삭제합니다. 해당 브랜드의 상품도 함께 삭제됩니다.") + ApiResponse delete(String adminLdap, Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java new file mode 100644 index 000000000..07d6d8821 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse create( + @AdminUser String adminLdap, + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> getAll( + @AdminUser String adminLdap, + Pageable pageable + ) { + Page response = brandFacade.getAll(pageable) + .map(BrandAdminV1Dto.BrandResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById( + @AdminUser String adminLdap, + @PathVariable(value = "brandId") Long brandId + ) { + BrandInfo info = brandFacade.getById(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse update( + @AdminUser String adminLdap, + @PathVariable(value = "brandId") Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete( + @AdminUser String adminLdap, + @PathVariable(value = "brandId") Long brandId + ) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java new file mode 100644 index 000000000..8a22e5d60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandAdminV1Dto { + + public record CreateRequest(String name, String description) {} + + public record UpdateRequest(String name, String description) {} + + public record BrandResponse(Long id, String name, String description) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..3453e5cdc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product Admin V1 API", description = "상품 관리 API") +public interface ProductAdminV1ApiSpec { + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다.") + ApiResponse create(String adminLdap, ProductAdminV1Dto.CreateRequest request); + + @Operation(summary = "상품 목록 조회 (관리자)", description = "관리자용 상품 목록을 조회합니다.") + ApiResponse> getAll(String adminLdap, Pageable pageable, String sortType); + + @Operation(summary = "상품 상세 조회 (관리자)", description = "관리자용 상품 상세 정보를 조회합니다.") + ApiResponse getById(String adminLdap, Long productId); + + @Operation(summary = "상품 수정", description = "상품 정보를 수정합니다.") + ApiResponse update(String adminLdap, Long productId, ProductAdminV1Dto.UpdateRequest request); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다.") + ApiResponse delete(String adminLdap, Long productId); + + @Operation(summary = "재고 수정", description = "상품 재고를 수정합니다.") + ApiResponse updateStock(String adminLdap, Long productId, ProductAdminV1Dto.UpdateStockRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..c348c5076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,99 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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-admin/v1/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse create( + @AdminUser String adminLdap, + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + ProductInfo.AdminDetail info = productFacade.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.stockQuantity() + ); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> getAll( + @AdminUser String adminLdap, + Pageable pageable, + @RequestParam(value = "sortType", defaultValue = "CREATED_DESC") String sortType + ) { + ProductSortType sort = ProductSortType.valueOf(sortType); + Page response = productFacade.getAllForAdmin(pageable, sort) + .map(ProductAdminV1Dto.ProductAdminSummaryResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId + ) { + ProductInfo.AdminDetail info = productFacade.getDetailForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse update( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductInfo.AdminDetail info = productFacade.update( + productId, request.name(), request.description(), new Money(request.price()) + ); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId + ) { + productFacade.delete(productId); + return ApiResponse.success(); + } + + @PatchMapping("/{productId}/stock") + @Override + public ApiResponse updateStock( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId, + @RequestBody ProductAdminV1Dto.UpdateStockRequest request + ) { + ProductInfo.AdminDetail info = productFacade.updateStock(productId, request.quantity()); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java new file mode 100644 index 000000000..2abdd5f52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +public class ProductAdminV1Dto { + + public record CreateRequest(String name, String description, int price, Long brandId, int stockQuantity) {} + + public record UpdateRequest(String name, String description, int price) {} + + public record UpdateStockRequest(int quantity) {} + + public record ProductAdminSummaryResponse( + Long id, String name, int price, String brandName, int stockQuantity + ) { + public static ProductAdminSummaryResponse from(ProductInfo.AdminSummary info) { + return new ProductAdminSummaryResponse( + info.id(), info.name(), info.price(), info.brandName(), info.stockQuantity() + ); + } + } + + public record ProductAdminDetailResponse( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, int stockQuantity + ) { + public static ProductAdminDetailResponse from(ProductInfo.AdminDetail info) { + return new ProductAdminDetailResponse( + info.id(), info.name(), info.description(), info.price(), + info.brandId(), info.brandName(), info.likeCount(), info.stockQuantity() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..9876a606f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "고객 상품 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 페이징하여 조회합니다.") + ApiResponse> getAll(Pageable pageable, String sortType); + + @Operation(summary = "상품 상세 조회", description = "상품 상세 정보를 조회합니다.") + ApiResponse getById(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..5f225fb79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse> getAll( + Pageable pageable, + @RequestParam(value = "sortType", defaultValue = "CREATED_DESC") String sortType + ) { + ProductSortType sort = ProductSortType.valueOf(sortType); + Page response = productFacade.getAllForCustomer(pageable, sort) + .map(ProductV1Dto.ProductSummaryResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @PathVariable(value = "productId") Long productId + ) { + return ApiResponse.success( + ProductV1Dto.ProductDetailResponse.from(productFacade.getDetailForCustomer(productId)) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..b6903421c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +public class ProductV1Dto { + + public record ProductSummaryResponse( + Long id, String name, int price, String brandName, String stockStatus + ) { + public static ProductSummaryResponse from(ProductInfo.Summary info) { + return new ProductSummaryResponse( + info.id(), info.name(), info.price(), info.brandName(), info.stockStatus().name() + ); + } + } + + public record ProductDetailResponse( + Long id, String name, String description, int price, + String brandName, int likeCount, String stockStatus + ) { + public static ProductDetailResponse from(ProductInfo.Detail info) { + return new ProductDetailResponse( + info.id(), info.name(), info.description(), info.price(), + info.brandName(), info.likeCount(), info.stockStatus().name() + ); + } + } +} From 2e8e0bba7693e549d57980a48f007a6672eeeb68 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:54:12 +0900 Subject: [PATCH 25/66] =?UTF-8?q?test:=20Brand/Product=20Facade=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8,=20.http=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacadeTest.java | 67 +++++++ .../product/ProductFacadeTest.java | 115 ++++++++++++ .../api/BrandAdminV1ApiE2ETest.java | 169 ++++++++++++++++++ .../api/ProductAdminV1ApiE2ETest.java | 149 +++++++++++++++ .../interfaces/api/ProductV1ApiE2ETest.java | 102 +++++++++++ http/commerce-api/brand-admin-v1.http | 31 ++++ http/commerce-api/product-admin-v1.http | 44 +++++ http/commerce-api/product-v1.http | 11 ++ 8 files changed, 688 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java create mode 100644 http/commerce-api/brand-admin-v1.http create mode 100644 http/commerce-api/product-admin-v1.http create mode 100644 http/commerce-api/product-v1.http 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..83e8739b3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,67 @@ +package com.loopers.application.brand; + +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class BrandFacadeTest { + + @InjectMocks + private BrandFacade brandFacade; + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("정상적으로 등록하고 BrandInfo를 반환한다") + @Test + void registersAndReturnsInfo() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠"); + given(brandService.register("나이키", "스포츠")).willReturn(brand); + // act + BrandInfo result = brandFacade.register("나이키", "스포츠"); + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("스포츠") + ); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("브랜드 삭제 시 해당 브랜드의 상품도 함께 삭제한다") + @Test + void deletesWithCascade() { + // arrange + Long brandId = 1L; + // act + brandFacade.delete(brandId); + // assert + then(brandService).should().delete(brandId); + then(productService).should().softDeleteByBrandId(brandId); + } + } +} 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..b48b88383 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,115 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @InjectMocks + private ProductFacade productFacade; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private StockService stockService; + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("브랜드가 존재하면 상품을 등록하고 AdminDetail을 반환한다") + @Test + void registersSuccessfully() { + // arrange + Long brandId = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + StockModel stock = new StockModel(1L, 100); + given(brandService.getById(brandId)).willReturn(brand); + given(productService.register(eq("에어맥스"), eq("러닝화"), any(Money.class), eq(brandId))).willReturn(product); + given(stockService.save(anyLong(), eq(100))).willReturn(stock); + // act + ProductInfo.AdminDetail result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> assertThat(result.price()).isEqualTo(129000), + () -> assertThat(result.stockQuantity()).isEqualTo(100) + ); + } + + @DisplayName("삭제된 브랜드면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnDeletedBrand() { + // arrange + Long brandId = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + brand.delete(); + given(brandService.getById(brandId)).willReturn(brand); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 상세 조회 (고객)") + @Nested + class GetDetailForCustomer { + + @DisplayName("상품, 브랜드, 재고 정보를 조합하여 반환한다") + @Test + void returnsDetailWithStockStatus() { + // arrange + Long productId = 1L; + Long brandId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + ReflectionTestUtils.setField(product, "id", productId); + BrandModel brand = new BrandModel("나이키", "스포츠"); + StockModel stock = new StockModel(productId, 3); + given(productService.getById(productId)).willReturn(product); + given(brandService.getById(brandId)).willReturn(brand); + given(stockService.getByProductId(productId)).willReturn(stock); + // act + ProductInfo.Detail result = productFacade.getDetailForCustomer(productId); + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> assertThat(result.brandName()).isEqualTo("나이키"), + () -> assertThat(result.stockStatus()).isEqualTo(ProductInfo.StockStatus.LOW_STOCK) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java new file mode 100644 index 000000000..ad93bb46e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java @@ -0,0 +1,169 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +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; + +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 BrandAdminV1ApiE2ETest { + + private static final String ENDPOINT_BASE = "/api-admin/v1/brands"; + private static final Function ENDPOINT_BY_ID = id -> ENDPOINT_BASE + "/" + id; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandAdminV1ApiE2ETest( + 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-AdminLdap", "admin"); + return headers; + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Create { + + @DisplayName("유효한 요청이면 브랜드를 등록하고 200을 반환한다") + @Test + void createsSuccessfully() { + // arrange + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "스포츠 브랜드"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("중복 이름이면 409를 반환한다") + @Test + void returnsConflictOnDuplicateName() { + // arrange + brandJpaRepository.save(new BrandModel("나이키", "기존")); + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "신규"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("관리자 헤더가 없으면 401을 반환한다") + @Test + void returnsUnauthorizedWithoutAdminHeader() { + // arrange + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "스포츠"); + HttpEntity entity = new HttpEntity<>(request); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/brands/{id}") + @Nested + class GetById { + + @DisplayName("존재하는 ID면 브랜드 정보를 반환한다") + @Test + void returnsForExistingId() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(brand.getId()), HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 ID면 404를 반환한다") + @Test + void returnsNotFoundForInvalidId() { + // arrange + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(999L), HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{id}") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면 200을 반환한다") + @Test + void deletesSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(brand.getId()), HttpMethod.DELETE, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java new file mode 100644 index 000000000..309b54a2e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java @@ -0,0 +1,149 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; +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; + +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 ProductAdminV1ApiE2ETest { + + private static final String ENDPOINT_BASE = "/api-admin/v1/products"; + private static final Function ENDPOINT_BY_ID = id -> ENDPOINT_BASE + "/" + id; + private static final Function ENDPOINT_STOCK = id -> ENDPOINT_BASE + "/" + id + "/stock"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final StockJpaRepository stockJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + StockJpaRepository stockJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.stockJpaRepository = stockJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-AdminLdap", "admin"); + return headers; + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Create { + + @DisplayName("유효한 요청이면 상품을 등록하고 200을 반환한다") + @Test + void createsSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "에어맥스", "러닝화", 129000, brand.getId(), 100 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + ); + } + } + + @DisplayName("PATCH /api-admin/v1/products/{id}/stock") + @Nested + class UpdateStock { + + @DisplayName("재고를 수정하고 200을 반환한다") + @Test + void updatesStockSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductModel product = productJpaRepository.save( + new ProductModel("에어맥스", "러닝화", new Money(129000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + ProductAdminV1Dto.UpdateStockRequest request = new ProductAdminV1Dto.UpdateStockRequest(50); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_STOCK.apply(product.getId()), HttpMethod.PATCH, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(50) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{id}") + @Nested + class Delete { + + @DisplayName("상품을 삭제하면 200을 반환한다") + @Test + void deletesSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductModel product = productJpaRepository.save( + new ProductModel("에어맥스", "러닝화", new Money(129000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(product.getId()), HttpMethod.DELETE, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..903319bb2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.interfaces.api.product.ProductV1Dto; +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 ProductV1ApiE2ETest { + + private static final String ENDPOINT_BASE = "/api/v1/products"; + private static final Function ENDPOINT_BY_ID = id -> ENDPOINT_BASE + "/" + id; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final StockJpaRepository stockJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + StockJpaRepository stockJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.stockJpaRepository = stockJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/products/{id}") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면 상세 정보를 반환한다") + @Test + void returnsProductDetail() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductModel product = productJpaRepository.save( + new ProductModel("에어맥스", "러닝화", new Money(129000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 50)); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(product.getId()), HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().stockStatus()).isEqualTo("IN_STOCK") + ); + } + + @DisplayName("존재하지 않는 상품이면 404를 반환한다") + @Test + void returnsNotFound() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(999L), HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/http/commerce-api/brand-admin-v1.http b/http/commerce-api/brand-admin-v1.http new file mode 100644 index 000000000..be8a78aa0 --- /dev/null +++ b/http/commerce-api/brand-admin-v1.http @@ -0,0 +1,31 @@ +### 브랜드 등록 +POST {{commerce-api}}/api-admin/v1/brands +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드" +} + +### 브랜드 목록 조회 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=10 +X-Loopers-AdminLdap: admin + +### 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-AdminLdap: admin + +### 브랜드 수정 +PUT {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "아디다스", + "description": "독일 스포츠 브랜드" +} + +### 브랜드 삭제 +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-AdminLdap: admin diff --git a/http/commerce-api/product-admin-v1.http b/http/commerce-api/product-admin-v1.http new file mode 100644 index 000000000..ac27b3a80 --- /dev/null +++ b/http/commerce-api/product-admin-v1.http @@ -0,0 +1,44 @@ +### 상품 등록 +POST {{commerce-api}}/api-admin/v1/products +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "에어맥스 90", + "description": "클래식 러닝화", + "price": 129000, + "brandId": 1, + "stockQuantity": 100 +} + +### 상품 목록 조회 (관리자) +GET {{commerce-api}}/api-admin/v1/products?page=0&size=10&sortType=CREATED_DESC +X-Loopers-AdminLdap: admin + +### 상품 상세 조회 (관리자) +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-AdminLdap: admin + +### 상품 수정 +PUT {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "에어맥스 95", + "description": "프리미엄 러닝화", + "price": 179000 +} + +### 재고 수정 +PATCH {{commerce-api}}/api-admin/v1/products/1/stock +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "quantity": 50 +} + +### 상품 삭제 +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-AdminLdap: admin diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..e48387c35 --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,11 @@ +### 상품 목록 조회 (최신순) +GET {{commerce-api}}/api/v1/products?page=0&size=10&sortType=CREATED_DESC + +### 상품 목록 조회 (가격 낮은순) +GET {{commerce-api}}/api/v1/products?page=0&size=10&sortType=PRICE_ASC + +### 상품 목록 조회 (좋아요 많은순) +GET {{commerce-api}}/api/v1/products?page=0&size=10&sortType=LIKES_DESC + +### 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1 From 643e6a83e99593c5abee2d69a043241b60d56f09 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:56:29 +0900 Subject: [PATCH 26/66] =?UTF-8?q?test:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(Red)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/like/LikeModelTest.java | 28 +++++ .../loopers/domain/like/LikeServiceTest.java | 117 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..cf36d9a8d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,28 @@ +package com.loopers.domain.like; + +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; + +class LikeModelTest { + + @DisplayName("좋아요 생성") + @Nested + class Create { + + @DisplayName("회원ID와 상품ID로 정상 생성된다") + @Test + void createsSuccessfully() { + // arrange & act + LikeModel like = new LikeModel(1L, 100L); + // assert + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(1L), + () -> assertThat(like.getProductId()).isEqualTo(100L) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..4d25df4e0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.like; + +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; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @DisplayName("좋아요 등록") + @Nested + class Register { + + @DisplayName("좋아요가 없으면 새로 생성하고 true를 반환한다") + @Test + void returnsTrueWhenNewLike() { + // arrange + Long memberId = 1L; + Long productId = 100L; + given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + given(likeRepository.save(any(LikeModel.class))).willReturn(new LikeModel(memberId, productId)); + // act + boolean result = likeService.register(memberId, productId); + // assert + assertThat(result).isTrue(); + then(likeRepository).should().save(any(LikeModel.class)); + } + + @DisplayName("이미 좋아요가 존재하면 false를 반환한다") + @Test + void returnsFalseWhenAlreadyExists() { + // arrange + Long memberId = 1L; + Long productId = 100L; + given(likeRepository.findByMemberIdAndProductId(memberId, productId)) + .willReturn(Optional.of(new LikeModel(memberId, productId))); + // act + boolean result = likeService.register(memberId, productId); + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Cancel { + + @DisplayName("좋아요가 존재하면 삭제하고 true를 반환한다") + @Test + void returnsTrueWhenCancelled() { + // arrange + Long memberId = 1L; + Long productId = 100L; + LikeModel like = new LikeModel(memberId, productId); + given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.of(like)); + // act + boolean result = likeService.cancel(memberId, productId); + // assert + assertThat(result).isTrue(); + then(likeRepository).should().delete(like); + } + + @DisplayName("좋아요가 없으면 false를 반환한다") + @Test + void returnsFalseWhenNotExists() { + // arrange + Long memberId = 1L; + Long productId = 100L; + given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + // act + boolean result = likeService.cancel(memberId, productId); + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("내 좋아요 목록 조회") + @Nested + class GetMyLikes { + + @DisplayName("회원의 좋아요 목록을 페이징으로 반환한다") + @Test + void returnsPagedLikes() { + // arrange + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List likes = List.of(new LikeModel(memberId, 1L), new LikeModel(memberId, 2L)); + given(likeRepository.findAllByMemberId(memberId, pageable)).willReturn(new PageImpl<>(likes)); + // act + Page result = likeService.getMyLikes(memberId, pageable); + // assert + assertThat(result.getContent()).hasSize(2); + } + } +} From 8933ee3074b4cb86217c1166ff2fe9f716cc4a8f Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:56:29 +0900 Subject: [PATCH 27/66] =?UTF-8?q?feat:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(Green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/like/LikeModel.java | 28 ++++++++++++++ .../loopers/domain/like/LikeRepository.java | 13 +++++++ .../com/loopers/domain/like/LikeService.java | 38 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..c12fca520 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,28 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "likes", uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "product_id"})) +public class LikeModel extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected LikeModel() {} + + public LikeModel(Long memberId, Long productId) { + this.memberId = memberId; + this.productId = productId; + } + + public Long getMemberId() { return memberId; } + public Long getProductId() { return productId; } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..aed192a27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface LikeRepository { + Optional findByMemberIdAndProductId(Long memberId, Long productId); + Page findAllByMemberId(Long memberId, Pageable pageable); + LikeModel save(LikeModel like); + void delete(LikeModel like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..b8c4baa30 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,38 @@ +package com.loopers.domain.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + @Transactional + public boolean register(Long memberId, Long productId) { + if (likeRepository.findByMemberIdAndProductId(memberId, productId).isPresent()) { + return false; + } + likeRepository.save(new LikeModel(memberId, productId)); + return true; + } + + @Transactional + public boolean cancel(Long memberId, Long productId) { + return likeRepository.findByMemberIdAndProductId(memberId, productId) + .map(like -> { + likeRepository.delete(like); + return true; + }) + .orElse(false); + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long memberId, Pageable pageable) { + return likeRepository.findAllByMemberId(memberId, pageable); + } +} From 55ab172e1c1c14839718b126c7522a0f04f3094d Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:59:37 +0900 Subject: [PATCH 28/66] =?UTF-8?q?feat:=20Like=20JPA=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../like/LikeJpaRepository.java | 13 +++++++ .../like/LikeRepositoryImpl.java | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..bcc17a859 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + Optional findByMemberIdAndProductId(Long memberId, Long productId); + Page findAllByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..8c0636ab7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.findByMemberIdAndProductId(memberId, productId); + } + + @Override + public Page findAllByMemberId(Long memberId, Pageable pageable) { + return likeJpaRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId, pageable); + } + + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(LikeModel like) { + likeJpaRepository.delete(like); + } +} From 303cacd72c9ba4b73c526bdd7b84a19a9396a3a1 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:59:38 +0900 Subject: [PATCH 29/66] =?UTF-8?q?feat:=20Like=20=EC=95=A0=ED=94=8C?= =?UTF-8?q?=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 65 +++++++++++++++++++ .../loopers/application/like/LikeInfo.java | 27 ++++++++ 2 files changed, 92 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java 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..0f0fc40bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,65 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public void register(Long memberId, Long productId) { + productService.getById(productId); + boolean created = likeService.register(memberId, productId); + if (created) { + productService.increaseLikeCount(productId); + } + } + + @Transactional + public void cancel(Long memberId, Long productId) { + productService.getById(productId); + boolean cancelled = likeService.cancel(memberId, productId); + if (cancelled) { + productService.decreaseLikeCount(productId); + } + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long memberId, Pageable pageable) { + Page likes = likeService.getMyLikes(memberId, pageable); + List filtered = likes.getContent().stream() + .map(like -> toLikeInfo(like)) + .filter(Objects::nonNull) + .toList(); + return new PageImpl<>(filtered, pageable, likes.getTotalElements()); + } + + private LikeInfo toLikeInfo(LikeModel like) { + try { + ProductModel product = productService.getById(like.getProductId()); + BrandModel brand = brandService.getById(product.getBrandId()); + return LikeInfo.from(like, product, brand); + } catch (CoreException e) { + return null; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..ffc0c09ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public record LikeInfo( + Long likeId, + Long productId, + String productName, + int productPrice, + String brandName, + ZonedDateTime likedAt +) { + public static LikeInfo from(LikeModel like, ProductModel product, BrandModel brand) { + return new LikeInfo( + like.getId(), + product.getId(), + product.getName(), + product.getPrice().value(), + brand.getName(), + like.getCreatedAt() + ); + } +} From 0cae1dc581a0fe9a734caa02dae4eb750d0b7560 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:59:38 +0900 Subject: [PATCH 30/66] =?UTF-8?q?feat:=20Like=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../interfaces/api/like/LikeV1ApiSpec.java | 21 ++++++++ .../interfaces/api/like/LikeV1Controller.java | 52 +++++++++++++++++++ .../interfaces/api/like/LikeV1Dto.java | 20 +++++++ 3 files changed, 93 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..875917550 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Like V1 API", description = "좋아요 API") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") + ApiResponse register(MemberModel member, Long productId); + + @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") + ApiResponse cancel(MemberModel member, Long productId); + + @Operation(summary = "내 좋아요 목록 조회", description = "내 좋아요 목록을 조회합니다.") + ApiResponse> getMyLikes(MemberModel member, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..cef35294e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse register( + @LoginMember MemberModel member, + @PathVariable(value = "productId") Long productId + ) { + likeFacade.register(member.getId(), productId); + return ApiResponse.success(); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse cancel( + @LoginMember MemberModel member, + @PathVariable(value = "productId") Long productId + ) { + likeFacade.cancel(member.getId(), productId); + return ApiResponse.success(); + } + + @GetMapping("/api/v1/likes") + @Override + public ApiResponse> getMyLikes( + @LoginMember MemberModel member, + Pageable pageable + ) { + Page response = likeFacade.getMyLikes(member.getId(), pageable) + .map(LikeV1Dto.LikeResponse::from); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..a3c33ea6e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; + +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, Long productId, String productName, + int productPrice, String brandName, ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.likeId(), info.productId(), info.productName(), + info.productPrice(), info.brandName(), info.likedAt() + ); + } + } +} From 39f5c4c54c6d263a6bce887afd1bb63196245182 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 17:59:38 +0900 Subject: [PATCH 31/66] =?UTF-8?q?test:=20Like=20Facade=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/like/LikeFacadeTest.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java 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..697a0f91f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,104 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +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; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class LikeFacadeTest { + + @InjectMocks + private LikeFacade likeFacade; + + @Mock + private LikeService likeService; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @DisplayName("좋아요 등록") + @Nested + class Register { + + @DisplayName("새로 생성되면 상품 좋아요 수를 증가시킨다") + @Test + void increasesLikeCountOnNewLike() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.register(memberId, productId)).willReturn(true); + // act + likeFacade.register(memberId, productId); + // assert + then(productService).should().increaseLikeCount(productId); + } + + @DisplayName("이미 존재하면 좋아요 수를 증가시키지 않는다") + @Test + void doesNotIncreaseLikeCountOnExistingLike() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.register(memberId, productId)).willReturn(false); + // act + likeFacade.register(memberId, productId); + // assert + then(productService).should(never()).increaseLikeCount(productId); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Cancel { + + @DisplayName("취소되면 상품 좋아요 수를 감소시킨다") + @Test + void decreasesLikeCountOnCancel() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.cancel(memberId, productId)).willReturn(true); + // act + likeFacade.cancel(memberId, productId); + // assert + then(productService).should().decreaseLikeCount(productId); + } + + @DisplayName("좋아요가 없었으면 좋아요 수를 감소시키지 않는다") + @Test + void doesNotDecreaseLikeCountWhenNotExists() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.cancel(memberId, productId)).willReturn(false); + // act + likeFacade.cancel(memberId, productId); + // assert + then(productService).should(never()).decreaseLikeCount(productId); + } + } +} From 5b8d461c93d5356de9a33e17ac4003c487670f3e Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 18:01:49 +0900 Subject: [PATCH 32/66] =?UTF-8?q?test:=20Order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(Red)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/order/OrderItemModelTest.java | 45 ++++++++++ .../loopers/domain/order/OrderModelTest.java | 65 +++++++++++++++ .../domain/order/OrderServiceTest.java | 82 +++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java 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..725b2ce36 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,45 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 OrderItemModelTest { + + @DisplayName("주문 항목 생성") + @Nested + class Create { + + @DisplayName("정상적으로 생성된다") + @Test + void createsSuccessfully() { + // arrange & act + OrderItemModel item = new OrderItemModel(1L, "에어맥스", new Money(129000), 2); + // assert + assertAll( + () -> assertThat(item.getProductId()).isEqualTo(1L), + () -> assertThat(item.getProductName()).isEqualTo("에어맥스"), + () -> assertThat(item.getProductPrice().value()).isEqualTo(129000), + () -> assertThat(item.getQuantity()).isEqualTo(2) + ); + } + + @DisplayName("수량이 0 이하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnZeroQuantity() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new OrderItemModel(1L, "에어맥스", new Money(129000), 0); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..7208f7235 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,65 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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; + +class OrderModelTest { + + @DisplayName("주문 생성") + @Nested + class Create { + + @DisplayName("회원ID로 정상 생성된다") + @Test + void createsSuccessfully() { + // arrange & act + OrderModel order = new OrderModel(1L); + // assert + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(1L), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(order.getTotalAmount().value()).isEqualTo(0), + () -> assertThat(order.getOrderItems()).isEmpty() + ); + } + } + + @DisplayName("주문 항목 추가 및 총액 계산") + @Nested + class AddItemAndCalculate { + + @DisplayName("항목 추가 후 총액을 계산한다") + @Test + void calculatesTotalAmount() { + // arrange + OrderModel order = new OrderModel(1L); + OrderItemModel item1 = new OrderItemModel(1L, "에어맥스", new Money(129000), 2); + OrderItemModel item2 = new OrderItemModel(2L, "에어포스", new Money(109000), 1); + // act + order.addOrderItem(item1); + order.addOrderItem(item2); + order.calculateTotalAmount(); + // assert + assertAll( + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getTotalAmount().value()).isEqualTo(129000 * 2 + 109000) + ); + } + + @DisplayName("항목이 없으면 총액은 0이다") + @Test + void zeroTotalWhenNoItems() { + // arrange + OrderModel order = new OrderModel(1L); + // act + order.calculateTotalAmount(); + // assert + assertThat(order.getTotalAmount().value()).isEqualTo(0); + } + } +} 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..1bda80473 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,82 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @InjectMocks + private OrderService orderService; + + @Mock + private OrderRepository orderRepository; + + @DisplayName("주문 저장") + @Nested + class Save { + + @DisplayName("주문을 저장한다") + @Test + void savesOrder() { + // arrange + OrderModel order = new OrderModel(1L); + given(orderRepository.save(any(OrderModel.class))).willReturn(order); + // act + OrderModel result = orderService.save(order); + // assert + assertThat(result.getMemberId()).isEqualTo(1L); + then(orderRepository).should().save(order); + } + } + + @DisplayName("주문 조회") + @Nested + class GetById { + + @DisplayName("존재하는 주문을 반환한다") + @Test + void returnsForExistingId() { + // arrange + Long id = 1L; + OrderModel order = new OrderModel(1L); + ReflectionTestUtils.setField(order, "id", id); + given(orderRepository.findById(id)).willReturn(Optional.of(order)); + // act + OrderModel result = orderService.getById(id); + // assert + assertThat(result.getMemberId()).isEqualTo(1L); + } + + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentId() { + // arrange + Long id = 999L; + given(orderRepository.findById(id)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} From b8853bd2487d6ff7bdaf20d54addde162159fb30 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 18:01:49 +0900 Subject: [PATCH 33/66] =?UTF-8?q?feat:=20Order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(Green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/order/OrderItemModel.java | 62 +++++++++++++++++++ .../com/loopers/domain/order/OrderModel.java | 62 +++++++++++++++++++ .../loopers/domain/order/OrderRepository.java | 13 ++++ .../loopers/domain/order/OrderService.java | 37 +++++++++++ 4 files changed, 174 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java 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..a73a4184a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,62 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +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.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_item") +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 = "product_name", nullable = false) + private String productName; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "product_price", nullable = false)) + private Money productPrice; + + @Column(nullable = false) + private int quantity; + + protected OrderItemModel() {} + + public OrderItemModel(Long productId, String productName, Money productPrice, int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1 이상이어야 합니다."); + } + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.quantity = quantity; + } + + void setOrder(OrderModel order) { + this.order = order; + } + + public OrderModel getOrder() { return order; } + public Long getProductId() { return productId; } + public String getProductName() { return productName; } + public Money getProductPrice() { return productPrice; } + public int getQuantity() { return quantity; } + + public Money getSubtotal() { + return productPrice.multiply(quantity); + } +} 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..173bca461 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,62 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +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.Collections; +import java.util.List; + +@Entity +@Table(name = "orders") +public class OrderModel extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderItems = new ArrayList<>(); + + protected OrderModel() {} + + public OrderModel(Long memberId) { + this.memberId = memberId; + this.totalAmount = new Money(0); + this.status = OrderStatus.CREATED; + } + + public Long getMemberId() { return memberId; } + public Money getTotalAmount() { return totalAmount; } + public OrderStatus getStatus() { return status; } + public List getOrderItems() { return Collections.unmodifiableList(orderItems); } + + public void addOrderItem(OrderItemModel item) { + item.setOrder(this); + this.orderItems.add(item); + } + + public void calculateTotalAmount() { + Money total = new Money(0); + for (OrderItemModel item : orderItems) { + total = total.add(item.getSubtotal()); + } + this.totalAmount = total; + } +} 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..66d3a1c22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface OrderRepository { + OrderModel save(OrderModel order); + Optional findById(Long id); + Page findByMemberId(Long memberId, Pageable pageable); + 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..811b41d86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional + public OrderModel save(OrderModel order) { + return orderRepository.save(order); + } + + @Transactional(readOnly = true) + public OrderModel getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다. [id = " + id + "]")); + } + + @Transactional(readOnly = true) + public Page getByMemberId(Long memberId, Pageable pageable) { + return orderRepository.findByMemberId(memberId, pageable); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return orderRepository.findAll(pageable); + } +} From a76d13c3488bca5c9111858094b5bf0b7cc9802b Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 18:04:58 +0900 Subject: [PATCH 34/66] =?UTF-8?q?feat:=20Order=20JPA=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../order/OrderJpaRepository.java | 11 ++++++ .../order/OrderRepositoryImpl.java | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java 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..2ca8c7e7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { + Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); + Page findAllByOrderByCreatedAtDesc(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..ca0cf882c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public Page findByMemberId(Long memberId, Pageable pageable) { + return orderJpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAllByOrderByCreatedAtDesc(pageable); + } +} From 84887e35df82e156be5fbc7f6daf284ac26ed675 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 18:04:59 +0900 Subject: [PATCH 35/66] =?UTF-8?q?feat:=20Order=20=EC=95=A0=ED=94=8C?= =?UTF-8?q?=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 88 +++++++++++++++++++ .../loopers/application/order/OrderInfo.java | 58 ++++++++++++ .../application/order/OrderItemCommand.java | 3 + 3 files changed, 149 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java 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..b9717ec55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,88 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final StockService stockService; + + @Transactional + public OrderInfo.Detail createOrder(Long memberId, List commands) { + List products = new ArrayList<>(); + List stocks = new ArrayList<>(); + + // 1. 모든 상품 존재/삭제 확인 + for (OrderItemCommand command : commands) { + ProductModel product = productService.getById(command.productId()); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 상품이 포함되어 있습니다: " + product.getName()); + } + products.add(product); + } + + // 2. 모든 재고 확인 + 차감 + for (int i = 0; i < commands.size(); i++) { + StockModel stock = stockService.getByProductId(commands.get(i).productId()); + if (!stock.hasEnough(commands.get(i).quantity())) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다: " + products.get(i).getName()); + } + stock.decrease(commands.get(i).quantity()); + stocks.add(stock); + } + + // 3. 스냅샷 생성 + 주문 저장 + OrderModel order = new OrderModel(memberId); + for (int i = 0; i < commands.size(); i++) { + ProductModel product = products.get(i); + OrderItemModel item = new OrderItemModel( + product.getId(), + product.getName(), + product.getPrice(), + commands.get(i).quantity() + ); + order.addOrderItem(item); + } + + // 4. 총액 계산 + 저장 + order.calculateTotalAmount(); + OrderModel saved = orderService.save(order); + return OrderInfo.Detail.from(saved); + } + + public OrderInfo.Detail getById(Long orderId) { + OrderModel order = orderService.getById(orderId); + return OrderInfo.Detail.from(order); + } + + public Page getMyOrders(Long memberId, Pageable pageable) { + return orderService.getByMemberId(memberId, pageable).map(OrderInfo.Summary::from); + } + + public Page getAllOrders(Pageable pageable) { + return orderService.getAll(pageable).map(OrderInfo.Summary::from); + } + + public OrderInfo.Detail getDetailForAdmin(Long orderId) { + return getById(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..2d96b12c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,58 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderInfo { + + public record Summary( + Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt + ) { + public static Summary from(OrderModel order) { + return new Summary( + order.getId(), + order.getTotalAmount().value(), + order.getStatus().name(), + order.getOrderItems().size(), + order.getCreatedAt() + ); + } + } + + public record Detail( + Long id, Long memberId, int totalAmount, String status, + List orderItems, ZonedDateTime createdAt + ) { + public static Detail from(OrderModel order) { + List items = order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList(); + return new Detail( + order.getId(), + order.getMemberId(), + order.getTotalAmount().value(), + order.getStatus().name(), + items, + order.getCreatedAt() + ); + } + } + + public record OrderItemInfo( + Long id, Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getProductPrice().value(), + item.getQuantity(), + item.getSubtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..c5ba083fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,3 @@ +package com.loopers.application.order; + +public record OrderItemCommand(Long productId, int quantity) {} From acd1ae312692363db218db9db818620fb82d5914 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 18:04:59 +0900 Subject: [PATCH 36/66] =?UTF-8?q?feat:=20Order=20=EA=B3=A0=EA=B0=9D/?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../api/order/OrderAdminV1ApiSpec.java | 17 ++++++ .../api/order/OrderAdminV1Controller.java | 42 +++++++++++++ .../interfaces/api/order/OrderAdminV1Dto.java | 33 ++++++++++ .../interfaces/api/order/OrderV1ApiSpec.java | 21 +++++++ .../api/order/OrderV1Controller.java | 61 +++++++++++++++++++ .../interfaces/api/order/OrderV1Dto.java | 48 +++++++++++++++ 6 files changed, 222 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java new file mode 100644 index 000000000..abdfde0c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Order Admin V1 API", description = "주문 관리 API") +public interface OrderAdminV1ApiSpec { + + @Operation(summary = "주문 목록 조회 (관리자)", description = "전체 주문 목록을 조회합니다.") + ApiResponse> getAll(String adminLdap, Pageable pageable); + + @Operation(summary = "주문 상세 조회 (관리자)", description = "주문 상세 정보를 조회합니다.") + ApiResponse getById(String adminLdap, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java new file mode 100644 index 000000000..fcfcc414a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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-admin/v1/orders") +public class OrderAdminV1Controller implements OrderAdminV1ApiSpec { + + private final OrderFacade orderFacade; + + @GetMapping + @Override + public ApiResponse> getAll( + @AdminUser String adminLdap, + Pageable pageable + ) { + Page response = orderFacade.getAllOrders(pageable) + .map(OrderAdminV1Dto.OrderAdminSummaryResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @AdminUser String adminLdap, + @PathVariable(value = "orderId") Long orderId + ) { + OrderInfo.Detail info = orderFacade.getDetailForAdmin(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java new file mode 100644 index 000000000..a77501ea1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderAdminSummaryResponse( + Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt + ) { + public static OrderAdminSummaryResponse from(OrderInfo.Summary info) { + return new OrderAdminSummaryResponse( + info.id(), info.totalAmount(), info.status(), info.itemCount(), info.createdAt() + ); + } + } + + public record OrderAdminDetailResponse( + Long id, Long memberId, int totalAmount, String status, + List orderItems, ZonedDateTime createdAt + ) { + public static OrderAdminDetailResponse from(OrderInfo.Detail info) { + List items = info.orderItems().stream() + .map(OrderV1Dto.OrderItemResponse::from) + .toList(); + return new OrderAdminDetailResponse( + info.id(), info.memberId(), info.totalAmount(), info.status(), items, info.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..21c758e17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Order V1 API", description = "고객 주문 API") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다.") + ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateOrderRequest request); + + @Operation(summary = "내 주문 목록 조회", description = "내 주문 목록을 조회합니다.") + ApiResponse> getMyOrders(MemberModel member, Pageable pageable); + + @Operation(summary = "주문 상세 조회", description = "주문 상세 정보를 조회합니다.") + ApiResponse getById(MemberModel member, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..3a268ccbe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ApiResponse createOrder( + @LoginMember MemberModel member, + @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + List commands = request.items().stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + OrderInfo.Detail info = orderFacade.createOrder(member.getId(), commands); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> getMyOrders( + @LoginMember MemberModel member, + Pageable pageable + ) { + Page response = orderFacade.getMyOrders(member.getId(), pageable) + .map(OrderV1Dto.OrderSummaryResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @LoginMember MemberModel member, + @PathVariable(value = "orderId") Long orderId + ) { + OrderInfo.Detail info = orderFacade.getById(orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..e9226f2b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record OrderItemRequest(Long productId, int quantity) {} + + public record CreateOrderRequest(List items) {} + + public record OrderSummaryResponse( + Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt + ) { + public static OrderSummaryResponse from(OrderInfo.Summary info) { + return new OrderSummaryResponse( + info.id(), info.totalAmount(), info.status(), info.itemCount(), info.createdAt() + ); + } + } + + public record OrderDetailResponse( + Long id, Long memberId, int totalAmount, String status, + List orderItems, ZonedDateTime createdAt + ) { + public static OrderDetailResponse from(OrderInfo.Detail info) { + List items = info.orderItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderDetailResponse( + info.id(), info.memberId(), info.totalAmount(), info.status(), items, info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long id, Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo info) { + return new OrderItemResponse( + info.id(), info.productId(), info.productName(), + info.productPrice(), info.quantity(), info.subtotal() + ); + } + } +} From 321227a1ad3cef997d28011610ad68f3febd3624 Mon Sep 17 00:00:00 2001 From: praesentia Date: Mon, 23 Feb 2026 18:05:00 +0900 Subject: [PATCH 37/66] =?UTF-8?q?test:=20Order=20Facade=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20.http=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 123 ++++++++++++++++++ http/commerce-api/order-v1.http | 30 +++++ 2 files changed, 153 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java create mode 100644 http/commerce-api/order-v1.http 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..8dd7a660b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,123 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @InjectMocks + private OrderFacade orderFacade; + + @Mock + private OrderService orderService; + + @Mock + private ProductService productService; + + @Mock + private StockService stockService; + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("정상적으로 주문을 생성한다") + @Test + void createsOrderSuccessfully() { + // arrange + Long memberId = 1L; + ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), 1L); + ReflectionTestUtils.setField(product2, "id", 2L); + StockModel stock1 = new StockModel(1L, 100); + StockModel stock2 = new StockModel(2L, 50); + + given(productService.getById(1L)).willReturn(product1); + given(productService.getById(2L)).willReturn(product2); + given(stockService.getByProductId(1L)).willReturn(stock1); + given(stockService.getByProductId(2L)).willReturn(stock2); + given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> invocation.getArgument(0)); + + List commands = List.of( + new OrderItemCommand(1L, 2), + new OrderItemCommand(2L, 1) + ); + // act + OrderInfo.Detail result = orderFacade.createOrder(memberId, commands); + // assert + assertAll( + () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2 + 109000), + () -> assertThat(result.orderItems()).hasSize(2) + ); + } + + @DisplayName("삭제된 상품이 포함되면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnDeletedProduct() { + // arrange + Long memberId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product, "id", 1L); + product.delete(); + given(productService.getById(1L)).willReturn(product); + + List commands = List.of(new OrderItemCommand(1L, 1)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.createOrder(memberId, commands); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + then(orderService).should(never()).save(any()); + } + + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnInsufficientStock() { + // arrange + Long memberId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product, "id", 1L); + StockModel stock = new StockModel(1L, 5); + + given(productService.getById(1L)).willReturn(product); + given(stockService.getByProductId(1L)).willReturn(stock); + + List commands = List.of(new OrderItemCommand(1L, 10)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.createOrder(memberId, commands); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + then(orderService).should(never()).save(any()); + } + } +} diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..98dba22c3 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,30 @@ +### 주문 생성 +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Password1! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 2, "quantity": 1 } + ] +} + +### 내 주문 목록 조회 +GET {{commerce-api}}/api/v1/orders?page=0&size=10 +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Password1! + +### 주문 상세 조회 +GET {{commerce-api}}/api/v1/orders/1 +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Password1! + +### 주문 목록 조회 (관리자) +GET {{commerce-api}}/api-admin/v1/orders?page=0&size=10 +X-Loopers-AdminLdap: admin + +### 주문 상세 조회 (관리자) +GET {{commerce-api}}/api-admin/v1/orders/1 +X-Loopers-AdminLdap: admin From 0081b04d768368128e76aa870b2bd9964bc18b3c Mon Sep 17 00:00:00 2001 From: praesentia Date: Tue, 24 Feb 2026 16:52:37 +0900 Subject: [PATCH 38/66] =?UTF-8?q?docs:=20CLAUDE.md=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8/=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e8a0f9510..ed3941fda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,23 @@ com.loopers/ └── support/ # Error, Util ``` +## 도메인 & 객체 설계 전략 +- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. +- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다. +- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다. +- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다. + +## 아키텍처, 패키지 구성 전략 +- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다. +- API request, response DTO와 응용 레이어의 DTO는 분리해 작성하도록 합니다. +- 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태로 작성합니다. + - 예시 + > /interfaces/api (presentation 레이어 - API) + /application/.. (application 레이어 - 도메인 레이어를 조합해 사용 가능한 기능을 제공) + /domain/.. (domain 레이어 - 도메인 객체 및 엔티티, Repository 인터페이스가 위치) + /infrastructure/.. (infrastructure 레이어 - JPA, Redis 등을 활용해 Repository 구현체를 제공) +- 설계 방식을 여러가지 개발자에게 제안하며, 제안한 부분에 대한 트레이드 오프를 알려줍니다. 그리고 최종 개발자가 선택한 방향으로 개발합니다. + ## 개발 규칙 ### 진행 Workflow - 증강 코딩 - **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. @@ -173,6 +190,7 @@ com.loopers/ - unused import 제거 - 성능 최적화 - 모든 테스트 케이스가 통과해야 함 + - ## 주의사항 ### 1. Never Do - 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것 From 2f43c7e0f0f48abaf4a317f171a92b335da03d3c Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 13:00:05 +0900 Subject: [PATCH 39/66] =?UTF-8?q?docs:=20API=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/requirements/api-spec.md | 154 +++++++++++++++++++++++++++++++++ docs/requirements/checkList.md | 46 ++++++++++ 2 files changed, 200 insertions(+) create mode 100644 docs/requirements/api-spec.md create mode 100644 docs/requirements/checkList.md diff --git a/docs/requirements/api-spec.md b/docs/requirements/api-spec.md new file mode 100644 index 000000000..1f2f5cbb7 --- /dev/null +++ b/docs/requirements/api-spec.md @@ -0,0 +1,154 @@ +## 🎯 배경 + +**좋아요** 누르고, **쿠폰** 쓰고, 주문 및 **결제**하는 **감성 이커머스**. + +내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결돼요. + +우린 이 흐름을 하나씩 직접 만들어갈 거예요. + +--- + +## 🧭 서비스 흐름 예시 + +1. 사용자가 **회원가입**을 하고 +2. 여러 브랜드의 상품을 둘러보고, 마음에 드는 상품엔 **좋아요**를 누르죠. +3. 사용자는 **쿠폰을 발급**받고, 여러 상품을 **한 번에 주문하고 결제**합니다. +4. 유저의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. + +--- + +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ```markdown + 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + + * **X-Loopers-LoginId** : 로그인 ID + * **X-Loopers-LoginPw** : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ```markdown + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + + +## ✅ 요구사항 + +## 👤 유저 (Users) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | 회원가입 | +| GET | `/api/v1/users/me` | O | 내 정보 조회 | +| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 상품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 💡 정렬 기준은 선택 구현입니다. +> +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **등록된 브랜드 목록 조회** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **브랜드 상세 조회** | +| POST | `/api-admin/v1/brands` | O | **브랜드 등록** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **브랜드 정보 수정** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **브랜드 삭제** +* 브랜드 제거 시, 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **등록된 상품 목록 조회** | +| GET | `/api-admin/v1/products/{productId}` | O | **상품 상세 조회** | +| POST | `/api-admin/v1/products` | O | **상품 등록** +* 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | **상품 정보 수정** +* 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | **상품 삭제** | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. +> + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/users/{userId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 유저의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +**주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +**주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +--- + +### 📡 나아가며 + +> ⚙️ **모든 기능의 동작을 개발한 후에 동시성, 멱등성, 일관성, 느린 조회, 동시 주문 등 실제 서비스에서 발생하는 문제들을 해결하게 됩니다.** +> \ No newline at end of file diff --git a/docs/requirements/checkList.md b/docs/requirements/checkList.md new file mode 100644 index 000000000..47fe20a75 --- /dev/null +++ b/docs/requirements/checkList.md @@ -0,0 +1,46 @@ +### 🏷 Product / Brand 도메인 + +- [ ] 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다. +- [ ] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 +- [ ] 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다 +- [ ] 재고의 음수 방지 처리는 도메인 레벨에서 처리된다 + +### 👍 Like 도메인 + +- [ ] 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다 +- [ ] 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다 +- [ ] 단위 테스트에서 좋아요 등록/취소 흐름을 검증했다 + +### 🛒 Order 도메인 + +- [ ] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 +- [ ] 주문 시 상품의 재고 차감을 수행한다 +- [ ] 재고 부족 예외 흐름을 고려해 설계되었다 +- [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 + +### 🧩 도메인 서비스 + +- [ ] 도메인 내부 규칙은 Domain Service에 위치시켰다 +- [ ] 상품 상세 조회 시 Product + Brand 정보 조합은 Application Layer 에서 처리했다 +- [ ] 복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다 +- [ ] 도메인 서비스는 상태 없이, 동일한 도메인 경계 내의 도메인 객체의 협력 중심으로 설계되었다 + +### **🧱 소프트웨어 아키텍처 & 설계** + +- [ ] 전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다 + - Application → **Domain** ← Infrastructure +- [ ] Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다 +- [ ] 핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다 +- [ ] Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다 +- [ ] 패키지는 계층 + 도메인 기준으로 구성되었다 (`/domain/order`, `/application/like` 등) +- [ ] 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다 + +### 🎯 Feature Suggestions + +- 상품이 좋아요 수를 직접 관리해야 할까? +- 상품 상세에서 브랜드를 함께 제공하려면 누가 조합해야 할까? +- VO를 도입한 이유는 무엇이며, 어느 시점에서 유리하게 작용했는가? +- Order, Product, User 중 누가 어떤 책임을 갖는 것이 자연스러웠나? +- Repository Interface 를 Domain Layer에 두는 이유는? +- 처음엔 도메인에 두려 했지만, 결국 Application Layer로 옮긴 이유는? +- 테스트 가능한 구조를 만들기 위해 가장 먼저 고려한 건 무엇이었나? \ No newline at end of file From 3a473e8b632cd582492c740256f7573b79d999c5 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 13:24:41 +0900 Subject: [PATCH 40/66] docs: api-specs checkList --- docs/design/requirements/api-spec.md | 0 docs/design/requirements/checkList.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/design/requirements/api-spec.md create mode 100644 docs/design/requirements/checkList.md diff --git a/docs/design/requirements/api-spec.md b/docs/design/requirements/api-spec.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/design/requirements/checkList.md b/docs/design/requirements/checkList.md new file mode 100644 index 000000000..e69de29bb From 5f0cff435e07efa1cae41a1d523aafebdd869d2c Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 13:32:38 +0900 Subject: [PATCH 41/66] =?UTF-8?q?docs:api-specs=20=EB=B0=8F=20checkList=20?= =?UTF-8?q?md=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/requirements/api-spec.md | 154 ++++++++++++++++++++++++++ docs/design/requirements/checkList.md | 46 ++++++++ 2 files changed, 200 insertions(+) diff --git a/docs/design/requirements/api-spec.md b/docs/design/requirements/api-spec.md index e69de29bb..1f2f5cbb7 100644 --- a/docs/design/requirements/api-spec.md +++ b/docs/design/requirements/api-spec.md @@ -0,0 +1,154 @@ +## 🎯 배경 + +**좋아요** 누르고, **쿠폰** 쓰고, 주문 및 **결제**하는 **감성 이커머스**. + +내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결돼요. + +우린 이 흐름을 하나씩 직접 만들어갈 거예요. + +--- + +## 🧭 서비스 흐름 예시 + +1. 사용자가 **회원가입**을 하고 +2. 여러 브랜드의 상품을 둘러보고, 마음에 드는 상품엔 **좋아요**를 누르죠. +3. 사용자는 **쿠폰을 발급**받고, 여러 상품을 **한 번에 주문하고 결제**합니다. +4. 유저의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. + +--- + +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ```markdown + 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + + * **X-Loopers-LoginId** : 로그인 ID + * **X-Loopers-LoginPw** : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ```markdown + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + + +## ✅ 요구사항 + +## 👤 유저 (Users) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | 회원가입 | +| GET | `/api/v1/users/me` | O | 내 정보 조회 | +| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 상품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 💡 정렬 기준은 선택 구현입니다. +> +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **등록된 브랜드 목록 조회** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **브랜드 상세 조회** | +| POST | `/api-admin/v1/brands` | O | **브랜드 등록** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **브랜드 정보 수정** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **브랜드 삭제** +* 브랜드 제거 시, 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **등록된 상품 목록 조회** | +| GET | `/api-admin/v1/products/{productId}` | O | **상품 상세 조회** | +| POST | `/api-admin/v1/products` | O | **상품 등록** +* 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | **상품 정보 수정** +* 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | **상품 삭제** | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. +> + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/users/{userId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 유저의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +**주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +**주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +--- + +### 📡 나아가며 + +> ⚙️ **모든 기능의 동작을 개발한 후에 동시성, 멱등성, 일관성, 느린 조회, 동시 주문 등 실제 서비스에서 발생하는 문제들을 해결하게 됩니다.** +> \ No newline at end of file diff --git a/docs/design/requirements/checkList.md b/docs/design/requirements/checkList.md index e69de29bb..47fe20a75 100644 --- a/docs/design/requirements/checkList.md +++ b/docs/design/requirements/checkList.md @@ -0,0 +1,46 @@ +### 🏷 Product / Brand 도메인 + +- [ ] 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다. +- [ ] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 +- [ ] 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다 +- [ ] 재고의 음수 방지 처리는 도메인 레벨에서 처리된다 + +### 👍 Like 도메인 + +- [ ] 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다 +- [ ] 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다 +- [ ] 단위 테스트에서 좋아요 등록/취소 흐름을 검증했다 + +### 🛒 Order 도메인 + +- [ ] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 +- [ ] 주문 시 상품의 재고 차감을 수행한다 +- [ ] 재고 부족 예외 흐름을 고려해 설계되었다 +- [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 + +### 🧩 도메인 서비스 + +- [ ] 도메인 내부 규칙은 Domain Service에 위치시켰다 +- [ ] 상품 상세 조회 시 Product + Brand 정보 조합은 Application Layer 에서 처리했다 +- [ ] 복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다 +- [ ] 도메인 서비스는 상태 없이, 동일한 도메인 경계 내의 도메인 객체의 협력 중심으로 설계되었다 + +### **🧱 소프트웨어 아키텍처 & 설계** + +- [ ] 전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다 + - Application → **Domain** ← Infrastructure +- [ ] Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다 +- [ ] 핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다 +- [ ] Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다 +- [ ] 패키지는 계층 + 도메인 기준으로 구성되었다 (`/domain/order`, `/application/like` 등) +- [ ] 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다 + +### 🎯 Feature Suggestions + +- 상품이 좋아요 수를 직접 관리해야 할까? +- 상품 상세에서 브랜드를 함께 제공하려면 누가 조합해야 할까? +- VO를 도입한 이유는 무엇이며, 어느 시점에서 유리하게 작용했는가? +- Order, Product, User 중 누가 어떤 책임을 갖는 것이 자연스러웠나? +- Repository Interface 를 Domain Layer에 두는 이유는? +- 처음엔 도메인에 두려 했지만, 결국 Application Layer로 옮긴 이유는? +- 테스트 가능한 구조를 만들기 위해 가장 먼저 고려한 건 무엇이었나? \ No newline at end of file From 37f468de8a28320183ef2f5d32fda4f7c406c0aa Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 16:53:08 +0900 Subject: [PATCH 42/66] =?UTF-8?q?fix:Brand/Product/Stock=EC=9D=98=20?= =?UTF-8?q?=EB=B0=94=EC=9A=B4=EB=94=94=EB=93=9C=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=EB=A1=9C=20=EB=B0=B0=EC=B9=98=EC=8B=9C?= =?UTF-8?q?=EC=BC=9C=20=EB=AC=B8=EC=84=9C=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/mermaid/02-ubiquitous-language.md | 32 +++++++++++++------ docs/design/mermaid/04-class-diagram.mmd | 5 +-- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/design/mermaid/02-ubiquitous-language.md b/docs/design/mermaid/02-ubiquitous-language.md index c7138535e..b8ef8bd86 100644 --- a/docs/design/mermaid/02-ubiquitous-language.md +++ b/docs/design/mermaid/02-ubiquitous-language.md @@ -14,19 +14,20 @@ --- -## 2. Brand 도메인 +## 2. 카탈로그 BC (Brand + Product + Stock) + +> 비즈니스 관심사: "판매할 상품 카탈로그를 관리한다" +> 브랜드 삭제 → 소속 상품 연쇄 soft delete가 하나의 트랜잭션으로 처리되므로 같은 BC에 속한다. + +### Brand (어그리게이트) | 용어 | 타입 | 설명 | |------|------|------| | **BrandModel** | Entity | 브랜드 엔티티. BaseEntity 상속. name(BrandName VO) + description | | **BrandName** | @Embeddable VO | 브랜드명 값 객체. 유니크 제약, 빈값 불가, `value()` 접근자 | | **BrandService** | Domain Service | 단일 도메인 로직. CRUD, 브랜드명 유니크 검증 | -| **BrandFacade** | Application Facade | 유스케이스 조합. 삭제 시 소속 상품 연쇄 soft delete | -| **브랜드 삭제 연쇄** | 비즈니스 규칙 | 브랜드 삭제 → 소속 상품 전체 soft delete. 하나의 트랜잭션 (Q1) | ---- - -## 3. Product 도메인 +### Product (어그리게이트) | 용어 | 타입 | 설명 | |------|------|------| @@ -36,12 +37,21 @@ | **StockStatus** | 표시 상태 | 고객에게 보여주는 재고 상태. IN_STOCK(>10), LOW_STOCK(1~10), OUT_OF_STOCK(0) | | **ProductService** | Domain Service | 상품 CRUD, likeCount 증감, soft delete | | **StockService** | Domain Service | 재고 생성, 조회, 차감(`checkAndDecrease`) | -| **ProductFacade** | Application Facade | 상품 + Stock 동시 생성, 브랜드 존재 확인 | | **initialStock** | 요청 파라미터 | 상품 등록 시 초기 재고 수량 | +### Application Layer + +| 용어 | 타입 | 설명 | +|------|------|------| +| **BrandFacade** | Application Facade | 유스케이스 조합. 삭제 시 소속 상품 연쇄 soft delete | +| **ProductFacade** | Application Facade | 상품 + Stock 동시 생성, 브랜드 존재 확인 | +| **브랜드 삭제 연쇄** | 비즈니스 규칙 | 브랜드 삭제 → 소속 상품 전체 soft delete. 하나의 트랜잭션 (Q1) | + --- -## 4. Like 도메인 +## 3. 좋아요 BC (Like) + +> 비즈니스 관심사: "고객의 상품 선호를 추적한다" | 용어 | 타입 | 설명 | |------|------|------| @@ -53,7 +63,9 @@ --- -## 5. Order 도메인 +## 4. 주문 BC (Order) + +> 비즈니스 관심사: "주문 이력을 기록하고 관리한다" | 용어 | 타입 | 설명 | |------|------|------| @@ -67,7 +79,7 @@ --- -## 6. 공통 패턴 +## 5. 공통 패턴 ### 6.1 엔티티 기반 diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd index dafa7ac6b..dbf61bc92 100644 --- a/docs/design/mermaid/04-class-diagram.mmd +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -1,7 +1,7 @@ classDiagram direction LR - namespace Brand { + namespace Catalog { class BrandName { <> -String value @@ -13,9 +13,6 @@ classDiagram class BrandFacade { 삭제 시 상품 연쇄 soft delete } - } - - namespace Product { class Money { <> -int value From 7513c0d8d487922fcc6bf20bcfd067428112482a Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 17:51:50 +0900 Subject: [PATCH 43/66] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrandName VO: 빈값/null/공백 검증, equals/hashCode - BrandModel Entity: BaseEntity 상속, soft delete - BrandService: 등록(중복체크), 조회, 수정(중복체크), 삭제, 목록 - BrandRepository 인터페이스 + JPA 구현체 - Customer API: GET /api/v1/brands/{brandId} - Admin API: POST/GET/PUT/DELETE /api-admin/v1/brands - 테스트: BrandNameTest, BrandModelTest, BrandServiceTest, BrandServiceIntegrationTest, BrandV1ApiE2ETest - HTTP 테스트 파일: brand-v1.http Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/brand/BrandModel.java | 38 +++ .../com/loopers/domain/brand/BrandName.java | 41 +++ .../loopers/domain/brand/BrandRepository.java | 17 + .../loopers/domain/brand/BrandService.java | 73 ++++ .../brand/BrandJpaRepository.java | 11 + .../brand/BrandRepositoryImpl.java | 37 +++ .../api/brand/BrandV1Controller.java | 24 ++ .../interfaces/api/brand/BrandV1Dto.java | 16 + .../brand/admin/BrandAdminV1Controller.java | 63 ++++ .../api/brand/admin/BrandAdminV1Dto.java | 38 +++ .../loopers/domain/brand/BrandModelTest.java | 72 ++++ .../loopers/domain/brand/BrandNameTest.java | 74 +++++ .../brand/BrandServiceIntegrationTest.java | 208 ++++++++++++ .../domain/brand/BrandServiceTest.java | 294 ++++++++++++++++ .../api/brand/BrandV1ApiE2ETest.java | 314 ++++++++++++++++++ http/commerce-api/brand-v1.http | 29 ++ 16 files changed, 1349 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java create mode 100644 http/commerce-api/brand-v1.http 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..c3183b6f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,38 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class BrandModel extends BaseEntity { + + @Embedded + private BrandName name; + + @Column(name = "description") + private String description; + + protected BrandModel() {} + + public BrandModel(BrandName name, String description) { + this.name = name; + this.description = description; + } + + public void update(BrandName name, String description) { + this.name = name; + this.description = description; + } + + public BrandName name() { + return name; + } + + public String description() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java new file mode 100644 index 000000000..1e26b649a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java @@ -0,0 +1,41 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandName { + + @Column(name = "name", nullable = false, unique = true) + private String value; + + public BrandName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BrandName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} 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..26a0febef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface BrandRepository { + + BrandModel save(BrandModel brand); + + Optional findById(Long id); + + Optional findByName(String name); + + Page findAll(Pageable pageable); +} 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..0691e2de2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,73 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); + + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + + BrandModel brand = new BrandModel(brandName, description); + return brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public BrandModel getBrand(Long brandId) { + BrandModel brand = findById(brandId); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return brand; + } + + @Transactional(readOnly = true) + public BrandModel getBrandForAdmin(Long brandId) { + return findById(brandId); + } + + @Transactional + public BrandModel update(Long brandId, String name, String description) { + BrandModel brand = findById(brandId); + BrandName newName = new BrandName(name); + + if (!brand.name().equals(newName)) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + } + + brand.update(newName, description); + return brand; + } + + @Transactional + public void delete(Long brandId) { + BrandModel brand = findById(brandId); + brand.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } +} 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..87c8e5dc9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByNameValue(String value); +} 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..6f7a3684d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameValue(name); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..380c2b8e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +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/brands") +public class BrandV1Controller { + + private final BrandService brandService; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandModel brand = brandService.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..7f969dcf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.BrandModel; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(BrandModel model) { + return new BrandResponse(model.getId(), model.name().value(), model.description()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java new file mode 100644 index 000000000..bf201ca3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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-admin/v1/brands") +public class BrandAdminV1Controller { + + private final BrandService brandService; + + @PostMapping + public ApiResponse create( + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandModel brand = brandService.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page result = brandService.getAll(PageRequest.of(page, size)); + return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandModel brand = brandService.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @PathVariable Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandModel brand = brandService.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse delete(@PathVariable Long brandId) { + brandService.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java new file mode 100644 index 000000000..dc88693d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.domain.brand.BrandModel; + +import java.time.ZonedDateTime; + +public class BrandAdminV1Dto { + + public record CreateRequest( + String name, + String description + ) {} + + public record UpdateRequest( + String name, + String description + ) {} + + public record BrandResponse( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static BrandResponse from(BrandModel model) { + return new BrandResponse( + model.getId(), + model.name().value(), + model.description(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } + } +} 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..9c13a938f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.brand; + +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; + +class BrandModelTest { + + @DisplayName("브랜드 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + BrandName name = new BrandName("나이키"); + String description = "스포츠 브랜드"; + + // when + BrandModel brand = new BrandModel(name, description); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isEqualTo(description) + ); + } + + @DisplayName("description이 null이어도 생성할 수 있다") + @Test + void createsWithNullDescription() { + // given + BrandName name = new BrandName("아디다스"); + + // when + BrandModel brand = new BrandModel(name, null); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isNull() + ); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("이름과 설명을 변경할 수 있다") + @Test + void updatesNameAndDescription() { + // given + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandName newName = new BrandName("뉴발란스"); + String newDescription = "라이프스타일 브랜드"; + + // when + brand.update(newName, newDescription); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(newName), + () -> assertThat(brand.description()).isEqualTo(newDescription) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java new file mode 100644 index 000000000..33c9fcf4d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.brand; + +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandNameTest { + + @DisplayName("BrandName 생성") + @Nested + class Create { + + @DisplayName("null, 빈 문자열, 공백은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new BrandName(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 값으로 생성할 수 있다") + @Test + void validNameCreatesSuccessfully() { + // given + String value = "나이키"; + + // when + BrandName name = new BrandName(value); + + // then + assertThat(name.value()).isEqualTo(value); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + BrandName one = new BrandName("나이키"); + BrandName another = new BrandName("나이키"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + BrandName one = new BrandName("나이키"); + BrandName another = new BrandName("아디다스"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..40d981593 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,208 @@ +package com.loopers.domain.brand; + +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +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 BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면 브랜드가 생성된다") + @Test + void createsBrandSuccessfully() { + // given & when + BrandModel result = brandService.register("나이키", "스포츠 브랜드"); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.name().value()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("스포츠 브랜드") + ); + } + + @DisplayName("중복 브랜드명이면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateName() { + // given + brandService.register("나이키", "스포츠 브랜드"); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.register("나이키", "다른 설명")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetBrand { + + @DisplayName("존재하고 미삭제 상태면 브랜드를 반환한다") + @Test + void returnsBrand() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + BrandModel result = brandService.getBrand(saved.getId()); + + // then + assertThat(result.name().value()).isEqualTo("나이키"); + } + + @DisplayName("삭제된 브랜드면 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenDeleted() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + brandService.delete(saved.getId()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(saved.getId())); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("이름과 설명을 변경할 수 있다") + @Test + void updatesSuccessfully() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + BrandModel result = brandService.update(saved.getId(), "뉴발란스", "라이프스타일 브랜드"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), + () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + ); + } + + @DisplayName("동일명 유지 시 중복 체크를 통과한다") + @Test + void skipsDuplicateCheckWhenSameName() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + BrandModel result = brandService.update(saved.getId(), "나이키", "설명만 변경"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("설명만 변경") + ); + } + + @DisplayName("다른 이름으로 변경 시 중복이면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateNameChange() { + // given + brandService.register("나이키", "스포츠 브랜드"); + BrandModel target = brandService.register("아디다스", "다른 브랜드"); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.update(target.getId(), "나이키", "변경 시도")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("soft delete 후 customer 조회에서 제외된다") + @Test + void excludedFromCustomerQueryAfterDelete() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + brandService.delete(saved.getId()); + + // then + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(saved.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("soft delete 후 admin 조회에서는 포함된다") + @Test + void includedInAdminQueryAfterDelete() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + brandService.delete(saved.getId()); + + // then + BrandModel result = brandService.getBrandForAdmin(saved.getId()); + assertThat(result.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("브랜드 목록 조회") + @Nested + class GetAll { + + @DisplayName("페이징된 결과를 반환한다") + @Test + void returnsPagedResult() { + // given + brandService.register("나이키", "스포츠"); + brandService.register("아디다스", "스포츠"); + brandService.register("뉴발란스", "라이프스타일"); + + // when + Page result = brandService.getAll(PageRequest.of(0, 2)); + + // then + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(3), + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getTotalPages()).isEqualTo(2) + ); + } + } +} 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..6577b447d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,294 @@ +package com.loopers.domain.brand; + +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.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; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + private BrandService brandService; + + @BeforeEach + void setUp() { + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("성공하면 저장된 BrandModel을 반환한다") + @Test + void returnsSavedBrand() { + // given + String name = "나이키"; + String description = "스포츠 브랜드"; + when(brandRepository.findByName(name)).thenReturn(Optional.empty()); + when(brandRepository.save(any(BrandModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + BrandModel result = brandService.register(name, description); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo(name), + () -> assertThat(result.description()).isEqualTo(description) + ); + verify(brandRepository).save(any(BrandModel.class)); + } + + @DisplayName("중복 브랜드명이면 CONFLICT 예외를 던진다") + @Test + void throwsOnDuplicateName() { + // given + String name = "나이키"; + BrandModel existing = new BrandModel(new BrandName(name), "기존 브랜드"); + when(brandRepository.findByName(name)).thenReturn(Optional.of(existing)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.register(name, "스포츠 브랜드")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + verify(brandRepository, never()).save(any()); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetBrand { + + @DisplayName("존재하고 미삭제 상태면 BrandModel을 반환한다") + @Test + void returnsBrandWhenExistsAndNotDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getBrand(brandId); + + // then + assertThat(result.name().value()).isEqualTo("나이키"); + } + + @DisplayName("삭제된 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 상세 조회 (Admin)") + @Nested + class GetBrandForAdmin { + + @DisplayName("존재하면 삭제 여부와 관계없이 반환한다") + @Test + void returnsBrandRegardlessOfDeletion() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getBrandForAdmin(brandId); + + // then + assertThat(result.name().value()).isEqualTo("나이키"); + } + + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrandForAdmin(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("성공하면 변경된 정보를 반환한다") + @Test + void updatesSuccessfully() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(brandRepository.findByName("뉴발란스")).thenReturn(Optional.empty()); + + // when + BrandModel result = brandService.update(brandId, "뉴발란스", "라이프스타일 브랜드"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), + () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + ); + } + + @DisplayName("동일명 유지 시 중복 체크를 통과한다") + @Test + void skipsDuplicateCheckWhenSameName() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.update(brandId, "나이키", "설명 변경"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("설명 변경") + ); + verify(brandRepository, never()).findByName(any()); + } + + @DisplayName("다른 이름으로 변경 시 중복이면 CONFLICT 예외를 던진다") + @Test + void throwsOnDuplicateNameChange() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandModel other = new BrandModel(new BrandName("아디다스"), "다른 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(brandRepository.findByName("아디다스")).thenReturn(Optional.of(other)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.update(brandId, "아디다스", "변경 시도")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 soft delete 한다") + @Test + void softDeletesSuccessfully() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + brandService.delete(brandId); + + // then + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.delete(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록 조회") + @Nested + class GetAll { + + @DisplayName("페이징된 결과를 반환한다") + @Test + void returnsPagedResult() { + // given + Pageable pageable = PageRequest.of(0, 10); + List brands = List.of( + new BrandModel(new BrandName("나이키"), "스포츠"), + new BrandModel(new BrandName("아디다스"), "스포츠") + ); + Page page = new PageImpl<>(brands, pageable, brands.size()); + when(brandRepository.findAll(pageable)).thenReturn(page); + + // when + Page result = brandService.getAll(pageable); + + // then + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).hasSize(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..b71af55aa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -0,0 +1,314 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +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 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 BrandV1ApiE2ETest { + + private static final String CUSTOMER_ENDPOINT = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name, String description) { + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + new ParameterizedTypeReference>() {} + ); + } + + // ========== Customer API ========== + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class CustomerGetBrand { + + @DisplayName("존재하는 브랜드를 조회하면 200과 브랜드 정보를 반환한다") + @Test + void returns200WithBrandInfo() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드") + ); + } + + @DisplayName("미존재 브랜드를 조회하면 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드를 조회하면 404를 반환한다") + @Test + void returns404WhenDeleted() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ========== Admin API ========== + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class AdminCreate { + + @DisplayName("유효한 정보로 등록하면 200과 브랜드 정보를 반환한다") + @Test + void returns200WithBrandInfo() { + // given + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("중복 브랜드명이면 409를 반환한다") + @Test + void returns409OnDuplicateName() { + // given + createBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "다른 설명"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("빈 이름이면 400을 반환한다") + @Test + void returns400OnEmptyName() { + // given + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("", "설명"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class AdminGetAll { + + @DisplayName("브랜드 목록을 페이징하여 반환한다") + @Test + void returns200WithPagedList() { + // given + createBrand("나이키", "스포츠"); + createBrand("아디다스", "스포츠"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class AdminGetBrand { + + @DisplayName("존재하는 브랜드를 조회하면 200과 브랜드 정보를 반환한다") + @Test + void returns200WithBrandInfo() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("미존재 브랜드를 조회하면 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class AdminUpdate { + + @DisplayName("수정 성공 시 200과 수정된 브랜드 정보를 반환한다") + @Test + void returns200WithUpdatedInfo() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("뉴발란스", "라이프스타일"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("뉴발란스"), + () -> assertThat(response.getBody().data().description()).isEqualTo("라이프스타일") + ); + } + + @DisplayName("중복명으로 변경하면 409를 반환한다") + @Test + void returns409OnDuplicateName() { + // given + createBrand("나이키", "스포츠"); + Long targetId = createBrand("아디다스", "스포츠"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", "변경 시도"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class AdminDelete { + + @DisplayName("삭제 성공 시 200을 반환한다") + @Test + void returns200OnSuccess() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("미존재 브랜드 삭제 시 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..3a3a35c55 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,29 @@ +### [Customer] 브랜드 조회 +GET {{commerce-api}}/api/v1/brands/1 + +### [Admin] 브랜드 등록 +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드" +} + +### [Admin] 브랜드 목록 조회 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=10 + +### [Admin] 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 + +### [Admin] 브랜드 수정 +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json + +{ + "name": "뉴발란스", + "description": "라이프스타일 브랜드" +} + +### [Admin] 브랜드 삭제 +DELETE {{commerce-api}}/api-admin/v1/brands/1 From 0cef9e0c39b2b6fd86a0775e45412f73337fa958 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 18:40:50 +0900 Subject: [PATCH 44/66] =?UTF-8?q?feat:=20Product=20+=20Stock=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Money VO, ProductModel, StockModel 엔티티 구현 - ProductService, StockService 도메인 서비스 구현 - Customer/Admin API 컨트롤러 및 DTO 구현 - 브랜드 삭제 시 소속 상품 연쇄 soft delete 구현 - Unit/Integration/E2E 테스트 작성 (51개 Unit 테스트 통과) - HTTP 수동 테스트 파일 추가 Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/brand/BrandService.java | 8 + .../com/loopers/domain/product/Money.java | 49 +++ .../loopers/domain/product/ProductModel.java | 63 ++++ .../domain/product/ProductRepository.java | 24 ++ .../domain/product/ProductService.java | 102 +++++ .../domain/product/ProductSortType.java | 20 + .../com/loopers/domain/stock/StockModel.java | 53 +++ .../loopers/domain/stock/StockRepository.java | 10 + .../loopers/domain/stock/StockService.java | 26 ++ .../com/loopers/domain/stock/StockStatus.java | 13 + .../product/ProductJpaRepository.java | 19 + .../product/ProductRepositoryImpl.java | 53 +++ .../stock/StockJpaRepository.java | 11 + .../stock/StockRepositoryImpl.java | 25 ++ .../api/product/ProductV1Controller.java | 48 +++ .../interfaces/api/product/ProductV1Dto.java | 31 ++ .../admin/ProductAdminV1Controller.java | 82 +++++ .../api/product/admin/ProductAdminV1Dto.java | 52 +++ .../domain/brand/BrandServiceTest.java | 31 +- .../com/loopers/domain/product/MoneyTest.java | 111 ++++++ .../domain/product/ProductModelTest.java | 91 +++++ .../ProductServiceIntegrationTest.java | 240 ++++++++++++ .../domain/product/ProductServiceTest.java | 340 +++++++++++++++++ .../loopers/domain/stock/StockModelTest.java | 141 +++++++ .../domain/stock/StockServiceTest.java | 97 +++++ .../api/product/ProductV1ApiE2ETest.java | 347 ++++++++++++++++++ http/commerce-api/product-v1.http | 45 +++ 27 files changed, 2131 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java create mode 100644 http/commerce-api/product-v1.http 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 index 0691e2de2..cbcb9d896 100644 --- 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 @@ -1,5 +1,7 @@ package com.loopers.domain.brand; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -8,11 +10,14 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @RequiredArgsConstructor @Component public class BrandService { private final BrandRepository brandRepository; + private final ProductRepository productRepository; @Transactional public BrandModel register(String name, String description) { @@ -59,6 +64,9 @@ public BrandModel update(Long brandId, String name, String description) { public void delete(Long brandId) { BrandModel brand = findById(brandId); brand.delete(); + + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..d26bd62a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,49 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + @Column(name = "price", nullable = false) + private int value; + + public Money(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 음수일 수 없습니다."); + } + this.value = value; + } + + public int value() { + return value; + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money multiply(int multiplier) { + return new Money(this.value * multiplier); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money that)) return false; + return value == that.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} 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..4e455250b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,63 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @Embedded + private Money price; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + protected ProductModel() {} + + public ProductModel(String name, String description, Money price, Long brandId) { + this.name = name; + this.description = description; + this.price = price; + this.brandId = brandId; + this.likeCount = 0; + } + + public void update(String name, String description, Money price) { + this.name = name; + this.description = description; + this.price = price; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public Money price() { + return price; + } + + public Long brandId() { + return brandId; + } + + public int likeCount() { + return likeCount; + } +} 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..fb66f8a7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,24 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + ProductModel save(ProductModel product); + + Optional findById(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAll(Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); +} 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..1bfe9a97c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,102 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final StockService stockService; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + BrandModel brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 브랜드에 상품을 등록할 수 없습니다."); + } + + ProductModel product = new ProductModel(name, description, price, brandId); + ProductModel saved = productRepository.save(product); + + stockService.create(saved.getId(), initialStock); + + return saved; + } + + @Transactional(readOnly = true) + public ProductModel getProduct(Long productId) { + ProductModel product = findById(productId); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + return product; + } + + @Transactional(readOnly = true) + public ProductModel getProductForAdmin(Long productId) { + return findById(productId); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); + if (brandId != null) { + return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, sortedPageable); + } + return productRepository.findAllByDeletedAtIsNull(sortedPageable); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + if (brandId != null) { + return productRepository.findAllByBrandId(brandId, pageable); + } + return productRepository.findAll(pageable); + } + + @Transactional + public ProductModel update(Long productId, String name, String description, Money price) { + ProductModel product = findById(productId); + product.update(name, description, price); + return product; + } + + @Transactional + public void delete(Long productId) { + ProductModel product = findById(productId); + product.delete(); + } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); + } + + public String getBrandName(Long brandId) { + return brandRepository.findById(brandId) + .map(brand -> brand.name().value()) + .orElse(null); + } + + private ProductModel findById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..cf0722075 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,20 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Sort; + +public enum ProductSortType { + LATEST(Sort.by(Sort.Direction.DESC, "createdAt")), + PRICE_ASC(Sort.by(Sort.Direction.ASC, "price.value")), + PRICE_DESC(Sort.by(Sort.Direction.DESC, "price.value")), + LIKES_DESC(Sort.by(Sort.Direction.DESC, "likeCount")); + + private final Sort sort; + + ProductSortType(Sort sort) { + this.sort = sort; + } + + public Sort toSort() { + return sort; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java new file mode 100644 index 000000000..1eac7b79c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain.stock; + +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; + +@Entity +@Table(name = "stock") +public class StockModel extends BaseEntity { + + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected StockModel() {} + + public StockModel(Long productId, int quantity) { + this.productId = productId; + this.quantity = quantity; + } + + public void decrease(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.quantity -= amount; + } + + public void increase(int amount) { + this.quantity += amount; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + public StockStatus toStatus() { + return StockStatus.from(this.quantity); + } + + public Long productId() { + return productId; + } + + public int quantity() { + return quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java new file mode 100644 index 000000000..968a9be83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.stock; + +import java.util.Optional; + +public interface StockRepository { + + StockModel save(StockModel stock); + + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java new file mode 100644 index 000000000..28b7a5e02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java @@ -0,0 +1,26 @@ +package com.loopers.domain.stock; + +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 StockService { + + private final StockRepository stockRepository; + + @Transactional + public StockModel create(Long productId, int quantity) { + StockModel stock = new StockModel(productId, quantity); + return stockRepository.save(stock); + } + + @Transactional(readOnly = true) + public StockModel getByProductId(Long productId) { + return stockRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java new file mode 100644 index 000000000..a02da2a9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java @@ -0,0 +1,13 @@ +package com.loopers.domain.stock; + +public enum StockStatus { + IN_STOCK, + LOW_STOCK, + OUT_OF_STOCK; + + public static StockStatus from(int quantity) { + if (quantity <= 0) return OUT_OF_STOCK; + if (quantity <= 10) return LOW_STOCK; + return IN_STOCK; + } +} 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..75c34e5d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); +} 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..2b63af7da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findAllByDeletedAtIsNull(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandId(brandId, pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java new file mode 100644 index 000000000..bd61eabae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StockJpaRepository extends JpaRepository { + + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java new file mode 100644 index 000000000..f2d91d987 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class StockRepositoryImpl implements StockRepository { + + private final StockJpaRepository stockJpaRepository; + + @Override + public StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } + + @Override + public Optional findByProductId(Long productId) { + return stockJpaRepository.findByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..9fd941dd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockService; +import com.loopers.interfaces.api.ApiResponse; +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.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/v1/products") +public class ProductV1Controller { + + private final ProductService productService; + private final StockService stockService; + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "LATEST") ProductSortType sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page products = productService.getProducts(brandId, sort, PageRequest.of(page, size)); + Page response = products.map(product -> { + String brandName = productService.getBrandName(product.brandId()); + var stockStatus = stockService.getByProductId(product.getId()).toStatus(); + return ProductV1Dto.ProductResponse.from(product, brandName, stockStatus); + }); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductModel product = productService.getProduct(productId); + String brandName = productService.getBrandName(product.brandId()); + var stockStatus = stockService.getByProductId(product.getId()).toStatus(); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brandName, stockStatus)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..49c7f969c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockStatus; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + StockStatus stockStatus + ) { + public static ProductResponse from(ProductModel model, String brandName, StockStatus stockStatus) { + return new ProductResponse( + model.getId(), + model.name(), + model.description(), + model.price().value(), + model.brandId(), + brandName, + model.likeCount(), + stockStatus + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java new file mode 100644 index 000000000..b8b0e6c42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -0,0 +1,82 @@ +package com.loopers.interfaces.api.product.admin; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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-admin/v1/products") +public class ProductAdminV1Controller { + + private final ProductService productService; + private final StockService stockService; + + @PostMapping + public ApiResponse create( + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + ProductModel product = productService.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.initialStock() + ); + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + } + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) Long brandId + ) { + Page result = productService.getProductsForAdmin(brandId, PageRequest.of(page, size)); + Page response = result.map(product -> { + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity()); + }); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductModel product = productService.getProductForAdmin(productId); + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + } + + @PutMapping("/{productId}") + public ApiResponse update( + @PathVariable Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductModel product = productService.update(productId, request.name(), request.description(), new Money(request.price())); + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@PathVariable Long productId) { + productService.delete(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java new file mode 100644 index 000000000..9399aba3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.product.admin; + +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public class ProductAdminV1Dto { + + public record CreateRequest( + String name, + String description, + int price, + Long brandId, + int initialStock + ) {} + + public record UpdateRequest( + String name, + String description, + int price + ) {} + + public record ProductResponse( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + int stockQuantity, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ProductResponse from(ProductModel model, String brandName, int stockQuantity) { + return new ProductResponse( + model.getId(), + model.name(), + model.description(), + model.price().value(), + model.brandId(), + brandName, + model.likeCount(), + stockQuantity, + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } + } +} 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 index 6577b447d..c939dbb29 100644 --- 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 @@ -1,5 +1,8 @@ package com.loopers.domain.brand; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -31,11 +34,14 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; + @Mock + private ProductRepository productRepository; + private BrandService brandService; @BeforeEach void setUp() { - brandService = new BrandService(brandRepository); + brandService = new BrandService(brandRepository, productRepository); } @DisplayName("브랜드 등록") @@ -241,6 +247,7 @@ void softDeletesSuccessfully() { Long brandId = 1L; BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of()); // when brandService.delete(brandId); @@ -249,6 +256,28 @@ void softDeletesSuccessfully() { assertThat(brand.getDeletedAt()).isNotNull(); } + @DisplayName("삭제 시 소속 상품도 연쇄 soft delete 한다") + @Test + void cascadeSoftDeletesProducts() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + ProductModel product1 = new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId); + ProductModel product2 = new ProductModel("에어맥스 95", "러닝화", new Money(159000), brandId); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); + + // when + brandService.delete(brandId); + + // then + assertAll( + () -> assertThat(brand.getDeletedAt()).isNotNull(), + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") @Test void throwsWhenNotFound() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..5343c7e77 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.product; + +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 MoneyTest { + + @DisplayName("Money 생성") + @Nested + class Create { + + @DisplayName("음수로 생성하면 BAD_REQUEST 예외를 던진다") + @Test + void throwsOnNegativeValue() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Money(-1)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("0으로 생성할 수 있다") + @Test + void createsWithZero() { + // given & when + Money money = new Money(0); + + // then + assertThat(money.value()).isEqualTo(0); + } + + @DisplayName("양수로 생성하면 value()로 값을 반환한다") + @Test + void createsWithPositiveValue() { + // given & when + Money money = new Money(10000); + + // then + assertThat(money.value()).isEqualTo(10000); + } + } + + @DisplayName("Money 연산") + @Nested + class Operations { + + @DisplayName("add()로 두 Money를 합산할 수 있다") + @Test + void addsTwo() { + // given + Money a = new Money(1000); + Money b = new Money(2000); + + // when + Money result = a.add(b); + + // then + assertThat(result.value()).isEqualTo(3000); + } + + @DisplayName("multiply()로 Money에 정수를 곱할 수 있다") + @Test + void multipliesByInt() { + // given + Money money = new Money(1000); + + // when + Money result = money.multiply(3); + + // then + assertThat(result.value()).isEqualTo(3000); + } + } + + @DisplayName("Money 동등성") + @Nested + class Equality { + + @DisplayName("같은 값의 Money는 동등하다") + @Test + void equalsWithSameValue() { + // given + Money a = new Money(1000); + Money b = new Money(1000); + + // when & then + assertAll( + () -> assertThat(a).isEqualTo(b), + () -> assertThat(a.hashCode()).isEqualTo(b.hashCode()) + ); + } + + @DisplayName("다른 값의 Money는 동등하지 않다") + @Test + void notEqualsWithDifferentValue() { + // given + Money a = new Money(1000); + Money b = new Money(2000); + + // when & then + assertThat(a).isNotEqualTo(b); + } + } +} 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..938f3b1bf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.product; + +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; + +class ProductModelTest { + + @DisplayName("상품 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + String name = "에어맥스 90"; + String description = "나이키 클래식 러닝화"; + Money price = new Money(129000); + Long brandId = 1L; + + // when + ProductModel product = new ProductModel(name, description, price, brandId); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo(name), + () -> assertThat(product.description()).isEqualTo(description), + () -> assertThat(product.price()).isEqualTo(price), + () -> assertThat(product.brandId()).isEqualTo(brandId), + () -> assertThat(product.likeCount()).isEqualTo(0) + ); + } + + @DisplayName("description이 null이어도 생성할 수 있다") + @Test + void createsWithNullDescription() { + // given & when + ProductModel product = new ProductModel("에어맥스 90", null, new Money(129000), 1L); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo("에어맥스 90"), + () -> assertThat(product.description()).isNull() + ); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("name, description, price를 변경할 수 있다") + @Test + void updatesNameDescriptionPrice() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + String newName = "에어맥스 95"; + String newDescription = "뉴 러닝화"; + Money newPrice = new Money(159000); + + // when + product.update(newName, newDescription, newPrice); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo(newName), + () -> assertThat(product.description()).isEqualTo(newDescription), + () -> assertThat(product.price()).isEqualTo(newPrice) + ); + } + } + + @DisplayName("likeCount 초기값") + @Nested + class LikeCount { + + @DisplayName("생성 시 likeCount는 0이다") + @Test + void defaultsToZero() { + // given & when + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..cba1a132f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,240 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +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 ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private StockService stockService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name) { + return brandService.register(name, "설명").getId(); + } + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면 상품과 재고가 생성된다") + @Test + void createsProductAndStock() { + // given + Long brandId = createBrand("나이키"); + + // when + ProductModel result = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("에어맥스 90"), + () -> assertThat(result.price()).isEqualTo(new Money(129000)), + () -> assertThat(result.brandId()).isEqualTo(brandId) + ); + + StockModel stock = stockService.getByProductId(result.getId()); + assertThat(stock.quantity()).isEqualTo(100); + } + + @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = createBrand("나이키"); + brandService.delete(brandId); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 조회") + @Nested + class GetProduct { + + @DisplayName("존재하고 미삭제 상태면 상품을 반환한다") + @Test + void returnsProduct() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + ProductModel result = productService.getProduct(saved.getId()); + + // then + assertThat(result.name()).isEqualTo("에어맥스 90"); + } + + @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenDeleted() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.delete(saved.getId()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(saved.getId())); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록 조회") + @Nested + class GetProducts { + + @DisplayName("미삭제 상품만 페이징하여 반환한다") + @Test + void returnsNotDeletedProducts() { + // given + Long brandId = createBrand("나이키"); + productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.register("에어맥스 95", "러닝화", new Money(159000), brandId, 50); + ProductModel deleted = productService.register("삭제될 상품", "설명", new Money(99000), brandId, 10); + productService.delete(deleted.getId()); + + // when + Page result = productService.getProducts(null, ProductSortType.LATEST, PageRequest.of(0, 10)); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersByBrandId() { + // given + Long nikeId = createBrand("나이키"); + Long adidasId = createBrand("아디다스"); + productService.register("에어맥스 90", "러닝화", new Money(129000), nikeId, 100); + productService.register("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); + + // when + Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스 90"); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("name, description, price를 변경할 수 있다") + @Test + void updatesSuccessfully() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + ProductModel result = productService.update(saved.getId(), "에어맥스 95", "뉴 러닝화", new Money(159000)); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스 95"), + () -> assertThat(result.description()).isEqualTo("뉴 러닝화"), + () -> assertThat(result.price()).isEqualTo(new Money(159000)) + ); + } + } + + @DisplayName("상품 삭제") + @Nested + class Delete { + + @DisplayName("soft delete 후 customer 조회에서 제외된다") + @Test + void excludedFromCustomerQueryAfterDelete() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + productService.delete(saved.getId()); + + // then + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(saved.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("soft delete 후 admin 조회에서는 포함된다") + @Test + void includedInAdminQueryAfterDelete() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + productService.delete(saved.getId()); + + // then + ProductModel result = productService.getProductForAdmin(saved.getId()); + assertThat(result.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("브랜드별 상품 전체 삭제") + @Nested + class DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") + @Test + void softDeletesAllProducts() { + // given + Long brandId = createBrand("나이키"); + productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.register("에어맥스 95", "러닝화", new Money(159000), brandId, 50); + + // when + productService.deleteAllByBrandId(brandId); + + // then + Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } +} 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..da4d8d6f1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,340 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.stock.StockService; +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.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; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @Mock + private StockService stockService; + + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(productRepository, brandRepository, stockService); + } + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("성공하면 저장된 ProductModel을 반환한다") + @Test + void returnsSavedProduct() { + // given + String name = "에어맥스 90"; + String description = "러닝화"; + Money price = new Money(129000); + Long brandId = 1L; + int initialStock = 100; + + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(productRepository.save(any(ProductModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ProductModel result = productService.register(name, description, price, brandId, initialStock); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo(name), + () -> assertThat(result.description()).isEqualTo(description), + () -> assertThat(result.price()).isEqualTo(price), + () -> assertThat(result.brandId()).isEqualTo(brandId) + ); + verify(productRepository).save(any(ProductModel.class)); + } + + @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 브랜드에 등록하면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenBrandNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 조회 (Customer)") + @Nested + class GetProduct { + + @DisplayName("미삭제 상품을 반환한다") + @Test + void returnsProductWhenNotDeleted() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getProduct(productId); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + } + + @DisplayName("삭제된 상품이면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenDeleted() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.delete(); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("미존재 상품이면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 조회 (Admin)") + @Nested + class GetProductForAdmin { + + @DisplayName("삭제 여부와 관계없이 반환한다") + @Test + void returnsProductRegardlessOfDeletion() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.delete(); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getProductForAdmin(productId); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + } + } + + @DisplayName("상품 목록 조회 (Customer)") + @Nested + class GetProducts { + + @DisplayName("brandId 없이 조회하면 미삭제 상품을 반환한다") + @Test + void returnsNotDeletedProducts() { + // given + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L), + new ProductModel("에어맥스 95", "러닝화", new Money(159000), 1L) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByDeletedAtIsNull(any(Pageable.class))).thenReturn(page); + + // when + Page result = productService.getProducts(null, ProductSortType.LATEST, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersbyBrandId() { + // given + Long brandId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByBrandIdAndDeletedAtIsNull(any(Long.class), any(Pageable.class))).thenReturn(page); + + // when + Page result = productService.getProducts(brandId, ProductSortType.LATEST, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("상품 목록 조회 (Admin)") + @Nested + class GetProductsForAdmin { + + @DisplayName("삭제 포함하여 조회한다") + @Test + void returnsAllProducts() { + // given + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAll(pageable)).thenReturn(page); + + // when + Page result = productService.getProductsForAdmin(null, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersByBrandId() { + // given + Long brandId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByBrandId(brandId, pageable)).thenReturn(page); + + // when + Page result = productService.getProductsForAdmin(brandId, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("name, description, price를 변경한다") + @Test + void updatesSuccessfully() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.update(productId, "에어맥스 95", "뉴 러닝화", new Money(159000)); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스 95"), + () -> assertThat(result.description()).isEqualTo("뉴 러닝화"), + () -> assertThat(result.price()).isEqualTo(new Money(159000)) + ); + } + } + + @DisplayName("상품 삭제") + @Nested + class Delete { + + @DisplayName("soft delete 한다") + @Test + void softDeletesSuccessfully() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + productService.delete(productId); + + // then + assertThat(product.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("브랜드별 상품 전체 삭제") + @Nested + class DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") + @Test + void softDeletesAllByBrandId() { + // given + Long brandId = 1L; + ProductModel product1 = new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId); + ProductModel product2 = new ProductModel("에어맥스 95", "러닝화", new Money(159000), brandId); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); + + // when + productService.deleteAllByBrandId(brandId); + + // then + assertAll( + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java new file mode 100644 index 000000000..fb2f56159 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.stock; + +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 StockModelTest { + + @DisplayName("재고 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given & when + StockModel stock = new StockModel(1L, 100); + + // then + assertAll( + () -> assertThat(stock.productId()).isEqualTo(1L), + () -> assertThat(stock.quantity()).isEqualTo(100) + ); + } + } + + @DisplayName("재고 감소") + @Nested + class Decrease { + + @DisplayName("충분한 수량이면 감소시킨다") + @Test + void decreasesQuantity() { + // given + StockModel stock = new StockModel(1L, 100); + + // when + stock.decrease(30); + + // then + assertThat(stock.quantity()).isEqualTo(70); + } + + @DisplayName("수량을 초과하면 BAD_REQUEST 예외를 던진다") + @Test + void throwsWhenExceedsQuantity() { + // given + StockModel stock = new StockModel(1L, 10); + + // when + CoreException result = assertThrows(CoreException.class, () -> stock.decrease(11)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 증가") + @Nested + class Increase { + + @DisplayName("수량을 증가시킨다") + @Test + void increasesQuantity() { + // given + StockModel stock = new StockModel(1L, 100); + + // when + stock.increase(50); + + // then + assertThat(stock.quantity()).isEqualTo(150); + } + } + + @DisplayName("재고 충분 여부 확인") + @Nested + class HasEnough { + + @DisplayName("수량이 충분하면 true를 반환한다") + @Test + void returnsTrueWhenEnough() { + // given + StockModel stock = new StockModel(1L, 10); + + // when & then + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("수량이 부족하면 false를 반환한다") + @Test + void returnsFalseWhenNotEnough() { + // given + StockModel stock = new StockModel(1L, 10); + + // when & then + assertThat(stock.hasEnough(11)).isFalse(); + } + } + + @DisplayName("재고 상태 변환") + @Nested + class ToStatus { + + @DisplayName("수량이 11 이상이면 IN_STOCK을 반환한다") + @Test + void returnsInStock() { + // given + StockModel stock = new StockModel(1L, 11); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.IN_STOCK); + } + + @DisplayName("수량이 1~10이면 LOW_STOCK을 반환한다") + @Test + void returnsLowStock() { + // given + StockModel stock = new StockModel(1L, 5); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.LOW_STOCK); + } + + @DisplayName("수량이 0이면 OUT_OF_STOCK을 반환한다") + @Test + void returnsOutOfStock() { + // given + StockModel stock = new StockModel(1L, 0); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.OUT_OF_STOCK); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java new file mode 100644 index 000000000..b5e0d13c7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java @@ -0,0 +1,97 @@ +package com.loopers.domain.stock; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StockServiceTest { + + @Mock + private StockRepository stockRepository; + + private StockService stockService; + + @BeforeEach + void setUp() { + stockService = new StockService(stockRepository); + } + + @DisplayName("재고 생성") + @Nested + class Create { + + @DisplayName("productId와 quantity로 재고를 생성한다") + @Test + void createsStock() { + // given + Long productId = 1L; + int quantity = 100; + when(stockRepository.save(any(StockModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + StockModel result = stockService.create(productId, quantity); + + // then + assertAll( + () -> assertThat(result.productId()).isEqualTo(productId), + () -> assertThat(result.quantity()).isEqualTo(quantity) + ); + verify(stockRepository).save(any(StockModel.class)); + } + } + + @DisplayName("상품별 재고 조회") + @Nested + class GetByProductId { + + @DisplayName("존재하면 재고를 반환한다") + @Test + void returnsStockWhenExists() { + // given + Long productId = 1L; + StockModel stock = new StockModel(productId, 100); + when(stockRepository.findByProductId(productId)).thenReturn(Optional.of(stock)); + + // when + StockModel result = stockService.getByProductId(productId); + + // then + assertAll( + () -> assertThat(result.productId()).isEqualTo(productId), + () -> assertThat(result.quantity()).isEqualTo(100) + ); + } + + @DisplayName("미존재 시 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long productId = 999L; + when(stockRepository.findByProductId(productId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> stockService.getByProductId(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..8694da652 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -0,0 +1,347 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +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 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 ProductV1ApiE2ETest { + + private static final String CUSTOMER_ENDPOINT = "/api/v1/products"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/products"; + private static final String BRAND_ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name) { + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, "설명"); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId, int initialStock) { + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, initialStock); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteProduct(Long productId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + new ParameterizedTypeReference>() {} + ); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + new ParameterizedTypeReference>() {} + ); + } + + // ========== Customer API ========== + + @DisplayName("GET /api/v1/products") + @Nested + class CustomerGetProducts { + + @DisplayName("상품 목록을 조회하면 200과 재고상태를 포함하여 반환한다") + @Test + void returns200WithStockStatus() { + // given + Long brandId = createBrand("나이키"); + createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersByBrandId() { + // given + Long nikeId = createBrand("나이키"); + Long adidasId = createBrand("아디다스"); + createProduct("에어맥스 90", 129000, nikeId, 100); + createProduct("슈퍼스타", 99000, adidasId, 50); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "?brandId=" + nikeId + "&page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class CustomerGetProduct { + + @DisplayName("존재하는 상품을 조회하면 200과 재고상태를 포함하여 반환한다") + @Test + void returns200WithStockStatus() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().stockStatus()).isNotNull() + ); + } + + @DisplayName("삭제된 상품을 조회하면 404를 반환한다") + @Test + void returns404WhenDeleted() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + deleteProduct(productId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("미존재 상품을 조회하면 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ========== Admin API ========== + + @DisplayName("POST /api-admin/v1/products") + @Nested + class AdminCreate { + + @DisplayName("유효한 정보로 등록하면 200과 재고수량을 포함하여 반환한다") + @Test + void returns200WithStockQuantity() { + // given + Long brandId = createBrand("나이키"); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "에어맥스 90", "러닝화", 129000, brandId, 100 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("삭제된 브랜드에 등록하면 404를 반환한다") + @Test + void returns404WhenBrandDeleted() { + // given + Long brandId = createBrand("나이키"); + deleteBrand(brandId); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "에어맥스 90", "러닝화", 129000, brandId, 100 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class AdminGetAll { + + @DisplayName("삭제 포함하여 목록을 반환한다") + @Test + void returns200IncludingDeleted() { + // given + Long brandId = createBrand("나이키"); + createProduct("에어맥스 90", 129000, brandId, 100); + Long deletedId = createProduct("삭제될 상품", 99000, brandId, 10); + deleteProduct(deletedId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class AdminGetProduct { + + @DisplayName("존재하는 상품을 조회하면 200을 반환한다") + @Test + void returns200() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class AdminUpdate { + + @DisplayName("수정 성공 시 200과 변경된 정보를 반환한다") + @Test + void returns200WithUpdatedInfo() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "에어맥스 95", "뉴 러닝화", 159000 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 95"), + () -> assertThat(response.getBody().data().description()).isEqualTo("뉴 러닝화"), + () -> assertThat(response.getBody().data().price()).isEqualTo(159000) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class AdminDelete { + + @DisplayName("삭제 성공 시 200을 반환한다") + @Test + void returns200OnSuccess() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("미존재 상품 삭제 시 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..0647ce3e3 --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,45 @@ +### [Customer] 상품 목록 조회 +GET {{commerce-api}}/api/v1/products?page=0&size=10 + +### [Customer] 상품 목록 조회 (브랜드 필터) +GET {{commerce-api}}/api/v1/products?brandId=1&sort=LATEST&page=0&size=10 + +### [Customer] 상품 목록 조회 (가격 오름차순) +GET {{commerce-api}}/api/v1/products?sort=PRICE_ASC&page=0&size=10 + +### [Customer] 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1 + +### [Admin] 상품 등록 +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json + +{ + "name": "에어맥스 90", + "description": "나이키 클래식 러닝화", + "price": 129000, + "brandId": 1, + "initialStock": 100 +} + +### [Admin] 상품 목록 조회 +GET {{commerce-api}}/api-admin/v1/products?page=0&size=10 + +### [Admin] 상품 목록 조회 (브랜드 필터) +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=10 + +### [Admin] 상품 상세 조회 +GET {{commerce-api}}/api-admin/v1/products/1 + +### [Admin] 상품 수정 +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json + +{ + "name": "에어맥스 95", + "description": "뉴 러닝화", + "price": 159000 +} + +### [Admin] 상품 삭제 +DELETE {{commerce-api}}/api-admin/v1/products/1 From ff71ce2f0b23cb1f5dd01161c17f1d2446d7a176 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 27 Feb 2026 08:13:02 +0900 Subject: [PATCH 45/66] =?UTF-8?q?feat:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LikeModel 엔티티 (userId, productId, soft delete) - LikeService, LikeRepository, LikeRepositoryImpl - LikeFacade: 좋아요 등록/취소 멱등성 처리 + likeCount 비정규화 - LikeV1Controller: POST/DELETE /api/v1/products/{productId}/likes, GET /api/v1/users/{userId}/likes - ProductModel: incrementLikeCount/decrementLikeCount 추가 (음수 방지) - ApiControllerAdvice: MissingRequestHeaderException 핸들러 추가 - 삭제된 상품 좋아요 목록 제외 (JPQL 서브쿼리) - Unit 6 + Integration 9 + E2E 7 테스트 전체 통과 (총 222개/0실패) Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 54 +++++ .../com/loopers/domain/like/LikeModel.java | 45 ++++ .../loopers/domain/like/LikeRepository.java | 17 ++ .../com/loopers/domain/like/LikeService.java | 31 +++ .../loopers/domain/product/ProductModel.java | 10 + .../like/LikeJpaRepository.java | 21 ++ .../like/LikeRepositoryImpl.java | 37 ++++ .../interfaces/api/ApiControllerAdvice.java | 7 + .../interfaces/api/like/LikeV1Controller.java | 69 ++++++ .../interfaces/api/like/LikeV1Dto.java | 27 +++ .../like/LikeFacadeIntegrationTest.java | 200 ++++++++++++++++++ .../loopers/domain/like/LikeModelTest.java | 58 +++++ .../domain/product/ProductModelTest.java | 43 +++- .../interfaces/api/like/LikeV1ApiE2ETest.java | 180 ++++++++++++++++ http/commerce-api/like-v1.http | 14 ++ 15 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java create mode 100644 http/commerce-api/like-v1.http 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..14fcf40cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductModel; +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.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + + @Transactional + public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + if (existing.isEmpty()) { + likeService.save(new LikeModel(userId, productId)); + product.incrementLikeCount(); + } else if (existing.get().getDeletedAt() != null) { + existing.get().restore(); + product.incrementLikeCount(); + } + // else: 이미 활성 좋아요 존재 → 멱등, 아무것도 안 함 + } + + @Transactional + public void unlike(Long userId, Long productId) { + Optional existing = likeService.findActiveLike(userId, productId); + + if (existing.isPresent()) { + existing.get().delete(); + ProductModel product = productService.getProduct(existing.get().productId()); + product.decrementLikeCount(); + } + // else: 좋아요가 없음 → 멱등, 아무것도 안 함 + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long userId, Pageable pageable) { + return likeService.getMyLikes(userId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..d8cbe4a6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,45 @@ +package com.loopers.domain.like; + +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; + +@Entity +@Table(name = "likes") +public class LikeModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected LikeModel() {} + + public LikeModel(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + guard(); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다."); + } + } + + public Long userId() { + return userId; + } + + public Long productId() { + return productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..e84f658f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface LikeRepository { + + LikeModel save(LikeModel like); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId); + + Page findActiveLikesWithActiveProduct(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..27571279b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + public LikeModel save(LikeModel like) { + return likeRepository.save(like); + } + + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + public Optional findActiveLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + public Page getMyLikes(Long userId, Pageable pageable) { + return likeRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} 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 index 4e455250b..84a5167c8 100644 --- 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 @@ -60,4 +60,14 @@ public Long brandId() { public int likeCount() { return likeCount; } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..d46e2c3d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId); + + @Query("SELECT l FROM LikeModel l WHERE l.userId = :userId AND l.deletedAt IS NULL " + + "AND EXISTS (SELECT 1 FROM ProductModel p WHERE p.id = l.productId AND p.deletedAt IS NULL)") + Page findActiveLikesWithActiveProduct(@Param("userId") Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..8143351fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + @Override + public Page findActiveLikesWithActiveProduct(Long userId, Pageable pageable) { + return likeJpaRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} 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 20b2809c8..638f37300 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 @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,12 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String message = String.format("필수 헤더 '%s'이(가) 누락되었습니다.", e.getHeaderName()); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..4406f122a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller { + + private final LikeFacade likeFacade; + private final MemberAuthService memberAuthService; + private final ProductService productService; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse like( + @PathVariable Long productId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + MemberModel member = memberAuthService.authenticate(loginId, loginPw); + likeFacade.like(member.getId(), productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse unlike( + @PathVariable Long productId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + MemberModel member = memberAuthService.authenticate(loginId, loginPw); + likeFacade.unlike(member.getId(), productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/users/{userId}/likes") + public ApiResponse> getMyLikes( + @PathVariable Long userId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + MemberModel member = memberAuthService.authenticate(loginId, loginPw); + Page likes = likeFacade.getMyLikes(member.getId(), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + Page response = likes.map(like -> { + ProductModel product = productService.getProduct(like.productId()); + return LikeV1Dto.LikeResponse.from(like, product); + }); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..b6a8a0aa4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, + Long productId, + String productName, + int productPrice, + ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeModel like, ProductModel product) { + return new LikeResponse( + like.getId(), + product.getId(), + product.name(), + product.price().value(), + like.getCreatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java new file mode 100644 index 000000000..c607834ff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -0,0 +1,200 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class LikeFacadeIntegrationTest { + + @Autowired private LikeFacade likeFacade; + @Autowired private ProductService productService; + @Autowired private BrandService brandService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } + private Long createProduct(String name, int price, Long brandId) { + return productService.register(name, "설명", new Money(price), brandId, 10).getId(); + } + + @DisplayName("좋아요 등록") + @Nested + class Like { + + @DisplayName("상품에 좋아요를 등록하면 likeCount가 증가한다") + @Test + void likesProductAndIncrementsCount() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + + // when + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("같은 상품에 두 번 좋아요해도 likeCount는 1이다 (멱등성)") + @Test + void likeIsIdempotent() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + + // when + likeFacade.like(1L, productId); + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 취소 후 다시 좋아요하면 복원된다") + @Test + void restoresAfterUnlike() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.unlike(1L, productId); + + // when + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Unlike { + + @DisplayName("좋아요를 취소하면 likeCount가 감소한다") + @Test + void unlikeDecrementsCount() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + + // when + likeFacade.unlike(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(0); + } + + @DisplayName("좋아요하지 않은 상품을 취소해도 예외가 발생하지 않는다 (멱등성)") + @Test + void unlikeIsIdempotent() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + + // when & then — 예외 없이 정상 완료 + likeFacade.unlike(1L, productId); + } + + @DisplayName("이미 취소한 좋아요를 다시 취소해도 예외가 발생하지 않는다") + @Test + void doubleUnlikeIsIdempotent() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.unlike(1L, productId); + + // when & then — 예외 없이 정상 완료 + likeFacade.unlike(1L, productId); + + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(0); + } + } + + @DisplayName("좋아요 목록 조회") + @Nested + class GetMyLikes { + + @DisplayName("본인의 좋아요 목록을 조회할 수 있다") + @Test + void getsMyLikes() { + // given + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId); + Long p2 = createProduct("조던", 159000, brandId); + likeFacade.like(1L, p1); + likeFacade.like(1L, p2); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(result.getContent()).hasSize(2); + } + + @DisplayName("삭제된 상품의 좋아요는 목록에서 제외된다") + @Test + void excludesDeletedProducts() { + // given + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId); + Long p2 = createProduct("조던", 159000, brandId); + likeFacade.like(1L, p1); + likeFacade.like(1L, p2); + productService.delete(p2); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).productId()).isEqualTo(p1) + ); + } + + @DisplayName("다른 사용자의 좋아요는 조회되지 않는다") + @Test + void doesNotReturnOtherUserLikes() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.like(2L, productId); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(result.getContent()).hasSize(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..13b248539 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.like; + +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 LikeModelTest { + + @DisplayName("좋아요 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + Long userId = 1L; + Long productId = 2L; + + // when + LikeModel like = new LikeModel(userId, productId); + + // then + assertAll( + () -> assertThat(like.userId()).isEqualTo(userId), + () -> assertThat(like.productId()).isEqualTo(productId) + ); + } + + @DisplayName("userId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenUserIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new LikeModel(null, 1L)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new LikeModel(1L, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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 index 938f3b1bf..81ca675e8 100644 --- 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 @@ -74,7 +74,7 @@ void updatesNameDescriptionPrice() { } } - @DisplayName("likeCount 초기값") + @DisplayName("likeCount") @Nested class LikeCount { @@ -87,5 +87,46 @@ void defaultsToZero() { // then assertThat(product.likeCount()).isEqualTo(0); } + + @DisplayName("incrementLikeCount()를 호출하면 likeCount가 1 증가한다") + @Test + void incrementsLikeCount() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // when + product.incrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("decrementLikeCount()를 호출하면 likeCount가 1 감소한다") + @Test + void decrementsLikeCount() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + product.incrementLikeCount(); + product.incrementLikeCount(); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("likeCount가 0일 때 decrementLikeCount()를 호출하면 0을 유지한다") + @Test + void doesNotGoBelowZero() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..08f1d61aa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -0,0 +1,180 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +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.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { + var req = new BrandAdminV1Dto.CreateRequest(name, "설명"); + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId) { + var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, 10); + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private void signupMember() { + var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "테스트유저", + LocalDate.of(1998, 1, 1), "test@example.com"); + testRestTemplate.exchange("/api/v1/members", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}); + } + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-LoginId", "testuser"); + h.set("X-Loopers-LoginPw", "Test1234!"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class LikeProduct { + + @DisplayName("상품에 좋아요를 등록한다") + @Test + void likesProduct() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("같은 상품에 두 번 좋아요해도 200을 반환한다 (멱등성)") + @Test + void likeIsIdempotent() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + productId + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("인증 없이 좋아요하면 400을 반환한다") + @Test + void returns400WhenNoAuth() { + var response = testRestTemplate.exchange( + "/api/v1/products/1/likes", HttpMethod.POST, null, + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class UnlikeProduct { + + @DisplayName("좋아요를 취소한다") + @Test + void unlikesProduct() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + productId + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.DELETE, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("좋아요하지 않은 상품을 취소해도 200을 반환한다 (멱등성)") + @Test + void unlikeIsIdempotent() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.DELETE, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/users/{userId}/likes") + @Nested + class GetMyLikes { + + @DisplayName("내가 좋아요한 상품 목록을 조회한다") + @Test + void getsMyLikes() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId); + Long p2 = createProduct("조던", 159000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + p1 + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + testRestTemplate.exchange("/api/v1/products/" + p2 + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/users/1/likes", HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..3cf72885f --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,14 @@ +### 상품 좋아요 등록 +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 상품 좋아요 취소 +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 내가 좋아요한 상품 목록 조회 +GET {{commerce-api}}/api/v1/users/1/likes?page=0&size=10 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! From 51e953eb37edc1f51c8d9419bee65d1f8e8ab5f9 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 27 Feb 2026 10:29:16 +0900 Subject: [PATCH 46/66] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @LoginMember: ArgumentResolver로 X-Loopers-LoginId/Pw 헤더 인증 후 MemberModel 주입 - @AdminUser: X-Loopers-Ldap 헤더 검증 후 AdminInfo 주입 - WebMvcConfig에 ArgumentResolver 등록 - MemberFacade에서 인증 책임 제거 (ArgumentResolver로 이동) - 전체 컨트롤러 리팩토링: 반복적인 인증 보일러플레이트 제거 - Facade 레이어 분리: BrandFacade, ProductFacade, LikeWithProduct 추가 - E2E 테스트에 어드민 LDAP 헤더 적용 Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 21 ++++ .../loopers/application/like/LikeFacade.java | 9 ++ .../application/like/LikeWithProduct.java | 9 ++ .../application/member/MemberFacade.java | 9 +- .../application/product/ProductDetail.java | 40 ++++++ .../application/product/ProductFacade.java | 77 ++++++++++++ .../java/com/loopers/config/WebMvcConfig.java | 24 ++++ .../loopers/domain/brand/BrandService.java | 8 -- .../domain/product/ProductService.java | 26 +--- .../brand/admin/BrandAdminV1Controller.java | 18 ++- .../interfaces/api/like/LikeV1Controller.java | 42 ++----- .../api/member/MemberV1ApiSpec.java | 9 +- .../api/member/MemberV1Controller.java | 22 ++-- .../api/product/ProductV1Controller.java | 25 ++-- .../interfaces/api/product/ProductV1Dto.java | 20 +-- .../admin/ProductAdminV1Controller.java | 49 ++++---- .../api/product/admin/ProductAdminV1Dto.java | 26 ++-- .../loopers/interfaces/auth/AdminInfo.java | 4 + .../loopers/interfaces/auth/AdminUser.java | 11 ++ .../auth/AdminUserArgumentResolver.java | 38 ++++++ .../loopers/interfaces/auth/LoginMember.java | 11 ++ .../auth/LoginMemberArgumentResolver.java | 39 ++++++ .../application/brand/BrandFacadeTest.java | 49 ++++++++ .../like/LikeFacadeIntegrationTest.java | 4 +- .../application/member/MemberFacadeTest.java | 11 +- .../product/ProductFacadeTest.java | 119 ++++++++++++++++++ .../domain/brand/BrandServiceTest.java | 31 +---- .../ProductServiceIntegrationTest.java | 36 +++--- .../domain/product/ProductServiceTest.java | 49 +------- .../interfaces/api/MemberV1ApiE2ETest.java | 18 +-- .../api/brand/BrandV1ApiE2ETest.java | 31 +++-- .../interfaces/api/like/LikeV1ApiE2ETest.java | 25 +++- .../api/product/ProductV1ApiE2ETest.java | 29 +++-- 33 files changed, 650 insertions(+), 289 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java 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..da00d4164 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,21 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + @Transactional + public void delete(Long brandId) { + brandService.delete(brandId); + productService.deleteAllByBrandId(brandId); + } +} 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 index 14fcf40cb..3901151c0 100644 --- 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 @@ -51,4 +51,13 @@ public void unlike(Long userId, Long productId) { public Page getMyLikes(Long userId, Pageable pageable) { return likeService.getMyLikes(userId, pageable); } + + @Transactional(readOnly = true) + public Page getMyLikesWithProducts(Long userId, Pageable pageable) { + Page likes = likeService.getMyLikes(userId, pageable); + return likes.map(like -> { + ProductModel product = productService.getProduct(like.productId()); + return new LikeWithProduct(like, product); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java new file mode 100644 index 000000000..5da08528b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java @@ -0,0 +1,9 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +public record LikeWithProduct( + LikeModel like, + ProductModel product +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 75658e7e8..b06c9c47d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -1,6 +1,5 @@ package com.loopers.application.member; -import com.loopers.domain.member.MemberAuthService; import com.loopers.domain.member.MemberModel; import com.loopers.domain.member.MemberPasswordService; import com.loopers.domain.member.MemberSignupService; @@ -14,7 +13,6 @@ public class MemberFacade { private final MemberSignupService memberSignupService; - private final MemberAuthService memberAuthService; private final MemberPasswordService memberPasswordService; public MemberInfo signup(String loginId, String password, String name, @@ -23,14 +21,11 @@ public MemberInfo signup(String loginId, String password, String name, return MemberInfo.from(member); } - public MemberInfo getMyInfo(String loginId, String password) { - MemberModel member = memberAuthService.authenticate(loginId, password); + public MemberInfo getMyInfo(MemberModel member) { return MemberInfo.fromWithMaskedName(member); } - public void changePassword(String loginId, String password, - String currentPassword, String newPassword) { - MemberModel member = memberAuthService.authenticate(loginId, password); + public void changePassword(MemberModel member, String currentPassword, String newPassword) { memberPasswordService.changePassword(member, currentPassword, newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java new file mode 100644 index 000000000..d2dd60302 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockStatus; + +import java.time.ZonedDateTime; + +public record ProductDetail( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + StockStatus stockStatus, + int stockQuantity, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + + public static ProductDetail ofCustomer(ProductModel product, String brandName, StockStatus stockStatus) { + return new ProductDetail( + product.getId(), product.name(), product.description(), + product.price().value(), product.brandId(), brandName, + product.likeCount(), stockStatus, 0, + product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() + ); + } + + public static ProductDetail ofAdmin(ProductModel product, String brandName, int stockQuantity) { + return new ProductDetail( + product.getId(), product.name(), product.description(), + product.price().value(), product.brandId(), brandName, + product.likeCount(), null, stockQuantity, + product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() + ); + } +} 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..1f5b0d8d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,77 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final StockService stockService; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + brandService.getBrand(brandId); + ProductModel product = productService.register(name, description, price, brandId); + stockService.create(product.getId(), initialStock); + return product; + } + + @Transactional(readOnly = true) + public ProductDetail getProduct(Long productId) { + ProductModel product = productService.getProduct(productId); + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(productId); + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + } + + @Transactional(readOnly = true) + public ProductDetail getProductForAdmin(Long productId) { + ProductModel product = productService.getProductForAdmin(productId); + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(productId); + return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Page products = productService.getProducts(brandId, sortType, pageable); + return products.map(product -> { + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + }); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + Page products = productService.getProductsForAdmin(brandId, pageable); + return products.map(product -> { + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + }); + } + + private String getBrandName(Long brandId) { + try { + BrandModel brand = brandService.getBrandForAdmin(brandId); + return brand.name().value(); + } catch (Exception e) { + return null; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..6e4cc110b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.loopers.config; + +import com.loopers.interfaces.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.auth.LoginMemberArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AdminUserArgumentResolver adminUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + resolvers.add(adminUserArgumentResolver); + } +} 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 index cbcb9d896..0691e2de2 100644 --- 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 @@ -1,7 +1,5 @@ package com.loopers.domain.brand; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -10,14 +8,11 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @RequiredArgsConstructor @Component public class BrandService { private final BrandRepository brandRepository; - private final ProductRepository productRepository; @Transactional public BrandModel register(String name, String description) { @@ -64,9 +59,6 @@ public BrandModel update(Long brandId, String name, String description) { public void delete(Long brandId) { BrandModel brand = findById(brandId); brand.delete(); - - List products = productRepository.findAllByBrandId(brandId); - products.forEach(ProductModel::delete); } @Transactional(readOnly = true) 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 index 1bfe9a97c..f1634d58e 100644 --- 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 @@ -1,8 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -19,24 +16,11 @@ public class ProductService { private final ProductRepository productRepository; - private final BrandRepository brandRepository; - private final StockService stockService; @Transactional - public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { - BrandModel brand = brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); - - if (brand.getDeletedAt() != null) { - throw new CoreException(ErrorType.NOT_FOUND, "삭제된 브랜드에 상품을 등록할 수 없습니다."); - } - + public ProductModel register(String name, String description, Money price, Long brandId) { ProductModel product = new ProductModel(name, description, price, brandId); - ProductModel saved = productRepository.save(product); - - stockService.create(saved.getId(), initialStock); - - return saved; + return productRepository.save(product); } @Transactional(readOnly = true) @@ -89,12 +73,6 @@ public void deleteAllByBrandId(Long brandId) { products.forEach(ProductModel::delete); } - public String getBrandName(Long brandId) { - return brandRepository.findById(brandId) - .map(brand -> brand.name().value()) - .orElse(null); - } - private ProductModel findById(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java index bf201ca3b..391cb54d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -1,8 +1,11 @@ package com.loopers.interfaces.api.brand.admin; +import com.loopers.application.brand.BrandFacade; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -22,9 +25,11 @@ public class BrandAdminV1Controller { private final BrandService brandService; + private final BrandFacade brandFacade; @PostMapping public ApiResponse create( + @AdminUser AdminInfo admin, @RequestBody BrandAdminV1Dto.CreateRequest request ) { BrandModel brand = brandService.register(request.name(), request.description()); @@ -33,21 +38,26 @@ public ApiResponse create( @GetMapping public ApiResponse> getAll( + @AdminUser AdminInfo admin, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size ) { Page result = brandService.getAll(PageRequest.of(page, size)); return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); } @GetMapping("/{brandId}") - public ApiResponse getBrand(@PathVariable Long brandId) { + public ApiResponse getBrand( + @AdminUser AdminInfo admin, + @PathVariable Long brandId + ) { BrandModel brand = brandService.getBrandForAdmin(brandId); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); } @PutMapping("/{brandId}") public ApiResponse update( + @AdminUser AdminInfo admin, @PathVariable Long brandId, @RequestBody BrandAdminV1Dto.UpdateRequest request ) { @@ -56,8 +66,8 @@ public ApiResponse update( } @DeleteMapping("/{brandId}") - public ApiResponse delete(@PathVariable Long brandId) { - brandService.delete(brandId); + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { + brandFacade.delete(brandId); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 4406f122a..cf4ba7de5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,12 +1,12 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeFacade; -import com.loopers.domain.like.LikeModel; -import com.loopers.domain.member.MemberAuthService; +import com.loopers.application.like.LikeWithProduct; import com.loopers.domain.member.MemberModel; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -15,7 +15,6 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -24,46 +23,31 @@ public class LikeV1Controller { private final LikeFacade likeFacade; - private final MemberAuthService memberAuthService; - private final ProductService productService; @PostMapping("/api/v1/products/{productId}/likes") - public ApiResponse like( - @PathVariable Long productId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw - ) { - MemberModel member = memberAuthService.authenticate(loginId, loginPw); + public ApiResponse like(@LoginMember MemberModel member, @PathVariable Long productId) { likeFacade.like(member.getId(), productId); return ApiResponse.success(null); } @DeleteMapping("/api/v1/products/{productId}/likes") - public ApiResponse unlike( - @PathVariable Long productId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw - ) { - MemberModel member = memberAuthService.authenticate(loginId, loginPw); + public ApiResponse unlike(@LoginMember MemberModel member, @PathVariable Long productId) { likeFacade.unlike(member.getId(), productId); return ApiResponse.success(null); } @GetMapping("/api/v1/users/{userId}/likes") public ApiResponse> getMyLikes( + @LoginMember MemberModel member, @PathVariable Long userId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size ) { - MemberModel member = memberAuthService.authenticate(loginId, loginPw); - Page likes = likeFacade.getMyLikes(member.getId(), + if (!member.getId().equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 좋아요 목록만 조회할 수 있습니다."); + } + Page likes = likeFacade.getMyLikesWithProducts(member.getId(), PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); - Page response = likes.map(like -> { - ProductModel product = productService.getProduct(like.productId()); - return LikeV1Dto.LikeResponse.from(like, product); - }); - return ApiResponse.success(response); + return ApiResponse.success(likes.map(lwp -> LikeV1Dto.LikeResponse.from(lwp.like(), lwp.product()))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index 9cca8fd0b..625df737d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.member; +import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; +import static com.loopers.interfaces.api.member.MemberV1Dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -8,12 +10,11 @@ public interface MemberV1ApiSpec { @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") - ApiResponse signup(MemberV1Dto.SignupRequest request); + ApiResponse signup(SignupRequest request); @Operation(summary = "내 정보 조회", description = "헤더 인증을 통해 내 정보를 조회합니다.") - ApiResponse getMe(String loginId, String password); + ApiResponse getMe(MemberModel member); @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") - ApiResponse changePassword(String loginId, String password, - MemberV1Dto.ChangePasswordRequest request); + ApiResponse changePassword(MemberModel member, ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index a3ee3eb2f..bcdcfd412 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -2,19 +2,20 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; +import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RestController -@RequestMapping("/api/v1/members") +@RequestMapping("/api/v1/users") public class MemberV1Controller implements MemberV1ApiSpec { private final MemberFacade memberFacade; @@ -33,23 +34,18 @@ public ApiResponse signup( @GetMapping("/me") @Override - public ApiResponse getMe( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ) { - MemberInfo info = memberFacade.getMyInfo(loginId, password); + public ApiResponse getMe(@LoginMember MemberModel member) { + MemberInfo info = memberFacade.getMyInfo(member); return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } - @PatchMapping("/me/password") + @PutMapping("/password") @Override public ApiResponse changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password, + @LoginMember MemberModel member, @RequestBody MemberV1Dto.ChangePasswordRequest request ) { - memberFacade.changePassword(loginId, password, - request.currentPassword(), request.newPassword()); + memberFacade.changePassword(member, request.currentPassword(), request.newPassword()); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 9fd941dd0..c6cfd3907 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,9 +1,8 @@ package com.loopers.interfaces.api.product; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.ProductSortType; -import com.loopers.domain.stock.StockService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -19,30 +18,22 @@ @RequestMapping("/api/v1/products") public class ProductV1Controller { - private final ProductService productService; - private final StockService stockService; + private final ProductFacade productFacade; @GetMapping public ApiResponse> getProducts( @RequestParam(required = false) Long brandId, @RequestParam(defaultValue = "LATEST") ProductSortType sort, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size ) { - Page products = productService.getProducts(brandId, sort, PageRequest.of(page, size)); - Page response = products.map(product -> { - String brandName = productService.getBrandName(product.brandId()); - var stockStatus = stockService.getByProductId(product.getId()).toStatus(); - return ProductV1Dto.ProductResponse.from(product, brandName, stockStatus); - }); - return ApiResponse.success(response); + Page products = productFacade.getProducts(brandId, sort, PageRequest.of(page, size)); + return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); } @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { - ProductModel product = productService.getProduct(productId); - String brandName = productService.getBrandName(product.brandId()); - var stockStatus = stockService.getByProductId(product.getId()).toStatus(); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brandName, stockStatus)); + ProductDetail detail = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 49c7f969c..29839c56d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product; -import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductDetail; import com.loopers.domain.stock.StockStatus; public class ProductV1Dto { @@ -15,16 +15,16 @@ public record ProductResponse( int likeCount, StockStatus stockStatus ) { - public static ProductResponse from(ProductModel model, String brandName, StockStatus stockStatus) { + public static ProductResponse from(ProductDetail detail) { return new ProductResponse( - model.getId(), - model.name(), - model.description(), - model.price().value(), - model.brandId(), - brandName, - model.likeCount(), - stockStatus + detail.id(), + detail.name(), + detail.description(), + detail.price(), + detail.brandId(), + detail.brandName(), + detail.likeCount(), + detail.stockStatus() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java index b8b0e6c42..34b65cbc6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -1,11 +1,13 @@ package com.loopers.interfaces.api.product.admin; +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; -import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -24,58 +26,55 @@ @RequestMapping("/api-admin/v1/products") public class ProductAdminV1Controller { + private final ProductFacade productFacade; private final ProductService productService; - private final StockService stockService; @PostMapping public ApiResponse create( + @AdminUser AdminInfo admin, @RequestBody ProductAdminV1Dto.CreateRequest request ) { - ProductModel product = productService.register( + ProductModel product = productFacade.register( request.name(), request.description(), new Money(request.price()), request.brandId(), request.initialStock() ); - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + ProductDetail detail = productFacade.getProductForAdmin(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @GetMapping public ApiResponse> getAll( + @AdminUser AdminInfo admin, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) Long brandId ) { - Page result = productService.getProductsForAdmin(brandId, PageRequest.of(page, size)); - Page response = result.map(product -> { - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity()); - }); - return ApiResponse.success(response); + Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); + return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { - ProductModel product = productService.getProductForAdmin(productId); - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + public ApiResponse getProduct( + @AdminUser AdminInfo admin, + @PathVariable Long productId + ) { + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @PutMapping("/{productId}") public ApiResponse update( + @AdminUser AdminInfo admin, @PathVariable Long productId, @RequestBody ProductAdminV1Dto.UpdateRequest request ) { - ProductModel product = productService.update(productId, request.name(), request.description(), new Money(request.price())); - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + productService.update(productId, request.name(), request.description(), new Money(request.price())); + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @DeleteMapping("/{productId}") - public ApiResponse delete(@PathVariable Long productId) { + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { productService.delete(productId); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java index 9399aba3e..5f58c5cb2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product.admin; -import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductDetail; import java.time.ZonedDateTime; @@ -33,19 +33,19 @@ public record ProductResponse( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static ProductResponse from(ProductModel model, String brandName, int stockQuantity) { + public static ProductResponse from(ProductDetail detail) { return new ProductResponse( - model.getId(), - model.name(), - model.description(), - model.price().value(), - model.brandId(), - brandName, - model.likeCount(), - stockQuantity, - model.getCreatedAt(), - model.getUpdatedAt(), - model.getDeletedAt() + detail.id(), + detail.name(), + detail.description(), + detail.price(), + detail.brandId(), + detail.brandName(), + detail.likeCount(), + detail.stockQuantity(), + detail.createdAt(), + detail.updatedAt(), + detail.deletedAt() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java new file mode 100644 index 000000000..516f8b1d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.auth; + +public record AdminInfo(String ldap) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java new file mode 100644 index 000000000..3cf3df48e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.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 AdminUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java new file mode 100644 index 000000000..969bd5d7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 +public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String VALID_LDAP = "loopers.admin"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AdminUser.class) + && AdminInfo.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String ldap = webRequest.getHeader("X-Loopers-Ldap"); + + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "어드민 인증 헤더가 누락되었습니다."); + } + + if (!VALID_LDAP.equals(ldap)) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 어드민 인증입니다."); + } + + return new AdminInfo(ldap); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java new file mode 100644 index 000000000..93ea3e09a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.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 LoginMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..21898e1b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.auth; + +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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; + +@RequiredArgsConstructor +@Component +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberAuthService memberAuthService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class) + && MemberModel.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String loginId = webRequest.getHeader("X-Loopers-LoginId"); + String loginPw = webRequest.getHeader("X-Loopers-LoginPw"); + + if (loginId == null || loginPw == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "인증 헤더가 누락되었습니다."); + } + + return memberAuthService.authenticate(loginId, loginPw); + } +} 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..34570fd9e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,49 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +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 static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandFacadeTest { + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + private BrandFacade brandFacade; + + @BeforeEach + void setUp() { + brandFacade = new BrandFacade(brandService, productService); + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("브랜드 soft delete 후 소속 상품을 연쇄 soft delete 한다") + @Test + void deletesBrandAndCascadesProducts() { + // given + Long brandId = 1L; + + // when + brandFacade.delete(brandId); + + // then + verify(brandService).delete(brandId); + verify(productService).deleteAllByBrandId(brandId); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index c607834ff..5e1389470 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeModel; import com.loopers.domain.product.Money; @@ -23,6 +24,7 @@ class LikeFacadeIntegrationTest { @Autowired private LikeFacade likeFacade; + @Autowired private ProductFacade productFacade; @Autowired private ProductService productService; @Autowired private BrandService brandService; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -32,7 +34,7 @@ class LikeFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId) { - return productService.register(name, "설명", new Money(price), brandId, 10).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, 10).getId(); } @DisplayName("좋아요 등록") diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java index 75353325b..acc6623ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -22,9 +22,6 @@ class MemberFacadeTest { @Mock private MemberSignupService memberSignupService; - @Mock - private MemberAuthService memberAuthService; - @Mock private MemberPasswordService memberPasswordService; @@ -32,7 +29,7 @@ class MemberFacadeTest { @BeforeEach void setUp() { - memberFacade = new MemberFacade(memberSignupService, memberAuthService, memberPasswordService); + memberFacade = new MemberFacade(memberSignupService, memberPasswordService); } @DisplayName("회원가입") @@ -73,10 +70,9 @@ void returnsWithMaskedName() { MemberModel member = new MemberModel( new LoginId("kwonmo"), "encoded", new MemberName("양권모"), LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); - when(memberAuthService.authenticate("kwonmo", "Test1234!")).thenReturn(member); // when - MemberInfo result = memberFacade.getMyInfo("kwonmo", "Test1234!"); + MemberInfo result = memberFacade.getMyInfo(member); // then assertAll( @@ -98,10 +94,9 @@ void delegatesToPasswordService() { MemberModel member = new MemberModel( new LoginId("kwonmo"), "encoded", new MemberName("양권모"), LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); - when(memberAuthService.authenticate("kwonmo", "Test1234!")).thenReturn(member); // when - memberFacade.changePassword("kwonmo", "Test1234!", "Current1!", "NewPass5678!"); + memberFacade.changePassword(member, "Current1!", "NewPass5678!"); // then verify(memberPasswordService).changePassword(member, "Current1!", "NewPass5678!"); 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..311563551 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,119 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.domain.stock.StockStatus; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private StockService stockService; + + private ProductFacade productFacade; + + @BeforeEach + void setUp() { + productFacade = new ProductFacade(productService, brandService, stockService); + } + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("브랜드 검증 후 상품 저장 및 재고 생성을 orchestrate 한다") + @Test + void orchestratesRegistration() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + + when(brandService.getBrand(brandId)).thenReturn(brand); + when(productService.register("에어맥스", "러닝화", new Money(129000), brandId)).thenReturn(product); + + // when + ProductModel result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> verify(brandService).getBrand(brandId), + () -> verify(productService).register("에어맥스", "러닝화", new Money(129000), brandId), + () -> verify(stockService).create(any(), eq(100)) + ); + } + + @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = 1L; + when(brandService.getBrand(brandId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + // when & then + assertThrows(CoreException.class, + () -> productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); + } + } + + @DisplayName("상품 상세 조회 (Customer)") + @Nested + class GetProduct { + + @DisplayName("상품 + 브랜드명 + 재고상태를 조합하여 반환한다") + @Test + void returnsProductDetail() { + // given + Long productId = 1L; + Long brandId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + StockModel stock = new StockModel(productId, 100); + + when(productService.getProduct(productId)).thenReturn(product); + when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); + when(stockService.getByProductId(productId)).thenReturn(stock); + + // when + ProductDetail result = productFacade.getProduct(productId); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> assertThat(result.brandName()).isEqualTo("나이키"), + () -> assertThat(result.stockStatus()).isEqualTo(StockStatus.IN_STOCK) + ); + } + } +} 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 index c939dbb29..6577b447d 100644 --- 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 @@ -1,8 +1,5 @@ package com.loopers.domain.brand; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.Money; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -34,14 +31,11 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; - @Mock - private ProductRepository productRepository; - private BrandService brandService; @BeforeEach void setUp() { - brandService = new BrandService(brandRepository, productRepository); + brandService = new BrandService(brandRepository); } @DisplayName("브랜드 등록") @@ -247,7 +241,6 @@ void softDeletesSuccessfully() { Long brandId = 1L; BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of()); // when brandService.delete(brandId); @@ -256,28 +249,6 @@ void softDeletesSuccessfully() { assertThat(brand.getDeletedAt()).isNotNull(); } - @DisplayName("삭제 시 소속 상품도 연쇄 soft delete 한다") - @Test - void cascadeSoftDeletesProducts() { - // given - Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); - ProductModel product1 = new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId); - ProductModel product2 = new ProductModel("에어맥스 95", "러닝화", new Money(159000), brandId); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); - - // when - brandService.delete(brandId); - - // then - assertAll( - () -> assertThat(brand.getDeletedAt()).isNotNull(), - () -> assertThat(product1.getDeletedAt()).isNotNull(), - () -> assertThat(product2.getDeletedAt()).isNotNull() - ); - } - @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") @Test void throwsWhenNotFound() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index cba1a132f..74174b1ca 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.brand.BrandService; import com.loopers.domain.stock.StockModel; import com.loopers.domain.stock.StockService; @@ -25,6 +26,9 @@ class ProductServiceIntegrationTest { @Autowired private ProductService productService; + @Autowired + private ProductFacade productFacade; + @Autowired private BrandService brandService; @@ -43,6 +47,10 @@ private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } + private ProductModel createProduct(String name, String description, Money price, Long brandId, int initialStock) { + return productFacade.register(name, description, price, brandId, initialStock); + } + @DisplayName("상품 등록") @Nested class Register { @@ -54,7 +62,7 @@ void createsProductAndStock() { Long brandId = createBrand("나이키"); // when - ProductModel result = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductModel result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // then assertAll( @@ -77,7 +85,7 @@ void throwsWhenBrandDeleted() { // when CoreException result = assertThrows(CoreException.class, - () -> productService.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); + () -> createProduct("에어맥스", "러닝화", new Money(129000), brandId, 100)); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -93,7 +101,7 @@ class GetProduct { void returnsProduct() { // given Long brandId = createBrand("나이키"); - ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when ProductModel result = productService.getProduct(saved.getId()); @@ -107,7 +115,7 @@ void returnsProduct() { void throwsWhenDeleted() { // given Long brandId = createBrand("나이키"); - ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); productService.delete(saved.getId()); // when @@ -128,9 +136,9 @@ class GetProducts { void returnsNotDeletedProducts() { // given Long brandId = createBrand("나이키"); - productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - productService.register("에어맥스 95", "러닝화", new Money(159000), brandId, 50); - ProductModel deleted = productService.register("삭제될 상품", "설명", new Money(99000), brandId, 10); + createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); + ProductModel deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); productService.delete(deleted.getId()); // when @@ -146,8 +154,8 @@ void filtersByBrandId() { // given Long nikeId = createBrand("나이키"); Long adidasId = createBrand("아디다스"); - productService.register("에어맥스 90", "러닝화", new Money(129000), nikeId, 100); - productService.register("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); + createProduct("에어맥스 90", "러닝화", new Money(129000), nikeId, 100); + createProduct("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); // when Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); @@ -167,7 +175,7 @@ class Update { void updatesSuccessfully() { // given Long brandId = createBrand("나이키"); - ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when ProductModel result = productService.update(saved.getId(), "에어맥스 95", "뉴 러닝화", new Money(159000)); @@ -190,7 +198,7 @@ class Delete { void excludedFromCustomerQueryAfterDelete() { // given Long brandId = createBrand("나이키"); - ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when productService.delete(saved.getId()); @@ -206,7 +214,7 @@ void excludedFromCustomerQueryAfterDelete() { void includedInAdminQueryAfterDelete() { // given Long brandId = createBrand("나이키"); - ProductModel saved = productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when productService.delete(saved.getId()); @@ -226,8 +234,8 @@ class DeleteAllByBrandId { void softDeletesAllProducts() { // given Long brandId = createBrand("나이키"); - productService.register("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - productService.register("에어맥스 95", "러닝화", new Money(159000), brandId, 50); + createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); // when productService.deleteAllByBrandId(brandId); 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 index da4d8d6f1..9b173dd26 100644 --- 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 @@ -1,9 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -34,17 +30,11 @@ class ProductServiceTest { @Mock private ProductRepository productRepository; - @Mock - private BrandRepository brandRepository; - - @Mock - private StockService stockService; - private ProductService productService; @BeforeEach void setUp() { - productService = new ProductService(productRepository, brandRepository, stockService); + productService = new ProductService(productRepository); } @DisplayName("상품 등록") @@ -59,15 +49,12 @@ void returnsSavedProduct() { String description = "러닝화"; Money price = new Money(129000); Long brandId = 1L; - int initialStock = 100; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); when(productRepository.save(any(ProductModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); // when - ProductModel result = productService.register(name, description, price, brandId, initialStock); + ProductModel result = productService.register(name, description, price, brandId); // then assertAll( @@ -78,38 +65,6 @@ void returnsSavedProduct() { ); verify(productRepository).save(any(ProductModel.class)); } - - @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외를 던진다") - @Test - void throwsWhenBrandDeleted() { - // given - Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); - brand.delete(); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - - // when - CoreException result = assertThrows(CoreException.class, - () -> productService.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); - - // then - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - - @DisplayName("존재하지 않는 브랜드에 등록하면 NOT_FOUND 예외를 던진다") - @Test - void throwsWhenBrandNotFound() { - // given - Long brandId = 999L; - when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); - - // when - CoreException result = assertThrows(CoreException.class, - () -> productService.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); - - // then - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } } @DisplayName("상품 조회 (Customer)") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index b79abda30..12ed55563 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -26,9 +26,9 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class MemberV1ApiE2ETest { - private static final String ENDPOINT_SIGNUP = "/api/v1/members"; - private static final String ENDPOINT_ME = "/api/v1/members/me"; - private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/members/me/password"; + private static final String ENDPOINT_SIGNUP = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; private final TestRestTemplate testRestTemplate; private final MemberJpaRepository memberJpaRepository; @@ -72,7 +72,7 @@ private HttpHeaders authHeaders(String loginId, String password) { return headers; } - @DisplayName("POST /api/v1/members") + @DisplayName("POST /api/v1/users") @Nested class Signup { @@ -140,7 +140,7 @@ void returns400OnInvalidLoginId() { } } - @DisplayName("GET /api/v1/members/me") + @DisplayName("GET /api/v1/users/me") @Nested class GetMe { @@ -186,7 +186,7 @@ void returns401OnWrongPassword() { } } - @DisplayName("PATCH /api/v1/members/me/password") + @DisplayName("PUT /api/v1/users/password") @Nested class ChangePassword { @@ -202,7 +202,7 @@ void returns200OnValidRequest() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), responseType); @@ -222,7 +222,7 @@ void returns400OnWrongCurrentPassword() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), responseType); @@ -242,7 +242,7 @@ void returns400OnInvalidNewPassword() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), responseType); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index b71af55aa..9c4f78e12 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -12,6 +12,7 @@ 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; @@ -40,10 +41,16 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + private Long createBrand(String name, String description) { BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, description); ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().id(); @@ -51,7 +58,7 @@ private Long createBrand(String name, String description) { private void deleteBrand(Long brandId) { testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {} ); } @@ -128,7 +135,7 @@ void returns200WithBrandInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -150,7 +157,7 @@ void returns409OnDuplicateName() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -166,7 +173,7 @@ void returns400OnEmptyName() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -188,7 +195,7 @@ void returns200WithPagedList() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -209,7 +216,7 @@ void returns200WithBrandInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -225,7 +232,7 @@ void returns200WithBrandInfo() { void returns404WhenNotFound() { // given & when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/999", HttpMethod.GET, null, + ADMIN_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -247,7 +254,7 @@ void returns200WithUpdatedInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request), + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -269,7 +276,7 @@ void returns409OnDuplicateName() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request), + ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -290,7 +297,7 @@ void returns200OnSuccess() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -303,7 +310,7 @@ void returns200OnSuccess() { void returns404WhenNotFound() { // given & when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java index 08f1d61aa..67152c396 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -35,22 +35,28 @@ public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp datab @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); } + private HttpHeaders adminHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-Ldap", "loopers.admin"); + return h; + } + private Long createBrand(String name) { var req = new BrandAdminV1Dto.CreateRequest(name, "설명"); - return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req), + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), new ParameterizedTypeReference>() {}).getBody().data().id(); } private Long createProduct(String name, int price, Long brandId) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, 10); - return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req), + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "테스트유저", LocalDate.of(1998, 1, 1), "test@example.com"); - testRestTemplate.exchange("/api/v1/members", HttpMethod.POST, new HttpEntity<>(req), + testRestTemplate.exchange("/api/v1/users", HttpMethod.POST, new HttpEntity<>(req), new ParameterizedTypeReference>() {}); } @@ -176,5 +182,18 @@ void getsMyLikes() { assertTrue(response.getStatusCode().is2xxSuccessful()); } + + @DisplayName("다른 유저의 좋아요 목록을 조회하면 400을 반환한다") + @Test + void returns400WhenUserIdMismatch() { + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/users/999/likes", HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index 8694da652..d2ae0befe 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -13,6 +13,7 @@ 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; @@ -42,10 +43,16 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + private Long createBrand(String name) { BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, "설명"); ResponseEntity> response = testRestTemplate.exchange( - BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().id(); @@ -54,7 +61,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId, int initialStock) { ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, initialStock); ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().id(); @@ -62,14 +69,14 @@ private Long createProduct(String name, int price, Long brandId, int initialStoc private void deleteProduct(Long productId) { testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {} ); } private void deleteBrand(Long brandId) { testRestTemplate.exchange( - BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {} ); } @@ -194,7 +201,7 @@ void returns200WithStockQuantity() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -221,7 +228,7 @@ void returns404WhenBrandDeleted() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -245,7 +252,7 @@ void returns200IncludingDeleted() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -267,7 +274,7 @@ void returns200() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, null, + ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -296,7 +303,7 @@ void returns200WithUpdatedInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request), + ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -323,7 +330,7 @@ void returns200OnSuccess() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -336,7 +343,7 @@ void returns200OnSuccess() { void returns404WhenNotFound() { // given & when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); From 848a34c943dbb74221217e4cfbd3b506bad7c9de Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 27 Feb 2026 10:43:19 +0900 Subject: [PATCH 47/66] =?UTF-8?q?feat:=20Order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderModel/OrderItemModel 엔티티, OrderStatus 상태 전이 - OrderService, OrderFacade (주문 생성 시 재고 차감 연동) - OrderV1Controller (고객), OrderAdminV1Controller (어드민) - 단위/통합/E2E 테스트 포함 (239 tests, 0 failures) Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 38 ++ .../loopers/application/brand/BrandInfo.java | 26 ++ .../application/order/OrderFacade.java | 61 ++++ .../application/order/OrderItemCommand.java | 4 + .../application/order/OrderResult.java | 16 + .../loopers/domain/brand/BrandRepository.java | 3 + .../loopers/domain/order/OrderItemModel.java | 83 +++++ .../domain/order/OrderItemRepository.java | 12 + .../com/loopers/domain/order/OrderModel.java | 55 +++ .../loopers/domain/order/OrderRepository.java | 21 ++ .../loopers/domain/order/OrderService.java | 64 ++++ .../com/loopers/domain/order/OrderStatus.java | 9 + .../com/loopers/domain/product/Money.java | 2 + .../loopers/domain/product/ProductModel.java | 10 + .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 17 + .../loopers/domain/stock/StockRepository.java | 3 + .../loopers/domain/stock/StockService.java | 11 + .../brand/BrandJpaRepository.java | 3 + .../brand/BrandRepositoryImpl.java | 6 + .../order/OrderItemJpaRepository.java | 11 + .../order/OrderItemRepositoryImpl.java | 30 ++ .../order/OrderJpaRepository.java | 18 + .../order/OrderRepositoryImpl.java | 41 +++ .../product/ProductJpaRepository.java | 2 + .../product/ProductRepositoryImpl.java | 5 + .../stock/StockJpaRepository.java | 3 + .../stock/StockRepositoryImpl.java | 6 + .../interfaces/api/ApiControllerAdvice.java | 8 + .../api/order/OrderV1Controller.java | 74 ++++ .../interfaces/api/order/OrderV1Dto.java | 95 +++++ .../order/admin/OrderAdminV1Controller.java | 43 +++ .../api/order/admin/OrderAdminV1Dto.java | 72 ++++ .../order/OrderFacadeIntegrationTest.java | 174 +++++++++ .../domain/order/OrderItemModelTest.java | 111 ++++++ .../loopers/domain/order/OrderModelTest.java | 70 ++++ .../domain/product/ProductModelTest.java | 43 ++- .../domain/product/ProductServiceTest.java | 79 ++++ .../domain/stock/StockServiceTest.java | 28 ++ .../api/order/OrderV1ApiE2ETest.java | 195 ++++++++++ .../design/mermaid/00-ddd-design-framework.md | 337 ++++++++++++++++++ http/commerce-api/order-v1.http | 28 ++ 42 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java create mode 100644 docs/design/mermaid/00-ddd-design-framework.md create mode 100644 http/commerce-api/order-v1.http 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..63e5d7b91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,38 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + + public BrandInfo register(String name, String description) { + return BrandInfo.from(brandService.register(name, description)); + } + + public BrandInfo getBrand(Long brandId) { + return BrandInfo.from(brandService.getBrand(brandId)); + } + + public BrandInfo getBrandForAdmin(Long brandId) { + return BrandInfo.from(brandService.getBrandForAdmin(brandId)); + } + + public BrandInfo update(Long brandId, String name, String description) { + return BrandInfo.from(brandService.update(brandId, name, description)); + } + + public void delete(Long brandId) { + brandService.delete(brandId); + } + + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..4a7db0896 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +import java.time.ZonedDateTime; + +public record BrandInfo( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + + public static BrandInfo from(BrandModel model) { + return new BrandInfo( + model.getId(), + model.name().value(), + model.description(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } +} 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..58f867cef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final StockService stockService; + + @Transactional + public OrderResult placeOrder(Long userId, List commands) { + Money totalAmount = Money.ZERO; + List snapshots = new ArrayList<>(); + + for (OrderItemCommand cmd : commands) { + ProductModel product = productService.getProduct(cmd.productId()); + + StockModel stock = stockService.getByProductId(cmd.productId()); + stock.decrease(cmd.quantity()); + + Money subtotal = product.price().multiply(cmd.quantity()); + totalAmount = totalAmount.add(subtotal); + + snapshots.add(new SnapshotHolder( + product.getId(), product.name(), product.price(), cmd.quantity() + )); + } + + OrderModel order = orderService.save(new OrderModel(userId, totalAmount)); + + List items = snapshots.stream() + .map(s -> new OrderItemModel( + order.getId(), s.productId(), s.productName(), s.productPrice(), s.quantity() + )) + .toList(); + + List savedItems = orderService.saveAllItems(items); + + return OrderResult.of(order, savedItems); + } + + private record SnapshotHolder( + Long productId, String productName, Money productPrice, int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..58c8ef391 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,4 @@ +package com.loopers.application.order; + +public record OrderItemCommand(Long productId, int quantity) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java new file mode 100644 index 000000000..56af5b560 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java @@ -0,0 +1,16 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.util.List; + +public record OrderResult( + OrderModel order, + List items +) { + + public static OrderResult of(OrderModel order, List items) { + return new OrderResult(order, items); + } +} 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 index 26a0febef..f83f3ec35 100644 --- 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 @@ -3,6 +3,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface BrandRepository { @@ -14,4 +15,6 @@ public interface BrandRepository { Optional findByName(String name); Page findAll(Pageable pageable); + + List findAllByIdIn(List ids); } 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..8e0b81dc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,83 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "order_item") +public class OrderItemModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "product_price", nullable = false)) + private Money productPrice; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected OrderItemModel() { + } + + public OrderItemModel(Long orderId, Long productId, String productName, Money productPrice, int quantity) { + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.quantity = quantity; + guard(); + } + + @Override + protected void guard() { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 정보는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다."); + } + if (productPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1 이상이어야 합니다."); + } + } + + public Money subtotal() { + return productPrice.multiply(quantity); + } + + public Long orderId() { + return orderId; + } + + public Long productId() { + return productId; + } + + public String productName() { + return productName; + } + + public Money productPrice() { + return productPrice; + } + + public int quantity() { + return quantity; + } +} 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..7fce7486b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + OrderItemModel save(OrderItemModel orderItem); + + List saveAll(List orderItems); + + List findAllByOrderId(Long orderId); +} 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..9c9b89de8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "orders") +public class OrderModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + protected OrderModel() { + } + + public OrderModel(Long userId, Money totalAmount) { + this.userId = userId; + this.status = OrderStatus.CREATED; + this.totalAmount = totalAmount; + guard(); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문자 정보는 필수입니다."); + } + if (totalAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 총액은 필수입니다."); + } + } + + public Long userId() { + return userId; + } + + public OrderStatus status() { + return status; + } + + public Money totalAmount() { + return totalAmount; + } +} 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..23a12f9bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + OrderModel save(OrderModel order); + + 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..d779d2ea1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel save(OrderModel order) { + return orderRepository.save(order); + } + + @Transactional + public List saveAllItems(List orderItems) { + return orderItemRepository.saveAll(orderItems); + } + + @Transactional(readOnly = true) + public OrderModel getOrder(Long orderId, Long userId) { + OrderModel order = findById(orderId); + if (!order.userId().equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); + } + return order; + } + + @Transactional(readOnly = true) + public OrderModel getOrderForAdmin(Long orderId) { + return findById(orderId); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + return orderRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + private OrderModel findById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } +} 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..0ffc53f2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + CONFIRMED, + SHIPPING, + DELIVERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index d26bd62a7..121376a04 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -13,6 +13,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Money { + public static final Money ZERO = new Money(0); + @Column(name = "price", nullable = false) private int value; 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 index 4e455250b..84a5167c8 100644 --- 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 @@ -60,4 +60,14 @@ public Long brandId() { public int likeCount() { return likeCount; } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } } 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 index fb66f8a7b..a9085b013 100644 --- 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 @@ -21,4 +21,6 @@ public interface ProductRepository { Page findAllByBrandId(Long brandId, Pageable pageable); List findAllByBrandId(Long brandId); + + List findAllByIdInAndDeletedAtIsNull(List ids); } 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 index 1bfe9a97c..009f83cc2 100644 --- 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 @@ -13,6 +13,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -89,6 +92,20 @@ public void deleteAllByBrandId(Long brandId) { products.forEach(ProductModel::delete); } + @Transactional(readOnly = true) + public Map getProductsByIds(List productIds) { + return productRepository.findAllByIdInAndDeletedAtIsNull(productIds) + .stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + } + + @Transactional(readOnly = true) + public Map getBrandNamesByIds(List brandIds) { + return brandRepository.findAllByIdIn(brandIds) + .stream() + .collect(Collectors.toMap(BrandModel::getId, brand -> brand.name().value())); + } + public String getBrandName(Long brandId) { return brandRepository.findById(brandId) .map(brand -> brand.name().value()) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java index 968a9be83..78e333f4d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.stock; +import java.util.List; import java.util.Optional; public interface StockRepository { @@ -7,4 +8,6 @@ public interface StockRepository { StockModel save(StockModel stock); Optional findByProductId(Long productId); + + List findAllByProductIdIn(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java index 28b7a5e02..05cdbcd4d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java @@ -6,6 +6,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class StockService { @@ -23,4 +27,11 @@ public StockModel getByProductId(Long productId) { return stockRepository.findByProductId(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); } + + @Transactional(readOnly = true) + public Map getByProductIds(List productIds) { + return stockRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + } } 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 index 87c8e5dc9..35ba50009 100644 --- 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 @@ -3,9 +3,12 @@ import com.loopers.domain.brand.BrandModel; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface BrandJpaRepository extends JpaRepository { Optional findByNameValue(String value); + + List findAllByIdIn(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 index 6f7a3684d..26ead19f0 100644 --- 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 @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -34,4 +35,9 @@ public Optional findByName(String name) { public Page findAll(Pageable pageable) { return brandJpaRepository.findAll(pageable); } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } } 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..c091c68d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +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..a39ac4aca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItemModel save(OrderItemModel orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } + + @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..e989b8197 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; + +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..d6bb42ca1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @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 index 75c34e5d8..c0b66f92a 100644 --- 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 @@ -16,4 +16,6 @@ public interface ProductJpaRepository extends JpaRepository Page findAllByBrandId(Long brandId, Pageable pageable); List findAllByBrandId(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 index 2b63af7da..eab1cd675 100644 --- 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 @@ -50,4 +50,9 @@ public Page findAllByBrandId(Long brandId, Pageable pageable) { public List findAllByBrandId(Long brandId) { return productJpaRepository.findAllByBrandId(brandId); } + + @Override + public List findAllByIdInAndDeletedAtIsNull(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java index bd61eabae..b378a8822 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -3,9 +3,12 @@ import com.loopers.domain.stock.StockModel; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface StockJpaRepository extends JpaRepository { Optional findByProductId(Long productId); + + List findAllByProductIdIn(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java index f2d91d987..e3335aac9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -22,4 +23,9 @@ public StockModel save(StockModel stock) { public Optional findByProductId(Long productId) { return stockJpaRepository.findByProductId(productId); } + + @Override + public List findAllByProductIdIn(List productIds) { + return stockJpaRepository.findAllByProductIdIn(productIds); + } } 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 20b2809c8..b42d0c491 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 @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,13 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String name = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", name); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..8cc7b9b25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class OrderV1Controller { + + private final OrderFacade orderFacade; + private final OrderService orderService; + private final MemberAuthService memberAuthService; + + @PostMapping("/api/v1/orders") + public ApiResponse createOrder( + @RequestBody OrderV1Dto.CreateRequest request, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberAuthService.authenticate(loginId, password); + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(result)); + } + + @GetMapping("/api/v1/orders") + public ApiResponse> getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt + ) { + MemberModel member = memberAuthService.authenticate(loginId, password); + ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); + + List orders = orderService.getOrdersByUser(member.getId(), start, end); + List response = orders.stream() + .map(OrderV1Dto.OrderSummaryResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/api/v1/orders/{orderId}") + public ApiResponse getOrder( + @PathVariable Long orderId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberAuthService.authenticate(loginId, password); + OrderModel order = orderService.getOrder(orderId, member.getId()); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(order, items)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..548109abf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest(List items) { + + public List toCommands() { + return items.stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + } + } + + public record OrderItemRequest(Long productId, int quantity) { + } + + public record OrderResponse( + Long orderId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderResult result) { + List items = result.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + result.order().getId(), + result.order().status().name(), + result.order().totalAmount().value(), + items, + result.order().getCreatedAt() + ); + } + + public static OrderResponse from(OrderModel order, List items) { + List itemResponses = items.stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.status().name(), + order.totalAmount().value(), + itemResponses, + order.getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderModel order) { + return new OrderSummaryResponse( + order.getId(), + order.status().name(), + order.totalAmount().value(), + order.getCreatedAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.productPrice().value(), + item.quantity(), + item.subtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java new file mode 100644 index 000000000..a174c61e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller { + + private final OrderService orderService; + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orders = orderService.getAllForAdmin(PageRequest.of(page, size)); + Page response = orders.map( + OrderAdminV1Dto.OrderSummaryResponse::from + ); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(order, items)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java new file mode 100644 index 000000000..d5cf035a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java @@ -0,0 +1,72 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderModel order, List items) { + List itemResponses = items.stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.userId(), + order.status().name(), + order.totalAmount().value(), + itemResponses, + order.getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderModel order) { + return new OrderSummaryResponse( + order.getId(), + order.userId(), + order.status().name(), + order.totalAmount().value(), + order.getCreatedAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.productPrice().value(), + item.quantity(), + item.subtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java new file mode 100644 index 000000000..a79842ecf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -0,0 +1,174 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockService; +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 java.util.List; + +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 OrderFacadeIntegrationTest { + + @Autowired private OrderFacade orderFacade; + @Autowired private OrderService orderService; + @Autowired private ProductService productService; + @Autowired private BrandService brandService; + @Autowired private StockService stockService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } + private Long createProduct(String name, int price, Long brandId, int stock) { + return productService.register(name, "설명", new Money(price), brandId, stock).getId(); + } + + @DisplayName("주문 생성") + @Nested + class PlaceOrder { + + @DisplayName("단일 상품 주문이 성공한다") + @Test + void placesSingleItemOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2))); + assertAll( + () -> assertThat(result.order().userId()).isEqualTo(1L), + () -> assertThat(result.order().status()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(258000)), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(result.items().get(0).quantity()).isEqualTo(2) + ); + } + + @DisplayName("복수 상품 주문이 성공한다") + @Test + void placesMultiItemOrder() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId, 10); + Long p2 = createProduct("조던", 159000, brandId, 5); + OrderResult result = orderFacade.placeOrder(1L, List.of( + new OrderItemCommand(p1, 2), new OrderItemCommand(p2, 1))); + assertAll( + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(417000)), + () -> assertThat(result.items()).hasSize(2) + ); + } + + @DisplayName("주문 시 재고가 차감된다") + @Test + void deductsStock() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3))); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); + } + + @DisplayName("재고 부족 시 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenInsufficientStock() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 2); + CoreException result = assertThrows(CoreException.class, + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 5)))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("두 번째 상품 재고 부족 시 첫 번째 상품 재고도 롤백된다") + @Test + void rollsBackOnPartialFailure() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId, 10); + Long p2 = createProduct("조던", 159000, brandId, 1); + assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( + new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)))); + assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); + assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); + } + + @DisplayName("삭제된 상품 주문 시 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenProductDeleted() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + productService.delete(productId); + CoreException result = assertThrows(CoreException.class, + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("스냅샷으로 주문 당시 상품 정보가 저장된다") + @Test + void savesProductSnapshot() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderItemModel item = result.items().get(0); + assertAll( + () -> assertThat(item.productName()).isEqualTo("에어맥스"), + () -> assertThat(item.productPrice()).isEqualTo(new Money(129000)) + ); + } + } + + @DisplayName("주문 조회") + @Nested + class GetOrder { + + @DisplayName("본인의 주문을 조회할 수 있다") + @Test + void getsOwnOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderModel order = orderService.getOrder(r.order().getId(), 1L); + assertThat(order.getId()).isEqualTo(r.order().getId()); + } + + @DisplayName("다른 사용자의 주문을 조회하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenAccessingOtherUserOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + CoreException result = assertThrows(CoreException.class, + () -> orderService.getOrder(r.order().getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 상품 목록을 조회할 수 있다") + @Test + void getsOrderItems() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2))); + List items = orderService.getOrderItems(r.order().getId()); + assertAll( + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(items.get(0).quantity()).isEqualTo(2) + ); + } + } +} 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..d98918bd1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 OrderItemModelTest { + + @DisplayName("주문 상품 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + Long orderId = 1L; + Long productId = 2L; + String productName = "에어맥스 90"; + Money productPrice = new Money(129000); + int quantity = 2; + + // when + OrderItemModel item = new OrderItemModel(orderId, productId, productName, productPrice, quantity); + + // then + assertAll( + () -> assertThat(item.orderId()).isEqualTo(orderId), + () -> assertThat(item.productId()).isEqualTo(productId), + () -> assertThat(item.productName()).isEqualTo(productName), + () -> assertThat(item.productPrice()).isEqualTo(productPrice), + () -> assertThat(item.quantity()).isEqualTo(quantity) + ); + } + + @DisplayName("orderId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenOrderIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(null, 1L, "상품", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, null, "상품", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductNameNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, null, new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductNameBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, " ", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productPrice가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductPriceNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, "상품", null, 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("quantity가 0이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenQuantityZero() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, "상품", new Money(1000), 0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소계 계산") + @Nested + class Subtotal { + + @DisplayName("가격 * 수량으로 소계를 계산한다") + @Test + void calculatesSubtotal() { + OrderItemModel item = new OrderItemModel(1L, 1L, "에어맥스", new Money(129000), 2); + assertThat(item.subtotal()).isEqualTo(new Money(258000)); + } + + @DisplayName("수량이 1이면 가격과 소계가 같다") + @Test + void subtotalEqualsUnitPriceWhenQuantityIsOne() { + Money price = new Money(59000); + OrderItemModel item = new OrderItemModel(1L, 1L, "양말", price, 1); + assertThat(item.subtotal()).isEqualTo(price); + } + } +} 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..d0c5ad35d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 OrderModelTest { + + @DisplayName("주문 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + Long userId = 1L; + Money totalAmount = new Money(258000); + + // when + OrderModel order = new OrderModel(userId, totalAmount); + + // then + assertAll( + () -> assertThat(order.userId()).isEqualTo(userId), + () -> assertThat(order.status()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(order.totalAmount()).isEqualTo(totalAmount) + ); + } + + @DisplayName("생성 시 상태는 CREATED이다") + @Test + void defaultStatusIsCreated() { + // given & when + OrderModel order = new OrderModel(1L, new Money(10000)); + + // then + assertThat(order.status()).isEqualTo(OrderStatus.CREATED); + } + + @DisplayName("userId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenUserIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new OrderModel(null, new Money(10000))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenTotalAmountNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new OrderModel(1L, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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 index 938f3b1bf..81ca675e8 100644 --- 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 @@ -74,7 +74,7 @@ void updatesNameDescriptionPrice() { } } - @DisplayName("likeCount 초기값") + @DisplayName("likeCount") @Nested class LikeCount { @@ -87,5 +87,46 @@ void defaultsToZero() { // then assertThat(product.likeCount()).isEqualTo(0); } + + @DisplayName("incrementLikeCount()를 호출하면 likeCount가 1 증가한다") + @Test + void incrementsLikeCount() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // when + product.incrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("decrementLikeCount()를 호출하면 likeCount가 1 감소한다") + @Test + void decrementsLikeCount() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + product.incrementLikeCount(); + product.incrementLikeCount(); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("likeCount가 0일 때 decrementLikeCount()를 호출하면 0을 유지한다") + @Test + void doesNotGoBelowZero() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } } } 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 index da4d8d6f1..91d249996 100644 --- 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 @@ -19,6 +19,7 @@ import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -27,6 +28,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class ProductServiceTest { @@ -314,6 +316,83 @@ void softDeletesSuccessfully() { } } + @DisplayName("상품 배치 조회") + @Nested + class GetProductsByIds { + + @DisplayName("ID 목록으로 미삭제 상품을 Map으로 반환한다") + @Test + void returnsMapOfProducts() { + // given + List productIds = List.of(1L, 2L); + ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + ProductModel product2 = new ProductModel("조던", "농구화", new Money(159000), 1L); + ReflectionTestUtils.setField(product2, "id", 2L); + when(productRepository.findAllByIdInAndDeletedAtIsNull(productIds)) + .thenReturn(List.of(product1, product2)); + + // when + Map result = productService.getProductsByIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L).name()).isEqualTo("에어맥스"), + () -> assertThat(result.get(2L).name()).isEqualTo("조던") + ); + } + + @DisplayName("삭제된 상품은 제외된다") + @Test + void excludesDeletedProducts() { + // given + List productIds = List.of(1L, 2L); + ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + when(productRepository.findAllByIdInAndDeletedAtIsNull(productIds)) + .thenReturn(List.of(product1)); + + // when + Map result = productService.getProductsByIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result).containsKey(1L), + () -> assertThat(result).doesNotContainKey(2L) + ); + } + } + + @DisplayName("브랜드명 배치 조회") + @Nested + class GetBrandNamesByIds { + + @DisplayName("브랜드 ID 목록으로 브랜드명 Map을 반환한다") + @Test + void returnsMapOfBrandNames() { + // given + List brandIds = List.of(1L, 2L); + BrandModel brand1 = new BrandModel(new BrandName("나이키"), "스포츠"); + ReflectionTestUtils.setField(brand1, "id", 1L); + BrandModel brand2 = new BrandModel(new BrandName("아디다스"), "스포츠"); + ReflectionTestUtils.setField(brand2, "id", 2L); + when(brandRepository.findAllByIdIn(brandIds)) + .thenReturn(List.of(brand1, brand2)); + + // when + Map result = productService.getBrandNamesByIds(brandIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L)).isEqualTo("나이키"), + () -> assertThat(result.get(2L)).isEqualTo("아디다스") + ); + } + } + @DisplayName("브랜드별 상품 전체 삭제") @Nested class DeleteAllByBrandId { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java index b5e0d13c7..b320819d7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java @@ -10,6 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -94,4 +96,30 @@ void throwsWhenNotFound() { assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } + + @DisplayName("상품별 재고 배치 조회") + @Nested + class GetByProductIds { + + @DisplayName("productId 목록으로 재고 Map을 반환한다") + @Test + void returnsMapOfStocks() { + // given + List productIds = List.of(1L, 2L); + StockModel stock1 = new StockModel(1L, 100); + StockModel stock2 = new StockModel(2L, 50); + when(stockRepository.findAllByProductIdIn(productIds)) + .thenReturn(List.of(stock1, stock2)); + + // when + Map result = stockService.getByProductIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L).quantity()).isEqualTo(100), + () -> assertThat(result.get(2L).quantity()).isEqualTo(50) + ); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..ad16e4213 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -0,0 +1,195 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +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.*; + +import java.time.LocalDate; +import java.util.List; + +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 OrderV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final MemberJpaRepository memberJpaRepository; + + @Autowired + public OrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp, + MemberJpaRepository memberJpaRepository) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.memberJpaRepository = memberJpaRepository; + } + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { + var req = new BrandAdminV1Dto.CreateRequest(name, "설명"); + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId, int stock) { + var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, stock); + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private void signupMember() { + var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "테스트유저", + LocalDate.of(1998, 1, 1), "test@example.com"); + testRestTemplate.exchange("/api/v1/members", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}); + } + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-LoginId", "testuser"); + h.set("X-Loopers-LoginPw", "Test1234!"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + private ResponseEntity> placeOrder(List items) { + return testRestTemplate.exchange("/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(new OrderV1Dto.CreateRequest(items), authHeaders()), + new ParameterizedTypeReference<>() {}); + } + + @DisplayName("POST /api/v1/orders") @Nested + class CreateOrder { + @DisplayName("단일 상품 주문이 성공한다") @Test + void createsSingleItemOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + var response = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 2))); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(258000), + () -> assertThat(response.getBody().data().status()).isEqualTo("CREATED"), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @DisplayName("복수 상품 주문이 성공한다") @Test + void createsMultiItemOrder() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId, 10); + Long p2 = createProduct("조던", 159000, brandId, 5); + signupMember(); + var response = placeOrder(List.of( + new OrderV1Dto.OrderItemRequest(p1, 2), new OrderV1Dto.OrderItemRequest(p2, 1))); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(417000), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("재고 부족 시 400을 반환한다") @Test + void returns400WhenInsufficientStock() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 1); + signupMember(); + ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", + HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 5))), authHeaders()), + new ParameterizedTypeReference<>() {}); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 헤더 누락 시 400을 반환한다") @Test + void returns400WhenNoAuth() { + ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", + HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(1L, 1)))), + new ParameterizedTypeReference<>() {}); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/orders") @Nested + class GetMyOrders { + @DisplayName("기간별 주문 목록을 조회한다") @Test + void returnsOrdersByDateRange() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + String today = LocalDate.now().toString(); + var response = testRestTemplate.exchange( + "/api/v1/orders?startAt=" + today + "&endAt=" + today, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") @Nested + class GetOrder { + @DisplayName("주문 상세를 조회한다") @Test + void returnsOrderDetail() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 2))); + Long orderId = orderResponse.getBody().data().orderId(); + var response = testRestTemplate.exchange("/api/v1/orders/" + orderId, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(258000), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/orders") @Nested + class AdminGetOrders { + @DisplayName("관리자 주문 목록을 조회한다") @Test + void returnsAllOrders() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + var response = testRestTemplate.exchange("/api-admin/v1/orders?page=0&size=20", + HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") @Nested + class AdminGetOrder { + @DisplayName("관리자 주문 상세를 조회한다") @Test + void returnsOrderDetailForAdmin() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + Long orderId = orderResponse.getBody().data().orderId(); + var response = testRestTemplate.exchange("/api-admin/v1/orders/" + orderId, + HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/docs/design/mermaid/00-ddd-design-framework.md b/docs/design/mermaid/00-ddd-design-framework.md new file mode 100644 index 000000000..9fe9a26fa --- /dev/null +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -0,0 +1,337 @@ +# DDD 설계 프레임워크 + +> 이 프로젝트의 도메인 설계 시 참조하는 사고 프레임워크. +> 실제 설계 과정에서 흐름을 검토하고, DDD 정석과 대조하여 조정한 결과물이다. + +--- + +## 1. DDD 설계 흐름 (수정 전 vs 수정 후) + +### 원래 흐름 + +``` +1. 최상위 도메인 구분 +2. 유비쿼터스 언어 구분 +3. 바운디드 컨텍스트 구분 +4. 루트 어그리게이트 +5. 레이어 구분 +6. 도메인 서비스 / 비즈니스 서비스 +``` + +### 조정된 흐름 (DDD 정석) + +``` +1. 서브도메인 식별 + 분류 (Core / Supporting / Generic) +2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) +3. 컨텍스트 매핑 (관계 정의) +4. 전술적 설계 (어그리게이트 + 엔티티/VO + 도메인 이벤트) +5. 레이어드 아키텍처 (구현) +6. 도메인 서비스 vs 애플리케이션 서비스 (용어 정정) +``` + +### 대조표 + +``` +수정 전 수정 후 +───────────────── ───────────────── +1. 최상위 도메인 구분 → 1. 서브도메인 식별 + 분류 (Core/Supporting/Generic) +2. 유비쿼터스 언어 구분 ┐ 2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) +3. 바운디드 컨텍스트 구분 ┘ + (빠짐) → 3. 컨텍스트 매핑 (관계 정의) +4. 루트 어그리게이트 → 4. 전술적 설계 (어그리게이트 + 엔티티/VO + 도메인 이벤트) +5. 레이어 구분 → 5. 레이어드 아키텍처 (구현) +6. 도메인 서비스/비즈니스 서비스 → 6. 도메인 서비스 vs 애플리케이션 서비스 (용어 정정) +``` + +**3가지 핵심 조정 사항:** +1. **2번과 3번은 분리된 단계가 아니라 하나의 동시 과정** — 언어 차이를 발견하는 것이 곧 경계를 긋는 것 +2. **컨텍스트 매핑이 누락** — 나눈 컨텍스트들의 통신 방식을 정의해야 함 +3. **"비즈니스 서비스"는 DDD 용어가 아님** — "애플리케이션 서비스"로 정정 + +--- + +## 2. 각 단계 상세 + +### 2-1. 서브도메인 식별 + 분류 + +> "이 사업에서 뭘 하는가"를 영역별로 쪼개고, 어디에 설계 역량을 집중할지 결정한다. + +"최상위 도메인"이라는 표현은 방향은 맞지만, DDD에서는 **서브도메인(Subdomain)**이라는 더 구체적인 분류 기준을 사용한다. + +**서브도메인의 3가지 유형:** + +| 유형 | 의미 | 설계 전략 | +|------|------|-----------| +| **Core** | 비즈니스 경쟁력의 핵심 | 직접 설계하고 정교하게 구현 | +| **Supporting** | Core를 보조. 중요하지만 차별화 요소는 아님 | 직접 구현하되 Core만큼의 투자는 불필요 | +| **Generic** | 어디서나 비슷하게 필요한 범용 기능 | 외부 솔루션 사용 가능 | + +이 분류가 왜 필요한가: Core 서브도메인은 직접 정교하게 설계하고, Generic은 외부 솔루션을 쓸 수 있다. +"어디에 시간을 쓸 것인가"의 판단 근거가 된다. + +### 2-2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) + +> "상품이 뭔데?"라고 물었을 때 대답이 달라지는 지점이 곧 경계다. +> 언어 차이를 발견하는 것과 경계를 긋는 것은 같은 행위의 양면이다. + +**비유:** 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것. "여기서부터 이 언어가 통하지 않는다"를 발견하는 순간이 곧 국경선이 그어지는 순간이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, **언어 차이가 국경을 드러낸다.** + +**판별 기준:** "같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계" + +**적용 범위 한정:** 이 기준은 **동일 도메인 용어가 여러 맥락에서 사용될 때만** 적용된다. 애초에 다른 단어를 쓰는 영역(예: "상품"과 "결제수단")은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다. + +### 2-3. 컨텍스트 매핑 + +> 바운디드 컨텍스트를 나눴으면, "얘네가 서로 어떻게 대화하는가"를 정해야 한다. + +현재 프로젝트에서 **Facade가 이 역할을 수행**하고 있다. 모놀리스에서는 직접 호출이 실용적이지만, 시스템이 커졌을 때 **어디서 잘라야 하는가**를 미리 인식하는 단계다. + +### 2-4. 전술적 설계 + +> 바운디드 컨텍스트 안에서 결정하는 것들. + +| 결정 사항 | 질문 | 프로젝트 예시 | +|-----------|------|--------------| +| **어그리게이트 경계** | 어디까지가 하나의 트랜잭션 단위인가? | Product와 Stock은 별도 어그리게이트 | +| **엔티티 vs 값 객체** | 이 객체에 고유 식별자가 필요한가? | `Money`는 VO, `ProductModel`은 엔티티 | +| **도메인 이벤트** | 컨텍스트 간 통신은 어떻게? | 좋아요 생성 → likeCount 갱신 | + +### 2-5. 레이어드 아키텍처 (구현) + +전략적/전술적 설계가 끝난 후 코드로 옮기는 단계. + +``` +interfaces/ → 외부 요청 수신 (Controller, DTO) +application/ → 유스케이스 조율 (Facade) +domain/ → 비즈니스 규칙 (Entity, VO, DomainService) +infrastructure/ → 기술 구현 (JpaRepository) +``` + +### 2-6. 도메인 서비스 vs 애플리케이션 서비스 + +> "비즈니스 서비스"는 DDD 용어가 아니다. 구분하려는 것은 아래 두 가지다. + +| | 도메인 서비스 | 애플리케이션 서비스 | +|---|---|---| +| **현재 프로젝트** | `domain/XxxService` | `application/XxxFacade` | +| **담는 것** | 비즈니스 **규칙** | 유스케이스 **절차** | +| **판별 질문** | "이 로직이 특정 엔티티 하나의 책임인가?" → No → 도메인 서비스 | "이것은 규칙인가, 절차인가?" → 절차 → 애플리케이션 서비스 | + +**적용 범위:** 이 "규칙 vs 절차" 구분은 **도메인 계층과 애플리케이션 계층 사이**에서만 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. + +--- + +## 3. 현재 프로젝트의 서브도메인 분류표 + +| 서브도메인 | 유형 | 근거 | +|-----------|------|------| +| **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | +| **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | +| **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 독자적 경쟁력은 아님 | +| **좋아요** | Supporting | 고객 선호 추적. 카탈로그 정렬(인기순)에 활용 | +| **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | + +**Member가 다른 도메인과 완전히 독립적인 이유:** +회원 인증은 Generic 서브도메인이다. 다른 비즈니스 로직과 결합될 이유가 없으며, 현재 프로젝트에서도 `userId`만으로 참조하고 있다. + +--- + +## 4. 바운디드 컨텍스트 발견 과정 + +### "상품이 뭔데?" — 같은 단어, 다른 의미 + +현재 `ProductModel`에 모든 관심사가 한 엔티티에 모여있다: + +```java +public class ProductModel extends BaseEntity { + private String name; // ← "상품을 전시한다" 관점 + private String description; // ← "상품을 전시한다" 관점 + private Money price; // ← "상품의 가치를 매긴다" 관점 + private Long brandId; // ← "상품이 어떤 브랜드인지" 관점 + private int likeCount; // ← "상품이 얼마나 인기있는지" 관점 +} +``` + +"상품이 뭔데?"라고 물으면 맥락마다 답이 다르다: + +| 맥락 | "상품"의 의미 | 관심 있는 속성 | 관심 없는 속성 | +|------|-------------|---------------|---------------| +| **카탈로그** | 고객에게 보여줄 전시물 | name, description, price, brand | quantity, likeCount | +| **재고** | 창고에서 관리할 물건 | productId, quantity, status | name, description, brand | +| **좋아요** | 사용자가 선호를 표현한 대상 | productId (참조만) | name, price, quantity | +| **주문** | 거래의 대상 (가격이 확정된 시점) | productId, 주문시점가격, 수량 | 현재가격, 재고, 좋아요 | + +같은 "상품"인데 **필요한 속성이 완전히 다르다.** 이 차이가 바운디드 컨텍스트의 경계다. + +### 무의식적으로 이미 적용하고 있는 경계 + +- `LikeModel`이 `ProductModel`을 직접 참조하지 않고 `productId`만 보유 +- `StockModel`도 `productId`만 보유 +- `OrderItemModel`에 주문 시점의 `productName`, `productPrice`를 **스냅샷**으로 복사 + +이것이 바로 바운디드 컨텍스트 간의 **느슨한 참조(ID 참조)**이다. + +### 프로젝트의 바운디드 컨텍스트 + +```mermaid +graph LR + subgraph "카탈로그 BC" + Brand["Brand (어그리게이트)"] + Product["Product (어그리게이트)"] + end + subgraph "재고 BC" + Stock["Stock (어그리게이트)"] + end + subgraph "좋아요 BC" + Like["Like (어그리게이트)"] + end + subgraph "주문 BC" + Order["Order (어그리게이트)"] + OrderItem["OrderItem (엔티티)"] + end + subgraph "회원/인증 BC" + Member["Member"] + end + + Product -- "brandId (ID 참조)" --> Brand + Stock -- "productId (ID 참조)" --> Product + Like -- "productId (ID 참조)" --> Product + Like -- "userId (ID 참조)" --> Member + Order -- "userId (ID 참조)" --> Member + OrderItem -- "productId + 스냅샷" --> Product +``` + +**브랜드와 상품이 같은 BC인 근거:** +브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete하는 것이 **하나의 트랜잭션**으로 처리된다(Q1). 이 트랜잭션 경계가 같은 BC에 속해야 하는 직접적인 이유다. + +--- + +## 5. 컨텍스트 매핑 + +### 의존 방향 + +```mermaid +graph TD + Auth["회원/인증 BC
(Generic)"] + Catalog["카탈로그 BC
(Core)"] + Inventory["재고 BC
(Supporting)"] + LikeCtx["좋아요 BC
(Supporting)"] + OrderCtx["주문 BC
(Core)"] + + LikeCtx -- "userId" --> Auth + OrderCtx -- "userId" --> Auth + Catalog -- "brandId → Product" --> Catalog + Inventory -- "productId" --> Catalog + LikeCtx -- "productId" --> Catalog + OrderCtx -- "productId + 스냅샷" --> Catalog + OrderCtx -- "재고 차감" --> Inventory + LikeCtx -. "likeCount 갱신" .-> Catalog +``` + +### 통신 방식 (현재 모놀리스) + +| 호출자 | 피호출자 | 방식 | 예시 | 분리 시 전환 | +|--------|---------|------|------|-------------| +| `ProductFacade` | `BrandService` | 직접 호출 | 브랜드 존재 확인 후 상품 생성 | 같은 BC — 분리 불필요 | +| `ProductFacade` | `StockService` | 직접 호출 | 상품 + 재고 동시 생성 | API 호출 또는 이벤트 | +| `LikeFacade` | `ProductService` | 직접 호출 | 삭제된 상품 체크 + likeCount 갱신 | **도메인 이벤트** | +| `OrderFacade` | `ProductService` + `StockService` | 직접 호출 | 상품 확인 → 재고 차감 → 주문 생성 | Saga 패턴 | + +### 설계적 주의 지점: `product.incrementLikeCount()` + +```java +// LikeFacade — 좋아요 컨텍스트가 카탈로그 컨텍스트를 직접 수정 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); // 카탈로그에서 검증 + // ... 좋아요 로직 + product.incrementLikeCount(); // ← 좋아요 BC가 카탈로그 BC의 엔티티를 직접 변경 +} +``` + +모놀리스에서는 실용적이지만, 물리적 분리 시 **도메인 이벤트**로 전환해야 한다: + +```java +// 분리 시: 좋아요 → 이벤트 발행 → 카탈로그가 수신하여 likeCount 갱신 +// 현재 모놀리스에서는 Facade에서 직접 호출하는 것이 실용적 +``` + +--- + +## 6. 도메인 서비스 vs 애플리케이션 서비스 판별 기준 + +### 판별 흐름 + +```mermaid +flowchart TD + A["로직이 있다"] --> B{"특정 엔티티 하나의 책임인가?"} + B -- "Yes" --> C["엔티티 메서드
예: product.incrementLikeCount()"] + B -- "No" --> D{"규칙인가, 절차인가?"} + D -- "규칙" --> E["도메인 서비스
domain/XxxService"] + D -- "절차" --> F["애플리케이션 서비스
application/XxxFacade"] + + style C fill:#e8f5e9 + style E fill:#e3f2fd + style F fill:#fff3e0 +``` + +### 코드로 보는 구분 + +**도메인 서비스 — 규칙을 담는다:** + +```java +// BrandService: "같은 이름의 브랜드는 등록할 수 없다" +public BrandModel register(String name, String description) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // ← 비즈니스 규칙 + }); + return brandRepository.save(new BrandModel(name, description)); +} +``` + +**애플리케이션 서비스(Facade) — 절차를 조율한다:** + +```java +// ProductFacade: "상품 등록 시 브랜드 확인 → 상품 생성 → 재고 생성" +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. 브랜드 존재 확인 (위임) + ProductModel product = productService.register(...); // 2. 상품 생성 (위임) + stockService.create(product.getId(), initialStock); // 3. 재고 생성 (위임) + return product; // ← 자체 규칙 없음, 절차만 있음 +} +``` + +### 판별 기준 요약표 + +| 질문 | 도메인 서비스 | 애플리케이션 서비스 | +|------|-------------|-------------------| +| 자체 비즈니스 규칙이 있는가? | **있다** (유니크 검증, 상태 전이) | **없다** (위임만 수행) | +| 다른 Service를 조합하는가? | 같은 도메인 내 객체만 | **여러 도메인 Service를 조합** | +| `@Transactional` 경계인가? | 아닐 수 있음 | **맞다** (유스케이스 단위) | +| 제거하면 비즈니스 규칙이 깨지는가? | **깨진다** | 절차가 사라질 뿐, 규칙은 유지됨 | + +### 현재 프로젝트의 배치 + +| 컴포넌트 | 계층 | 역할 | +|---------|------|------| +| `BrandService` | domain | 브랜드명 유니크 검증, CRUD | +| `ProductService` | domain | 상품 CRUD, likeCount 증감 | +| `StockService` | domain | 재고 생성, 차감(`checkAndDecrease`) | +| `LikeService` | domain | 좋아요 등록/취소, 존재 여부 조회 | +| `BrandFacade` | application | 삭제 시 소속 상품 연쇄 soft delete | +| `ProductFacade` | application | 상품 + Stock 동시 생성, 브랜드 존재 확인 | +| `LikeFacade` | application | 삭제된 상품 체크, likeCount 동기화 | + +--- + +## 부록: 어그리게이트 분리 판단 — Product vs Stock + +`ProductModel`과 `StockModel`은 1:1이지만 **별도 어그리게이트**다. + +**근거:** 상품 정보를 수정할 때 재고를 함께 잠글 필요가 없고, 재고를 변경할 때 상품 정보를 함께 잠글 필요가 없다. 독립적으로 변경 가능한 단위이므로 별도 어그리게이트가 맞다. + +| 변경 시나리오 | Product 변경? | Stock 변경? | 결론 | +|-------------|:----------:|:----------:|------| +| 상품명 수정 | O | X | 독립 | +| 가격 수정 | O | X | 독립 | +| 재고 차감 (주문) | X | O | 독립 | +| 상품 등록 (초기 재고 포함) | O | O | Facade에서 조율 | diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..a11405a78 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,28 @@ +### 주문 생성 +POST {{commerce-api}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 2, "quantity": 1 } + ] +} + +### 내 주문 목록 조회 (기간별) +GET {{commerce-api}}/api/v1/orders?startAt=2026-01-01&endAt=2026-12-31 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 주문 상세 조회 +GET {{commerce-api}}/api/v1/orders/1 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### [ADMIN] 주문 목록 조회 +GET {{commerce-api}}/api-admin/v1/orders?page=0&size=20 + +### [ADMIN] 주문 상세 조회 +GET {{commerce-api}}/api-admin/v1/orders/1 From f199cbca7e3beeb6a921f8c4507f4c276ec5c2ea Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 11:29:53 +0900 Subject: [PATCH 48/66] =?UTF-8?q?refactor:=20Example=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=20Service=EB=A5=BC=20application=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99,=20pass-through=20Facade=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/example/ExampleFacade.java | 17 ----------------- .../example/ExampleService.java | 9 ++++++--- .../api/example/ExampleV1Controller.java | 6 +++--- .../example/ExampleServiceIntegrationTest.java | 11 ++++++----- 4 files changed, 15 insertions(+), 28 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/example/ExampleService.java (65%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/example/ExampleServiceIntegrationTest.java (82%) 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/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleService.java similarity index 65% rename from apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java rename to apps/commerce-api/src/main/java/com/loopers/application/example/ExampleService.java index c0e8431e8..cf61eec61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.example; +package com.loopers.application.example; +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -13,8 +15,9 @@ public class ExampleService { private final ExampleRepository exampleRepository; @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) + public ExampleInfo getExample(Long id) { + ExampleModel example = exampleRepository.find(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + return ExampleInfo.from(example); } } 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 index 917376016..c9cfa6e8d 100644 --- 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 @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.example; -import com.loopers.application.example.ExampleFacade; import com.loopers.application.example.ExampleInfo; +import com.loopers.application.example.ExampleService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -14,14 +14,14 @@ @RequestMapping("/api/v1/examples") public class ExampleV1Controller implements ExampleV1ApiSpec { - private final ExampleFacade exampleFacade; + private final ExampleService exampleService; @GetMapping("/{exampleId}") @Override public ApiResponse getExample( @PathVariable(value = "exampleId") Long exampleId ) { - ExampleInfo info = exampleFacade.getExample(exampleId); + ExampleInfo info = exampleService.getExample(exampleId); ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/example/ExampleServiceIntegrationTest.java similarity index 82% rename from apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/example/ExampleServiceIntegrationTest.java index 7a74d1076..db0986ed8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/example/ExampleServiceIntegrationTest.java @@ -1,5 +1,6 @@ -package com.loopers.domain.example; +package com.loopers.application.example; +import com.loopers.domain.example.ExampleModel; import com.loopers.infrastructure.example.ExampleJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -43,14 +44,14 @@ void returnsExampleForExistingId() { ); // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); + ExampleInfo 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()) + () -> assertThat(result.id()).isEqualTo(exampleModel.getId()), + () -> assertThat(result.name()).isEqualTo(exampleModel.getName()), + () -> assertThat(result.description()).isEqualTo(exampleModel.getDescription()) ); } From ae07b519ddbb63e5c89b92afe6fe29a28b2e1ca1 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 11:32:18 +0900 Subject: [PATCH 49/66] =?UTF-8?q?refactor:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=20LikeService=EB=A5=BC=20application=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository에 의존하는 LikeService를 domain 패키지에서 application 패키지로 이동하여 레이어 책임을 명확히 분리 Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 1 - .../like/LikeService.java | 4 +- .../application/order/OrderFacade.java | 2 +- .../application/product/ProductFacade.java | 2 +- .../stock/StockService.java | 4 +- .../stock/StockServiceTest.java | 4 +- ...2026-03-05-layer-separation-refactoring.md | 1449 +++++++++++++++++ 7 files changed, 1460 insertions(+), 6 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/like/LikeService.java (87%) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/stock/StockService.java (90%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/stock/StockServiceTest.java (96%) create mode 100644 docs/plans/2026-03-05-layer-separation-refactoring.md 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 index 3901151c0..2c781dffb 100644 --- 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 @@ -1,7 +1,6 @@ package com.loopers.application.like; import com.loopers.domain.like.LikeModel; -import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java rename to apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 27571279b..3621907fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.like; +package com.loopers.application.like; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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 index 58f867cef..6c2e41d09 100644 --- 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 @@ -7,7 +7,7 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; 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 index 1f5b0d8d1..00145a443 100644 --- 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 @@ -7,7 +7,7 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java similarity index 90% rename from apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java rename to apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java index 05cdbcd4d..642f649f4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.stock; +package com.loopers.application.stock; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java similarity index 96% rename from apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java index b320819d7..377cc0b9d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java @@ -1,5 +1,7 @@ -package com.loopers.domain.stock; +package com.loopers.application.stock; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; diff --git a/docs/plans/2026-03-05-layer-separation-refactoring.md b/docs/plans/2026-03-05-layer-separation-refactoring.md new file mode 100644 index 000000000..72a384e15 --- /dev/null +++ b/docs/plans/2026-03-05-layer-separation-refactoring.md @@ -0,0 +1,1449 @@ +# 아키텍처 레이어 분리 리팩토링 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Domain/Application/Presentation 레이어의 책임을 올바르게 분리한다. + +**Architecture:** +- **Domain**: Entity, VO, Repository 인터페이스만 존재. 비즈니스 의사결정이 필요한 경우에만 Domain Service 허용. +- **Application**: Service(CRUD+트랜잭션 조율) + Facade(다중 도메인 조합). Application DTO(Info/Result record)를 통해 Presentation에 데이터 전달. +- **Presentation(interfaces)**: Controller는 Application 레이어만 의존. Dto는 Application DTO로부터 변환. Domain 모델 직접 참조 금지. + +**Tech Stack:** Java 21, Spring Boot 3.4.4, JPA, JUnit 5 + +**판별 기준 (블로그 참고):** +- 비즈니스 의사결정을 내리는 코드 → Domain (Entity/VO/Domain Service) +- 의사결정을 조율하고 외부 세계와 상호작용하는 코드 → Application Service +- 여러 도메인 서비스를 조합하는 코드 → Application Facade + +--- + +## 현재 문제 요약 + +### 1. Service가 domain에 있지만 전부 Application Service +모든 Service가 Repository 의존 + 트랜잭션 관리 = 유스케이스 조율 역할. +비즈니스 의사결정은 Entity/VO가 이미 담당하고 있음. + +### 2. Controller가 domain Service를 직접 호출 (레이어 위반) +| Controller | domain Service 직접 호출 | +|---|---| +| `BrandV1Controller` | `BrandService` | +| `BrandAdminV1Controller` | `BrandService` | +| `ProductAdminV1Controller` | `ProductService` | +| `OrderV1Controller` | `OrderService`, `MemberAuthService` | +| `OrderAdminV1Controller` | `OrderService` | + +### 3. Presentation Dto가 Domain Model 직접 참조 +| Dto | domain 모델 직접 참조 | +|---|---| +| `BrandV1Dto.BrandResponse` | `BrandModel` | +| `BrandAdminV1Dto.BrandResponse` | `BrandModel` | +| `OrderV1Dto.OrderResponse` | `OrderModel`, `OrderItemModel` | +| `OrderV1Dto.OrderSummaryResponse` | `OrderModel` | +| `OrderAdminV1Dto.*` | `OrderModel`, `OrderItemModel` | + +--- + +## 리팩토링 후 목표 구조 + +``` +domain/ +├── brand/ +│ ├── BrandModel.java, BrandName.java, BrandRepository.java +├── example/ +│ ├── ExampleModel.java, ExampleRepository.java +├── like/ +│ ├── LikeModel.java, LikeRepository.java +├── member/ +│ ├── MemberModel.java, LoginId.java, Email.java, MemberName.java, Password.java +│ └── MemberRepository.java +├── order/ +│ ├── OrderModel.java, OrderItemModel.java, OrderStatus.java +│ └── OrderRepository.java, OrderItemRepository.java +├── product/ +│ ├── ProductModel.java, Money.java, ProductSortType.java, ProductRepository.java +└── stock/ + ├── StockModel.java, StockStatus.java, StockRepository.java + +application/ +├── brand/ +│ ├── BrandService.java (moved from domain) +│ ├── BrandFacade.java (cross-domain: brand + product 삭제) +│ └── BrandInfo.java +├── example/ +│ ├── ExampleService.java (moved from domain, ExampleFacade 합침) +│ └── ExampleInfo.java +├── like/ +│ ├── LikeService.java (moved from domain) +│ ├── LikeFacade.java (cross-domain: like + product) +│ └── LikeWithProduct.java +├── member/ +│ ├── MemberSignupService.java (moved from domain) +│ ├── MemberAuthService.java (moved from domain) +│ ├── MemberPasswordService.java (moved from domain) +│ ├── MemberFacade.java +│ └── MemberInfo.java +├── order/ +│ ├── OrderService.java (moved from domain) +│ ├── OrderFacade.java (cross-domain: order + product + stock) +│ ├── OrderItemCommand.java, OrderResult.java +│ └── OrderInfo.java (NEW) +├── product/ +│ ├── ProductService.java (moved from domain) +│ ├── ProductFacade.java (cross-domain: product + brand + stock) +│ └── ProductDetail.java +└── stock/ + └── StockService.java (moved from domain) + +interfaces/api/ (변경사항: Controller → application만 의존, Dto → application DTO만 참조) +``` + +--- + +## Task 1: Example 도메인 (패턴 확립) + +가장 단순한 도메인으로 리팩토링 패턴을 확립한다. +ExampleFacade는 pass-through이므로 ExampleService에 합친다. + +**Files:** +- Move: `domain/example/ExampleService.java` → `application/example/ExampleService.java` +- Delete: `application/example/ExampleFacade.java` (ExampleService에 합침) +- Modify: `interfaces/api/example/ExampleV1Controller.java` +- Move test: `domain/example/ExampleServiceIntegrationTest.java` → `application/example/ExampleServiceIntegrationTest.java` + +**Step 1: ExampleService를 application으로 이동 + ExampleFacade 합침** + +`application/example/ExampleService.java`: +```java +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; +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 ExampleInfo getExample(Long id) { + ExampleModel example = exampleRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + return ExampleInfo.from(example); + } +} +``` + +**Step 2: ExampleV1Controller 수정 (Facade → Service)** + +```java +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleInfo; +import com.loopers.application.example.ExampleService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/examples") +public class ExampleV1Controller implements ExampleV1ApiSpec { + + private final ExampleService exampleService; + + @GetMapping("/{exampleId}") + @Override + public ApiResponse getExample( + @PathVariable(value = "exampleId") Long exampleId + ) { + ExampleInfo info = exampleService.getExample(exampleId); + ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); + return ApiResponse.success(response); + } +} +``` + +**Step 3: domain/example/ExampleService.java, application/example/ExampleFacade.java 삭제** + +**Step 4: 테스트 패키지 이동 + import 수정** +- `ExampleServiceIntegrationTest` → `application/example/` 로 이동, import 수정 + +**Step 5: 테스트 실행** + +```bash +./gradlew test --tests "*Example*" +``` +Expected: ALL PASS + +**Step 6: 커밋** + +```bash +git add -A +git commit -m "refactor: Example 도메인 레이어 분리 - Service를 application으로 이동, pass-through Facade 제거" +``` + +--- + +## Task 2: Stock 도메인 + +Controller 없음. Service 이동만 하면 됨. + +**Files:** +- Move: `domain/stock/StockService.java` → `application/stock/StockService.java` +- Modify: `application/product/ProductFacade.java` (import 변경) +- Modify: `application/order/OrderFacade.java` (import 변경) +- Move test: `domain/stock/StockServiceTest.java` → `application/stock/StockServiceTest.java` + +**Step 1: StockService를 application으로 이동** + +`application/stock/StockService.java`: +```java +package com.loopers.application.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +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; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class StockService { + + private final StockRepository stockRepository; + + @Transactional + public StockModel create(Long productId, int quantity) { + StockModel stock = new StockModel(productId, quantity); + return stockRepository.save(stock); + } + + @Transactional(readOnly = true) + public StockModel getByProductId(Long productId) { + return stockRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Map getByProductIds(List productIds) { + return stockRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + } +} +``` + +**Step 2: ProductFacade, OrderFacade import 수정** + +```java +// 변경 전 +import com.loopers.domain.stock.StockService; +// 변경 후 +import com.loopers.application.stock.StockService; +``` + +**Step 3: domain/stock/StockService.java 삭제** + +**Step 4: StockServiceTest 패키지 이동 + import 수정** + +**Step 5: 테스트 실행** + +```bash +./gradlew test --tests "*Stock*" --tests "*Product*" --tests "*Order*" +``` +Expected: ALL PASS + +**Step 6: 커밋** + +```bash +git commit -m "refactor: Stock 도메인 레이어 분리 - StockService를 application으로 이동" +``` + +--- + +## Task 3: Like 도메인 + +**Files:** +- Move: `domain/like/LikeService.java` → `application/like/LikeService.java` +- Modify: `application/like/LikeFacade.java` (import 변경) +- Move test: (LikeService 단독 테스트 없음, LikeFacadeIntegrationTest만 존재) + +**Step 1: LikeService를 application으로 이동** + +`application/like/LikeService.java`: +```java +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + public LikeModel save(LikeModel like) { + return likeRepository.save(like); + } + + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + public Optional findActiveLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + public Page getMyLikes(Long userId, Pageable pageable) { + return likeRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} +``` + +**Step 2: LikeFacade import 수정** + +```java +// 변경 전 +import com.loopers.domain.like.LikeService; +// 변경 후 +// LikeService가 같은 패키지(application.like)에 있으므로 import 불필요 +``` + +**Step 3: domain/like/LikeService.java 삭제** + +**Step 4: 테스트 실행** + +```bash +./gradlew test --tests "*Like*" +``` +Expected: ALL PASS + +**Step 5: 커밋** + +```bash +git commit -m "refactor: Like 도메인 레이어 분리 - LikeService를 application으로 이동" +``` + +--- + +## Task 4: Brand 도메인 + +이 도메인부터 Presentation 레이어 위반도 함께 수정한다. + +**문제점:** +1. `BrandV1Controller` → `BrandService` 직접 호출 +2. `BrandAdminV1Controller` → `BrandService` + `BrandFacade` 혼용 +3. `BrandV1Dto.BrandResponse.from(BrandModel)` → domain 모델 직접 참조 +4. `BrandAdminV1Dto.BrandResponse.from(BrandModel)` → domain 모델 직접 참조 + +**Files:** +- Move: `domain/brand/BrandService.java` → `application/brand/BrandService.java` +- Modify: `application/brand/BrandFacade.java` (import 변경) +- Modify: `interfaces/api/brand/BrandV1Controller.java` (BrandFacade 경유) +- Modify: `interfaces/api/brand/BrandV1Dto.java` (BrandInfo로 변환) +- Modify: `interfaces/api/brand/admin/BrandAdminV1Controller.java` (BrandFacade만 의존) +- Modify: `interfaces/api/brand/admin/BrandAdminV1Dto.java` (BrandInfo로 변환) +- Move tests: `domain/brand/BrandServiceTest.java`, `BrandServiceIntegrationTest.java` → `application/brand/` + +**Step 1: BrandService를 application으로 이동** + +`application/brand/BrandService.java`: +```java +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); + + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + + BrandModel brand = new BrandModel(brandName, description); + return brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public BrandModel getBrand(Long brandId) { + BrandModel brand = findById(brandId); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return brand; + } + + @Transactional(readOnly = true) + public BrandModel getBrandForAdmin(Long brandId) { + return findById(brandId); + } + + @Transactional + public BrandModel update(Long brandId, String name, String description) { + BrandModel brand = findById(brandId); + BrandName newName = new BrandName(name); + + if (!brand.name().equals(newName)) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + } + + brand.update(newName, description); + return brand; + } + + @Transactional + public void delete(Long brandId) { + BrandModel brand = findById(brandId); + brand.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } +} +``` + +**Step 2: BrandFacade 수정 - 누락된 메서드 추가 (Controller가 Facade만 호출하도록)** + +`application/brand/BrandFacade.java`: +```java +package com.loopers.application.brand; + +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo register(String name, String description) { + return BrandInfo.from(brandService.register(name, description)); + } + + public BrandInfo getBrand(Long brandId) { + return BrandInfo.from(brandService.getBrand(brandId)); + } + + public BrandInfo getBrandForAdmin(Long brandId) { + return BrandInfo.from(brandService.getBrandForAdmin(brandId)); + } + + public BrandInfo update(Long brandId, String name, String description) { + return BrandInfo.from(brandService.update(brandId, name, description)); + } + + @Transactional + public void delete(Long brandId) { + brandService.delete(brandId); + productService.deleteAllByBrandId(brandId); + } + + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } +} +``` + +**Step 3: BrandV1Controller 수정 - BrandFacade만 의존** + +```java +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} +``` + +**Step 4: BrandV1Dto 수정 - BrandInfo로 변환** + +```java +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} +``` + +**Step 5: BrandAdminV1Controller 수정 - BrandFacade만 의존** + +```java +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +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 BrandAdminV1Controller { + + private final BrandFacade brandFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page result = brandFacade.getAll(PageRequest.of(page, size)); + return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand( + @AdminUser AdminInfo admin, + @PathVariable Long brandId + ) { + BrandInfo info = brandFacade.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } +} +``` + +**Step 6: BrandAdminV1Dto 수정 - BrandInfo로 변환** + +```java +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandInfo; + +import java.time.ZonedDateTime; + +public class BrandAdminV1Dto { + + public record CreateRequest(String name, String description) {} + + public record UpdateRequest(String name, String description) {} + + public record BrandResponse( + Long id, String name, String description, + ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.id(), info.name(), info.description(), + info.createdAt(), info.updatedAt(), info.deletedAt() + ); + } + } +} +``` + +**Step 7: domain/brand/BrandService.java 삭제** + +**Step 8: 테스트 패키지 이동 + import 수정** +- `BrandServiceTest.java` → `application/brand/BrandServiceTest.java` +- `BrandServiceIntegrationTest.java` → `application/brand/BrandServiceIntegrationTest.java` + +**Step 9: 테스트 실행** + +```bash +./gradlew test --tests "*Brand*" +``` +Expected: ALL PASS + +**Step 10: 커밋** + +```bash +git commit -m "refactor: Brand 도메인 레이어 분리 - Service를 application으로 이동, Controller는 Facade만 의존, Dto는 BrandInfo로 변환" +``` + +--- + +## Task 5: Product 도메인 + +**문제점:** +1. `ProductAdminV1Controller` → `ProductService` + `ProductFacade` 혼용 +2. ProductFacade에 없는 update/delete 메서드를 Controller에서 ProductService 직접 호출 + +**Files:** +- Move: `domain/product/ProductService.java` → `application/product/ProductService.java` +- Modify: `application/product/ProductFacade.java` (import + update/delete 메서드 추가) +- Modify: `interfaces/api/product/admin/ProductAdminV1Controller.java` (ProductFacade만 의존) +- Move tests: `domain/product/ProductServiceTest.java`, `ProductServiceIntegrationTest.java` → `application/product/` + +**Step 1: ProductService를 application으로 이동** + +`application/product/ProductService.java` — 기존 코드 그대로, 패키지만 변경: +```java +package com.loopers.application.product; +// ... (기존 코드 동일, import만 domain.product.* 유지) +``` + +**Step 2: ProductFacade에 update/delete 메서드 추가** + +```java +// ProductFacade에 추가 +@Transactional +public ProductDetail update(Long productId, String name, String description, Money price) { + productService.update(productId, name, description, price); + return getProductForAdmin(productId); +} + +public void delete(Long productId) { + productService.delete(productId); +} +``` + +**Step 3: ProductAdminV1Controller 수정 - ProductFacade만 의존** + +```java +package com.loopers.interfaces.api.product.admin; + +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Money; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +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 ProductAdminV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + var product = productFacade.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.initialStock() + ); + ProductDetail detail = productFacade.getProductForAdmin(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Long brandId + ) { + Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); + return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct( + @AdminUser AdminInfo admin, + @PathVariable Long productId + ) { + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @PutMapping("/{productId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { + productFacade.delete(productId); + return ApiResponse.success(); + } +} +``` + +**Step 4: domain/product/ProductService.java 삭제** + +**Step 5: 테스트 패키지 이동 + import 수정** + +**Step 6: 테스트 실행** + +```bash +./gradlew test --tests "*Product*" +``` +Expected: ALL PASS + +**Step 7: 커밋** + +```bash +git commit -m "refactor: Product 도메인 레이어 분리 - Service를 application으로 이동, AdminController는 Facade만 의존" +``` + +--- + +## Task 6: Order 도메인 + +**문제점:** +1. `OrderV1Controller` → `OrderService`, `MemberAuthService` 직접 호출 +2. `OrderAdminV1Controller` → `OrderService` 직접 호출 +3. `OrderV1Dto`, `OrderAdminV1Dto` → `OrderModel`, `OrderItemModel` 직접 참조 +4. `OrderModel`에 소유권 검증 로직이 Service에 새어나옴 + +**Files:** +- Move: `domain/order/OrderService.java` → `application/order/OrderService.java` +- Modify: `domain/order/OrderModel.java` (validateOwner 추가) +- Create: `application/order/OrderInfo.java` (NEW) +- Modify: `application/order/OrderFacade.java` (조회 메서드 추가) +- Modify: `interfaces/api/order/OrderV1Controller.java` +- Modify: `interfaces/api/order/OrderV1Dto.java` +- Modify: `interfaces/api/order/admin/OrderAdminV1Controller.java` +- Modify: `interfaces/api/order/admin/OrderAdminV1Dto.java` + +**Step 1: OrderModel에 소유권 검증 메서드 추가** + +`domain/order/OrderModel.java`에 추가: +```java +public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); + } +} +``` + +**Step 2: OrderInfo 생성 (application DTO)** + +`application/order/OrderInfo.java`: +```java +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt +) { + + public static OrderInfo from(OrderModel order, List items) { + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), itemInfos, order.getCreatedAt() + ); + } + + public static OrderInfo summaryFrom(OrderModel order) { + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), List.of(), order.getCreatedAt() + ); + } + + public record OrderItemInfo( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + ); + } + } +} +``` + +**Step 3: OrderService를 application으로 이동 + validateOwner 사용** + +`application/order/OrderService.java`: +```java +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel save(OrderModel order) { + return orderRepository.save(order); + } + + @Transactional + public List saveAllItems(List orderItems) { + return orderItemRepository.saveAll(orderItems); + } + + @Transactional(readOnly = true) + public OrderModel getOrder(Long orderId, Long userId) { + OrderModel order = findById(orderId); + order.validateOwner(userId); + return order; + } + + @Transactional(readOnly = true) + public OrderModel getOrderForAdmin(Long orderId) { + return findById(orderId); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + return orderRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + private OrderModel findById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } +} +``` + +**Step 4: OrderFacade에 조회 메서드 추가** + +```java +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import com.loopers.application.stock.StockService; +import com.loopers.domain.stock.StockModel; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final StockService stockService; + + @Transactional + public OrderResult placeOrder(Long userId, List commands) { + Money totalAmount = Money.ZERO; + List snapshots = new ArrayList<>(); + + for (OrderItemCommand cmd : commands) { + ProductModel product = productService.getProduct(cmd.productId()); + + StockModel stock = stockService.getByProductId(cmd.productId()); + stock.decrease(cmd.quantity()); + + Money subtotal = product.price().multiply(cmd.quantity()); + totalAmount = totalAmount.add(subtotal); + + snapshots.add(new SnapshotHolder( + product.getId(), product.name(), product.price(), cmd.quantity() + )); + } + + OrderModel order = orderService.save(new OrderModel(userId, totalAmount)); + + List items = snapshots.stream() + .map(s -> new OrderItemModel( + order.getId(), s.productId(), s.productName(), s.productPrice(), s.quantity() + )) + .toList(); + + List savedItems = orderService.saveAllItems(items); + + return OrderResult.of(order, savedItems); + } + + @Transactional(readOnly = true) + public OrderInfo getOrder(Long orderId, Long userId) { + OrderModel order = orderService.getOrder(orderId, userId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + List orders = orderService.getOrdersByUser(userId, startAt, endAt); + return orders.stream() + .map(OrderInfo::summaryFrom) + .toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderForAdmin(Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + Page orders = orderService.getAllForAdmin(pageable); + return orders.map(OrderInfo::summaryFrom); + } + + private record SnapshotHolder( + Long productId, String productName, Money productPrice, int quantity + ) {} +} +``` + +**Step 5: OrderV1Controller 수정 - OrderFacade만 의존** + +```java +package com.loopers.interfaces.api.order; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class OrderV1Controller { + + private final OrderFacade orderFacade; + private final MemberFacade memberFacade; + + @PostMapping("/api/v1/orders") + public ApiResponse createOrder( + @RequestBody OrderV1Dto.CreateRequest request, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); + return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); + } + + @GetMapping("/api/v1/orders") + public ApiResponse> getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); + + List orders = orderFacade.getOrdersByUser(member.getId(), start, end); + List response = orders.stream() + .map(OrderV1Dto.OrderSummaryResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/api/v1/orders/{orderId}") + public ApiResponse getOrder( + @PathVariable Long orderId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + OrderInfo info = orderFacade.getOrder(orderId, member.getId()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} +``` + +**Step 6: OrderV1Dto 수정 - OrderInfo로 변환** + +```java +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderResult; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest(List items) { + public List toCommands() { + return items.stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + } + } + + public record OrderItemRequest(Long productId, int quantity) {} + + public record OrderResponse( + Long orderId, String status, int totalAmount, + List items, ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), info.status(), info.totalAmount(), items, info.createdAt() + ); + } + + public static OrderResponse fromResult(OrderResult result) { + List items = result.items().stream() + .map(item -> new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + )) + .toList(); + return new OrderResponse( + result.order().getId(), result.order().status().name(), + result.order().totalAmount().value(), items, result.order().getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, String status, int totalAmount, ZonedDateTime createdAt + ) { + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), info.status(), info.totalAmount(), info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() + ); + } + } +} +``` + +**Step 7: OrderAdminV1Controller 수정 - OrderFacade만 의존** + +```java +package com.loopers.interfaces.api.order.admin; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +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/orders") +public class OrderAdminV1Controller { + + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); + return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + OrderInfo info = orderFacade.getOrderForAdmin(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); + } +} +``` + +**Step 8: OrderAdminV1Dto 수정 - OrderInfo로 변환** + +```java +package com.loopers.interfaces.api.order.admin; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderResponse( + Long orderId, Long userId, String status, int totalAmount, + List items, ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), info.userId(), info.status(), + info.totalAmount(), items, info.createdAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, Long userId, String status, int totalAmount, ZonedDateTime createdAt + ) { + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), info.userId(), info.status(), + info.totalAmount(), info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() + ); + } + } +} +``` + +**Step 9: domain/order/OrderService.java 삭제** + +**Step 10: 테스트 실행** + +```bash +./gradlew test --tests "*Order*" +``` +Expected: ALL PASS + +**Step 11: 커밋** + +```bash +git commit -m "refactor: Order 도메인 레이어 분리 - Service를 application으로 이동, validateOwner를 엔티티로 이관, Controller/Dto가 OrderInfo 경유" +``` + +--- + +## Task 7: Member 도메인 + +**문제점:** +1. `MemberAuthService`가 `domain/`에 있지만 application service +2. `OrderV1Controller`가 `MemberAuthService` 직접 호출 → Task 6에서 `MemberFacade.authenticate()` 경유로 변경 +3. `MemberFacade`에 `authenticate()` 메서드 추가 필요 + +**Files:** +- Move: `domain/member/MemberSignupService.java` → `application/member/MemberSignupService.java` +- Move: `domain/member/MemberAuthService.java` → `application/member/MemberAuthService.java` +- Move: `domain/member/MemberPasswordService.java` → `application/member/MemberPasswordService.java` +- Modify: `application/member/MemberFacade.java` (authenticate 추가) +- Move tests: 6개 테스트 파일 이동 + +**Step 1: MemberSignupService를 application으로 이동** + +`application/member/MemberSignupService.java`: +```java +package com.loopers.application.member; +// ... (기존 코드 동일, 패키지만 변경) +``` + +**Step 2: MemberAuthService를 application으로 이동** + +`application/member/MemberAuthService.java`: +```java +package com.loopers.application.member; +// ... (기존 코드 동일, 패키지만 변경) +``` + +**Step 3: MemberPasswordService를 application으로 이동** + +`application/member/MemberPasswordService.java`: +```java +package com.loopers.application.member; +// ... (기존 코드 동일, 패키지만 변경) +``` + +**Step 4: MemberFacade에 authenticate() 추가** + +```java +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberSignupService memberSignupService; + private final MemberAuthService memberAuthService; + private final MemberPasswordService memberPasswordService; + + public MemberInfo signup(String loginId, String password, String name, + LocalDate birthDate, String email) { + MemberModel member = memberSignupService.signup(loginId, password, name, birthDate, email); + return MemberInfo.from(member); + } + + public MemberModel authenticate(String loginId, String password) { + return memberAuthService.authenticate(loginId, password); + } + + public MemberInfo getMyInfo(MemberModel member) { + return MemberInfo.fromWithMaskedName(member); + } + + public void changePassword(MemberModel member, String currentPassword, String newPassword) { + memberPasswordService.changePassword(member, currentPassword, newPassword); + } +} +``` + +**Step 5: domain/member/ 에서 3개 Service 파일 삭제** + +**Step 6: 테스트 패키지 이동 (6개 파일)** +- `MemberSignupServiceTest.java` → `application/member/` +- `MemberSignupServiceIntegrationTest.java` → `application/member/` +- `MemberAuthServiceTest.java` → `application/member/` +- `MemberAuthServiceIntegrationTest.java` → `application/member/` +- `MemberPasswordServiceTest.java` → `application/member/` +- `MemberPasswordServiceIntegrationTest.java` → `application/member/` + +**Step 7: 테스트 실행** + +```bash +./gradlew test --tests "*Member*" +``` +Expected: ALL PASS + +**Step 8: 커밋** + +```bash +git commit -m "refactor: Member 도메인 레이어 분리 - 3개 Service를 application으로 이동, MemberFacade에 authenticate 추가" +``` + +--- + +## Task 8: 전체 테스트 + 레이어 위반 검증 + +**Step 1: 전체 테스트 실행** + +```bash +./gradlew test +``` +Expected: ALL PASS + +**Step 2: 레이어 위반 확인 - domain에 Service가 남아있지 않은지** + +```bash +# domain 패키지에 Service 클래스가 없어야 함 +find apps/commerce-api/src/main/java/com/loopers/domain -name "*Service*.java" -type f +``` +Expected: 0 results + +**Step 3: 레이어 위반 확인 - Controller가 domain Service를 import하지 않는지** + +```bash +# interfaces 패키지에서 domain import 중 Service import가 없어야 함 +grep -r "import com.loopers.domain.*Service" apps/commerce-api/src/main/java/com/loopers/interfaces/ +``` +Expected: 0 results + +**Step 4: 레이어 위반 확인 - Dto가 domain Model을 직접 참조하지 않는지** + +```bash +# Dto 파일에서 domain 모델 import가 없어야 함 (Money 같은 VO는 예외 가능) +grep -r "import com.loopers.domain.*Model" apps/commerce-api/src/main/java/com/loopers/interfaces/ +``` +Expected: MemberModel만 남음 (LoginMember 어노테이션 때문에 Controller에서 사용, 이건 인증 인프라 문제로 별도 처리) + +**Step 5: 최종 커밋** + +```bash +git commit -m "refactor: 아키텍처 레이어 분리 완료 - 전체 테스트 통과 및 레이어 위반 검증" +``` + +--- + +## 참고: 리팩토링 후 의존 방향 + +``` +interfaces/api (Presentation) + ↓ depends on +application (Service + Facade + Info/Result DTOs) + ↓ depends on +domain (Entity + VO + Repository interface) + ↑ implements +infrastructure (JPA Repository 구현체) +``` + +**Controller 의존 규칙:** +- Controller → Facade (다중 도메인 조합이 필요한 경우) +- Controller → Service (단일 도메인이고 Facade가 불필요한 경우) — 현재 해당 없음, 모든 Controller가 Facade 경유 +- Controller ✗ domain Service/Model 직접 참조 금지 + +**Dto 변환 규칙:** +- Dto.from(Info/Result record) — application DTO로부터 변환 +- Dto ✗ from(Entity/Model) 금지 From 76803d96c6f945eae0d44de7b850a17a4f2ec2d3 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 11:36:50 +0900 Subject: [PATCH 50/66] =?UTF-8?q?refactor:=20Stock=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=20StockService=EB=A5=BC=20application=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 파일들의 import를 domain.stock.StockService에서 application.stock.StockService로 수정 Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/order/OrderFacadeIntegrationTest.java | 2 +- .../java/com/loopers/application/product/ProductFacadeTest.java | 2 +- .../loopers/domain/product/ProductServiceIntegrationTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index fdf72c473..1b8660fc2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -8,7 +8,7 @@ import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductService; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; 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 index 311563551..630a5b2a2 100644 --- 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 @@ -7,7 +7,7 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import com.loopers.domain.stock.StockStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index 74174b1ca..0c22ad414 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -3,7 +3,7 @@ import com.loopers.application.product.ProductFacade; import com.loopers.domain.brand.BrandService; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; From 9966b5e2f9766a1055a931d6258a281a8373c60c Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 11:44:01 +0900 Subject: [PATCH 51/66] =?UTF-8?q?refactor:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=20Service=EB=A5=BC=20application=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99,=20Controller/Dto=EA=B0=80=20BrandInfo=20?= =?UTF-8?q?=EA=B2=BD=EC=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/brand/BrandFacade.java | 1 - .../brand/BrandService.java | 5 ++++- .../application/product/ProductFacade.java | 2 +- .../api/brand/BrandV1Controller.java | 10 +++++----- .../interfaces/api/brand/BrandV1Dto.java | 6 +++--- .../brand/admin/BrandAdminV1Controller.java | 18 ++++++++---------- .../api/brand/admin/BrandAdminV1Dto.java | 16 ++++++++-------- .../application/brand/BrandFacadeTest.java | 2 +- .../brand/BrandServiceIntegrationTest.java | 3 ++- .../brand/BrandServiceTest.java | 5 ++++- .../like/LikeFacadeIntegrationTest.java | 2 +- .../order/OrderFacadeIntegrationTest.java | 2 +- .../application/product/ProductFacadeTest.java | 2 +- .../product/ProductServiceIntegrationTest.java | 2 +- 14 files changed, 40 insertions(+), 36 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/brand/BrandService.java (93%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/brand/BrandServiceIntegrationTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/brand/BrandServiceTest.java (98%) 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 index 5344e643d..330e5ab0c 100644 --- 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 @@ -1,6 +1,5 @@ package com.loopers.application.brand; -import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 0691e2de2..69e6a6205 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,5 +1,8 @@ -package com.loopers.domain.brand; +package com.loopers.application.brand; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; 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 index 00145a443..8d58a34ca 100644 --- 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 @@ -1,7 +1,7 @@ package com.loopers.application.product; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index 380c2b8e6..92a9ea7e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -14,11 +14,11 @@ @RequestMapping("/api/v1/brands") public class BrandV1Controller { - private final BrandService brandService; + private final BrandFacade brandFacade; @GetMapping("/{brandId}") public ApiResponse getBrand(@PathVariable Long brandId) { - BrandModel brand = brandService.getBrand(brandId); - return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java index 7f969dcf8..107c8ca6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.brand; -import com.loopers.domain.brand.BrandModel; +import com.loopers.application.brand.BrandInfo; public class BrandV1Dto { @@ -9,8 +9,8 @@ public record BrandResponse( String name, String description ) { - public static BrandResponse from(BrandModel model) { - return new BrandResponse(model.getId(), model.name().value(), model.description()); + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java index 391cb54d1..680103f80 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -1,8 +1,7 @@ package com.loopers.interfaces.api.brand.admin; import com.loopers.application.brand.BrandFacade; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandInfo; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.auth.AdminInfo; import com.loopers.interfaces.auth.AdminUser; @@ -24,7 +23,6 @@ @RequestMapping("/api-admin/v1/brands") public class BrandAdminV1Controller { - private final BrandService brandService; private final BrandFacade brandFacade; @PostMapping @@ -32,8 +30,8 @@ public ApiResponse create( @AdminUser AdminInfo admin, @RequestBody BrandAdminV1Dto.CreateRequest request ) { - BrandModel brand = brandService.register(request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @GetMapping @@ -42,7 +40,7 @@ public ApiResponse> getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - Page result = brandService.getAll(PageRequest.of(page, size)); + Page result = brandFacade.getAll(PageRequest.of(page, size)); return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); } @@ -51,8 +49,8 @@ public ApiResponse getBrand( @AdminUser AdminInfo admin, @PathVariable Long brandId ) { - BrandModel brand = brandService.getBrandForAdmin(brandId); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + BrandInfo info = brandFacade.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @PutMapping("/{brandId}") @@ -61,8 +59,8 @@ public ApiResponse update( @PathVariable Long brandId, @RequestBody BrandAdminV1Dto.UpdateRequest request ) { - BrandModel brand = brandService.update(brandId, request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @DeleteMapping("/{brandId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java index dc88693d4..7ae7e03fe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.brand.admin; -import com.loopers.domain.brand.BrandModel; +import com.loopers.application.brand.BrandInfo; import java.time.ZonedDateTime; @@ -24,14 +24,14 @@ public record BrandResponse( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static BrandResponse from(BrandModel model) { + public static BrandResponse from(BrandInfo info) { return new BrandResponse( - model.getId(), - model.name().value(), - model.description(), - model.getCreatedAt(), - model.getUpdatedAt(), - model.getDeletedAt() + info.id(), + info.name(), + info.description(), + info.createdAt(), + info.updatedAt(), + info.deletedAt() ); } } 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 index 34570fd9e..29f2fbb18 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.application.brand; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandService; import com.loopers.domain.product.ProductService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index 40d981593..dca5344fd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -1,5 +1,6 @@ -package com.loopers.domain.brand; +package com.loopers.application.brand; +import com.loopers.domain.brand.BrandModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index 6577b447d..092ae43ae 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -1,5 +1,8 @@ -package com.loopers.domain.brand; +package com.loopers.application.brand; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index 5e1389470..1ab10d956 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -1,7 +1,7 @@ package com.loopers.application.like; import com.loopers.application.product.ProductFacade; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandService; import com.loopers.domain.like.LikeModel; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 1b8660fc2..0e2971167 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -1,6 +1,6 @@ package com.loopers.application.order; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandService; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; 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 index 630a5b2a2..0aed99123 100644 --- 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 @@ -2,7 +2,7 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandName; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index 0c22ad414..f61792b56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.product; import com.loopers.application.product.ProductFacade; -import com.loopers.domain.brand.BrandService; +import com.loopers.application.brand.BrandService; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; From 77bbc30b28bc75b25bfd2ac3e1f00b91fadeef98 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 11:52:52 +0900 Subject: [PATCH 52/66] =?UTF-8?q?refactor:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=20Service=EB=A5=BC=20application=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99,=20AdminController=EB=8A=94=20Facade?= =?UTF-8?q?=EB=A7=8C=20=EC=9D=98=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/brand/BrandFacade.java | 2 +- .../java/com/loopers/application/like/LikeFacade.java | 2 +- .../com/loopers/application/order/OrderFacade.java | 2 +- .../loopers/application/product/ProductFacade.java | 11 ++++++++++- .../product/ProductService.java | 6 +++++- .../api/product/admin/ProductAdminV1Controller.java | 10 +++------- .../loopers/application/brand/BrandFacadeTest.java | 2 +- .../application/like/LikeFacadeIntegrationTest.java | 2 +- .../application/order/OrderFacadeIntegrationTest.java | 2 +- .../application/product/ProductFacadeTest.java | 2 +- .../product/ProductServiceIntegrationTest.java | 6 ++++-- .../product/ProductServiceTest.java | 6 +++++- 12 files changed, 34 insertions(+), 19 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/product/ProductService.java (93%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/product/ProductServiceIntegrationTest.java (97%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/product/ProductServiceTest.java (98%) 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 index 330e5ab0c..7c643c92e 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.application.brand; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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 index 2c781dffb..2d1784e9b 100644 --- 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 @@ -2,7 +2,7 @@ import com.loopers.domain.like.LikeModel; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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 index 6c2e41d09..23af36c80 100644 --- 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 @@ -5,7 +5,7 @@ import com.loopers.domain.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import lombok.RequiredArgsConstructor; 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 index 8d58a34ca..0b8eb307b 100644 --- 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 @@ -4,7 +4,6 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; @@ -66,6 +65,16 @@ public Page getProductsForAdmin(Long brandId, Pageable pageable) }); } + @Transactional + public ProductDetail update(Long productId, String name, String description, Money price) { + productService.update(productId, name, description, price); + return getProductForAdmin(productId); + } + + public void delete(Long productId) { + productService.delete(productId); + } + private String getBrandName(Long brandId) { try { BrandModel brand = brandService.getBrandForAdmin(brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 702474e28..c21240fee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -1,5 +1,9 @@ -package com.loopers.domain.product; +package com.loopers.application.product; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java index 34b65cbc6..341540a36 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -3,8 +3,6 @@ import com.loopers.application.product.ProductDetail; import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.auth.AdminInfo; import com.loopers.interfaces.auth.AdminUser; @@ -27,14 +25,13 @@ public class ProductAdminV1Controller { private final ProductFacade productFacade; - private final ProductService productService; @PostMapping public ApiResponse create( @AdminUser AdminInfo admin, @RequestBody ProductAdminV1Dto.CreateRequest request ) { - ProductModel product = productFacade.register( + var product = productFacade.register( request.name(), request.description(), new Money(request.price()), request.brandId(), request.initialStock() ); @@ -68,14 +65,13 @@ public ApiResponse update( @PathVariable Long productId, @RequestBody ProductAdminV1Dto.UpdateRequest request ) { - productService.update(productId, request.name(), request.description(), new Money(request.price())); - ProductDetail detail = productFacade.getProductForAdmin(productId); + ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @DeleteMapping("/{productId}") public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { - productService.delete(productId); + productFacade.delete(productId); return ApiResponse.success(); } } 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 index 29f2fbb18..c16164ffa 100644 --- 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 @@ -1,7 +1,7 @@ package com.loopers.application.brand; import com.loopers.application.brand.BrandService; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index 1ab10d956..afee36977 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -5,7 +5,7 @@ import com.loopers.domain.like.LikeModel; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 0e2971167..9d3223298 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -7,7 +7,7 @@ import com.loopers.domain.order.OrderStatus; import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; 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 index 0aed99123..28572b257 100644 --- 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 @@ -5,7 +5,7 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import com.loopers.domain.stock.StockStatus; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java similarity index 97% rename from apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index f61792b56..93b7242f3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -1,7 +1,9 @@ -package com.loopers.domain.product; +package com.loopers.application.product; -import com.loopers.application.product.ProductFacade; import com.loopers.application.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 4510387dd..6a2228024 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -1,5 +1,9 @@ -package com.loopers.domain.product; +package com.loopers.application.product; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; From 008d45d0dcc021450f4aef74be33e89e9cfb2502 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 11:59:15 +0900 Subject: [PATCH 53/66] =?UTF-8?q?refactor:=20Order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=20Service=EB=A5=BC=20application=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99,=20validateOwner=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=9D=B4=EA=B4=80,=20Controller/Dto=EA=B0=80=20Ord?= =?UTF-8?q?erInfo=20=EA=B2=BD=EC=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 32 ++++++++++++- .../loopers/application/order/OrderInfo.java | 45 +++++++++++++++++++ .../order/OrderService.java | 10 +++-- .../com/loopers/domain/order/OrderModel.java | 6 +++ .../api/order/OrderV1Controller.java | 15 +++---- .../interfaces/api/order/OrderV1Dto.java | 45 +++++++------------ .../order/admin/OrderAdminV1Controller.java | 21 +++------ .../api/order/admin/OrderAdminV1Dto.java | 33 +++++--------- .../order/OrderFacadeIntegrationTest.java | 2 +- 9 files changed, 128 insertions(+), 81 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/order/OrderService.java (87%) 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 index 23af36c80..abfb1444f 100644 --- 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 @@ -2,16 +2,18 @@ import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; import com.loopers.application.product.ProductService; -import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; +import com.loopers.domain.stock.StockModel; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -55,6 +57,32 @@ public OrderResult placeOrder(Long userId, List commands) { return OrderResult.of(order, savedItems); } + @Transactional(readOnly = true) + public OrderInfo getOrder(Long orderId, Long userId) { + OrderModel order = orderService.getOrder(orderId, userId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + List orders = orderService.getOrdersByUser(userId, startAt, endAt); + return orders.stream().map(OrderInfo::summaryFrom).toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderForAdmin(Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + Page orders = orderService.getAllForAdmin(pageable); + return orders.map(OrderInfo::summaryFrom); + } + private record SnapshotHolder( Long productId, String productName, Money productPrice, int quantity ) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..08e9ff8ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,45 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt +) { + + public static OrderInfo from(OrderModel order, List items) { + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), itemInfos, order.getCreatedAt() + ); + } + + public static OrderInfo summaryFrom(OrderModel order) { + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), List.of(), order.getCreatedAt() + ); + } + + public record OrderItemInfo( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index d779d2ea1..db03fc5a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -1,5 +1,9 @@ -package com.loopers.domain.order; +package com.loopers.application.order; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -31,9 +35,7 @@ public List saveAllItems(List orderItems) { @Transactional(readOnly = true) public OrderModel getOrder(Long orderId, Long userId) { OrderModel order = findById(orderId); - if (!order.userId().equals(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); - } + order.validateOwner(userId); return order; } 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 index 9c9b89de8..742840c72 100644 --- 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 @@ -52,4 +52,10 @@ public OrderStatus status() { public Money totalAmount() { return totalAmount; } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 8cc7b9b25..4f2f05668 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,12 +1,10 @@ package com.loopers.interfaces.api.order; import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderResult; import com.loopers.domain.member.MemberAuthService; import com.loopers.domain.member.MemberModel; -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -28,7 +26,6 @@ public class OrderV1Controller { private final OrderFacade orderFacade; - private final OrderService orderService; private final MemberAuthService memberAuthService; @PostMapping("/api/v1/orders") @@ -39,7 +36,7 @@ public ApiResponse createOrder( ) { MemberModel member = memberAuthService.authenticate(loginId, password); OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); - return ApiResponse.success(OrderV1Dto.OrderResponse.from(result)); + return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); } @GetMapping("/api/v1/orders") @@ -52,8 +49,7 @@ public ApiResponse> getMyOrders( MemberModel member = memberAuthService.authenticate(loginId, password); ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); - - List orders = orderService.getOrdersByUser(member.getId(), start, end); + List orders = orderFacade.getOrdersByUser(member.getId(), start, end); List response = orders.stream() .map(OrderV1Dto.OrderSummaryResponse::from) .toList(); @@ -67,8 +63,7 @@ public ApiResponse getOrder( @RequestHeader("X-Loopers-LoginPw") String password ) { MemberModel member = memberAuthService.authenticate(loginId, password); - OrderModel order = orderService.getOrder(orderId, member.getId()); - List items = orderService.getOrderItems(orderId); - return ApiResponse.success(OrderV1Dto.OrderResponse.from(order, items)); + OrderInfo info = orderFacade.getOrder(orderId, member.getId()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 548109abf..23da5fcfb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -1,9 +1,8 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderItemCommand; import com.loopers.application.order.OrderResult; -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; import java.time.ZonedDateTime; import java.util.List; @@ -30,29 +29,25 @@ public record OrderResponse( ZonedDateTime createdAt ) { - public static OrderResponse from(OrderResult result) { - List items = result.items().stream() + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() .map(OrderItemResponse::from) .toList(); return new OrderResponse( - result.order().getId(), - result.order().status().name(), - result.order().totalAmount().value(), - items, - result.order().getCreatedAt() + info.orderId(), info.status(), info.totalAmount(), items, info.createdAt() ); } - public static OrderResponse from(OrderModel order, List items) { - List itemResponses = items.stream() - .map(OrderItemResponse::from) + public static OrderResponse fromResult(OrderResult result) { + List items = result.items().stream() + .map(item -> new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + )) .toList(); return new OrderResponse( - order.getId(), - order.status().name(), - order.totalAmount().value(), - itemResponses, - order.getCreatedAt() + result.order().getId(), result.order().status().name(), + result.order().totalAmount().value(), items, result.order().getCreatedAt() ); } } @@ -64,12 +59,9 @@ public record OrderSummaryResponse( ZonedDateTime createdAt ) { - public static OrderSummaryResponse from(OrderModel order) { + public static OrderSummaryResponse from(OrderInfo info) { return new OrderSummaryResponse( - order.getId(), - order.status().name(), - order.totalAmount().value(), - order.getCreatedAt() + info.orderId(), info.status(), info.totalAmount(), info.createdAt() ); } } @@ -82,13 +74,10 @@ public record OrderItemResponse( int subtotal ) { - public static OrderItemResponse from(OrderItemModel item) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { return new OrderItemResponse( - item.productId(), - item.productName(), - item.productPrice().value(), - item.quantity(), - item.subtotal().value() + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java index a174c61e8..caeff7fad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java @@ -1,8 +1,7 @@ package com.loopers.interfaces.api.order.admin; -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -13,31 +12,25 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RequiredArgsConstructor @RestController @RequestMapping("/api-admin/v1/orders") public class OrderAdminV1Controller { - private final OrderService orderService; + private final OrderFacade orderFacade; @GetMapping public ApiResponse> getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - Page orders = orderService.getAllForAdmin(PageRequest.of(page, size)); - Page response = orders.map( - OrderAdminV1Dto.OrderSummaryResponse::from - ); - return ApiResponse.success(response); + Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); + return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); } @GetMapping("/{orderId}") public ApiResponse getOrder(@PathVariable Long orderId) { - OrderModel order = orderService.getOrderForAdmin(orderId); - List items = orderService.getOrderItems(orderId); - return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(order, items)); + OrderInfo info = orderFacade.getOrderForAdmin(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java index d5cf035a1..a7c275e62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java @@ -1,7 +1,6 @@ package com.loopers.interfaces.api.order.admin; -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; +import com.loopers.application.order.OrderInfo; import java.time.ZonedDateTime; import java.util.List; @@ -17,17 +16,13 @@ public record OrderResponse( ZonedDateTime createdAt ) { - public static OrderResponse from(OrderModel order, List items) { - List itemResponses = items.stream() + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() .map(OrderItemResponse::from) .toList(); return new OrderResponse( - order.getId(), - order.userId(), - order.status().name(), - order.totalAmount().value(), - itemResponses, - order.getCreatedAt() + info.orderId(), info.userId(), info.status(), + info.totalAmount(), items, info.createdAt() ); } } @@ -40,13 +35,10 @@ public record OrderSummaryResponse( ZonedDateTime createdAt ) { - public static OrderSummaryResponse from(OrderModel order) { + public static OrderSummaryResponse from(OrderInfo info) { return new OrderSummaryResponse( - order.getId(), - order.userId(), - order.status().name(), - order.totalAmount().value(), - order.getCreatedAt() + info.orderId(), info.userId(), info.status(), + info.totalAmount(), info.createdAt() ); } } @@ -59,13 +51,10 @@ public record OrderItemResponse( int subtotal ) { - public static OrderItemResponse from(OrderItemModel item) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { return new OrderItemResponse( - item.productId(), - item.productName(), - item.productPrice().value(), - item.quantity(), - item.subtotal().value() + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 9d3223298..6c3d2f647 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -3,7 +3,7 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; +import com.loopers.application.order.OrderService; import com.loopers.domain.order.OrderStatus; import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; From e514939d7025a00aaae6b21faeba78ae4372acad Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 12:05:36 +0900 Subject: [PATCH 54/66] =?UTF-8?q?refactor:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?-=203=EA=B0=9C=20Service=EB=A5=BC=20application=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99,=20MemberFacade=EC=97=90=20authe?= =?UTF-8?q?nticate=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../member/MemberAuthService.java | 4 +++- .../com/loopers/application/member/MemberFacade.java | 7 +++++-- .../member/MemberPasswordService.java | 5 ++++- .../member/MemberSignupService.java | 3 ++- .../interfaces/api/order/OrderV1Controller.java | 10 +++++----- .../interfaces/auth/LoginMemberArgumentResolver.java | 2 +- .../member/MemberAuthServiceIntegrationTest.java | 4 +++- .../member/MemberAuthServiceTest.java | 4 +++- .../member/MemberPasswordServiceIntegrationTest.java | 4 +++- .../member/MemberPasswordServiceTest.java | 4 +++- .../member/MemberSignupServiceIntegrationTest.java | 4 +++- .../member/MemberSignupServiceTest.java | 4 +++- 12 files changed, 38 insertions(+), 17 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/member/MemberAuthService.java (87%) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/member/MemberPasswordService.java (86%) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/member/MemberSignupService.java (94%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/member/MemberAuthServiceIntegrationTest.java (96%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/member/MemberAuthServiceTest.java (97%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/member/MemberPasswordServiceIntegrationTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/member/MemberPasswordServiceTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/member/MemberSignupServiceIntegrationTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/member/MemberSignupServiceTest.java (98%) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthService.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java rename to apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthService.java index f90f86e5a..5423755d9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.member; +package com.loopers.application.member; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index b06c9c47d..037977724 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -1,8 +1,6 @@ package com.loopers.application.member; import com.loopers.domain.member.MemberModel; -import com.loopers.domain.member.MemberPasswordService; -import com.loopers.domain.member.MemberSignupService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,6 +11,7 @@ public class MemberFacade { private final MemberSignupService memberSignupService; + private final MemberAuthService memberAuthService; private final MemberPasswordService memberPasswordService; public MemberInfo signup(String loginId, String password, String name, @@ -21,6 +20,10 @@ public MemberInfo signup(String loginId, String password, String name, return MemberInfo.from(member); } + public MemberModel authenticate(String loginId, String password) { + return memberAuthService.authenticate(loginId, password); + } + public MemberInfo getMyInfo(MemberModel member) { return MemberInfo.fromWithMaskedName(member); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberPasswordService.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java rename to apps/commerce-api/src/main/java/com/loopers/application/member/MemberPasswordService.java index 76e9d93f9..bab399a37 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberPasswordService.java @@ -1,5 +1,8 @@ -package com.loopers.domain.member; +package com.loopers.application.member; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.Password; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberSignupService.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java rename to apps/commerce-api/src/main/java/com/loopers/application/member/MemberSignupService.java index 89505937d..95afdde38 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberSignupService.java @@ -1,5 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; +import com.loopers.domain.member.*; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 4f2f05668..ee72a18a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.member.MemberFacade; import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderResult; -import com.loopers.domain.member.MemberAuthService; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; @@ -26,7 +26,7 @@ public class OrderV1Controller { private final OrderFacade orderFacade; - private final MemberAuthService memberAuthService; + private final MemberFacade memberFacade; @PostMapping("/api/v1/orders") public ApiResponse createOrder( @@ -34,7 +34,7 @@ public ApiResponse createOrder( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ) { - MemberModel member = memberAuthService.authenticate(loginId, password); + MemberModel member = memberFacade.authenticate(loginId, password); OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); } @@ -46,7 +46,7 @@ public ApiResponse> getMyOrders( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt ) { - MemberModel member = memberAuthService.authenticate(loginId, password); + MemberModel member = memberFacade.authenticate(loginId, password); ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); List orders = orderFacade.getOrdersByUser(member.getId(), start, end); @@ -62,7 +62,7 @@ public ApiResponse getOrder( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ) { - MemberModel member = memberAuthService.authenticate(loginId, password); + MemberModel member = memberFacade.authenticate(loginId, password); OrderInfo info = orderFacade.getOrder(orderId, member.getId()); return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java index 21898e1b1..7b8ccac5e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.auth; -import com.loopers.domain.member.MemberAuthService; +import com.loopers.application.member.MemberAuthService; import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceIntegrationTest.java similarity index 96% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceIntegrationTest.java index 7700520a7..e128c75a5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceIntegrationTest.java @@ -1,4 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceTest.java similarity index 97% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceTest.java index 42ebd3a1a..131af4cfd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceTest.java @@ -1,4 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; + +import com.loopers.domain.member.*; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceIntegrationTest.java index f09cb43f0..17852eee1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceIntegrationTest.java @@ -1,4 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceTest.java index 04703f1ed..b6e2b187d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceTest.java @@ -1,4 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; + +import com.loopers.domain.member.*; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceIntegrationTest.java index 8fc4fa9ba..1b6a203ce 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceIntegrationTest.java @@ -1,4 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceTest.java index a2441df86..97b113131 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceTest.java @@ -1,4 +1,6 @@ -package com.loopers.domain.member; +package com.loopers.application.member; + +import com.loopers.domain.member.*; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; From 6368b796ac9e58f390cc179801dede539eec4968 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 12:11:41 +0900 Subject: [PATCH 55/66] =?UTF-8?q?refactor:=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=20=EC=9C=84=EB=B0=98=20=EC=88=98=EC=A0=95=20-=20LikeV1Dto?= =?UTF-8?q?=EC=9D=98=20domain=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C=EA=B1=B0,?= =?UTF-8?q?=20MemberFacadeTest=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 8 +- .../application/like/LikeWithProduct.java | 10 +- .../interfaces/api/like/LikeV1Controller.java | 2 +- .../interfaces/api/like/LikeV1Dto.java | 15 +- .../application/member/MemberFacadeTest.java | 5 +- docs/blog/ddd-responsibility-separation.md | 543 ++++++++++++------ 6 files changed, 384 insertions(+), 199 deletions(-) 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 index 2d1784e9b..9c526279e 100644 --- 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 @@ -56,7 +56,13 @@ public Page getMyLikesWithProducts(Long userId, Pageable pageab Page likes = likeService.getMyLikes(userId, pageable); return likes.map(like -> { ProductModel product = productService.getProduct(like.productId()); - return new LikeWithProduct(like, product); + return new LikeWithProduct( + like.getId(), + product.getId(), + product.name(), + product.price().value(), + like.getCreatedAt() + ); }); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java index 5da08528b..0d778e2fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java @@ -1,9 +1,11 @@ package com.loopers.application.like; -import com.loopers.domain.like.LikeModel; -import com.loopers.domain.product.ProductModel; +import java.time.ZonedDateTime; public record LikeWithProduct( - LikeModel like, - ProductModel product + Long likeId, + Long productId, + String productName, + int productPrice, + ZonedDateTime likedAt ) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index cf4ba7de5..da90d2f46 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -48,6 +48,6 @@ public ApiResponse> getMyLikes( } Page likes = likeFacade.getMyLikesWithProducts(member.getId(), PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); - return ApiResponse.success(likes.map(lwp -> LikeV1Dto.LikeResponse.from(lwp.like(), lwp.product()))); + return ApiResponse.success(likes.map(LikeV1Dto.LikeResponse::from)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index b6a8a0aa4..157541905 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -1,7 +1,6 @@ package com.loopers.interfaces.api.like; -import com.loopers.domain.like.LikeModel; -import com.loopers.domain.product.ProductModel; +import com.loopers.application.like.LikeWithProduct; import java.time.ZonedDateTime; @@ -14,13 +13,13 @@ public record LikeResponse( int productPrice, ZonedDateTime likedAt ) { - public static LikeResponse from(LikeModel like, ProductModel product) { + public static LikeResponse from(LikeWithProduct lwp) { return new LikeResponse( - like.getId(), - product.getId(), - product.name(), - product.price().value(), - like.getCreatedAt() + lwp.likeId(), + lwp.productId(), + lwp.productName(), + lwp.productPrice(), + lwp.likedAt() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java index acc6623ed..4d456bdfa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -22,6 +22,9 @@ class MemberFacadeTest { @Mock private MemberSignupService memberSignupService; + @Mock + private MemberAuthService memberAuthService; + @Mock private MemberPasswordService memberPasswordService; @@ -29,7 +32,7 @@ class MemberFacadeTest { @BeforeEach void setUp() { - memberFacade = new MemberFacade(memberSignupService, memberPasswordService); + memberFacade = new MemberFacade(memberSignupService, memberAuthService, memberPasswordService); } @DisplayName("회원가입") diff --git a/docs/blog/ddd-responsibility-separation.md b/docs/blog/ddd-responsibility-separation.md index 59d3c8ba6..dec724328 100644 --- a/docs/blog/ddd-responsibility-separation.md +++ b/docs/blog/ddd-responsibility-separation.md @@ -1,9 +1,10 @@ - +![](https://velog.velcdn.com/images/praesentia-ykm/post/b3979ec4-a67a-4276-8c32-cd22d8e3cbbb/image.png) ## 들어가며 +[이전 글](https://velog.io/@praesentia-ykm)에서 33개의 Q&A로 설계를 먼저 한 이야기를 했다. 이번에는 그 설계 과정에서 가장 머리를 싸맸던 부분에 대해 써보려 한다. -[이전 글](https://velog.io/@praesentia-ykm)에서 33개의 Q&A로 설계를 먼저 한 이야기를 했다. 이번에는 그 설계 과정에서 가장 머리를 싸맸던 부분 — **"이 코드를 어디에 둬야 하는가?"** — 에 대해 써보려 한다. +**"이 코드를 어디에 둬야 하는가?"** -DDD(Domain-Driven Design)를 공부하면 "바운디드 컨텍스트", "어그리게이트", "도메인 서비스" 같은 용어가 쏟아진다. 개념 자체는 어렵지 않다. 문제는 **실제 코드에 적용하려 할 때** 발생한다. +DDD를 공부하면 "바운디드 컨텍스트", "어그리게이트", "도메인 서비스" 같은 용어가 쏟아진다. 개념 자체는 어렵지 않았다. 문제는 **실제 코드에 적용하려 할 때** 발생했다. > "ProductService에 있어야 해, 아니면 ProductFacade에 있어야 해?" > "Stock이랑 Product를 한 테이블에 두면 안 돼?" @@ -11,80 +12,95 @@ DDD(Domain-Driven Design)를 공부하면 "바운디드 컨텍스트", "어그 이런 질문에 "상황에 따라 다릅니다"는 답이 되지 않는다. **어떤 상황에서 어떻게 달라지는지**, 그 갈림길의 기준을 찾고 싶었다. +따라서, 이번엔 DDD 설계 흐름을 10개의 키워드로 정리하고, 각 키워드마다 **"어떤 기준으로 개념을 분리하는가"**에 대한 나의 생각을 표현해보려고 한다. + --- -## 내가 처음 그린 설계 흐름, 그리고 수정 +## DDD 설계 흐름을 그려보자! -DDD를 나름대로 공부하고 처음 머릿속에 그린 설계 흐름은 이랬다. +| # | 단계 | 키워드 | 핵심 질문 | +|---|------|--------|----------| +| 1 | 전략 | 서브도메인 식별 | 이 사업의 핵심은 무엇이고, 어디에 설계 역량을 집중할 것인가? | +| 2 | 전략 | 유비쿼터스 언어 | 이 단어가 이 맥락에서 뭘 의미하나? | +| 3 | 전략 | 바운디드 컨텍스트 | 같은 단어가 다른 의미를 갖는 경계는? | +| 4 | 전략 | 컨텍스트 매핑 | 나눈 컨텍스트들이 서로 어떻게 대화하는가? | +| 5 | 전술 | 어그리게이트 | 이 데이터를 단독으로 다룰 일이 있는가? | +| 6 | 전술 | 엔티티 vs 값 객체 | 이것이 고유 정체성을 갖는가, 속성의 묶음인가? | +| 7 | 전술 | 도메인 이벤트 | 경계를 넘는 통신은 어떻게 하는가? | +| 8 | 전술 | 도메인 서비스 vs 애플리케이션 서비스 | 이 로직이 규칙인가, 절차인가? | +| 9 | 전술 | 리포지토리 경계 | 어그리게이트 루트 단위로만 존재하는가? | +| 10 | 아키텍처 | 레이어 구분 | 의존성 방향이 안쪽을 향하는가? | -``` -1. 최상위 도메인 구분 -2. 유비쿼터스 언어 구분 -3. 바운디드 컨텍스트 구분 -4. 루트 어그리게이트 -5. 레이어 구분 -6. 도메인 서비스 / 비즈니스 서비스 -``` +![](https://velog.velcdn.com/images/praesentia-ykm/post/72623405-b0b5-484b-9ab8-5269dc0d3f63/image.png) -뭔가 그럴듯해 보인다. 그런데 이 흐름대로 설계를 해보니 세 군데서 걸렸다. +1~4는 **"무엇을 나눌 것인가"**(전략), 5~9는 **"나눈 것을 어떻게 구현할 것인가"**(전술), 10은 **"구현물을 어떻게 배치할 것인가"**(아키텍처)다. -### 걸림돌 1 — 유비쿼터스 언어와 바운디드 컨텍스트는 분리할 수 없다 +한 가지 미리 짚어둘 게 있다. **나누기만 하면 부서진다.** 1~4에서 경계를 그으면, 5~9에서 그 경계 사이의 관계를 정의해야 한다. 나누기와 연결하기는 항상 쌍이다. -2번에서 "유비쿼터스 언어를 정리"하고, 3번에서 "바운디드 컨텍스트를 나누자"라고 했는데 — 이 두 개는 순차적으로 할 수 있는 일이 아니었다. +--- -"상품"이라는 단어를 정의하려는 순간, **카탈로그 팀에서의 "상품"과 주문 팀에서의 "상품"이 다르다**는 걸 발견한다. 이 발견 자체가 곧 경계를 긋는 행위다. +## 전략적 설계 — 무엇을 나눌 것인가 -비유를 들자면, 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것이다. "여기서부터 이 언어가 통하지 않는다"를 발견하는 순간이 곧 국경선이 그어지는 순간이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, **언어 차이가 국경을 드러낸다.** +### 1. 서브도메인 식별 — "이 중에 뭐가 제일 중요한가?" -### 걸림돌 2 — 컨텍스트 매핑이 빠져 있었다 +**키워드: Core / Supporting / Generic** -바운디드 컨텍스트를 나눈 건 좋은데, **"나눈 것들이 서로 어떻게 대화하는가?"**를 정의하는 단계가 없었다. 카탈로그와 주문이 분리되었으면 주문할 때 상품 정보를 어떻게 가져올지, 좋아요 수를 어떻게 갱신할지 — 이 통신 방식을 결정하는 게 컨텍스트 매핑이다. +서브도메인 식별은 "사업에서 어떤 영역이 있는가"를 나누고, **"어디에 설계 역량을 집중할 것인가"**를 결정하는 단계다. -이걸 빼먹으면 "잘 나눈 것 같은데 결국 다 얽혀있네?"라는 상황이 된다. +| 유형 | 의미 | 설계 전략 | +|------|------|-----------| +| **Core** | 비즈니스 경쟁력의 핵심 | 직접 설계하고 정교하게 구현 | +| **Supporting** | Core를 보조. 중요하지만 차별화 요소는 아님 | 직접 구현하되 Core만큼의 투자는 불필요 | +| **Generic** | 어디서나 비슷하게 필요한 범용 기능 | 외부 솔루션 사용 가능 | -### 걸림돌 3 — "비즈니스 서비스"라는 건 없다 +이커머스에서 이걸 적용하면: -마지막에 "도메인 서비스 / 비즈니스 서비스"라고 적었는데, DDD에는 "비즈니스 서비스"라는 용어가 없다. 내가 "비즈니스 서비스"라고 부르고 있던 것은 사실 **애플리케이션 서비스**였다. +| 서브도메인 | 유형 | 판단 근거 | +|-----------|------|----------| +| **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | +| **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | +| **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 독자적 경쟁력은 아님 | +| **좋아요** | Supporting | 고객 선호 추적. 카탈로그 정렬(인기순)에 활용 | +| **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | +![](https://velog.velcdn.com/images/praesentia-ykm/post/97df448e-cc08-4ad8-a4eb-b4fdf5b87a0f/image.png) -이름이 중요한 게 아니라고 생각할 수 있지만, 이름이 모호하면 기준도 모호해진다. "비즈니스 서비스에 뭘 넣지?"라는 질문에는 "비즈니스 로직?"이라는 동어반복밖에 나오지 않는다. "애플리케이션 서비스에 뭘 넣지?"라는 질문에는 "유스케이스 절차"라는 명확한 답이 나온다. -### 수정된 흐름 +근데 왜 재고가 Supporting이지? -세 가지를 고치면 이렇게 된다. +처음엔 이렇게 생각했다. 재고는 **"반응하는 도메인"**이다. 주문이 들어오면 차감되고, 상품이 등록되면 생성된다. 스스로 뭔가를 일으키기보다, 다른 도메인의 상태 변경에 영향을 받는 자식도메인 같은 느낌이었다. -``` -수정 전 수정 후 -───────────────── ───────────────── -1. 최상위 도메인 구분 → 1. 서브도메인 식별 + 분류 (Core/Supporting/Generic) -2. 유비쿼터스 언어 구분 ┐ 2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) -3. 바운디드 컨텍스트 구분 ┘ - (빠짐) → 3. 컨텍스트 매핑 (관계 정의) -4. 루트 어그리게이트 → 4. 전술적 설계 (어그리게이트 + 엔티티/VO) -5. 레이어 구분 → 5. 레이어드 아키텍처 -6. 도메인 서비스/비즈니스 서비스 → 6. 도메인 서비스 vs 애플리케이션 서비스 (용어 정정) -``` +근데 이 기준만으로는 부족했다. 주문도 "고객이 구매 버튼을 누르면" 반응하는 도메인인데, Core잖아. "반응한다"는 것만으로 Supporting을 판별할 수 없었다. + +차이는 여기서 갈렸다: **"이 도메인이 없어도 사업이 성립하는가?"** + +- 주문이 없으면? 이커머스가 아니다. 물건을 팔 수 없다. +- 재고가 없으면? 판매는 된다. 다만 관리가 허술해질 뿐이다. 실제로 소규모 쇼핑몰은 재고 관리 없이도 돌아간다. + +**"반응만 하는 도메인 + 없어도 사업이 돌아가면 = Supporting."** 없으면 사업 자체가 불가능하면 아무리 반응형이어도 Core다. -방향 자체가 틀렸던 건 아니다. 다만 2번과 3번이 하나의 동시 과정이라는 것, 컨텍스트 매핑이 빠져 있었다는 것, 용어가 부정확했다는 것 — 이 세 가지를 인식하니 설계가 더 선명해졌다. +이 분류가 코드에 주는 영향은 명확하다. 카탈로그와 주문은 도메인 모델을 정교하게 설계하고, 회원/인증은 `userId`만 받아서 참조한다. 실제로 `MemberFacade`는 단순한 CRUD뿐이고, `OrderFacade`는 재고 차감, 스냅샷 생성, All or Nothing 검증까지 복잡한 규칙이 들어있다. --- -## "상품이 뭔데?" — 바운디드 컨텍스트의 경계를 찾는 법 +### 2. 유비쿼터스 언어 — "이 단어가 이 맥락에서 뭘 의미하나?" -위에서 "언어 차이가 곧 경계"라고 했다. 그 과정을 실제 코드로 보여주겠다. +**키워드: 같은 단어, 다른 의미** + +"상품이 뭔데?"라고 물었을 때, 대답이 달라지는 지점이 있다. 현재 `ProductModel`은 이렇게 생겼다. ```java public class ProductModel extends BaseEntity { - private String name; // ← "상품을 전시한다" 관점 - private String description; // ← "상품을 전시한다" 관점 - private Money price; // ← "상품의 가치를 매긴다" 관점 - private Long brandId; // ← "상품이 어떤 브랜드인지" 관점 - private int likeCount; // ← "상품이 얼마나 인기있는지" 관점 + private String name; // "상품을 전시한다" 관점 + private String description; // "상품을 전시한다" 관점 + private Money price; // "상품의 가치를 매긴다" 관점 + private Long brandId; // "상품이 어떤 브랜드인지" 관점 + private int likeCount; // "상품이 얼마나 인기있는지" 관점 } ``` -하나의 클래스에 다섯 가지 관심사가 공존한다. 이 상태에서 "상품이 뭔데?"라고 물어보면 맥락마다 답이 완전히 다르다. +하나의 클래스에 다섯 가지 관심사가 공존한다. "상품이 뭔데?"라고 물으면 맥락마다 답이 완전히 다르다. | 맥락 | "상품"의 의미 | 관심 있는 속성 | 관심 없는 속성 | |------|-------------|---------------|---------------| @@ -93,82 +109,117 @@ public class ProductModel extends BaseEntity { | **좋아요** | 사용자가 선호를 표현한 대상 | productId (참조만) | name, price, quantity | | **주문** | 거래의 대상 (가격이 확정된 시점) | productId, 주문시점가격, 수량 | 현재가격, 재고, 좋아요 | -같은 "상품"인데 **필요한 속성이 완전히 다르다.** 이 차이가 바운디드 컨텍스트의 경계다. +같은 "상품"인데 **필요한 속성이 완전히 다르다.** 이 차이를 인식하는 것 자체가 유비쿼터스 언어를 정의하는 과정이다. + +그리고 이 과정은 다음 단계인 바운디드 컨텍스트와 **동시에** 일어난다. "상품"이라는 단어의 의미가 달라지는 지점을 발견하는 순간이 곧 경계를 긋는 순간이다. + +비유를 들자면, 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, **언어 차이가 국경을 드러낸다.** + +--- -### 기준을 한 문장으로 요약하면 +### 3. 바운디드 컨텍스트 — "같은 단어가 다른 의미를 갖는 경계는?" -> **같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계** +**키워드: 경계 긋기** -단, 이 기준은 **같은 도메인 용어가 여러 맥락에서 쓰일 때만** 적용된다. 애초에 다른 단어를 쓰는 영역(예: "상품"과 "결제수단")은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다. +유비쿼터스 언어에서 의미가 갈라지는 지점이 바운디드 컨텍스트의 경계다. -### 무의식적으로 이미 적용하고 있었다 +> **판별 기준:** 같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계 -Q&A 브레인스토밍을 하면서 나도 모르게 이 경계를 지키고 있었다. +이 기준은 **동일 도메인 용어가 여러 맥락에서 사용될 때만** 적용된다. 애초에 다른 단어를 쓰는 영역(예: "상품"과 "결제수단")은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다. + +재밌는 건 무의식적으로 이미 이 경계를 지키고 있었다는 것이다. - `LikeModel`은 `ProductModel`을 직접 참조하지 않고 `productId`만 보유한다. - `StockModel`도 `productId`만 보유한다. - `OrderItemModel`에는 주문 시점의 `productName`, `productPrice`를 **스냅샷**으로 복사한다. -즉, 각 도메인은 "상품" 전체를 알 필요 없이 자기에게 필요한 단편만 들고 있다. 이것이 바로 바운디드 컨텍스트 간의 **느슨한 참조(ID 참조)**이고, 의식하든 안 하든 자연스럽게 흘러가는 설계의 방향이었다. +각 도메인은 "상품" 전체를 알 필요 없이 자기에게 필요한 단편만 들고 있다. 이것이 바운디드 컨텍스트 간의 **느슨한 참조(ID 참조)**다. ---- +![](https://velog.velcdn.com/images/praesentia-ykm/post/6a57db0a-1182-4a68-bc92-c64b8ab6fde2/image.png) -## 서브도메인 분류 — "어디에 시간을 쓸 것인가" +근데 "의미가 다르면 나눈다"로만 끝나지 않았다. **"이 둘이 하나의 트랜잭션으로 묶여야 하는가?"**도 경계 판단에 영향을 줬다. -경계를 나눈 다음에는 "이 중에 뭐가 제일 중요한가?"를 판단해야 한다. DDD에서는 이걸 서브도메인 분류라고 부른다. +Brand와 Product가 그 예시다. 직감적으로는 분리하고 싶었다. 브랜드는 브랜드고 상품은 상품이니까. 근데 Q&A 과정에서 이런 질문을 던졌었다. -| 유형 | 의미 | 설계 전략 | -|------|------|-----------| -| **Core** | 비즈니스 경쟁력의 핵심 | 직접 설계하고 정교하게 구현 | -| **Supporting** | Core를 보조. 중요하지만 차별화 요소는 아님 | 직접 구현하되 Core만큼의 투자는 불필요 | -| **Generic** | 어디서나 비슷하게 필요한 범용 기능 | 외부 솔루션 사용 가능 | +> Q1: 브랜드를 삭제하면 소속 상품은 어떻게 되는가? -이커머스에서 이걸 적용하면: +답은 "브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete"이고, 이것은 **하나의 트랜잭션**으로 처리되어야 한다. -| 서브도메인 | 유형 | 왜? | -|-----------|------|-----| -| **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | -| **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | -| **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 그 자체가 경쟁력은 아님 | -| **좋아요** | Supporting | 고객 선호 추적. 카탈로그의 인기순 정렬에 활용 | -| **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | +```java +@Transactional +public void deleteBrand(Long brandId) { + brandService.delete(brandId); + productService.softDeleteByBrandId(brandId); // 연쇄 삭제 — 같은 트랜잭션 +} +``` + +만약 Brand와 Product가 다른 바운디드 컨텍스트에 있다면 이 트랜잭션은 **분산 트랜잭션**이 된다. "브랜드 삭제"라는 단순한 요구사항에 Saga 패턴 같은 복잡도를 도입하는 건 과하다. + +**경계 판단 기준 정리:** +1. 같은 단어가 다른 속성/행위를 요구하면 → 다른 BC +2. 하나의 트랜잭션으로 묶여야 하면 → 같은 BC -이 분류가 코드에 주는 영향은 명확하다. 카탈로그와 주문은 도메인 모델을 정교하게 설계하고, 회원/인증은 `userId`만 받아서 참조한다. 실제로 현재 코드에서 `MemberFacade`는 단순한 CRUD뿐이고, `OrderFacade`는 재고 차감, 스냅샷 생성, All or Nothing 검증까지 복잡한 규칙이 들어있다. +![](https://velog.velcdn.com/images/praesentia-ykm/post/f0268056-f930-42c8-b0e7-d8bf89309f5d/image.png) --- -## 브랜드와 상품이 같은 BC인 이유 — 트랜잭션이 답했다 +### 4. 컨텍스트 매핑 — "나눈 것들이 어떻게 대화하는가?" -바운디드 컨텍스트를 나누다 보면 "이 둘은 같은 데 넣어야 하나, 따로 빼야 하나?"라는 판단이 필요하다. Brand와 Product가 그랬다. +**키워드: 관계 정의** -직감적으로는 분리하고 싶었다. 브랜드는 브랜드고 상품은 상품이니까. 그런데 Q&A 과정에서 이런 질문을 던졌었다. +바운디드 컨텍스트를 나눠 놓고 끝이 아니다. **나눈 것들 사이의 통신 방식을 정해야 한다.** 이걸 빼먹으면 "잘 나눈 것 같은데 결국 다 얽혀있네?"라는 상황이 된다. -> **Q1: 브랜드를 삭제하면 소속 상품은 어떻게 되는가?** +현재 프로젝트의 의존 관계: -답은 "브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete"였고, 이것은 **하나의 트랜잭션**으로 처리되어야 했다. +![](https://velog.velcdn.com/images/praesentia-ykm/post/d8132a29-13a3-415e-b604-2a50c5532bf8/image.png) + +모놀리스에서는 Facade가 다른 도메인의 Service를 직접 호출한다. 여기서 중요한 건 **"지금은 직접 호출하되, 시스템이 커졌을 때 어디서 잘라야 하는가"를 아는 것**이다. + +| 호출 | 방식 | 시스템 분리 시 전환 | +|------|------|-------------------| +| `ProductFacade` → `BrandService` | 직접 호출 | 같은 BC — 분리 불필요 | +| `ProductFacade` → `StockService` | 직접 호출 | API 호출 또는 이벤트 | +| `LikeFacade` → `ProductService` | 직접 호출 | **도메인 이벤트** | +| `OrderFacade` → `ProductService` + `StockService` | 직접 호출 | Saga 패턴 | + +특히 `LikeFacade`가 카탈로그 BC의 엔티티를 직접 수정하는 부분을 보면: ```java -// BrandFacade — 하나의 트랜잭션 안에서 브랜드 + 소속 상품 전체 삭제 -@Transactional -public void deleteBrand(Long brandId) { - brandService.delete(brandId); // 브랜드 soft delete - productService.softDeleteByBrandId(brandId); // 소속 상품 전체 soft delete +// LikeFacade — 좋아요 BC가 카탈로그 BC의 엔티티를 직접 수정 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + // ... 좋아요 로직 + product.incrementLikeCount(); // ← 다른 BC의 엔티티를 직접 변경 } ``` -만약 Brand와 Product가 다른 바운디드 컨텍스트에 있다면 이 트랜잭션은 **분산 트랜잭션**이 된다. 모놀리스에서는 문제없지만, 시스템을 분리하는 순간 Saga 패턴 같은 복잡한 메커니즘이 필요해진다. "브랜드 삭제"라는 단순한 요구사항에 그 복잡도는 과하다. +모놀리스에서는 이게 실용적이다. 하지만 이 코드가 **BC 경계를 넘는 직접 수정**이라는 사실은 인식하고 있어야 한다. 시스템이 커져서 물리적으로 분리할 때, 이 부분은 도메인 이벤트로 전환된다. -**결론:** 하나의 트랜잭션으로 처리되어야 하는 연산이 있으면, 같은 바운디드 컨텍스트에 둔다. +> 좋아요 발생 → `LikeCreatedEvent` 발행 → 카탈로그 BC가 수신 → `likeCount` 갱신 + +"지금은 직접 호출하되, 여기가 나중에 잘라야 할 지점"이라는 걸 아는 것과 모르는 것은 다르다. 컨텍스트 매핑의 가치가 여기에 있다. --- -## 어그리게이트 분리 — Product와 Stock은 왜 따로인가 +## 전술적 설계 — 나눈 것을 어떻게 구현할 것인가 + +### 5. 어그리게이트 — "단독으로 접근할 일이 있는가?" + +**키워드: 접근성과 잠금** 같은 바운디드 컨텍스트 안에서도 "어디까지를 하나의 단위로 묶을 것인가"를 결정해야 한다. 이게 어그리게이트 경계다. -Product와 Stock은 1:1 관계인데, 왜 하나로 합치지 않았을까? +Product와 Stock은 1:1 관계인데, 깊은 관계니까 하나로 합쳐야 하지 않을까? 처음엔 그렇게 생각했다. 근데 직감적으로 **"재고를 보기 위해 매번 상품을 거쳐야 한다면?"**이 걸렸다. 재고 차감은 상품 정보가 필요 없는데, 상품을 통해서만 접근해야 하면 비효율적이지 않은가. -[이전 글](https://velog.io/@praesentia-ykm)의 트레이드오프 분석에서 이미 결정한 내용이지만 DDD 관점에서 다시 한번 증명해보면, 핵심 기준은 **"같이 잠글 필요가 있는가?"**다. +이 직감을 기준으로 정리하면: + +| 케이스 | "단독으로 접근할 일 있나?" | 결론 | +|--------|:---:|------| +| 재고 ← 상품 | 있다 (재고만 차감) | 분리 | +| 상품 ← 브랜드 | 있다 (상품만 조회) | 분리 | +| 주문항목 ← 주문 | 없다 (항상 주문 통해) | 합침 | + +이 기준은 DDD에서 흔히 쓰는 **"같이 잠글 필요가 있는가?"**와 결국 같은 얘기다. | 변경 시나리오 | Product 변경? | Stock 변경? | 결론 | |-------------|:----------:|:----------:|------| @@ -177,12 +228,14 @@ Product와 Stock은 1:1 관계인데, 왜 하나로 합치지 않았을까? | 재고 차감 (주문) | X | O | 독립 | | 상품 등록 (초기 재고 포함) | O | O | Facade에서 조율 | -4개 시나리오 중 3개가 독립적이다. 상품 정보를 수정할 때 재고를 잠글 필요가 없고, 재고를 차감할 때 상품 정보를 잠글 필요가 없다. +![](https://velog.velcdn.com/images/praesentia-ykm/post/d113df10-a09f-4601-aa25-ee039d93a65c/image.png) + + +단독으로 접근이 필요하다는 건 곧 독립적으로 변경된다는 뜻이고, 독립적으로 변경되면 같이 잠글 필요가 없다. **접근성에서 출발해도 잠금에서 출발해도 같은 결론에 도달한다.** -유일하게 둘 다 변경되는 "상품 등록"은 Facade에서 조율한다. 이건 **비즈니스 규칙이 아니라 절차**다. "상품을 등록하면서 초기 재고도 만든다"는 순서의 문제이지, 둘이 반드시 원자적으로 잠겨야 하는 건 아니다. +유일하게 둘 다 변경되는 "상품 등록"은 **비즈니스 규칙이 아니라 절차**다. "상품을 등록하면서 초기 재고도 만든다"는 순서의 문제이지, 둘이 반드시 원자적으로 잠겨야 하는 건 아니다. Facade에서 조율하면 된다. ```java -// ProductFacade — 절차를 조율 @Transactional public ProductModel register(..., Long brandId, int initialStock) { brandService.getBrand(brandId); // 1. 브랜드 존재 확인 @@ -192,131 +245,256 @@ public ProductModel register(..., Long brandId, int initialStock) { } ``` -**어그리게이트 분리 기준을 한 문장으로:** +> **단독으로 접근할 일이 있으면 별도 어그리게이트. 동시 변경이 필요한 경우는 Facade에서 조율.** + +--- + +### 6. 엔티티 vs 값 객체 — "이것이 고유 정체성을 갖는가?" + +**키워드: 정체성 유무** + +어그리게이트 안의 객체는 엔티티(Entity)와 값 객체(Value Object)로 나뉜다. 판별 기준은 하나다. + +> **고유한 식별자(id)가 필요한가? → 엔티티. 속성 값이 같으면 같은 것인가? → 값 객체.** + +"어떤 5000원"인지가 중요하면 엔티티다. "5000원이면 다 같은 5000원"이면 값 객체다. + +```java +// 값 객체 — 5000원이면 다 같은 5000원 +@Embeddable +public class Money { + public static final Money ZERO = new Money(0); + + @Column(name = "price", nullable = false) + private int value; + + public Money(int value) { + if (value < 0) throw new CoreException(ErrorType.BAD_REQUEST, "가격은 음수일 수 없습니다."); + this.value = value; + } + + public Money add(Money other) { return new Money(this.value + other.value); } + public Money multiply(int multiplier) { return new Money(this.value * multiplier); } +} +``` + +```java +// 값 객체 — 같은 이름이면 같은 브랜드명 +@Embeddable +public class BrandName { + @Column(name = "name", nullable = false, unique = true) + private String value; + + public BrandName(String value) { + if (value == null || value.isBlank()) + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + this.value = value; + } +} +``` + +값 객체의 특징 세 가지: +1. **불변(Immutable):** `add()`는 기존 객체를 바꾸지 않고 새 객체를 반환한다 +2. **자기 검증:** 생성 시점에 유효성을 강제한다 (음수 불가, 빈값 불가) +3. **행위 포함 가능:** `Money.multiply()`처럼 해당 값과 관련된 연산을 가질 수 있다 -> **같이 잠글 필요 없으면 별도 어그리게이트. 동시 변경이 필요한 경우는 Facade에서 조율.** +현재 프로젝트의 분류: + +| 객체 | 타입 | 근거 | +|------|------|------| +| `ProductModel` | 엔티티 | 고유 id로 식별. "상품 A"와 "상품 B"는 속성이 같아도 다른 상품 | +| `Money` | 값 객체 | 5000원은 어떤 5000원이든 같은 5000원 | +| `BrandName` | 값 객체 | "나이키"라는 이름은 그 자체로 동일한 의미 | +| `MemberName` | 값 객체 | 이름 값 자체가 의미. `masked()` 행위 메서드 보유 | +| `LoginId` | 값 객체 | 영문+숫자 패턴 검증을 생성 시 강제 | --- -## 도메인 서비스 vs 애플리케이션 서비스 — 가장 많이 헷갈린 구분 +### 7. 도메인 이벤트 — "경계를 넘는 통신을 어떻게 하는가?" + +**키워드: 비동기 통신 / 결합도 제거** -설계하면서 가장 오래 고민한 건 이거다. **"이 로직을 `domain/XxxService`에 둘까, `application/XxxFacade`에 둘까?"** +4번(컨텍스트 매핑)에서 "나눈 것들이 어떻게 대화하는가"를 정했다면, 도메인 이벤트는 그 대화의 **구현 수단** 중 하나다. -### "규칙"인가, "절차"인가 +현재 프로젝트에는 도메인 이벤트가 **하나도 없다.** 모든 컨텍스트 간 통신은 Facade에서 직접 호출한다. -결론부터 말하면, 판별 기준은 하나다. +```java +// 현재: LikeFacade가 ProductService를 직접 호출 +product.incrementLikeCount(); + +// 도메인 이벤트 도입 시: +// 좋아요 생성 → LikeCreatedEvent 발행 → 카탈로그 BC가 수신 → likeCount 갱신 +``` -> **규칙이면 도메인 서비스. 절차면 애플리케이션 서비스.** +왜 지금 도입하지 않았을까? 트레이드오프 판단이었다. -이것만으로는 부족하다. "규칙"과 "절차"가 뭔지를 구분할 수 있어야 한다. +| 기준 | 직접 호출 | 도메인 이벤트 | +|------|----------|-------------| +| 구현 복잡도 | 낮음 | 높음 (이벤트 발행/구독 인프라 필요) | +| 정합성 | 즉시 정합성 | 최종 정합성 (eventual consistency) | +| 결합도 | 높음 (다른 BC 서비스 직접 참조) | 낮음 (이벤트만 알면 됨) | +| 적합한 규모 | 모놀리스 | 마이크로서비스 | + +![](https://velog.velcdn.com/images/praesentia-ykm/post/140597e1-5b12-404b-97b1-9bb7ee0e5cde/image.png) + +모놀리스에서 도메인 이벤트를 도입하면 "아직 필요 없는 복잡도"가 된다. 다만 **어디가 이벤트로 전환될 지점인지**는 컨텍스트 매핑(4번)에서 이미 식별해뒀다. 필요해지는 시점에 전환하면 된다. + +--- + +### 8. 도메인 서비스 vs 애플리케이션 서비스 — "영업부가 알아듣는가?" + +**키워드: 규칙 vs 절차, 그리고 배치** + +"이 로직을 `domain/XxxService`에 둘까, `application/XxxFacade`에 둘까?" — 설계하면서 가장 오래 고민한 질문이다. + +처음에 세운 기준은 이거였다. + +> **규칙은 영업부에 설명해도 알아들을 수 있는 것. 절차는 개발자들만 고민하고 구성해야 하는, 사용자 요청에 대한 시나리오.** + +"같은 이름의 브랜드는 등록할 수 없다" — 영업부도 안다. 규칙이다. → `BrandService` +"브랜드 확인하고, 상품 만들고, 재고 만들어라" — 영업부는 모른다. 개발자가 짠 순서다. 절차다. → `ProductFacade` + +여기까지는 잘 작동했다. 근데 반례가 나왔다. + +> "브랜드를 삭제하면 소속 상품도 같이 삭제한다." + +영업부도 아는 규칙이다. "나이키 브랜드 빼면 나이키 상품 전부 내려야지"라고 말할 수 있다. 근데 이 로직은 `BrandFacade`에 있다. **규칙인데 Facade에 있다.** "규칙이면 도메인 서비스"라는 기준이 깨졌다. + +왜 깨지는가? **"영업부가 알아듣는가?"는 이 로직이 규칙인지 절차인지(WHAT)를 판별하는 데는 정확하지만, 어디에 둘지(WHERE)는 별개의 문제**이기 때문이다. + +그래서 2단계가 필요하다. + +![](https://velog.velcdn.com/images/praesentia-ykm/post/adf659f8-c0a3-4586-99b5-fea7c266d28e/image.png) + +코드로 보면: + +**엔티티 메서드 — 엔티티 하나로 해결되는 규칙:** + +```java +// "재고가 부족하면 차감할 수 없다" — 영업부도 안다, StockModel 하나로 해결 +public void decrease(int amount) { + if (this.quantity < amount) + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + this.quantity -= amount; +} +``` -**규칙(Rule):** "같은 이름의 브랜드는 등록할 수 없다." 이건 사업부에서 정한 비즈니스 제약이다. 이 규칙을 빼면 시스템의 일관성이 깨진다. +**도메인 서비스 — 하나의 도메인 + Repository가 필요한 규칙:** ```java -// BrandService (도메인 서비스) — 규칙을 담는다 +// "같은 이름의 브랜드는 등록할 수 없다" — 영업부도 안다, 하지만 Repository 조회 필요 public BrandModel register(String name, String description) { brandRepository.findByName(name).ifPresent(existing -> { - throw new CoreException(ErrorType.CONFLICT); // ← 비즈니스 규칙 + throw new CoreException(ErrorType.CONFLICT); }); return brandRepository.save(new BrandModel(name, description)); } ``` -**절차(Procedure):** "상품을 등록할 때는 브랜드 존재를 확인하고, 상품을 만들고, 재고를 만든다." 이건 순서다. 각 단계의 규칙은 각자의 도메인 서비스가 갖고 있고, Facade는 "어떤 순서로 호출할지"만 안다. +**Facade (절차) — 영업부가 모르는 개발자의 시나리오:** ```java -// ProductFacade (애플리케이션 서비스) — 절차를 조율한다 +// "브랜드 확인 → 상품 생성 → 재고 생성" — 영업부는 모른다, 개발자가 짠 순서 public ProductModel register(..., Long brandId, int initialStock) { - brandService.getBrand(brandId); // 1. 위임 - ProductModel product = productService.register(...); // 2. 위임 - stockService.create(product.getId(), initialStock); // 3. 위임 - return product; // ← 자체 규칙 없음 + brandService.getBrand(brandId); + ProductModel product = productService.register(...); + stockService.create(product.getId(), initialStock); + return product; } ``` -### 구분의 리트머스 테스트 +**Facade (규칙) — 영업부도 알지만, 여러 도메인에 걸치는 규칙:** -더 실용적인 판별법 네 가지. +```java +// "브랜드 삭제 시 소속 상품도 삭제" — 영업부도 안다, 하지만 Brand + Product 두 도메인에 걸침 +@Transactional +public void deleteBrand(Long brandId) { + brandService.delete(brandId); + productService.softDeleteByBrandId(brandId); // ← 규칙이지만, 여러 도메인이라 Facade +} +``` -| 질문 | 도메인 서비스 | 애플리케이션 서비스 | -|------|-------------|-------------------| -| 자체 비즈니스 규칙이 있는가? | **있다** (유니크 검증, 상태 전이, 음수 방지) | **없다** (위임만 수행) | -| 다른 Service를 조합하는가? | 같은 도메인 내 객체만 다룬다 | **여러 도메인 Service를 조합**한다 | -| `@Transactional` 경계인가? | 아닐 수 있다 | **맞다** (유스케이스 단위) | -| 이걸 제거하면? | **비즈니스 규칙이 깨진다** | 절차가 사라질 뿐, 규칙은 유지된다 | +핵심은 이거다. **Facade에 규칙이 있을 수 있다.** "Facade = 규칙 없음"이 아니라 "Facade = 여러 도메인을 조율하는 곳"이다. 조율 내용이 절차일 수도 있고, 여러 도메인에 걸친 규칙일 수도 있다. -4번이 가장 강력한 리트머스다. `BrandService`를 제거하면 "같은 이름 브랜드 방지" 규칙이 사라진다. `ProductFacade`를 제거하면? 각 도메인 서비스의 규칙은 그대로 남아있고, "이 순서로 호출하는" 편의만 사라진다. +**리트머스 테스트:** -### 판별 범위 한정 +| 질문 | 도메인 서비스 | Facade (절차) | Facade (규칙) | +|------|-------------|-------------|-------------| +| 영업부가 알아듣는가? | Yes | **No** | Yes | +| 도메인 하나로 해결? | Yes (+ Repo) | No | **No** | +| 제거하면 뭐가 깨지는가? | 비즈니스 규칙 | 절차가 사라짐 | **비즈니스 규칙** | +| 예시 | 유니크 검증 | 등록 순서 조율 | 연쇄 삭제 | -이 "규칙 vs 절차" 구분은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 요청/응답 변환)나 Repository(데이터 저장소 접근)에는 적용하지 않는다. Controller에 "규칙이 있느냐"고 물으면 "없다"인데, 그렇다고 Controller가 애플리케이션 서비스인 건 아니다. +**범위 한정:** 이 "영업부 기준 + 도메인 개수 기준"은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. --- -## 컨텍스트 매핑 — 나눈 것들이 대화하는 방법 +### 9. 리포지토리 경계 — "어그리게이트 루트 단위로만 존재하는가?" -바운디드 컨텍스트를 나눠 놓고 끝이 아니다. **"얘네가 서로 어떻게 대화하는가?"**를 정해야 한다. +**키워드: 어그리게이트 루트 = 리포지토리 단위** -현재 프로젝트의 의존 관계를 그려보면: +DDD의 원칙: **리포지토리는 어그리게이트 루트 하나당 하나.** 어그리게이트 내부의 엔티티는 루트를 통해서만 접근한다. -``` - ┌─────────────┐ - │ 회원/인증 BC │ - │ (Generic) │ - └──────┬──────┘ - │ userId - ┌────────┼────────┐ - ▼ ▼ │ - ┌──────────┐ ┌────────┐ │ - │좋아요 BC │ │주문 BC │ │ - │(Support) │ │(Core) │ │ - └────┬─────┘ └───┬────┘ │ - │ │ │ - likeCount│ 재고차감 │ │ - 갱신 │ ▼ │ - │ ┌──────────┐ │ - │ │ 재고 BC │ │ - │ │(Support) │ │ - │ └────┬─────┘ │ - │ │ │ - ▼ productId │ - ┌───────────────┴────────┘ - │ 카탈로그 BC (Core) - │ Brand ── Product - └──────────────────────── +근데 이 원칙을 의도적으로 깬 곳이 있다. + +```java +// OrderService — OrderItemRepository가 별도로 존재 +public class OrderService { + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; // ← 원칙대로라면 없어야 함 +} ``` -모놀리스에서는 Facade가 다른 도메인의 Service를 직접 호출한다. +DDD 정석으로는 `OrderItem`은 `Order` 어그리게이트의 내부 엔티티이므로, `OrderRepository`를 통해서만 접근해야 한다. 그러려면 JPA 연관관계(`@OneToMany`)가 필요하다. 근데 이 프로젝트는 ID 참조만 쓴다. -| 호출 | 방식 | 시스템 분리 시 전환 | -|------|------|-------------------| -| `ProductFacade` → `BrandService` | 직접 호출 | 같은 BC, 분리 불필요 | -| `ProductFacade` → `StockService` | 직접 호출 | API 호출 또는 이벤트 | -| `LikeFacade` → `ProductService` | 직접 호출 | **도메인 이벤트** | -| `OrderFacade` → `ProductService` + `StockService` | 직접 호출 | Saga 패턴 | +왜 JPA 연관관계 대신 ID 참조를 택했을까? 두 방식의 차이를 보면: -여기서 한 가지 설계적 주의 지점이 있다. +| 기준 | ID 참조 | JPA 연관관계 (`@OneToMany`) | +|------|---------|---------------------------| +| 조회 | `findByOrderId(id)` — 명시적 | `order.getItems()` — 암시적 lazy loading | +| N+1 문제 | 없음 | 있음 (fetch join 필요) | +| 양방향 동기화 | 불필요 | 필수 (`item.setOrder(this)` 빠뜨리면 버그) | +| 삭제 | 명시적 delete 호출 | `orphanRemoval`이면 리스트에서 빼는 것만으로 삭제 | +| 테스트 | ID만 넣으면 됨 | 전체 객체 그래프 구성 필요 | -```java -// LikeFacade — 좋아요 BC가 카탈로그 BC의 엔티티를 직접 수정한다 -public void like(Long userId, Long productId) { - ProductModel product = productService.getProduct(productId); - // ... 좋아요 로직 - product.incrementLikeCount(); // ← 다른 BC의 엔티티를 직접 변경 -} -``` +JPA 연관관계의 복잡도는 **실제로 코드에서 터지는 문제들**이다. N+1은 성능 이슈를, 양방향 동기화 누락은 버그를, 암시적 삭제는 데이터 유실을 만든다. -모놀리스에서는 이게 실용적이다. 하지만 이 코드가 **BC 경계를 넘는 직접 수정**이라는 사실은 인식하고 있어야 한다. 시스템이 커져서 물리적으로 분리할 때, 이 부분은 도메인 이벤트로 전환해야 한다. +반대로 DDD 리포지토리 원칙을 깨면 뭐가 터질까? "누군가 `OrderItemRepository`를 직접 호출할 수 있다"는 위험이 생긴다. 하지만 현재는 `OrderService`가 두 Repository를 모두 들고 있고, `OrderItem` 접근은 항상 `OrderService`를 통한다. **서비스 레이어가 접근을 통제하고 있으므로 실질적 위험은 낮다.** -> 좋아요 발생 → `LikeCreatedEvent` 발행 → 카탈로그 BC가 수신 → `likeCount` 갱신 +원칙을 깨도 되는지 판단할 때 세 가지를 물었다: -"지금은 직접 호출하되, 여기가 나중에 잘라야 할 지점"이라는 걸 아는 것과 모르는 것은 다르다. 컨텍스트 매핑의 가치가 여기에 있다. +1. **깨면 실제로 터지는가?** — JPA 연관관계는 실제 버그를 만든다 → 피한다 +2. **다른 수단으로 보호 가능한가?** — 서비스 레이어가 접근 통제 중 → 보호됨 +3. **그 보호를 조직이 유지할 수 있는가?** — 현재 규모에서 감당 가능 → 깨도 된다 + +이 판단은 영원히 유효하진 않다. 팀이 커지면 3번 조건이 깨질 수 있고, 그때는 다른 보호 수단을 마련해야 한다. + +--- + +## 아키텍처 — 구현물을 어떻게 배치할 것인가 + +### 10. 레이어 구분 — "의존성 방향이 안쪽을 향하는가?" + +**키워드: 의존성 방향** + +전략적/전술적 설계가 끝난 후 코드로 옮기는 단계다. 각 레이어의 역할과 의존 방향을 정한다. + +![](https://velog.velcdn.com/images/praesentia-ykm/post/4016c78a-3e3a-4136-a03f-c7b22602d70a/image.png) + + +핵심 규칙: **의존성은 항상 안쪽(domain)을 향한다.** + +- Controller는 Facade를 알지만, Facade는 Controller를 모른다 +- Facade는 Service를 알지만, Service는 Facade를 모른다 +- Service는 Repository Interface를 알지만, JPA 구현체를 모른다 + +Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다. Domain Layer는 "데이터를 저장하고 조회하는 능력"이 필요하지만, "그것이 JPA로 구현되는지 MyBatis로 구현되는지"는 알 필요가 없다. Interface를 domain에 두고 구현체를 infrastructure에 두면, domain은 기술 선택에 의존하지 않는다. --- -## 현재 프로젝트의 전체 배치 +## 최종 설계 -지금까지의 판별 기준을 적용한 결과물이다. +10가지 키워드를 적용한 결과물이다. ### 도메인 서비스 (규칙) @@ -328,34 +506,31 @@ public void like(Long userId, Long productId) { | `LikeService` | 좋아요 등록/취소, 멱등성, 존재 여부 조회 | | `OrderService` | 주문 생성, 총액 계산 | -### 애플리케이션 서비스 (절차) +### 애플리케이션 서비스 (조율) -| 컴포넌트 | 조율하는 절차 | -|---------|------------| -| `BrandFacade` | 브랜드 삭제 → 소속 상품 연쇄 soft delete | -| `ProductFacade` | 브랜드 존재 확인 → 상품 생성 → 재고 생성 | -| `LikeFacade` | 삭제된 상품 체크 → 좋아요 처리 → likeCount 동기화 | -| `OrderFacade` | 상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing) | +| 컴포넌트 | 조율 내용 | 성격 | +|---------|---------|------| +| `BrandFacade` | 브랜드 삭제 → 소속 상품 연쇄 soft delete | 규칙 (여러 도메인에 걸침) | +| `ProductFacade` | 브랜드 존재 확인 → 상품 생성 → 재고 생성 | 절차 | +| `LikeFacade` | 삭제된 상품 체크 → 좋아요 처리 → likeCount 동기화 | 절차 | +| `OrderFacade` | 상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing) | 규칙 (여러 도메인에 걸침) | -모든 Facade에는 공통점이 있다. **자체 규칙이 없고, 여러 도메인 서비스를 순서대로 호출**한다. 각 단계의 비즈니스 규칙은 해당 도메인 서비스가 갖고 있다. +Facade의 공통점은 **여러 도메인 서비스를 조율하는 곳**이라는 점이다. 다만 조율 내용은 두 종류로 나뉜다. 영업부가 모르는 **절차**(등록 순서)와, 영업부도 아는 **규칙이지만 여러 도메인에 걸쳐 있어** 도메인 서비스 하나로는 해결 안 되는 것(연쇄 삭제, All or Nothing). 어느 쪽이든 각 단계의 세부 비즈니스 규칙은 해당 도메인 서비스가 갖고 있다. --- -## 회고 — 나만의 판별 기준 세 줄 - -DDD 책을 읽으면 "컨텍스트 경계를 잘 나누세요", "도메인 서비스에 비즈니스 로직을 두세요"라고 한다. 맞는 말인데, **"잘"이 뭔데?** +## 회고: 설계엔 정답은 없지만 오답은 있다. -이번 프로젝트를 거치며 내가 내린 판별 기준은 세 가지다. +10개의 키워드는 결국 두 가지 행위의 반복이었다: **나누기**와 **연결하기**. -**1. 바운디드 컨텍스트 경계:** -> 같은 단어가 다른 속성/행위를 요구하면 경계다. 하나의 트랜잭션으로 묶여야 하면 같은 BC다. +전략적 설계(1~4)에서 비즈니스 영역을 나누고 그 관계를 정의한다. 전술적 설계(5~9)에서 코드 단위를 나누고 그 통신 방식을 정의한다. 아키텍처(10)에서 배치를 나누고 의존 방향을 정의한다. -**2. 어그리게이트 분리:** -> 같이 잠글 필요가 없으면 별도 어그리게이트. 동시 변경은 Facade에서 조율. +어느 단계에서든 "나누기만 하고 연결하기를 빼먹으면" 시스템이 부서진다. 바운디드 컨텍스트를 나누고 컨텍스트 매핑을 안 하면 "잘 나눈 것 같은데 결국 다 얽혀있네?"가 된다. 어그리게이트를 나누고 Facade 조율을 안 하면 "각각은 깔끔한데 전체 유스케이스가 안 돌아가네?"가 된다. -**3. 도메인 서비스 vs 애플리케이션 서비스:** -> 제거했을 때 비즈니스 규칙이 깨지면 도메인 서비스. 절차만 사라지면 애플리케이션 서비스. +이 과정을 겪으면서 의외였던 것이 두 가지 있었다. -이 세 줄이 모든 경우를 커버하진 않는다. 하지만 "어디에 뭘 둬야 하지?"라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, **기준을 들이대고 검증할 수 있다**는 점에서 설계의 질이 달라졌다. +첫째, **책임 분리는 결국 비즈니스가 결정한다.** 서브도메인은 "없어도 사업이 돌아가는가?"로, 규칙과 절차는 "영업부가 알아듣는가?"로, 어그리게이트는 "단독으로 접근할 비즈니스 시나리오가 있는가?"로 갈린다. 기술적 판단처럼 보이는 것도 출발점은 비즈니스였다. +둘째, **원칙은 깰 수 있다. 단, 조건이 있다.** 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다. +오늘도 느낀 바지만 역시 "은탄환은 없다" 하지만 시작은 "어디에 뭘 둬야 하지?"라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, **키워드를 들이대고 검증할 수 있다**는 점에서 설계의 질이 달라질 수 있다고 생각한다. From 529de580cd6b6eedd4dfa6b989f73c7f2024fe5f Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 12:25:13 +0900 Subject: [PATCH 56/66] =?UTF-8?q?docs:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §2-5 레이어 구조에 Service 추가, Controller 의존 규칙 명시 - §2-6 도메인/애플리케이션 서비스 판별 기준을 '비즈니스 의사결정' 기준으로 변경 - 플로우차트에 Service vs Facade 분기 추가 - 프로젝트 배치 테이블에 전체 14개 컴포넌트 반영 - 클래스 다이어그램 의존 화살표를 Facade→Service→Model 구조로 수정 Co-Authored-By: Claude Opus 4.6 --- .../design/mermaid/00-ddd-design-framework.md | 55 ++++++++++++------- docs/design/mermaid/04-class-diagram.mmd | 24 ++++---- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/docs/design/mermaid/00-ddd-design-framework.md b/docs/design/mermaid/00-ddd-design-framework.md index 9fe9a26fa..cff60cf06 100644 --- a/docs/design/mermaid/00-ddd-design-framework.md +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -102,20 +102,25 @@ ``` interfaces/ → 외부 요청 수신 (Controller, DTO) -application/ → 유스케이스 조율 (Facade) -domain/ → 비즈니스 규칙 (Entity, VO, DomainService) +application/ → 유스케이스 조율 (Facade, Service) +domain/ → 비즈니스 규칙 (Entity, VO, Repository 인터페이스) infrastructure/ → 기술 구현 (JpaRepository) ``` +**Controller 의존 규칙:** +- Controller → Facade 또는 Service (application 레이어만 의존) +- Controller ✗ domain 직접 참조 금지 +- Dto는 application DTO(Info/Result record)로부터 변환 + ### 2-6. 도메인 서비스 vs 애플리케이션 서비스 > "비즈니스 서비스"는 DDD 용어가 아니다. 구분하려는 것은 아래 두 가지다. | | 도메인 서비스 | 애플리케이션 서비스 | |---|---|---| -| **현재 프로젝트** | `domain/XxxService` | `application/XxxFacade` | -| **담는 것** | 비즈니스 **규칙** | 유스케이스 **절차** | -| **판별 질문** | "이 로직이 특정 엔티티 하나의 책임인가?" → No → 도메인 서비스 | "이것은 규칙인가, 절차인가?" → 절차 → 애플리케이션 서비스 | +| **현재 프로젝트** | `domain/` (현재 해당 없음) | `application/XxxService` + `application/XxxFacade` | +| **담는 것** | 비즈니스 **규칙** (엔티티 하나로 표현 불가한) | 유스케이스 **절차** (CRUD 조율 + 다중 도메인 조합) | +| **판별 질문** | "이 코드가 비즈니스 의사결정을 내리는가?" → Yes → 도메인 서비스 | "의사결정을 조율하고 외부와 상호작용하는가?" → Yes → 애플리케이션 서비스 | **적용 범위:** 이 "규칙 vs 절차" 구분은 **도메인 계층과 애플리케이션 계층 사이**에서만 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. @@ -265,33 +270,38 @@ public void like(Long userId, Long productId) { flowchart TD A["로직이 있다"] --> B{"특정 엔티티 하나의 책임인가?"} B -- "Yes" --> C["엔티티 메서드
예: product.incrementLikeCount()"] - B -- "No" --> D{"규칙인가, 절차인가?"} - D -- "규칙" --> E["도메인 서비스
domain/XxxService"] - D -- "절차" --> F["애플리케이션 서비스
application/XxxFacade"] + B -- "No" --> D{"비즈니스 의사결정을 내리는가?"} + D -- "Yes" --> E["도메인 서비스
domain/"] + D -- "No (조율/절차)" --> F{"여러 도메인을 조합하는가?"} + F -- "Yes" --> G["Facade
application/XxxFacade"] + F -- "No (단일 도메인 CRUD)" --> H["Service
application/XxxService"] style C fill:#e8f5e9 style E fill:#e3f2fd - style F fill:#fff3e0 + style G fill:#fff3e0 + style H fill:#fff3e0 ``` ### 코드로 보는 구분 -**도메인 서비스 — 규칙을 담는다:** +**애플리케이션 서비스(Service) — 단일 도메인 CRUD 조율:** ```java -// BrandService: "같은 이름의 브랜드는 등록할 수 없다" +// application/brand/BrandService: Repository를 호출하여 CRUD 수행 +// 중복 이름 체크는 비즈니스 규칙이지만, 엔티티/VO가 판단을 내리고 Service는 조율함 public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); // VO가 이름 유효성 검증 brandRepository.findByName(name).ifPresent(existing -> { - throw new CoreException(ErrorType.CONFLICT); // ← 비즈니스 규칙 + throw new CoreException(ErrorType.CONFLICT); // ← 조율: DB 상태 확인 후 거부 }); - return brandRepository.save(new BrandModel(name, description)); + return brandRepository.save(new BrandModel(brandName, description)); } ``` -**애플리케이션 서비스(Facade) — 절차를 조율한다:** +**애플리케이션 서비스(Facade) — 다중 도메인 조합:** ```java -// ProductFacade: "상품 등록 시 브랜드 확인 → 상품 생성 → 재고 생성" +// application/product/ProductFacade: 여러 Service를 조합하여 유스케이스 실행 public ProductModel register(..., Long brandId, int initialStock) { brandService.getBrand(brandId); // 1. 브랜드 존재 확인 (위임) ProductModel product = productService.register(...); // 2. 상품 생성 (위임) @@ -313,13 +323,20 @@ public ProductModel register(..., Long brandId, int initialStock) { | 컴포넌트 | 계층 | 역할 | |---------|------|------| -| `BrandService` | domain | 브랜드명 유니크 검증, CRUD | -| `ProductService` | domain | 상품 CRUD, likeCount 증감 | -| `StockService` | domain | 재고 생성, 차감(`checkAndDecrease`) | -| `LikeService` | domain | 좋아요 등록/취소, 존재 여부 조회 | +| `ExampleService` | application | 예시 조회 (ExampleFacade 통합) | +| `BrandService` | application | 브랜드명 유니크 검증, CRUD | +| `ProductService` | application | 상품 CRUD | +| `StockService` | application | 재고 생성, 차감 | +| `LikeService` | application | 좋아요 등록/취소, 존재 여부 조회 | +| `OrderService` | application | 주문/주문상품 CRUD, 소유권 검증(엔티티 위임) | +| `MemberSignupService` | application | 회원가입 (중복 ID 체크 + 생성) | +| `MemberAuthService` | application | 인증 (비밀번호 검증은 엔티티 위임) | +| `MemberPasswordService` | application | 비밀번호 변경 (검증은 엔티티/VO 위임) | | `BrandFacade` | application | 삭제 시 소속 상품 연쇄 soft delete | | `ProductFacade` | application | 상품 + Stock 동시 생성, 브랜드 존재 확인 | | `LikeFacade` | application | 삭제된 상품 체크, likeCount 동기화 | +| `OrderFacade` | application | 재고 차감 + 스냅샷 + 주문 생성, 주문 조회 | +| `MemberFacade` | application | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | --- diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd index dbf61bc92..b9c9a3f5d 100644 --- a/docs/design/mermaid/04-class-diagram.mmd +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -83,13 +83,17 @@ classDiagram OrderModel *-- Money OrderItemModel *-- Money - BrandFacade --> BrandModel - ProductFacade --> ProductModel - ProductFacade --> StockModel - LikeFacade --> LikeModel - OrderFacade --> OrderModel - OrderFacade --> OrderItemModel - - BrandFacade --> ProductModel - LikeFacade --> ProductModel - OrderFacade --> StockModel \ No newline at end of file + BrandFacade --> BrandService + BrandService --> BrandModel + ProductFacade --> ProductService + ProductFacade --> StockService + ProductService --> ProductModel + StockService --> StockModel + LikeFacade --> LikeService + LikeFacade --> ProductService + LikeService --> LikeModel + OrderFacade --> OrderService + OrderFacade --> ProductService + OrderFacade --> StockService + OrderService --> OrderModel + OrderService --> OrderItemModel \ No newline at end of file From 9a8aae4c9104d2ead310d9fb79777a89ff7ab324 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 12:28:59 +0900 Subject: [PATCH 57/66] =?UTF-8?q?docs:=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EA=B8=80=EC=97=90=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=ED=9B=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=9C=20=ED=8C=90=EB=8B=A8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §8 판별 기준을 "규칙 vs 절차" → "비즈니스 의사결정을 내리는가?"로 변경 - Service가 domain이 아닌 application 레이어임을 명시 (의사결정은 엔티티/VO가 담당) - 최종 설계를 "도메인(의사결정)" + "애플리케이션(Service+Facade)" 구조로 재분류 - §10 레이어 의존 규칙에 Controller→application만 의존 원칙 추가 - 회고에 "규칙을 담는다 ≠ 의사결정을 내린다" 깨달음 추가 Co-Authored-By: Claude Opus 4.6 --- docs/blog/ddd-responsibility-separation.md | 147 +++++++++++++++------ 1 file changed, 103 insertions(+), 44 deletions(-) diff --git a/docs/blog/ddd-responsibility-separation.md b/docs/blog/ddd-responsibility-separation.md index dec724328..eb22bdd5f 100644 --- a/docs/blog/ddd-responsibility-separation.md +++ b/docs/blog/ddd-responsibility-separation.md @@ -27,7 +27,7 @@ DDD를 공부하면 "바운디드 컨텍스트", "어그리게이트", "도메 | 5 | 전술 | 어그리게이트 | 이 데이터를 단독으로 다룰 일이 있는가? | | 6 | 전술 | 엔티티 vs 값 객체 | 이것이 고유 정체성을 갖는가, 속성의 묶음인가? | | 7 | 전술 | 도메인 이벤트 | 경계를 넘는 통신은 어떻게 하는가? | -| 8 | 전술 | 도메인 서비스 vs 애플리케이션 서비스 | 이 로직이 규칙인가, 절차인가? | +| 8 | 전술 | 도메인 서비스 vs 애플리케이션 서비스 | 이 코드가 비즈니스 의사결정을 내리는가? | | 9 | 전술 | 리포지토리 경계 | 어그리게이트 루트 단위로만 존재하는가? | | 10 | 아키텍처 | 레이어 구분 | 의존성 방향이 안쪽을 향하는가? | @@ -341,11 +341,11 @@ product.incrementLikeCount(); --- -### 8. 도메인 서비스 vs 애플리케이션 서비스 — "영업부가 알아듣는가?" +### 8. 도메인 서비스 vs 애플리케이션 서비스 — "이 코드가 비즈니스 의사결정을 내리는가?" -**키워드: 규칙 vs 절차, 그리고 배치** +**키워드: 의사결정 vs 조율** -"이 로직을 `domain/XxxService`에 둘까, `application/XxxFacade`에 둘까?" — 설계하면서 가장 오래 고민한 질문이다. +"이 로직을 `domain/`에 둘까, `application/`에 둘까?" — 설계하면서 가장 오래 고민한 질문이다. 처음에 세운 기준은 이거였다. @@ -354,24 +354,65 @@ product.incrementLikeCount(); "같은 이름의 브랜드는 등록할 수 없다" — 영업부도 안다. 규칙이다. → `BrandService` "브랜드 확인하고, 상품 만들고, 재고 만들어라" — 영업부는 모른다. 개발자가 짠 순서다. 절차다. → `ProductFacade` -여기까지는 잘 작동했다. 근데 반례가 나왔다. +여기까지는 잘 작동했다. 그리고 `BrandService`는 `domain/` 패키지에 넣었다. "규칙을 담으니까 도메인 서비스"라고 생각했다. -> "브랜드를 삭제하면 소속 상품도 같이 삭제한다." +**근데 이 판단이 틀렸다.** -영업부도 아는 규칙이다. "나이키 브랜드 빼면 나이키 상품 전부 내려야지"라고 말할 수 있다. 근데 이 로직은 `BrandFacade`에 있다. **규칙인데 Facade에 있다.** "규칙이면 도메인 서비스"라는 기준이 깨졌다. +리팩토링 과정에서 더 날카로운 기준을 만났다. -왜 깨지는가? **"영업부가 알아듣는가?"는 이 로직이 규칙인지 절차인지(WHAT)를 판별하는 데는 정확하지만, 어디에 둘지(WHERE)는 별개의 문제**이기 때문이다. +> **"이 코드가 비즈니스 의사결정을 내리는가?"** -그래서 2단계가 필요하다. +이 질문을 `BrandService.register()`에 대입해봤다. + +```java +public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); // ← VO가 이름 유효성 검증 (의사결정) + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // ← DB 상태 확인 후 거부 (조율) + }); + return brandRepository.save(new BrandModel(brandName, description)); +} +``` + +**"브랜드 이름이 비어있으면 안 된다"** — 이건 `BrandName` VO가 생성 시점에 스스로 결정한다. 의사결정이다. +**"중복 이름이 있는지 DB에서 확인한다"** — 이건 `BrandService`가 Repository를 호출해서 상태를 확인하는 것이다. 조율이다. + +핵심은 이거였다. `BrandService`는 **의사결정을 내리는 게 아니라, 엔티티/VO가 내린 의사결정이 실행될 수 있도록 정보를 준비하고 조율하는 것**이다. 3단계로 보면: + +1. **정보 준비** (application) — Repository에서 데이터 조회 +2. **비즈니스 의사결정** (domain) — 엔티티/VO가 규칙을 적용 +3. **결과 적용** (application) — 저장, 이벤트 발행 등 + +`BrandService`의 "유니크 검증"도 결국 1→3이다. DB 상태를 확인(정보 준비)하고 → 충돌이면 거부(결과 적용). **비즈니스 의사결정 자체는 `BrandName` VO가 이미 담당하고 있다.** + +이 기준으로 현재 프로젝트의 모든 Service를 점검했다. + +| Service | 하는 일 | 의사결정을 내리는가? | 결론 | +|---------|--------|:---:|------| +| `BrandService` | 이름 유니크 체크 + CRUD | No (VO가 유효성 검증) | **애플리케이션 서비스** | +| `ProductService` | 상품 CRUD | No (Money VO가 가격 검증) | **애플리케이션 서비스** | +| `StockService` | 재고 생성/차감 | No (StockModel.decrease()가 판단) | **애플리케이션 서비스** | +| `LikeService` | 좋아요 등록/취소 | No (멱등성은 DB 상태 확인) | **애플리케이션 서비스** | +| `OrderService` | 주문/주문상품 CRUD | No (OrderModel.validateOwner()가 판단) | **애플리케이션 서비스** | + +**도메인 서비스가 하나도 없었다.** 모든 비즈니스 의사결정은 엔티티와 VO가 내리고 있었고, Service는 그 결정이 실행되도록 조율하는 역할이었다. 그래서 전부 `domain/` → `application/`으로 이동시켰다. ![](https://velog.velcdn.com/images/praesentia-ykm/post/adf659f8-c0a3-4586-99b5-fea7c266d28e/image.png) +그럼 `application/` 안에서 **Service와 Facade는 어떻게 구분하는가?** + +| 구분 | Service | Facade | +|------|---------|--------| +| **역할** | 단일 도메인 CRUD 조율 | 여러 도메인 Service를 조합 | +| **의존** | Repository (하나) | Service (여러 개) | +| **예시** | `BrandService` (브랜드 CRUD) | `ProductFacade` (브랜드 확인 + 상품 생성 + 재고 생성) | + 코드로 보면: -**엔티티 메서드 — 엔티티 하나로 해결되는 규칙:** +**엔티티 메서드 — 비즈니스 의사결정을 내린다:** ```java -// "재고가 부족하면 차감할 수 없다" — 영업부도 안다, StockModel 하나로 해결 +// "재고가 부족하면 차감할 수 없다" — StockModel이 스스로 판단 public void decrease(int amount) { if (this.quantity < amount) throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); @@ -379,34 +420,36 @@ public void decrease(int amount) { } ``` -**도메인 서비스 — 하나의 도메인 + Repository가 필요한 규칙:** +**Service (애플리케이션) — 단일 도메인 CRUD를 조율한다:** ```java -// "같은 이름의 브랜드는 등록할 수 없다" — 영업부도 안다, 하지만 Repository 조회 필요 +// application/brand/BrandService: Repository를 호출하여 CRUD 수행 +// 의사결정은 BrandName VO가 내리고, Service는 DB 상태 확인 + 저장을 조율 public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); // VO가 이름 유효성 검증 brandRepository.findByName(name).ifPresent(existing -> { - throw new CoreException(ErrorType.CONFLICT); + throw new CoreException(ErrorType.CONFLICT); // DB 상태 확인 후 거부 }); - return brandRepository.save(new BrandModel(name, description)); + return brandRepository.save(new BrandModel(brandName, description)); } ``` -**Facade (절차) — 영업부가 모르는 개발자의 시나리오:** +**Facade (애플리케이션) — 여러 도메인 Service를 조합한다:** ```java -// "브랜드 확인 → 상품 생성 → 재고 생성" — 영업부는 모른다, 개발자가 짠 순서 +// application/product/ProductFacade: 여러 Service를 조합하여 유스케이스 실행 public ProductModel register(..., Long brandId, int initialStock) { - brandService.getBrand(brandId); - ProductModel product = productService.register(...); - stockService.create(product.getId(), initialStock); + brandService.getBrand(brandId); // 1. 브랜드 존재 확인 (위임) + ProductModel product = productService.register(...); // 2. 상품 생성 (위임) + stockService.create(product.getId(), initialStock); // 3. 재고 생성 (위임) return product; } ``` -**Facade (규칙) — 영업부도 알지만, 여러 도메인에 걸치는 규칙:** +**Facade에 규칙이 있을 수도 있다:** ```java -// "브랜드 삭제 시 소속 상품도 삭제" — 영업부도 안다, 하지만 Brand + Product 두 도메인에 걸침 +// "브랜드 삭제 시 소속 상품도 삭제" — 영업부도 아는 규칙이지만, 두 도메인에 걸침 @Transactional public void deleteBrand(Long brandId) { brandService.delete(brandId); @@ -414,18 +457,11 @@ public void deleteBrand(Long brandId) { } ``` -핵심은 이거다. **Facade에 규칙이 있을 수 있다.** "Facade = 규칙 없음"이 아니라 "Facade = 여러 도메인을 조율하는 곳"이다. 조율 내용이 절차일 수도 있고, 여러 도메인에 걸친 규칙일 수도 있다. - -**리트머스 테스트:** +이전에 "규칙이면 도메인 서비스"라고 단순하게 분류했던 것이 틀렸다. **핵심 기준은 "규칙인가 절차인가"가 아니라 "비즈니스 의사결정을 내리는가"다.** 그리고 이 프로젝트에서는 모든 의사결정이 엔티티/VO 안에 잘 캡슐화되어 있었기에, 도메인 서비스가 필요한 경우가 없었다. -| 질문 | 도메인 서비스 | Facade (절차) | Facade (규칙) | -|------|-------------|-------------|-------------| -| 영업부가 알아듣는가? | Yes | **No** | Yes | -| 도메인 하나로 해결? | Yes (+ Repo) | No | **No** | -| 제거하면 뭐가 깨지는가? | 비즈니스 규칙 | 절차가 사라짐 | **비즈니스 규칙** | -| 예시 | 유니크 검증 | 등록 순서 조율 | 연쇄 삭제 | +**도메인 서비스가 필요한 경우는?** 두 개 이상의 어그리게이트에 걸친 비즈니스 의사결정을 내려야 하는데, 어느 한쪽 엔티티에 넣기 어려운 경우다. 예를 들어 "이체 가능 여부를 출금 계좌와 입금 계좌의 상태를 종합해 판단하는" 같은 로직이 그렇다. 현재 프로젝트에는 이런 케이스가 없었다. -**범위 한정:** 이 "영업부 기준 + 도메인 개수 기준"은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. +**범위 한정:** 이 "의사결정" 기준은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. --- @@ -484,8 +520,9 @@ JPA 연관관계의 복잡도는 **실제로 코드에서 터지는 문제들** 핵심 규칙: **의존성은 항상 안쪽(domain)을 향한다.** -- Controller는 Facade를 알지만, Facade는 Controller를 모른다 -- Facade는 Service를 알지만, Service는 Facade를 모른다 +- Controller는 Facade/Service(application)를 알지만, application은 Controller를 모른다 +- Controller는 domain을 직접 참조하지 않는다. Dto도 application DTO(Info/Result record)를 경유한다 +- Facade는 여러 Service를 알지만, Service는 Facade를 모른다 - Service는 Repository Interface를 알지만, JPA 구현체를 모른다 Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다. Domain Layer는 "데이터를 저장하고 조회하는 능력"이 필요하지만, "그것이 JPA로 구현되는지 MyBatis로 구현되는지"는 알 필요가 없다. Interface를 domain에 두고 구현체를 infrastructure에 두면, domain은 기술 선택에 의존하지 않는다. @@ -496,17 +533,36 @@ Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다 10가지 키워드를 적용한 결과물이다. -### 도메인 서비스 (규칙) +### 도메인 — 비즈니스 의사결정 -| 컴포넌트 | 담당 규칙 | +| 컴포넌트 | 담당하는 의사결정 | |---------|---------| -| `BrandService` | 브랜드명 유니크 검증, CRUD | +| `BrandName` (VO) | 브랜드 이름 유효성 (비어있으면 안 됨) | +| `Money` (VO) | 가격 유효성 (음수 불가), 연산 (`add`, `multiply`) | +| `LoginId` (VO) | 로그인 ID 형식 검증 (영문+숫자 패턴) | +| `MemberName` (VO) | 이름 유효성, 마스킹 (`masked()`) | +| `StockModel` (Entity) | 재고 충분 여부 판단, 차감/증가 (`decrease`, `increase`) | +| `OrderModel` (Entity) | 주문 소유권 검증 (`validateOwner`), 상태 전이 | +| `ProductModel` (Entity) | likeCount 증감, 삭제 상태 검증 | + +현재 프로젝트에는 **도메인 서비스가 없다.** 모든 비즈니스 의사결정이 엔티티와 VO 안에 캡슐화되어 있기 때문이다. + +### 애플리케이션 — 조율 (Service + Facade) + +**Service — 단일 도메인 CRUD 조율:** + +| 컴포넌트 | 조율 내용 | +|---------|---------| +| `BrandService` | 브랜드명 유니크 체크 + CRUD (이름 유효성은 `BrandName` VO가 판단) | | `ProductService` | 상품 CRUD, likeCount 증감, soft delete | -| `StockService` | 재고 생성, `checkAndDecrease` (음수 방지) | +| `StockService` | 재고 생성, 차감 (부족 여부는 `StockModel`이 판단) | | `LikeService` | 좋아요 등록/취소, 멱등성, 존재 여부 조회 | -| `OrderService` | 주문 생성, 총액 계산 | +| `OrderService` | 주문/주문상품 CRUD (소유권 검증은 `OrderModel`이 판단) | +| `MemberSignupService` | 회원가입 (ID 중복 체크 + 생성, 형식 검증은 `LoginId` VO가 판단) | +| `MemberAuthService` | 인증 (비밀번호 검증은 엔티티에 위임) | +| `MemberPasswordService` | 비밀번호 변경 (검증은 엔티티/VO에 위임) | -### 애플리케이션 서비스 (조율) +**Facade — 여러 도메인 Service를 조합:** | 컴포넌트 | 조율 내용 | 성격 | |---------|---------|------| @@ -514,8 +570,9 @@ Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다 | `ProductFacade` | 브랜드 존재 확인 → 상품 생성 → 재고 생성 | 절차 | | `LikeFacade` | 삭제된 상품 체크 → 좋아요 처리 → likeCount 동기화 | 절차 | | `OrderFacade` | 상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing) | 규칙 (여러 도메인에 걸침) | +| `MemberFacade` | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | 절차 | -Facade의 공통점은 **여러 도메인 서비스를 조율하는 곳**이라는 점이다. 다만 조율 내용은 두 종류로 나뉜다. 영업부가 모르는 **절차**(등록 순서)와, 영업부도 아는 **규칙이지만 여러 도메인에 걸쳐 있어** 도메인 서비스 하나로는 해결 안 되는 것(연쇄 삭제, All or Nothing). 어느 쪽이든 각 단계의 세부 비즈니스 규칙은 해당 도메인 서비스가 갖고 있다. +Service와 Facade 모두 `application/` 레이어에 있다. 차이는 **의존 범위**다. Service는 자기 도메인의 Repository 하나만 의존하고, Facade는 여러 Service를 조합한다. 어느 쪽이든 비즈니스 의사결정 자체는 엔티티/VO가 내리고, application 레이어는 그 결정이 실행되도록 정보를 준비하고 결과를 적용한다. --- @@ -527,10 +584,12 @@ Facade의 공통점은 **여러 도메인 서비스를 조율하는 곳**이라 어느 단계에서든 "나누기만 하고 연결하기를 빼먹으면" 시스템이 부서진다. 바운디드 컨텍스트를 나누고 컨텍스트 매핑을 안 하면 "잘 나눈 것 같은데 결국 다 얽혀있네?"가 된다. 어그리게이트를 나누고 Facade 조율을 안 하면 "각각은 깔끔한데 전체 유스케이스가 안 돌아가네?"가 된다. -이 과정을 겪으면서 의외였던 것이 두 가지 있었다. +이 과정을 겪으면서 의외였던 것이 세 가지 있었다. + +첫째, **책임 분리는 결국 비즈니스가 결정한다.** 서브도메인은 "없어도 사업이 돌아가는가?"로, 어그리게이트는 "단독으로 접근할 비즈니스 시나리오가 있는가?"로 갈린다. 기술적 판단처럼 보이는 것도 출발점은 비즈니스였다. -첫째, **책임 분리는 결국 비즈니스가 결정한다.** 서브도메인은 "없어도 사업이 돌아가는가?"로, 규칙과 절차는 "영업부가 알아듣는가?"로, 어그리게이트는 "단독으로 접근할 비즈니스 시나리오가 있는가?"로 갈린다. 기술적 판단처럼 보이는 것도 출발점은 비즈니스였다. +둘째, **"규칙을 담는다"와 "의사결정을 내린다"는 다르다.** 처음에 `BrandService`가 "중복 이름 검증"이라는 규칙을 담고 있으니 도메인 서비스라고 생각했다. 하지만 리팩토링 후 깨달았다. Service가 하는 건 DB 상태를 확인하고 결과를 적용하는 **조율**이다. 실제 의사결정("이 이름이 유효한가?")은 `BrandName` VO가 내린다. "규칙이 있으면 도메인 서비스"가 아니라 **"비즈니스 의사결정을 직접 내리면 도메인 서비스"**다. 이 차이를 모르면 Service를 전부 `domain/`에 두는 실수를 하게 된다 — 실제로 내가 그랬다. -둘째, **원칙은 깰 수 있다. 단, 조건이 있다.** 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다. +셋째, **원칙은 깰 수 있다. 단, 조건이 있다.** 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다. 오늘도 느낀 바지만 역시 "은탄환은 없다" 하지만 시작은 "어디에 뭘 둬야 하지?"라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, **키워드를 들이대고 검증할 수 있다**는 점에서 설계의 질이 달라질 수 있다고 생각한다. From a349ab0794f01ea179ab42fb95f42918eed1c879 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 13:05:49 +0900 Subject: [PATCH 58/66] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4(LikeT?= =?UTF-8?q?oggleService)=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LikeToggleService (domain): Like+Product 두 엔티티 상태를 종합하여 좋아요 반응 결정 - 순수 비즈니스 의사결정만 담당 (인프라 의존 없음, @Component 등록) - 신규 생성 / 복구 / 멱등 무시 3가지 분기 판단 - LikeFacade (application): 데이터 조회/저장만 담당, 판단은 도메인 서비스에 위임 - LikeToggleServiceTest: DB 없는 순수 단위 테스트 4개 추가 - 블로그/설계 문서에 도메인 서비스 내용 반영 Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 24 ++-- .../domain/like/LikeToggleService.java | 46 +++++++ .../domain/like/LikeToggleServiceTest.java | 112 ++++++++++++++++++ docs/blog/ddd-responsibility-separation.md | 51 +++++++- .../design/mermaid/00-ddd-design-framework.md | 7 +- docs/design/mermaid/04-class-diagram.mmd | 10 +- 6 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java 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 index 9c526279e..243a7e9a8 100644 --- 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 @@ -1,6 +1,7 @@ package com.loopers.application.like; import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeToggleService; import com.loopers.domain.product.ProductModel; import com.loopers.application.product.ProductService; import lombok.RequiredArgsConstructor; @@ -17,33 +18,24 @@ public class LikeFacade { private final LikeService likeService; private final ProductService productService; + private final LikeToggleService likeToggleService; @Transactional public void like(Long userId, Long productId) { ProductModel product = productService.getProduct(productId); - Optional existing = likeService.findByUserIdAndProductId(userId, productId); - if (existing.isEmpty()) { - likeService.save(new LikeModel(userId, productId)); - product.incrementLikeCount(); - } else if (existing.get().getDeletedAt() != null) { - existing.get().restore(); - product.incrementLikeCount(); - } - // else: 이미 활성 좋아요 존재 → 멱등, 아무것도 안 함 + Optional newLike = likeToggleService.like(existing, product, userId, productId); + newLike.ifPresent(likeService::save); } @Transactional public void unlike(Long userId, Long productId) { - Optional existing = likeService.findActiveLike(userId, productId); + Optional activeLike = likeService.findActiveLike(userId, productId); + if (activeLike.isEmpty()) return; - if (existing.isPresent()) { - existing.get().delete(); - ProductModel product = productService.getProduct(existing.get().productId()); - product.decrementLikeCount(); - } - // else: 좋아요가 없음 → 멱등, 아무것도 안 함 + ProductModel product = productService.getProduct(activeLike.get().productId()); + likeToggleService.unlike(activeLike.get(), product); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java new file mode 100644 index 000000000..70cc1f8d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductModel; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 좋아요 토글 도메인 서비스. + * Like와 Product 두 엔티티의 상태를 종합하여 좋아요 반응을 결정한다. + * + * 인프라(Repository, DB) 의존 없이 순수 비즈니스 의사결정만 담당. + */ +@Component +public class LikeToggleService { + + /** + * 좋아요 토글: 상태에 따라 신규 생성 / 복구 / 멱등 무시를 결정한다. + * + * @return 새로 생성된 LikeModel (저장 필요), 또는 empty (기존 엔티티 변경만 발생) + */ + public Optional like(Optional existing, ProductModel product, + Long userId, Long productId) { + if (existing.isEmpty()) { + product.incrementLikeCount(); + return Optional.of(new LikeModel(userId, productId)); + } + + LikeModel like = existing.get(); + if (like.getDeletedAt() != null) { + like.restore(); + product.incrementLikeCount(); + } + // else: 이미 활성 좋아요 → 멱등 무시 + + return Optional.empty(); + } + + /** + * 좋아요 취소: 활성 좋아요를 삭제하고 likeCount를 감소시킨다. + */ + public void unlike(LikeModel like, ProductModel product) { + like.delete(); + product.decrementLikeCount(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java new file mode 100644 index 000000000..8fe913f02 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java @@ -0,0 +1,112 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class LikeToggleServiceTest { + + private LikeToggleService likeToggleService; + + @BeforeEach + void setUp() { + likeToggleService = new LikeToggleService(); + } + + private ProductModel createProduct() { + return new ProductModel("에어맥스", "설명", new Money(129000), 1L); + } + + @DisplayName("좋아요 토글 - like") + @Nested + class Like { + + @DisplayName("좋아요가 없으면 새로 생성하고 likeCount를 증가시킨다") + @Test + void createsNewLikeWhenNoneExists() { + // given + ProductModel product = createProduct(); + Optional existing = Optional.empty(); + + // when + Optional result = likeToggleService.like(existing, product, 1L, 100L); + + // then + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().userId()).isEqualTo(1L), + () -> assertThat(result.get().productId()).isEqualTo(100L), + () -> assertThat(product.likeCount()).isEqualTo(1) + ); + } + + @DisplayName("삭제된 좋아요가 있으면 복구하고 likeCount를 증가시킨다") + @Test + void restoresDeletedLike() { + // given + ProductModel product = createProduct(); + LikeModel deletedLike = new LikeModel(1L, 100L); + deletedLike.delete(); + Optional existing = Optional.of(deletedLike); + + // when + Optional result = likeToggleService.like(existing, product, 1L, 100L); + + // then + assertAll( + () -> assertThat(result).isEmpty(), + () -> assertThat(deletedLike.getDeletedAt()).isNull(), + () -> assertThat(product.likeCount()).isEqualTo(1) + ); + } + + @DisplayName("이미 활성 좋아요가 있으면 아무것도 하지 않는다 (멱등)") + @Test + void skipsWhenAlreadyActive() { + // given + ProductModel product = createProduct(); + LikeModel activeLike = new LikeModel(1L, 100L); + Optional existing = Optional.of(activeLike); + + // when + Optional result = likeToggleService.like(existing, product, 1L, 100L); + + // then + assertAll( + () -> assertThat(result).isEmpty(), + () -> assertThat(product.likeCount()).isEqualTo(0) + ); + } + } + + @DisplayName("좋아요 토글 - unlike") + @Nested + class Unlike { + + @DisplayName("활성 좋아요를 삭제하고 likeCount를 감소시킨다") + @Test + void deletesLikeAndDecrementsCount() { + // given + ProductModel product = createProduct(); + product.incrementLikeCount(); + LikeModel activeLike = new LikeModel(1L, 100L); + + // when + likeToggleService.unlike(activeLike, product); + + // then + assertAll( + () -> assertThat(activeLike.getDeletedAt()).isNotNull(), + () -> assertThat(product.likeCount()).isEqualTo(0) + ); + } + } +} diff --git a/docs/blog/ddd-responsibility-separation.md b/docs/blog/ddd-responsibility-separation.md index eb22bdd5f..0199843a1 100644 --- a/docs/blog/ddd-responsibility-separation.md +++ b/docs/blog/ddd-responsibility-separation.md @@ -457,9 +457,45 @@ public void deleteBrand(Long brandId) { } ``` -이전에 "규칙이면 도메인 서비스"라고 단순하게 분류했던 것이 틀렸다. **핵심 기준은 "규칙인가 절차인가"가 아니라 "비즈니스 의사결정을 내리는가"다.** 그리고 이 프로젝트에서는 모든 의사결정이 엔티티/VO 안에 잘 캡슐화되어 있었기에, 도메인 서비스가 필요한 경우가 없었다. +이전에 "규칙이면 도메인 서비스"라고 단순하게 분류했던 것이 틀렸다. **핵심 기준은 "규칙인가 절차인가"가 아니라 "비즈니스 의사결정을 내리는가"다.** 대부분의 의사결정은 엔티티/VO 안에 캡슐화되어 있지만, 하나 발견했다. -**도메인 서비스가 필요한 경우는?** 두 개 이상의 어그리게이트에 걸친 비즈니스 의사결정을 내려야 하는데, 어느 한쪽 엔티티에 넣기 어려운 경우다. 예를 들어 "이체 가능 여부를 출금 계좌와 입금 계좌의 상태를 종합해 판단하는" 같은 로직이 그렇다. 현재 프로젝트에는 이런 케이스가 없었다. +**LikeFacade에 숨어있던 도메인 서비스:** + +좋아요 멱등성 로직은 `LikeModel`과 `ProductModel` 두 엔티티의 상태를 종합해서 판단한다. "좋아요가 없으면 생성, 삭제됐으면 복구, 이미 있으면 무시" — 이 판단을 어느 한쪽 엔티티에 넣을 수 없다. `LikeModel`은 `ProductModel`을 모르고, `ProductModel`은 `LikeModel`을 모른다. + +이 로직을 지우면 **"멱등성 있는 좋아요"라는 비즈니스 규칙 자체가 사라진다.** 조율이 아니라 의사결정이다. 그래서 도메인 서비스로 분리했다. + +```java +// domain/like/LikeToggleService — 도메인 서비스 +// Like + Product 두 엔티티의 상태를 종합하여 판단. 인프라 의존 없음. +public Optional like(Optional existing, ProductModel product, + Long userId, Long productId) { + if (existing.isEmpty()) { // 판단: 신규 → 생성 + 카운트 증가 + product.incrementLikeCount(); + return Optional.of(new LikeModel(userId, productId)); + } + if (existing.get().getDeletedAt() != null) { // 판단: 삭제됨 → 복구 + 카운트 증가 + existing.get().restore(); + product.incrementLikeCount(); + } + // else: 이미 활성 → 멱등 무시 + return Optional.empty(); +} +``` + +```java +// application/like/LikeFacade — 애플리케이션 서비스 (조율만) +// 데이터 조회 → 도메인 서비스에 판단 위임 → 결과 저장 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + Optional newLike = likeToggleService.like(existing, product, userId, productId); + newLike.ifPresent(likeService::save); +} +``` + +**도메인 서비스가 필요한 조건:** 두 개 이상의 어그리게이트에 걸친 비즈니스 의사결정을 내려야 하는데, 어느 한쪽 엔티티에 넣기 어려운 경우다. `LikeToggleService`가 정확히 이 케이스다. **범위 한정:** 이 "의사결정" 기준은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. @@ -544,8 +580,9 @@ Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다 | `StockModel` (Entity) | 재고 충분 여부 판단, 차감/증가 (`decrease`, `increase`) | | `OrderModel` (Entity) | 주문 소유권 검증 (`validateOwner`), 상태 전이 | | `ProductModel` (Entity) | likeCount 증감, 삭제 상태 검증 | +| `LikeToggleService` (도메인 서비스) | Like + Product 두 엔티티의 상태를 종합하여 좋아요 반응 결정 (신규/복구/멱등 무시) | -현재 프로젝트에는 **도메인 서비스가 없다.** 모든 비즈니스 의사결정이 엔티티와 VO 안에 캡슐화되어 있기 때문이다. +대부분의 비즈니스 의사결정은 엔티티와 VO 안에 캡슐화되어 있다. 하지만 **좋아요 멱등성 판단**은 `LikeModel`과 `ProductModel` 두 엔티티의 상태를 종합해야 하므로 어느 한쪽 엔티티에 넣을 수 없다. 이것이 `LikeToggleService` 도메인 서비스가 필요한 이유다. ### 애플리케이션 — 조율 (Service + Facade) @@ -556,7 +593,7 @@ Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다 | `BrandService` | 브랜드명 유니크 체크 + CRUD (이름 유효성은 `BrandName` VO가 판단) | | `ProductService` | 상품 CRUD, likeCount 증감, soft delete | | `StockService` | 재고 생성, 차감 (부족 여부는 `StockModel`이 판단) | -| `LikeService` | 좋아요 등록/취소, 멱등성, 존재 여부 조회 | +| `LikeService` | 좋아요 저장/조회 (멱등성 판단은 `LikeToggleService` 도메인 서비스가 담당) | | `OrderService` | 주문/주문상품 CRUD (소유권 검증은 `OrderModel`이 판단) | | `MemberSignupService` | 회원가입 (ID 중복 체크 + 생성, 형식 검증은 `LoginId` VO가 판단) | | `MemberAuthService` | 인증 (비밀번호 검증은 엔티티에 위임) | @@ -568,7 +605,7 @@ Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다 |---------|---------|------| | `BrandFacade` | 브랜드 삭제 → 소속 상품 연쇄 soft delete | 규칙 (여러 도메인에 걸침) | | `ProductFacade` | 브랜드 존재 확인 → 상품 생성 → 재고 생성 | 절차 | -| `LikeFacade` | 삭제된 상품 체크 → 좋아요 처리 → likeCount 동기화 | 절차 | +| `LikeFacade` | 데이터 조회 → `LikeToggleService`에 판단 위임 → 결과 저장 | 절차 | | `OrderFacade` | 상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing) | 규칙 (여러 도메인에 걸침) | | `MemberFacade` | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | 절차 | @@ -590,6 +627,8 @@ Service와 Facade 모두 `application/` 레이어에 있다. 차이는 **의존 둘째, **"규칙을 담는다"와 "의사결정을 내린다"는 다르다.** 처음에 `BrandService`가 "중복 이름 검증"이라는 규칙을 담고 있으니 도메인 서비스라고 생각했다. 하지만 리팩토링 후 깨달았다. Service가 하는 건 DB 상태를 확인하고 결과를 적용하는 **조율**이다. 실제 의사결정("이 이름이 유효한가?")은 `BrandName` VO가 내린다. "규칙이 있으면 도메인 서비스"가 아니라 **"비즈니스 의사결정을 직접 내리면 도메인 서비스"**다. 이 차이를 모르면 Service를 전부 `domain/`에 두는 실수를 하게 된다 — 실제로 내가 그랬다. -셋째, **원칙은 깰 수 있다. 단, 조건이 있다.** 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다. +셋째, **"도메인 서비스가 없다"는 의심해봐야 한다.** 처음에 모든 Service를 application으로 옮기고 "도메인 서비스가 하나도 없다"고 결론 내렸다. 근데 곰곰이 보니 `LikeFacade.like()`의 if/else 분기가 비즈니스 의사결정이었다. 지우면 규칙이 사라지는 코드가 application 레이어에 있었던 것이다. "지우면 뭐가 사라지는가?"를 물었더니 숨어있던 도메인 서비스가 드러났다. + +넷째, **원칙은 깰 수 있다. 단, 조건이 있다.** 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다. 오늘도 느낀 바지만 역시 "은탄환은 없다" 하지만 시작은 "어디에 뭘 둬야 하지?"라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, **키워드를 들이대고 검증할 수 있다**는 점에서 설계의 질이 달라질 수 있다고 생각한다. diff --git a/docs/design/mermaid/00-ddd-design-framework.md b/docs/design/mermaid/00-ddd-design-framework.md index cff60cf06..d76c8cec5 100644 --- a/docs/design/mermaid/00-ddd-design-framework.md +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -314,7 +314,7 @@ public ProductModel register(..., Long brandId, int initialStock) { | 질문 | 도메인 서비스 | 애플리케이션 서비스 | |------|-------------|-------------------| -| 자체 비즈니스 규칙이 있는가? | **있다** (유니크 검증, 상태 전이) | **없다** (위임만 수행) | +| 비즈니스 의사결정을 직접 내리는가? | **내린다** (여러 엔티티 상태 종합 판단) | **안 내린다** (엔티티/VO에 위임) | | 다른 Service를 조합하는가? | 같은 도메인 내 객체만 | **여러 도메인 Service를 조합** | | `@Transactional` 경계인가? | 아닐 수 있음 | **맞다** (유스케이스 단위) | | 제거하면 비즈니스 규칙이 깨지는가? | **깨진다** | 절차가 사라질 뿐, 규칙은 유지됨 | @@ -327,14 +327,15 @@ public ProductModel register(..., Long brandId, int initialStock) { | `BrandService` | application | 브랜드명 유니크 검증, CRUD | | `ProductService` | application | 상품 CRUD | | `StockService` | application | 재고 생성, 차감 | -| `LikeService` | application | 좋아요 등록/취소, 존재 여부 조회 | +| `LikeToggleService` | **domain** | 좋아요 멱등성 판단 (Like + Product 종합 의사결정) | +| `LikeService` | application | 좋아요 저장/조회 | | `OrderService` | application | 주문/주문상품 CRUD, 소유권 검증(엔티티 위임) | | `MemberSignupService` | application | 회원가입 (중복 ID 체크 + 생성) | | `MemberAuthService` | application | 인증 (비밀번호 검증은 엔티티 위임) | | `MemberPasswordService` | application | 비밀번호 변경 (검증은 엔티티/VO 위임) | | `BrandFacade` | application | 삭제 시 소속 상품 연쇄 soft delete | | `ProductFacade` | application | 상품 + Stock 동시 생성, 브랜드 존재 확인 | -| `LikeFacade` | application | 삭제된 상품 체크, likeCount 동기화 | +| `LikeFacade` | application | 데이터 조회 → LikeToggleService에 판단 위임 → 결과 저장 | | `OrderFacade` | application | 재고 차감 + 스냅샷 + 주문 생성, 주문 조회 | | `MemberFacade` | application | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd index b9c9a3f5d..1857a280c 100644 --- a/docs/design/mermaid/04-class-diagram.mmd +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -45,8 +45,13 @@ classDiagram -Long userId -Long productId } + class LikeToggleService { + <> + +like(existing, product, userId, productId) Optional~LikeModel~ + +unlike(like, product) + } class LikeFacade { - 멱등성 + likeCount 동기화 + 데이터 조회 → 판단 위임 → 결과 저장 } } @@ -91,6 +96,9 @@ classDiagram StockService --> StockModel LikeFacade --> LikeService LikeFacade --> ProductService + LikeFacade --> LikeToggleService + LikeToggleService --> LikeModel + LikeToggleService --> ProductModel LikeService --> LikeModel OrderFacade --> OrderService OrderFacade --> ProductService From db6aee5b8fae6deced6e1686dcc0ccb5aed07776 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Mar 2026 14:53:21 +0900 Subject: [PATCH 59/66] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84=20-=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=ED=85=9C=ED=94=8C=EB=A6=BF/=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0,=20CRUD=20API,=20=ED=95=A0=EC=9D=B8?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponModel(템플릿): 정액/정률 할인, 최소주문금액, 만료일 관리 - CouponIssueModel(발급): 상태 파생(usedAt/expiredAt 기반), 사용 처리 - CouponType enum 다형성으로 할인 계산 (FIXED/RATE) - Money VO에 divide, subtract, min 메서드 추가 - Repository 3계층 패턴 (domain interface → infra impl → JPA) - Admin API (CRUD) + User API (발급/조회) Co-Authored-By: Claude Opus 4.6 --- .../coupon/CouponIssueService.java | 57 ++++++++++ .../application/coupon/CouponService.java | 51 +++++++++ .../domain/coupon/CouponIssueModel.java | 103 ++++++++++++++++++ .../domain/coupon/CouponIssueRepository.java | 17 +++ .../domain/coupon/CouponIssueStatus.java | 5 + .../loopers/domain/coupon/CouponModel.java | 103 ++++++++++++++++++ .../domain/coupon/CouponRepository.java | 14 +++ .../com/loopers/domain/coupon/CouponType.java | 20 ++++ .../com/loopers/domain/product/Money.java | 15 +++ .../coupon/CouponIssueJpaRepository.java | 16 +++ .../coupon/CouponIssueRepositoryImpl.java | 41 +++++++ .../coupon/CouponJpaRepository.java | 7 ++ .../coupon/CouponRepositoryImpl.java | 36 ++++++ .../api/coupon/CouponAdminV1Controller.java | 74 +++++++++++++ .../api/coupon/CouponAdminV1Dto.java | 54 +++++++++ .../api/coupon/CouponV1Controller.java | 47 ++++++++ .../interfaces/api/coupon/CouponV1Dto.java | 28 +++++ 17 files changed, 688 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java new file mode 100644 index 000000000..d0d17bedf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java @@ -0,0 +1,57 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponIssueRepository; +import com.loopers.domain.coupon.CouponModel; +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; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CouponIssueService { + + private final CouponIssueRepository couponIssueRepository; + + @Transactional + public CouponIssueModel issue(CouponModel coupon, Long userId) { + if (coupon.isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급할 수 없습니다."); + } + + couponIssueRepository.findByUserIdAndCouponId(userId, coupon.getId()) + .ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 발급받은 쿠폰입니다."); + }); + + CouponIssueModel couponIssue = new CouponIssueModel(coupon.getId(), userId, coupon.expiredAt()); + return couponIssueRepository.save(couponIssue); + } + + @Transactional(readOnly = true) + public CouponIssueModel getCouponIssue(Long couponIssueId) { + return couponIssueRepository.findById(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getMyIssues(Long userId) { + return couponIssueRepository.findAllByUserId(userId); + } + + @Transactional(readOnly = true) + public List getIssuesByCoupon(Long couponId) { + return couponIssueRepository.findAllByCouponId(couponId); + } + + @Transactional + public void use(Long couponIssueId, Long userId, Long orderId) { + CouponIssueModel couponIssue = getCouponIssue(couponIssueId); + couponIssue.validateOwner(userId); + couponIssue.use(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java new file mode 100644 index 000000000..edc605a5c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -0,0 +1,51 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.product.Money; +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; + +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CouponService { + + private final CouponRepository couponRepository; + + @Transactional + public CouponModel create(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + CouponModel coupon = new CouponModel(name, type, value, minOrderAmount, expiredAt); + return couponRepository.save(coupon); + } + + @Transactional(readOnly = true) + public CouponModel getCoupon(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getAllCoupons() { + return couponRepository.findAll(); + } + + @Transactional + public CouponModel update(Long couponId, String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + CouponModel coupon = getCoupon(couponId); + coupon.update(name, type, value, minOrderAmount, expiredAt); + return coupon; + } + + @Transactional + public void delete(Long couponId) { + CouponModel coupon = getCoupon(couponId); + coupon.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java new file mode 100644 index 000000000..a7af0787f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java @@ -0,0 +1,103 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupon_issue") +public class CouponIssueModel extends BaseEntity { + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Column(name = "order_id") + private Long orderId; + + protected CouponIssueModel() { + } + + public CouponIssueModel(Long couponId, Long userId, LocalDateTime expiredAt) { + this.couponId = couponId; + this.userId = userId; + this.expiredAt = expiredAt; + guard(); + } + + @Override + protected void guard() { + if (couponId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 ID는 필수입니다."); + } + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료일은 필수입니다."); + } + } + + public void use(Long orderId) { + if (!isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용할 수 없는 쿠폰입니다."); + } + this.usedAt = LocalDateTime.now(); + this.orderId = orderId; + } + + public boolean isUsed() { + return usedAt != null; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiredAt); + } + + public boolean isAvailable() { + return !isUsed() && !isExpired(); + } + + public CouponIssueStatus status() { + if (isUsed()) return CouponIssueStatus.USED; + if (isExpired()) return CouponIssueStatus.EXPIRED; + return CouponIssueStatus.AVAILABLE; + } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 쿠폰만 사용할 수 있습니다."); + } + } + + public Long couponId() { + return couponId; + } + + public Long userId() { + return userId; + } + + public LocalDateTime expiredAt() { + return expiredAt; + } + + public LocalDateTime usedAt() { + return usedAt; + } + + public Long orderId() { + return orderId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java new file mode 100644 index 000000000..6a5889f6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.coupon; + +import java.util.List; +import java.util.Optional; + +public interface CouponIssueRepository { + + CouponIssueModel save(CouponIssueModel couponIssue); + + Optional findById(Long id); + + List findAllByUserId(Long userId); + + List findAllByCouponId(Long couponId); + + Optional findByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java new file mode 100644 index 000000000..14997b2ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.coupon; + +public enum CouponIssueStatus { + AVAILABLE, USED, EXPIRED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java new file mode 100644 index 000000000..6b6ce66c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java @@ -0,0 +1,103 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupon") +public class CouponModel extends BaseEntity { + + @Column(name = "name", nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @Column(name = "value", nullable = false) + private int value; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "min_order_amount")) + private Money minOrderAmount; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + protected CouponModel() { + } + + public CouponModel(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + guard(); + } + + @Override + protected void guard() { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 이름은 필수입니다."); + } + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 타입은 필수입니다."); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 값은 0보다 커야 합니다."); + } + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료일은 필수입니다."); + } + } + + public Money calculateDiscount(Money orderAmount) { + return this.type.calculateDiscount(this.value, orderAmount); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiredAt); + } + + public boolean meetsMinOrderAmount(Money orderAmount) { + if (this.minOrderAmount == null) { + return true; + } + return orderAmount.value() >= this.minOrderAmount.value(); + } + + public void update(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + guard(); + } + + public String name() { + return name; + } + + public CouponType type() { + return type; + } + + public int value() { + return value; + } + + public Money minOrderAmount() { + return minOrderAmount; + } + + public LocalDateTime expiredAt() { + return expiredAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..9882a48ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponRepository { + + CouponModel save(CouponModel coupon); + + Optional findById(Long id); + + java.util.List findAll(); + + void delete(CouponModel coupon); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..b9d482bc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,20 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.product.Money; + +public enum CouponType { + FIXED { + @Override + public Money calculateDiscount(int value, Money orderAmount) { + return new Money(value).min(orderAmount); + } + }, + RATE { + @Override + public Money calculateDiscount(int value, Money orderAmount) { + return orderAmount.multiply(value).divide(100).min(orderAmount); + } + }; + + public abstract Money calculateDiscount(int value, Money orderAmount); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index 121376a04..37f7fc6c9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -37,6 +37,21 @@ public Money multiply(int multiplier) { return new Money(this.value * multiplier); } + public Money divide(int divisor) { + if (divisor == 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0으로 나눌 수 없습니다."); + } + return new Money(this.value / divisor); + } + + public Money subtract(Money other) { + return new Money(Math.max(this.value - other.value, 0)); + } + + public Money min(Money other) { + return this.value <= other.value ? this : other; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java new file mode 100644 index 000000000..818912cf3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CouponIssueJpaRepository extends JpaRepository { + + List findAllByUserId(Long userId); + + List findAllByCouponId(Long couponId); + + Optional findByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java new file mode 100644 index 000000000..40102654a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponIssueRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponIssueRepositoryImpl implements CouponIssueRepository { + + private final CouponIssueJpaRepository couponIssueJpaRepository; + + @Override + public CouponIssueModel save(CouponIssueModel couponIssue) { + return couponIssueJpaRepository.save(couponIssue); + } + + @Override + public Optional findById(Long id) { + return couponIssueJpaRepository.findById(id); + } + + @Override + public List findAllByUserId(Long userId) { + return couponIssueJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAllByCouponId(Long couponId) { + return couponIssueJpaRepository.findAllByCouponId(couponId); + } + + @Override + public Optional findByUserIdAndCouponId(Long userId, Long couponId) { + return couponIssueJpaRepository.findByUserIdAndCouponId(userId, couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..9b5403156 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..b67bbfcf9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public CouponModel save(CouponModel coupon) { + return couponJpaRepository.save(coupon); + } + + @Override + public Optional findById(Long id) { + return couponJpaRepository.findById(id); + } + + @Override + public List findAll() { + return couponJpaRepository.findAll(); + } + + @Override + public void delete(CouponModel coupon) { + couponJpaRepository.delete(coupon); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java new file mode 100644 index 000000000..deea17d91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class CouponAdminV1Controller { + + private final CouponService couponService; + private final CouponIssueService couponIssueService; + + @GetMapping("/api-admin/v1/coupons") + public ApiResponse> getCoupons() { + List coupons = couponService.getAllCoupons(); + List response = coupons.stream() + .map(CouponAdminV1Dto.CouponResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/api-admin/v1/coupons/{couponId}") + public ApiResponse getCoupon(@PathVariable Long couponId) { + CouponModel coupon = couponService.getCoupon(couponId); + return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); + } + + @PostMapping("/api-admin/v1/coupons") + public ApiResponse createCoupon( + @RequestBody CouponAdminV1Dto.CreateRequest request + ) { + CouponModel coupon = couponService.create( + request.name(), request.type(), request.value(), + request.toMinOrderAmount(), request.expiredAt() + ); + return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); + } + + @PutMapping("/api-admin/v1/coupons/{couponId}") + public ApiResponse updateCoupon( + @PathVariable Long couponId, + @RequestBody CouponAdminV1Dto.UpdateRequest request + ) { + CouponModel coupon = couponService.update( + couponId, request.name(), request.type(), request.value(), + request.toMinOrderAmount(), request.expiredAt() + ); + return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); + } + + @DeleteMapping("/api-admin/v1/coupons/{couponId}") + public ApiResponse deleteCoupon(@PathVariable Long couponId) { + couponService.delete(couponId); + return ApiResponse.success(null); + } + + @GetMapping("/api-admin/v1/coupons/{couponId}/issues") + public ApiResponse> getCouponIssues( + @PathVariable Long couponId + ) { + List issues = couponIssueService.getIssuesByCoupon(couponId); + List response = issues.stream() + .map(CouponV1Dto.CouponIssueResponse::from) + .toList(); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java new file mode 100644 index 000000000..87aee64ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.product.Money; + +import java.time.LocalDateTime; + +public class CouponAdminV1Dto { + + public record CreateRequest( + String name, + CouponType type, + int value, + Integer minOrderAmount, + LocalDateTime expiredAt + ) { + public Money toMinOrderAmount() { + return minOrderAmount != null ? new Money(minOrderAmount) : null; + } + } + + public record UpdateRequest( + String name, + CouponType type, + int value, + Integer minOrderAmount, + LocalDateTime expiredAt + ) { + public Money toMinOrderAmount() { + return minOrderAmount != null ? new Money(minOrderAmount) : null; + } + } + + public record CouponResponse( + Long id, + String name, + String type, + int value, + Integer minOrderAmount, + LocalDateTime expiredAt + ) { + public static CouponResponse from(CouponModel coupon) { + return new CouponResponse( + coupon.getId(), + coupon.name(), + coupon.type().name(), + coupon.value(), + coupon.minOrderAmount() != null ? coupon.minOrderAmount().value() : null, + coupon.expiredAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java new file mode 100644 index 000000000..63a7ad78e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.application.member.MemberFacade; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class CouponV1Controller { + + private final CouponService couponService; + private final CouponIssueService couponIssueService; + private final MemberFacade memberFacade; + + @PostMapping("/api/v1/coupons/{couponId}/issue") + public ApiResponse issueCoupon( + @PathVariable Long couponId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + CouponModel coupon = couponService.getCoupon(couponId); + CouponIssueModel issue = couponIssueService.issue(coupon, member.getId()); + return ApiResponse.success(CouponV1Dto.CouponIssueResponse.from(issue)); + } + + @GetMapping("/api/v1/users/me/coupons") + public ApiResponse> getMyCoupons( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + List issues = couponIssueService.getMyIssues(member.getId()); + List response = issues.stream() + .map(CouponV1Dto.CouponIssueResponse::from) + .toList(); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java new file mode 100644 index 000000000..590607135 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; + +import java.time.LocalDateTime; + +public class CouponV1Dto { + + public record CouponIssueResponse( + Long id, + Long couponId, + String status, + LocalDateTime usedAt, + Long orderId, + LocalDateTime expiredAt + ) { + public static CouponIssueResponse from(CouponIssueModel issue) { + return new CouponIssueResponse( + issue.getId(), + issue.couponId(), + issue.status().name(), + issue.usedAt(), + issue.orderId(), + issue.expiredAt() + ); + } + } +} From ed85884e27ecf4a6ae52b8c0139508bfef3be535 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 09:35:12 +0900 Subject: [PATCH 60/66] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8-=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=BF=A0=ED=8F=B0?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderModel에 discountAmount, finalAmount, couponIssueId 필드 추가 - OrderFacade.placeOrder에 쿠폰 검증/할인/사용 처리 로직 통합 - OrderV1Dto, OrderInfo, OrderV1Controller 할인 필드 반영 - CouponModel에 validateUsable() 메서드 추가 - CouponIssueModel에 setOrderId() 메서드 추가 - CouponModelTest: 생성/할인계산/사용가능검증 단위 테스트 - CouponIssueModelTest: 생성/사용/소유자검증/상태판별 단위 테스트 - 기존 Order 테스트 시그니처 3-parameter로 수정 Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 29 +++- .../loopers/application/order/OrderInfo.java | 8 +- .../domain/coupon/CouponIssueModel.java | 4 + .../loopers/domain/coupon/CouponModel.java | 9 ++ .../com/loopers/domain/order/OrderModel.java | 36 +++-- .../api/order/OrderV1Controller.java | 2 +- .../interfaces/api/order/OrderV1Dto.java | 12 +- .../order/OrderFacadeIntegrationTest.java | 20 +-- .../domain/coupon/CouponIssueModelTest.java | 141 +++++++++++++++++ .../domain/coupon/CouponModelTest.java | 145 ++++++++++++++++++ .../loopers/domain/order/OrderModelTest.java | 8 +- .../api/order/OrderV1ApiE2ETest.java | 6 +- 12 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java 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 index abfb1444f..263042f55 100644 --- 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 @@ -1,5 +1,9 @@ package com.loopers.application.order; +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; import com.loopers.domain.product.Money; @@ -24,9 +28,12 @@ public class OrderFacade { private final OrderService orderService; private final ProductService productService; private final StockService stockService; + private final CouponIssueService couponIssueService; + private final CouponService couponService; @Transactional - public OrderResult placeOrder(Long userId, List commands) { + public OrderResult placeOrder(Long userId, List commands, Long couponIssueId) { + // 1. 상품 조회 + 금액 계산 Money totalAmount = Money.ZERO; List snapshots = new ArrayList<>(); @@ -44,7 +51,25 @@ public OrderResult placeOrder(Long userId, List commands) { )); } - OrderModel order = orderService.save(new OrderModel(userId, totalAmount)); + // 2. 쿠폰 검증 + 할인 계산 + Money discountAmount = Money.ZERO; + if (couponIssueId != null) { + CouponIssueModel couponIssue = couponIssueService.getCouponIssue(couponIssueId); + couponIssue.validateOwner(userId); + CouponModel coupon = couponService.getCoupon(couponIssue.couponId()); + coupon.validateUsable(totalAmount); + discountAmount = coupon.calculateDiscount(totalAmount); + couponIssue.use(null); + } + + // 3. 주문 생성 + OrderModel order = orderService.save(new OrderModel(userId, totalAmount, discountAmount, couponIssueId)); + + // 4. 쿠폰에 orderId 연결 + if (couponIssueId != null) { + CouponIssueModel couponIssue = couponIssueService.getCouponIssue(couponIssueId); + couponIssue.setOrderId(order.getId()); + } List items = snapshots.stream() .map(s -> new OrderItemModel( diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 08e9ff8ae..e6e14e3a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -11,6 +11,8 @@ public record OrderInfo( Long userId, String status, int totalAmount, + int discountAmount, + int finalAmount, List items, ZonedDateTime createdAt ) { @@ -21,14 +23,16 @@ public static OrderInfo from(OrderModel order, List items) { .toList(); return new OrderInfo( order.getId(), order.userId(), order.status().name(), - order.totalAmount().value(), itemInfos, order.getCreatedAt() + order.totalAmount().value(), order.discountAmount().value(), + order.finalAmount().value(), itemInfos, order.getCreatedAt() ); } public static OrderInfo summaryFrom(OrderModel order) { return new OrderInfo( order.getId(), order.userId(), order.status().name(), - order.totalAmount().value(), List.of(), order.getCreatedAt() + order.totalAmount().value(), order.discountAmount().value(), + order.finalAmount().value(), List.of(), order.getCreatedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java index a7af0787f..62b704ee8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java @@ -100,4 +100,8 @@ public LocalDateTime usedAt() { public Long orderId() { return orderId; } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java index 6b6ce66c1..808c66a36 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java @@ -72,6 +72,15 @@ public boolean meetsMinOrderAmount(Money orderAmount) { return orderAmount.value() >= this.minOrderAmount.value(); } + public void validateUsable(Money orderAmount) { + if (isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + if (!meetsMinOrderAmount(orderAmount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "최소 주문 금액 조건을 충족하지 않습니다."); + } + } + public void update(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { this.name = name; this.type = type; 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 index 742840c72..24ca1d150 100644 --- 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 @@ -21,14 +21,28 @@ public class OrderModel extends BaseEntity { @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) private Money totalAmount; + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "discount_amount", nullable = false)) + private Money discountAmount; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "final_amount", nullable = false)) + private Money finalAmount; + + @Column(name = "coupon_issue_id") + private Long couponIssueId; + protected OrderModel() { } - public OrderModel(Long userId, Money totalAmount) { + public OrderModel(Long userId, Money totalAmount, Money discountAmount, Long couponIssueId) { this.userId = userId; this.status = OrderStatus.CREATED; this.totalAmount = totalAmount; + this.discountAmount = discountAmount; + this.couponIssueId = couponIssueId; guard(); + this.finalAmount = totalAmount.subtract(discountAmount); } @Override @@ -39,19 +53,17 @@ protected void guard() { if (totalAmount == null) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 총액은 필수입니다."); } + if (discountAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 필수입니다."); + } } - public Long userId() { - return userId; - } - - public OrderStatus status() { - return status; - } - - public Money totalAmount() { - return totalAmount; - } + public Long userId() { return userId; } + public OrderStatus status() { return status; } + public Money totalAmount() { return totalAmount; } + public Money discountAmount() { return discountAmount; } + public Money finalAmount() { return finalAmount; } + public Long couponIssueId() { return couponIssueId; } public void validateOwner(Long userId) { if (!this.userId.equals(userId)) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index ee72a18a5..fb6bc1b56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -35,7 +35,7 @@ public ApiResponse createOrder( @RequestHeader("X-Loopers-LoginPw") String password ) { MemberModel member = memberFacade.authenticate(loginId, password); - OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands(), request.couponIssueId()); return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 23da5fcfb..edb40eb58 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -9,7 +9,7 @@ public class OrderV1Dto { - public record CreateRequest(List items) { + public record CreateRequest(List items, Long couponIssueId) { public List toCommands() { return items.stream() @@ -25,6 +25,8 @@ public record OrderResponse( Long orderId, String status, int totalAmount, + int discountAmount, + int finalAmount, List items, ZonedDateTime createdAt ) { @@ -34,7 +36,8 @@ public static OrderResponse from(OrderInfo info) { .map(OrderItemResponse::from) .toList(); return new OrderResponse( - info.orderId(), info.status(), info.totalAmount(), items, info.createdAt() + info.orderId(), info.status(), info.totalAmount(), + info.discountAmount(), info.finalAmount(), items, info.createdAt() ); } @@ -47,7 +50,10 @@ public static OrderResponse fromResult(OrderResult result) { .toList(); return new OrderResponse( result.order().getId(), result.order().status().name(), - result.order().totalAmount().value(), items, result.order().getCreatedAt() + result.order().totalAmount().value(), + result.order().discountAmount().value(), + result.order().finalAmount().value(), + items, result.order().getCreatedAt() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 6c3d2f647..977759387 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -53,7 +53,7 @@ class PlaceOrder { void placesSingleItemOrder() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); - OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2))); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2)), null); assertAll( () -> assertThat(result.order().userId()).isEqualTo(1L), () -> assertThat(result.order().status()).isEqualTo(OrderStatus.CREATED), @@ -71,7 +71,7 @@ void placesMultiItemOrder() { Long p1 = createProduct("에어맥스", 129000, brandId, 10); Long p2 = createProduct("조던", 159000, brandId, 5); OrderResult result = orderFacade.placeOrder(1L, List.of( - new OrderItemCommand(p1, 2), new OrderItemCommand(p2, 1))); + new OrderItemCommand(p1, 2), new OrderItemCommand(p2, 1)), null); assertAll( () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(417000)), () -> assertThat(result.items()).hasSize(2) @@ -83,7 +83,7 @@ void placesMultiItemOrder() { void deductsStock() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); - orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3))); + orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3)), null); assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); } @@ -93,7 +93,7 @@ void throwsWhenInsufficientStock() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 2); CoreException result = assertThrows(CoreException.class, - () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 5)))); + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 5)), null)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @@ -104,7 +104,7 @@ void rollsBackOnPartialFailure() { Long p1 = createProduct("에어맥스", 129000, brandId, 10); Long p2 = createProduct("조던", 159000, brandId, 1); assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( - new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)))); + new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)), null)); assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); } @@ -116,7 +116,7 @@ void throwsWhenProductDeleted() { Long productId = createProduct("에어맥스", 129000, brandId, 10); productService.delete(productId); CoreException result = assertThrows(CoreException.class, - () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)))); + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null)); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } @@ -125,7 +125,7 @@ void throwsWhenProductDeleted() { void savesProductSnapshot() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); - OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); OrderItemModel item = result.items().get(0); assertAll( () -> assertThat(item.productName()).isEqualTo("에어맥스"), @@ -143,7 +143,7 @@ class GetOrder { void getsOwnOrder() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); - OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); OrderModel order = orderService.getOrder(r.order().getId(), 1L); assertThat(order.getId()).isEqualTo(r.order().getId()); } @@ -153,7 +153,7 @@ void getsOwnOrder() { void throwsWhenAccessingOtherUserOrder() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); - OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); CoreException result = assertThrows(CoreException.class, () -> orderService.getOrder(r.order().getId(), 999L)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -164,7 +164,7 @@ void throwsWhenAccessingOtherUserOrder() { void getsOrderItems() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); - OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2))); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2)), null); List items = orderService.getOrderItems(r.order().getId()); assertAll( () -> assertThat(items).hasSize(1), diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java new file mode 100644 index 000000000..d5a58a496 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.coupon; + +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 java.time.LocalDateTime; + +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 CouponIssueModelTest { + + private CouponIssueModel createIssue(LocalDateTime expiredAt) { + return new CouponIssueModel(1L, 100L, expiredAt); + } + + @DisplayName("쿠폰 발급 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given & when + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + + // then + assertAll( + () -> assertThat(issue.couponId()).isEqualTo(1L), + () -> assertThat(issue.userId()).isEqualTo(100L), + () -> assertThat(issue.status()).isEqualTo(CouponIssueStatus.AVAILABLE), + () -> assertThat(issue.isUsed()).isFalse() + ); + } + + @DisplayName("couponId가 null이면 예외가 발생한다") + @Test + void throwsWhenCouponIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new CouponIssueModel(null, 100L, LocalDateTime.of(2099, 12, 31, 23, 59))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("쿠폰 사용") + @Nested + class Use { + + @DisplayName("사용 가능한 쿠폰을 사용할 수 있다") + @Test + void usesAvailableCoupon() { + // given + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + issue.use(999L); + + // then + assertAll( + () -> assertThat(issue.isUsed()).isTrue(), + () -> assertThat(issue.status()).isEqualTo(CouponIssueStatus.USED), + () -> assertThat(issue.orderId()).isEqualTo(999L) + ); + } + + @DisplayName("이미 사용된 쿠폰은 다시 사용할 수 없다") + @Test + void throwsWhenAlreadyUsed() { + // given + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + issue.use(999L); + + // when & then + CoreException result = assertThrows(CoreException.class, () -> issue.use(1000L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만료된 쿠폰은 사용할 수 없다") + @Test + void throwsWhenExpired() { + // given + CouponIssueModel issue = createIssue(LocalDateTime.of(2020, 1, 1, 0, 0)); + + // when & then + CoreException result = assertThrows(CoreException.class, () -> issue.use(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소유자 검증") + @Nested + class ValidateOwner { + + @DisplayName("본인의 쿠폰이면 통과한다") + @Test + void passesForOwner() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + issue.validateOwner(100L); // 예외 없이 통과 + } + + @DisplayName("다른 사용자의 쿠폰이면 예외가 발생한다") + @Test + void throwsForNonOwner() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + CoreException result = assertThrows(CoreException.class, + () -> issue.validateOwner(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 판별") + @Nested + class Status { + + @DisplayName("사용 전 + 만료 전이면 AVAILABLE이다") + @Test + void availableWhenNotUsedNotExpired() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + assertThat(issue.status()).isEqualTo(CouponIssueStatus.AVAILABLE); + } + + @DisplayName("사용 후에는 USED이다") + @Test + void usedAfterUse() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + issue.use(999L); + assertThat(issue.status()).isEqualTo(CouponIssueStatus.USED); + } + + @DisplayName("만료 후에는 EXPIRED이다") + @Test + void expiredWhenPastDate() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2020, 1, 1, 0, 0)); + assertThat(issue.status()).isEqualTo(CouponIssueStatus.EXPIRED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java new file mode 100644 index 000000000..763656d9b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.product.Money; +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 java.time.LocalDateTime; + +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 CouponModelTest { + + @DisplayName("쿠폰 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given & when + CouponModel coupon = new CouponModel("10% 할인", CouponType.RATE, 10, + new Money(10000), LocalDateTime.of(2099, 12, 31, 23, 59)); + + // then + assertAll( + () -> assertThat(coupon.name()).isEqualTo("10% 할인"), + () -> assertThat(coupon.type()).isEqualTo(CouponType.RATE), + () -> assertThat(coupon.value()).isEqualTo(10), + () -> assertThat(coupon.minOrderAmount()).isEqualTo(new Money(10000)) + ); + } + + @DisplayName("이름이 빈 값이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenNameBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new CouponModel("", CouponType.FIXED, 1000, null, + LocalDateTime.of(2099, 12, 31, 23, 59))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("값이 0이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenValueZero() { + CoreException result = assertThrows(CoreException.class, + () -> new CouponModel("쿠폰", CouponType.FIXED, 0, null, + LocalDateTime.of(2099, 12, 31, 23, 59))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("할인 계산") + @Nested + class CalculateDiscount { + + @DisplayName("정액 쿠폰은 고정 금액을 할인한다") + @Test + void fixedDiscount() { + // given + CouponModel coupon = new CouponModel("5000원 할인", CouponType.FIXED, 5000, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + Money discount = coupon.calculateDiscount(new Money(20000)); + + // then + assertThat(discount).isEqualTo(new Money(5000)); + } + + @DisplayName("정액 쿠폰 할인이 주문 금액보다 크면 주문 금액만큼만 할인한다") + @Test + void fixedDiscountCappedAtOrderAmount() { + // given + CouponModel coupon = new CouponModel("50000원 할인", CouponType.FIXED, 50000, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + Money discount = coupon.calculateDiscount(new Money(20000)); + + // then + assertThat(discount).isEqualTo(new Money(20000)); + } + + @DisplayName("정률 쿠폰은 주문 금액의 비율로 할인한다") + @Test + void rateDiscount() { + // given + CouponModel coupon = new CouponModel("10% 할인", CouponType.RATE, 10, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + Money discount = coupon.calculateDiscount(new Money(100000)); + + // then + assertThat(discount).isEqualTo(new Money(10000)); + } + } + + @DisplayName("사용 가능 검증") + @Nested + class ValidateUsable { + + @DisplayName("만료된 쿠폰은 사용할 수 없다") + @Test + void throwsWhenExpired() { + // given + CouponModel coupon = new CouponModel("쿠폰", CouponType.FIXED, 1000, + null, LocalDateTime.of(2020, 1, 1, 0, 0)); + + // when & then + CoreException result = assertThrows(CoreException.class, + () -> coupon.validateUsable(new Money(10000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("최소 주문 금액 미달 시 사용할 수 없다") + @Test + void throwsWhenBelowMinOrderAmount() { + // given + CouponModel coupon = new CouponModel("쿠폰", CouponType.FIXED, 1000, + new Money(50000), LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when & then + CoreException result = assertThrows(CoreException.class, + () -> coupon.validateUsable(new Money(10000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 쿠폰은 검증을 통과한다") + @Test + void passesWhenValid() { + // given + CouponModel coupon = new CouponModel("쿠폰", CouponType.FIXED, 1000, + new Money(10000), LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when & then (예외 없이 통과) + coupon.validateUsable(new Money(20000)); + } + } +} 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 index d0c5ad35d..92ff4e9d6 100644 --- 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 @@ -25,7 +25,7 @@ void createsWithValidInput() { Money totalAmount = new Money(258000); // when - OrderModel order = new OrderModel(userId, totalAmount); + OrderModel order = new OrderModel(userId, totalAmount, Money.ZERO, null); // then assertAll( @@ -39,7 +39,7 @@ void createsWithValidInput() { @Test void defaultStatusIsCreated() { // given & when - OrderModel order = new OrderModel(1L, new Money(10000)); + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); // then assertThat(order.status()).isEqualTo(OrderStatus.CREATED); @@ -50,7 +50,7 @@ void defaultStatusIsCreated() { void throwsWhenUserIdNull() { // given & when CoreException result = assertThrows(CoreException.class, - () -> new OrderModel(null, new Money(10000))); + () -> new OrderModel(null, new Money(10000), Money.ZERO, null)); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -61,7 +61,7 @@ void throwsWhenUserIdNull() { void throwsWhenTotalAmountNull() { // given & when CoreException result = assertThrows(CoreException.class, - () -> new OrderModel(1L, null)); + () -> new OrderModel(1L, null, Money.ZERO, null)); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java index 40c0f8165..d18c694cf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -79,7 +79,7 @@ private HttpHeaders authHeaders() { private ResponseEntity> placeOrder(List items) { return testRestTemplate.exchange("/api/v1/orders", HttpMethod.POST, - new HttpEntity<>(new OrderV1Dto.CreateRequest(items), authHeaders()), + new HttpEntity<>(new OrderV1Dto.CreateRequest(items, null), authHeaders()), new ParameterizedTypeReference<>() {}); } @@ -121,7 +121,7 @@ void returns400WhenInsufficientStock() { signupMember(); ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( - List.of(new OrderV1Dto.OrderItemRequest(productId, 5))), authHeaders()), + List.of(new OrderV1Dto.OrderItemRequest(productId, 5)), null), authHeaders()), new ParameterizedTypeReference<>() {}); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @@ -130,7 +130,7 @@ void returns400WhenInsufficientStock() { void returns400WhenNoAuth() { ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( - List.of(new OrderV1Dto.OrderItemRequest(1L, 1)))), + List.of(new OrderV1Dto.OrderItemRequest(1L, 1)), null)), new ParameterizedTypeReference<>() {}); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } From 4dfefc0962feb348a97eea779a81412bbf555d41 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 09:43:01 +0900 Subject: [PATCH 61/66] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EA=B5=AC=ED=98=84=20-=20=EB=B9=84?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD(=EC=9E=AC=EA=B3=A0/=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0)=20+=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD(?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재고 동시성 (비관적 락): - StockRepository/JpaRepository에 findByProductIdForUpdate 추가 - OrderFacade에서 productId 오름차순 정렬 후 FOR UPDATE로 재고 차감 (데드락 방지) 쿠폰 동시성 (비관적 락): - CouponIssueRepository/JpaRepository에 findByIdForUpdate 추가 - OrderFacade에서 쿠폰 사용 시 FOR UPDATE로 이중 사용 방지 좋아요 동시성 (낙관적 락): - ProductModel에 @Version 필드 추가 - LikeTransactionService 분리로 self-invocation 문제 해결 - LikeFacade에서 OptimisticLockException 발생 시 최대 3회 재시도 OrderFacade 트랜잭션 최적화: - 락 없이 할 수 있는 작업(상품 조회, 금액 계산) 먼저 수행 - 비관적 락 구간을 트랜잭션 후반으로 최소화 동시성 테스트: - 재고: 10명 동시 주문 시 정확한 차감, 재고 부족 시 일부만 성공 - 쿠폰: 동일 쿠폰 동시 사용 시 1회만 성공 - 좋아요: 10명 동시 좋아요 시 정확한 카운트 Co-Authored-By: Claude Opus 4.6 --- .../coupon/CouponIssueService.java | 5 + .../loopers/application/like/LikeFacade.java | 35 +-- .../like/LikeTransactionService.java | 38 ++++ .../application/order/OrderFacade.java | 33 ++- .../application/stock/StockService.java | 5 + .../domain/coupon/CouponIssueRepository.java | 2 + .../loopers/domain/product/ProductModel.java | 4 + .../loopers/domain/stock/StockRepository.java | 2 + .../coupon/CouponIssueJpaRepository.java | 8 + .../coupon/CouponIssueRepositoryImpl.java | 5 + .../stock/StockJpaRepository.java | 8 + .../stock/StockRepositoryImpl.java | 5 + .../ConcurrencyIntegrationTest.java | 202 ++++++++++++++++++ 13 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java index d0d17bedf..da88bf9f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java @@ -38,6 +38,11 @@ public CouponIssueModel getCouponIssue(Long couponIssueId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); } + public CouponIssueModel getCouponIssueForUpdate(Long couponIssueId) { + return couponIssueRepository.findByIdForUpdate(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); + } + @Transactional(readOnly = true) public List getMyIssues(Long userId) { return couponIssueRepository.findAllByUserId(userId); 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 index 243a7e9a8..f6eb09227 100644 --- 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 @@ -1,41 +1,46 @@ package com.loopers.application.like; import com.loopers.domain.like.LikeModel; -import com.loopers.domain.like.LikeToggleService; import com.loopers.domain.product.ProductModel; import com.loopers.application.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - @RequiredArgsConstructor @Component public class LikeFacade { private final LikeService likeService; private final ProductService productService; - private final LikeToggleService likeToggleService; + private final LikeTransactionService likeTransactionService; - @Transactional - public void like(Long userId, Long productId) { - ProductModel product = productService.getProduct(productId); - Optional existing = likeService.findByUserIdAndProductId(userId, productId); + private static final int MAX_RETRY = 3; - Optional newLike = likeToggleService.like(existing, product, userId, productId); - newLike.ifPresent(likeService::save); + public void like(Long userId, Long productId) { + retryOnOptimisticLock(() -> likeTransactionService.doLike(userId, productId)); } - @Transactional public void unlike(Long userId, Long productId) { - Optional activeLike = likeService.findActiveLike(userId, productId); - if (activeLike.isEmpty()) return; + retryOnOptimisticLock(() -> likeTransactionService.doUnlike(userId, productId)); + } - ProductModel product = productService.getProduct(activeLike.get().productId()); - likeToggleService.unlike(activeLike.get(), product); + private void retryOnOptimisticLock(Runnable action) { + for (int attempt = 0; attempt < MAX_RETRY; attempt++) { + try { + action.run(); + return; + } catch (ObjectOptimisticLockingFailureException e) { + if (attempt == MAX_RETRY - 1) { + throw new CoreException(ErrorType.CONFLICT, "동시 요청이 많아 처리에 실패했습니다. 다시 시도해주세요."); + } + } + } } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java new file mode 100644 index 000000000..9ad9a277c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java @@ -0,0 +1,38 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeToggleService; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeTransactionService { + + private final LikeService likeService; + private final ProductService productService; + private final LikeToggleService likeToggleService; + + @Transactional + public void doLike(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + Optional newLike = likeToggleService.like(existing, product, userId, productId); + newLike.ifPresent(likeService::save); + } + + @Transactional + public void doUnlike(Long userId, Long productId) { + Optional activeLike = likeService.findActiveLike(userId, productId); + if (activeLike.isEmpty()) return; + + ProductModel product = productService.getProduct(activeLike.get().productId()); + likeToggleService.unlike(activeLike.get(), product); + } +} 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 index 263042f55..0bdae30ce 100644 --- 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 @@ -19,6 +19,7 @@ import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; @RequiredArgsConstructor @@ -33,25 +34,24 @@ public class OrderFacade { @Transactional public OrderResult placeOrder(Long userId, List commands, Long couponIssueId) { - // 1. 상품 조회 + 금액 계산 + // 1. 락 없이: 상품 조회 + 금액 계산 (productId 오름차순 정렬 — 데드락 방지) + List sorted = commands.stream() + .sorted(Comparator.comparing(OrderItemCommand::productId)) + .toList(); + Money totalAmount = Money.ZERO; List snapshots = new ArrayList<>(); - for (OrderItemCommand cmd : commands) { + for (OrderItemCommand cmd : sorted) { ProductModel product = productService.getProduct(cmd.productId()); - - StockModel stock = stockService.getByProductId(cmd.productId()); - stock.decrease(cmd.quantity()); - Money subtotal = product.price().multiply(cmd.quantity()); totalAmount = totalAmount.add(subtotal); - snapshots.add(new SnapshotHolder( product.getId(), product.name(), product.price(), cmd.quantity() )); } - // 2. 쿠폰 검증 + 할인 계산 + // 2. 쿠폰 검증 + 할인 계산 (락 불필요 — 읽기 전용) Money discountAmount = Money.ZERO; if (couponIssueId != null) { CouponIssueModel couponIssue = couponIssueService.getCouponIssue(couponIssueId); @@ -59,13 +59,24 @@ public OrderResult placeOrder(Long userId, List commands, Long CouponModel coupon = couponService.getCoupon(couponIssue.couponId()); coupon.validateUsable(totalAmount); discountAmount = coupon.calculateDiscount(totalAmount); - couponIssue.use(null); } - // 3. 주문 생성 + // 3. 비관적 락: 재고 차감 (productId 오름차순 — 데드락 방지) + for (OrderItemCommand cmd : sorted) { + StockModel stock = stockService.getByProductIdForUpdate(cmd.productId()); + stock.decrease(cmd.quantity()); + } + + // 4. 비관적 락: 쿠폰 사용 처리 + if (couponIssueId != null) { + CouponIssueModel lockedCouponIssue = couponIssueService.getCouponIssueForUpdate(couponIssueId); + lockedCouponIssue.use(null); + } + + // 5. 주문 생성 OrderModel order = orderService.save(new OrderModel(userId, totalAmount, discountAmount, couponIssueId)); - // 4. 쿠폰에 orderId 연결 + // 6. 쿠폰에 orderId 연결 if (couponIssueId != null) { CouponIssueModel couponIssue = couponIssueService.getCouponIssue(couponIssueId); couponIssue.setOrderId(order.getId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java index 642f649f4..2e2a595cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -30,6 +30,11 @@ public StockModel getByProductId(Long productId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); } + public StockModel getByProductIdForUpdate(Long productId) { + return stockRepository.findByProductIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); + } + @Transactional(readOnly = true) public Map getByProductIds(List productIds) { return stockRepository.findAllByProductIdIn(productIds) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java index 6a5889f6b..ff3f4b7a4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java @@ -9,6 +9,8 @@ public interface CouponIssueRepository { Optional findById(Long id); + Optional findByIdForUpdate(Long id); + List findAllByUserId(Long userId); List findAllByCouponId(Long couponId); 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 index 84a5167c8..98e9f9a24 100644 --- 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 @@ -5,6 +5,7 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.Version; @Entity @Table(name = "product") @@ -25,6 +26,9 @@ public class ProductModel extends BaseEntity { @Column(name = "like_count", nullable = false) private int likeCount; + @Version + private Long version; + protected ProductModel() {} public ProductModel(String name, String description, Money price, Long brandId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java index 78e333f4d..fb2a68d21 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -9,5 +9,7 @@ public interface StockRepository { Optional findByProductId(Long productId); + Optional findByProductIdForUpdate(Long productId); + List findAllByProductIdIn(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java index 818912cf3..104e5c06c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -1,13 +1,21 @@ package com.loopers.infrastructure.coupon; import com.loopers.domain.coupon.CouponIssueModel; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface CouponIssueJpaRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT ci FROM CouponIssueModel ci WHERE ci.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + List findAllByUserId(Long userId); List findAllByCouponId(Long couponId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java index 40102654a..f62b0f186 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java @@ -24,6 +24,11 @@ public Optional findById(Long id) { return couponIssueJpaRepository.findById(id); } + @Override + public Optional findByIdForUpdate(Long id) { + return couponIssueJpaRepository.findByIdForUpdate(id); + } + @Override public List findAllByUserId(Long userId) { return couponIssueJpaRepository.findAllByUserId(userId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java index b378a8822..6c5102be8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -1,7 +1,11 @@ package com.loopers.infrastructure.stock; import com.loopers.domain.stock.StockModel; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -10,5 +14,9 @@ public interface StockJpaRepository extends JpaRepository { Optional findByProductId(Long productId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM StockModel s WHERE s.productId = :productId") + Optional findByProductIdForUpdate(@Param("productId") Long productId); + List findAllByProductIdIn(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java index e3335aac9..d95eb90c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -24,6 +24,11 @@ public Optional findByProductId(Long productId) { return stockJpaRepository.findByProductId(productId); } + @Override + public Optional findByProductIdForUpdate(Long productId) { + return stockJpaRepository.findByProductIdForUpdate(productId); + } + @Override public List findAllByProductIdIn(List productIds) { return stockJpaRepository.findAllByProductIdIn(productIds); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java new file mode 100644 index 000000000..cf8316b86 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java @@ -0,0 +1,202 @@ +package com.loopers.application; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.application.like.LikeFacade; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.stock.StockService; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.product.Money; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ConcurrencyIntegrationTest { + + @Autowired private OrderFacade orderFacade; + @Autowired private LikeFacade likeFacade; + @Autowired private ProductFacade productFacade; + @Autowired private BrandService brandService; + @Autowired private StockService stockService; + @Autowired private CouponService couponService; + @Autowired private CouponIssueService couponIssueService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand() { return brandService.register("테스트브랜드", "설명").getId(); } + private Long createProduct(Long brandId, int stock) { + return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).getId(); + } + + @DisplayName("재고 동시성") + @Nested + class StockConcurrency { + + @DisplayName("10명이 동시에 1개씩 주문해도 재고가 정확히 차감된다") + @Test + void stockDecreasedCorrectlyUnderConcurrency() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 100); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + orderFacade.placeOrder(userId, + List.of(new OrderItemCommand(productId, 1)), null); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(10); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(90); + } + + @DisplayName("재고 5개인 상품에 10명이 동시 주문하면 5명만 성공한다") + @Test + void onlyAvailableStockSucceeds() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 5); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + orderFacade.placeOrder(userId, + List.of(new OrderItemCommand(productId, 1)), null); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(5); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(0); + } + } + + @DisplayName("쿠폰 동시성") + @Nested + class CouponConcurrency { + + @DisplayName("동일 쿠폰으로 여러 기기에서 동시 주문해도 1번만 사용된다") + @Test + void couponUsedOnlyOnce() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 100); + CouponModel coupon = couponService.create("테스트쿠폰", CouponType.FIXED, 1000, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + Long userId = 1L; + CouponIssueModel issue = couponIssueService.issue(coupon, userId); + + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + orderFacade.placeOrder(userId, + List.of(new OrderItemCommand(productId, 1)), issue.getId()); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(1); + } + } + + @DisplayName("좋아요 동시성") + @Nested + class LikeConcurrency { + + @DisplayName("10명이 동시에 좋아요해도 좋아요 수가 정확하다") + @Test + void likeCountAccurateUnderConcurrency() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 10); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + likeFacade.like(userId, productId); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(10); + } + } +} From d0add6d035bcd31993a67f42aed133b1656ce3ab Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 10:01:07 +0900 Subject: [PATCH 62/66] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=A4=ED=8C=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=BF=A0=ED=8F=B0=201=EC=B0=A8=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B8=EC=A0=9C=20+=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=9A=9F=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderFacade: coupon issue를 FOR UPDATE 한 번만 조회하도록 restructure (기존: 락 없이 먼저 읽어 1차 캐시에 stale 엔티티 적재 → FOR UPDATE 시 갱신 안 됨) - LikeFacade: MAX_RETRY 3→10 (10 스레드 동시 경합 시 재시도 부족) Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 2 +- .../application/order/OrderFacade.java | 31 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) 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 index f6eb09227..7fb0e0425 100644 --- 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 @@ -20,7 +20,7 @@ public class LikeFacade { private final ProductService productService; private final LikeTransactionService likeTransactionService; - private static final int MAX_RETRY = 3; + private static final int MAX_RETRY = 10; public void like(Long userId, Long productId) { retryOnOptimisticLock(() -> likeTransactionService.doLike(userId, productId)); 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 index 0bdae30ce..443496bdb 100644 --- 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 @@ -51,34 +51,29 @@ public OrderResult placeOrder(Long userId, List commands, Long )); } - // 2. 쿠폰 검증 + 할인 계산 (락 불필요 — 읽기 전용) - Money discountAmount = Money.ZERO; - if (couponIssueId != null) { - CouponIssueModel couponIssue = couponIssueService.getCouponIssue(couponIssueId); - couponIssue.validateOwner(userId); - CouponModel coupon = couponService.getCoupon(couponIssue.couponId()); - coupon.validateUsable(totalAmount); - discountAmount = coupon.calculateDiscount(totalAmount); - } - - // 3. 비관적 락: 재고 차감 (productId 오름차순 — 데드락 방지) + // 2. 비관적 락: 재고 차감 (productId 오름차순 — 데드락 방지) for (OrderItemCommand cmd : sorted) { StockModel stock = stockService.getByProductIdForUpdate(cmd.productId()); stock.decrease(cmd.quantity()); } - // 4. 비관적 락: 쿠폰 사용 처리 + // 3. 비관적 락: 쿠폰 검증 + 할인 + 사용 처리 (한 번만 FOR UPDATE로 조회) + Money discountAmount = Money.ZERO; + CouponIssueModel couponIssue = null; if (couponIssueId != null) { - CouponIssueModel lockedCouponIssue = couponIssueService.getCouponIssueForUpdate(couponIssueId); - lockedCouponIssue.use(null); + couponIssue = couponIssueService.getCouponIssueForUpdate(couponIssueId); + couponIssue.validateOwner(userId); + CouponModel coupon = couponService.getCoupon(couponIssue.couponId()); + coupon.validateUsable(totalAmount); + discountAmount = coupon.calculateDiscount(totalAmount); + couponIssue.use(null); } - // 5. 주문 생성 + // 4. 주문 생성 OrderModel order = orderService.save(new OrderModel(userId, totalAmount, discountAmount, couponIssueId)); - // 6. 쿠폰에 orderId 연결 - if (couponIssueId != null) { - CouponIssueModel couponIssue = couponIssueService.getCouponIssue(couponIssueId); + // 5. 쿠폰에 orderId 연결 + if (couponIssue != null) { couponIssue.setOrderId(order.getId()); } From 5801711ebb5a5259debf6f08ef930fd0adb4d39a Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 10:50:38 +0900 Subject: [PATCH 63/66] =?UTF-8?q?feat:=20Admin=20=EC=BF=A0=ED=8F=B0=20API?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api-admin/v1/coupons?page=0&size=20 — Pageable 파라미터 추가 - GET /api-admin/v1/coupons/{couponId}/issues?page=0&size=20 — Pageable 파라미터 추가 - Repository/Service/Controller 전 레이어 Page 반환으로 변경 Co-Authored-By: Claude Opus 4.6 --- .../coupon/CouponIssueService.java | 6 +++-- .../application/coupon/CouponService.java | 7 ++--- .../domain/coupon/CouponIssueRepository.java | 5 +++- .../domain/coupon/CouponRepository.java | 5 +++- .../coupon/CouponIssueJpaRepository.java | 4 ++- .../coupon/CouponIssueRepositoryImpl.java | 7 +++-- .../coupon/CouponJpaRepository.java | 4 +++ .../coupon/CouponRepositoryImpl.java | 8 +++--- .../api/coupon/CouponAdminV1Controller.java | 27 ++++++++----------- 9 files changed, 44 insertions(+), 29 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java index d0d17bedf..9fd4190b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java @@ -6,6 +6,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -44,8 +46,8 @@ public List getMyIssues(Long userId) { } @Transactional(readOnly = true) - public List getIssuesByCoupon(Long couponId) { - return couponIssueRepository.findAllByCouponId(couponId); + public Page getIssuesByCoupon(Long couponId, Pageable pageable) { + return couponIssueRepository.findAllByCouponId(couponId, pageable); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java index edc605a5c..09156db15 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -7,11 +7,12 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.List; @RequiredArgsConstructor @Component @@ -32,8 +33,8 @@ public CouponModel getCoupon(Long couponId) { } @Transactional(readOnly = true) - public List getAllCoupons() { - return couponRepository.findAll(); + public Page getAllCoupons(Pageable pageable) { + return couponRepository.findAll(pageable); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java index 6a5889f6b..2f2007ba4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java @@ -1,5 +1,8 @@ package com.loopers.domain.coupon; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; @@ -11,7 +14,7 @@ public interface CouponIssueRepository { List findAllByUserId(Long userId); - List findAllByCouponId(Long couponId); + Page findAllByCouponId(Long couponId, Pageable pageable); Optional findByUserIdAndCouponId(Long userId, Long couponId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java index 9882a48ce..5a258b8de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -1,5 +1,8 @@ package com.loopers.domain.coupon; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.Optional; public interface CouponRepository { @@ -8,7 +11,7 @@ public interface CouponRepository { Optional findById(Long id); - java.util.List findAll(); + Page findAll(Pageable pageable); void delete(CouponModel coupon); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java index 818912cf3..ab2e184a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -1,6 +1,8 @@ package com.loopers.infrastructure.coupon; import com.loopers.domain.coupon.CouponIssueModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -10,7 +12,7 @@ public interface CouponIssueJpaRepository extends JpaRepository findAllByUserId(Long userId); - List findAllByCouponId(Long couponId); + Page findAllByCouponId(Long couponId, Pageable pageable); Optional findByUserIdAndCouponId(Long userId, Long couponId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java index 40102654a..948dea72c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java @@ -5,6 +5,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; @@ -30,8 +33,8 @@ public List findAllByUserId(Long userId) { } @Override - public List findAllByCouponId(Long couponId) { - return couponIssueJpaRepository.findAllByCouponId(couponId); + public Page findAllByCouponId(Long couponId, Pageable pageable) { + return couponIssueJpaRepository.findAllByCouponId(couponId, pageable); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java index 9b5403156..bea7a62e9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -1,7 +1,11 @@ package com.loopers.infrastructure.coupon; import com.loopers.domain.coupon.CouponModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface CouponJpaRepository extends JpaRepository { + + Page findAll(Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java index b67bbfcf9..1fce579b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -5,7 +5,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.Optional; @RequiredArgsConstructor @@ -25,8 +27,8 @@ public Optional findById(Long id) { } @Override - public List findAll() { - return couponJpaRepository.findAll(); + public Page findAll(Pageable pageable) { + return couponJpaRepository.findAll(pageable); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java index deea17d91..9cd922bc4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -2,14 +2,13 @@ import com.loopers.application.coupon.CouponIssueService; import com.loopers.application.coupon.CouponService; -import com.loopers.domain.coupon.CouponIssueModel; import com.loopers.domain.coupon.CouponModel; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RequiredArgsConstructor @RestController public class CouponAdminV1Controller { @@ -18,12 +17,9 @@ public class CouponAdminV1Controller { private final CouponIssueService couponIssueService; @GetMapping("/api-admin/v1/coupons") - public ApiResponse> getCoupons() { - List coupons = couponService.getAllCoupons(); - List response = coupons.stream() - .map(CouponAdminV1Dto.CouponResponse::from) - .toList(); - return ApiResponse.success(response); + public ApiResponse> getCoupons(Pageable pageable) { + Page coupons = couponService.getAllCoupons(pageable); + return ApiResponse.success(coupons.map(CouponAdminV1Dto.CouponResponse::from)); } @GetMapping("/api-admin/v1/coupons/{couponId}") @@ -62,13 +58,12 @@ public ApiResponse deleteCoupon(@PathVariable Long couponId) { } @GetMapping("/api-admin/v1/coupons/{couponId}/issues") - public ApiResponse> getCouponIssues( - @PathVariable Long couponId + public ApiResponse> getCouponIssues( + @PathVariable Long couponId, Pageable pageable ) { - List issues = couponIssueService.getIssuesByCoupon(couponId); - List response = issues.stream() - .map(CouponV1Dto.CouponIssueResponse::from) - .toList(); - return ApiResponse.success(response); + return ApiResponse.success( + couponIssueService.getIssuesByCoupon(couponId, pageable) + .map(CouponV1Dto.CouponIssueResponse::from) + ); } } From e852910dd77a13ab6bc6ae31c782238dd0fbaef4 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 13:07:34 +0900 Subject: [PATCH 64/66] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20@LoginMember,=20@AdminUser=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderV1Controller: @RequestHeader 수동 인증 → @LoginMember 전환 - CouponV1Controller: @RequestHeader 수동 인증 → @LoginMember 전환 - OrderAdminV1Controller: 인증 없음 → @AdminUser 적용 - CouponAdminV1Controller: 인증 없음 → @AdminUser 적용 Co-Authored-By: Claude Opus 4.6 --- .../api/coupon/CouponAdminV1Controller.java | 23 +++++++++++++++---- .../api/coupon/CouponV1Controller.java | 13 ++++------- .../api/order/OrderV1Controller.java | 20 +++++----------- .../order/admin/OrderAdminV1Controller.java | 8 ++++++- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java index 9cd922bc4..ea9294343 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -4,6 +4,8 @@ import com.loopers.application.coupon.CouponService; import com.loopers.domain.coupon.CouponModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -17,19 +19,26 @@ public class CouponAdminV1Controller { private final CouponIssueService couponIssueService; @GetMapping("/api-admin/v1/coupons") - public ApiResponse> getCoupons(Pageable pageable) { + public ApiResponse> getCoupons( + @AdminUser AdminInfo admin, + Pageable pageable + ) { Page coupons = couponService.getAllCoupons(pageable); return ApiResponse.success(coupons.map(CouponAdminV1Dto.CouponResponse::from)); } @GetMapping("/api-admin/v1/coupons/{couponId}") - public ApiResponse getCoupon(@PathVariable Long couponId) { + public ApiResponse getCoupon( + @AdminUser AdminInfo admin, + @PathVariable Long couponId + ) { CouponModel coupon = couponService.getCoupon(couponId); return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); } @PostMapping("/api-admin/v1/coupons") public ApiResponse createCoupon( + @AdminUser AdminInfo admin, @RequestBody CouponAdminV1Dto.CreateRequest request ) { CouponModel coupon = couponService.create( @@ -41,6 +50,7 @@ public ApiResponse createCoupon( @PutMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse updateCoupon( + @AdminUser AdminInfo admin, @PathVariable Long couponId, @RequestBody CouponAdminV1Dto.UpdateRequest request ) { @@ -52,14 +62,19 @@ public ApiResponse updateCoupon( } @DeleteMapping("/api-admin/v1/coupons/{couponId}") - public ApiResponse deleteCoupon(@PathVariable Long couponId) { + public ApiResponse deleteCoupon( + @AdminUser AdminInfo admin, + @PathVariable Long couponId + ) { couponService.delete(couponId); return ApiResponse.success(null); } @GetMapping("/api-admin/v1/coupons/{couponId}/issues") public ApiResponse> getCouponIssues( - @PathVariable Long couponId, Pageable pageable + @AdminUser AdminInfo admin, + @PathVariable Long couponId, + Pageable pageable ) { return ApiResponse.success( couponIssueService.getIssuesByCoupon(couponId, pageable) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index 63a7ad78e..cbbe0042e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -2,11 +2,11 @@ import com.loopers.application.coupon.CouponIssueService; import com.loopers.application.coupon.CouponService; -import com.loopers.application.member.MemberFacade; import com.loopers.domain.coupon.CouponIssueModel; import com.loopers.domain.coupon.CouponModel; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -18,15 +18,12 @@ public class CouponV1Controller { private final CouponService couponService; private final CouponIssueService couponIssueService; - private final MemberFacade memberFacade; @PostMapping("/api/v1/coupons/{couponId}/issue") public ApiResponse issueCoupon( - @PathVariable Long couponId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password + @LoginMember MemberModel member, + @PathVariable Long couponId ) { - MemberModel member = memberFacade.authenticate(loginId, password); CouponModel coupon = couponService.getCoupon(couponId); CouponIssueModel issue = couponIssueService.issue(coupon, member.getId()); return ApiResponse.success(CouponV1Dto.CouponIssueResponse.from(issue)); @@ -34,10 +31,8 @@ public ApiResponse issueCoupon( @GetMapping("/api/v1/users/me/coupons") public ApiResponse> getMyCoupons( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password + @LoginMember MemberModel member ) { - MemberModel member = memberFacade.authenticate(loginId, password); List issues = couponIssueService.getMyIssues(member.getId()); List response = issues.stream() .map(CouponV1Dto.CouponIssueResponse::from) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index fb6bc1b56..385550781 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,18 +1,17 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.member.MemberFacade; import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderResult; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -26,27 +25,22 @@ public class OrderV1Controller { private final OrderFacade orderFacade; - private final MemberFacade memberFacade; @PostMapping("/api/v1/orders") public ApiResponse createOrder( - @RequestBody OrderV1Dto.CreateRequest request, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password + @LoginMember MemberModel member, + @RequestBody OrderV1Dto.CreateRequest request ) { - MemberModel member = memberFacade.authenticate(loginId, password); OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands(), request.couponIssueId()); return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); } @GetMapping("/api/v1/orders") public ApiResponse> getMyOrders( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password, + @LoginMember MemberModel member, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt ) { - MemberModel member = memberFacade.authenticate(loginId, password); ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); List orders = orderFacade.getOrdersByUser(member.getId(), start, end); @@ -58,11 +52,9 @@ public ApiResponse> getMyOrders( @GetMapping("/api/v1/orders/{orderId}") public ApiResponse getOrder( - @PathVariable Long orderId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password + @LoginMember MemberModel member, + @PathVariable Long orderId ) { - MemberModel member = memberFacade.authenticate(loginId, password); OrderInfo info = orderFacade.getOrder(orderId, member.getId()); return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java index caeff7fad..1067c8535 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java @@ -3,6 +3,8 @@ import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -21,6 +23,7 @@ public class OrderAdminV1Controller { @GetMapping public ApiResponse> getAll( + @AdminUser AdminInfo admin, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { @@ -29,7 +32,10 @@ public ApiResponse> getAll( } @GetMapping("/{orderId}") - public ApiResponse getOrder(@PathVariable Long orderId) { + public ApiResponse getOrder( + @AdminUser AdminInfo admin, + @PathVariable Long orderId + ) { OrderInfo info = orderFacade.getOrderForAdmin(orderId); return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); } From e38da1e7632f2c8f35778eafb363896e624a468e Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 13:11:28 +0900 Subject: [PATCH 65/66] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=A0=81=EC=9A=A9=20-=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=B0=9C=EA=B8=89=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponIssueModel: (user_id, coupon_id) 유니크 키 추가 - CouponIssueService: DataIntegrityViolationException 처리로 중복 발급 시 CONFLICT 응답 Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/coupon/CouponIssueService.java | 7 ++++++- .../java/com/loopers/domain/coupon/CouponIssueModel.java | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java index 9f482c939..80888765e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java @@ -5,6 +5,7 @@ import com.loopers.domain.coupon.CouponModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.springframework.dao.DataIntegrityViolationException; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -31,7 +32,11 @@ public CouponIssueModel issue(CouponModel coupon, Long userId) { }); CouponIssueModel couponIssue = new CouponIssueModel(coupon.getId(), userId, coupon.expiredAt()); - return couponIssueRepository.save(couponIssue); + try { + return couponIssueRepository.save(couponIssue); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 발급받은 쿠폰입니다."); + } } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java index 62b704ee8..c395ca09e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java @@ -8,7 +8,9 @@ import java.time.LocalDateTime; @Entity -@Table(name = "coupon_issue") +@Table(name = "coupon_issue", uniqueConstraints = { + @UniqueConstraint(name = "uk_coupon_issue_user_coupon", columnNames = {"user_id", "coupon_id"}) +}) public class CouponIssueModel extends BaseEntity { @Column(name = "coupon_id", nullable = false) From deda2b1d4fe97dd3d715818e5e301aa6d5826404 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 6 Mar 2026 14:53:47 +0900 Subject: [PATCH 66/66] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9B=90=EC=9E=90=EC=A0=81=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Version이 엔티티 레벨이라 상품 수정과 좋아요가 False Conflict를 일으키는 문제를 해결하기 위해, likeCount를 @Modifying @Query 원자적 업데이트로 전환. - ProductModel: @Version 제거, incrementLikeCount/decrementLikeCount 제거 - ProductJpaRepository: 원자적 UPDATE 쿼리 추가 - LikeToggleService: ProductModel 의존 제거, LikeResult 반환 - LikeFacade: retryOnOptimisticLock 재시도 로직 제거 - OrderV1ApiE2ETest: 관리자 API 테스트 adminHeaders 누락 수정 Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 22 +------- .../like/LikeTransactionService.java | 15 +++--- .../application/product/ProductService.java | 10 ++++ .../com/loopers/domain/like/LikeResult.java | 11 ++++ .../domain/like/LikeToggleService.java | 23 ++++----- .../loopers/domain/product/ProductModel.java | 14 ------ .../domain/product/ProductRepository.java | 4 ++ .../product/ProductJpaRepository.java | 11 ++++ .../product/ProductRepositoryImpl.java | 10 ++++ .../domain/like/LikeToggleServiceTest.java | 50 +++++++------------ .../domain/product/ProductModelTest.java | 41 --------------- .../api/order/OrderV1ApiE2ETest.java | 4 +- 12 files changed, 87 insertions(+), 128 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java 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 index 7fb0e0425..bb5669f65 100644 --- 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 @@ -3,12 +3,9 @@ import com.loopers.domain.like.LikeModel; import com.loopers.domain.product.ProductModel; import com.loopers.application.product.ProductService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -20,27 +17,12 @@ public class LikeFacade { private final ProductService productService; private final LikeTransactionService likeTransactionService; - private static final int MAX_RETRY = 10; - public void like(Long userId, Long productId) { - retryOnOptimisticLock(() -> likeTransactionService.doLike(userId, productId)); + likeTransactionService.doLike(userId, productId); } public void unlike(Long userId, Long productId) { - retryOnOptimisticLock(() -> likeTransactionService.doUnlike(userId, productId)); - } - - private void retryOnOptimisticLock(Runnable action) { - for (int attempt = 0; attempt < MAX_RETRY; attempt++) { - try { - action.run(); - return; - } catch (ObjectOptimisticLockingFailureException e) { - if (attempt == MAX_RETRY - 1) { - throw new CoreException(ErrorType.CONFLICT, "동시 요청이 많아 처리에 실패했습니다. 다시 시도해주세요."); - } - } - } + likeTransactionService.doUnlike(userId, productId); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java index 9ad9a277c..a3b5dd6c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java @@ -1,8 +1,8 @@ package com.loopers.application.like; +import com.loopers.domain.like.LikeResult; import com.loopers.domain.like.LikeModel; import com.loopers.domain.like.LikeToggleService; -import com.loopers.domain.product.ProductModel; import com.loopers.application.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -20,11 +20,14 @@ public class LikeTransactionService { @Transactional public void doLike(Long userId, Long productId) { - ProductModel product = productService.getProduct(productId); Optional existing = likeService.findByUserIdAndProductId(userId, productId); - Optional newLike = likeToggleService.like(existing, product, userId, productId); - newLike.ifPresent(likeService::save); + LikeResult result = likeToggleService.like(existing, userId, productId); + result.newLike().ifPresent(likeService::save); + + if (result.countChanged()) { + productService.incrementLikeCount(productId); + } } @Transactional @@ -32,7 +35,7 @@ public void doUnlike(Long userId, Long productId) { Optional activeLike = likeService.findActiveLike(userId, productId); if (activeLike.isEmpty()) return; - ProductModel product = productService.getProduct(activeLike.get().productId()); - likeToggleService.unlike(activeLike.get(), product); + likeToggleService.unlike(activeLike.get()); + productService.decrementLikeCount(activeLike.get().productId()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index c21240fee..dccc99c42 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -87,6 +87,16 @@ public Map getProductsByIds(List productIds) { .collect(Collectors.toMap(ProductModel::getId, Function.identity())); } + @Transactional + public void incrementLikeCount(Long productId) { + productRepository.incrementLikeCount(productId); + } + + @Transactional + public void decrementLikeCount(Long productId) { + productRepository.decrementLikeCount(productId); + } + private ProductModel findById(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java new file mode 100644 index 000000000..f5c1b7103 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java @@ -0,0 +1,11 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +/** + * 좋아요 토글 결과. + * @param newLike 새로 생성된 LikeModel (저장 필요), 기존 엔티티 변경만 발생한 경우 empty + * @param countChanged likeCount 변경이 필요한지 여부 + */ +public record LikeResult(Optional newLike, boolean countChanged) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java index 70cc1f8d8..421e6b251 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java @@ -1,15 +1,15 @@ package com.loopers.domain.like; -import com.loopers.domain.product.ProductModel; import org.springframework.stereotype.Component; import java.util.Optional; /** * 좋아요 토글 도메인 서비스. - * Like와 Product 두 엔티티의 상태를 종합하여 좋아요 반응을 결정한다. + * Like 엔티티의 상태를 종합하여 좋아요 반응을 결정한다. * * 인프라(Repository, DB) 의존 없이 순수 비즈니스 의사결정만 담당. + * likeCount 변경은 호출자가 원자적 업데이트로 처리한다. */ @Component public class LikeToggleService { @@ -17,30 +17,27 @@ public class LikeToggleService { /** * 좋아요 토글: 상태에 따라 신규 생성 / 복구 / 멱등 무시를 결정한다. * - * @return 새로 생성된 LikeModel (저장 필요), 또는 empty (기존 엔티티 변경만 발생) + * @return LikeResult — 새 LikeModel(저장 필요 여부) + likeCount 변경 여부 */ - public Optional like(Optional existing, ProductModel product, - Long userId, Long productId) { + public LikeResult like(Optional existing, Long userId, Long productId) { if (existing.isEmpty()) { - product.incrementLikeCount(); - return Optional.of(new LikeModel(userId, productId)); + return new LikeResult(Optional.of(new LikeModel(userId, productId)), true); } LikeModel like = existing.get(); if (like.getDeletedAt() != null) { like.restore(); - product.incrementLikeCount(); + return new LikeResult(Optional.empty(), true); } - // else: 이미 활성 좋아요 → 멱등 무시 - return Optional.empty(); + // 이미 활성 좋아요 → 멱등 무시 + return new LikeResult(Optional.empty(), false); } /** - * 좋아요 취소: 활성 좋아요를 삭제하고 likeCount를 감소시킨다. + * 좋아요 취소: 활성 좋아요를 삭제한다. */ - public void unlike(LikeModel like, ProductModel product) { + public void unlike(LikeModel like) { like.delete(); - product.decrementLikeCount(); } } 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 index 98e9f9a24..4e455250b 100644 --- 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 @@ -5,7 +5,6 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; -import jakarta.persistence.Version; @Entity @Table(name = "product") @@ -26,9 +25,6 @@ public class ProductModel extends BaseEntity { @Column(name = "like_count", nullable = false) private int likeCount; - @Version - private Long version; - protected ProductModel() {} public ProductModel(String name, String description, Money price, Long brandId) { @@ -64,14 +60,4 @@ public Long brandId() { public int likeCount() { return likeCount; } - - public void incrementLikeCount() { - this.likeCount++; - } - - public void decrementLikeCount() { - if (this.likeCount > 0) { - this.likeCount--; - } - } } 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 index a9085b013..258dff5d2 100644 --- 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 @@ -10,6 +10,10 @@ public interface ProductRepository { ProductModel save(ProductModel product); + int incrementLikeCount(Long productId); + + int decrementLikeCount(Long productId); + Optional findById(Long id); Page findAllByDeletedAtIsNull(Pageable 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 index c0b66f92a..872758f8a 100644 --- 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 @@ -4,11 +4,22 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface ProductJpaRepository extends JpaRepository { + @Modifying + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id") + int incrementLikeCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") + int decrementLikeCount(@Param("id") Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); 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 index eab1cd675..c3f08bee3 100644 --- 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 @@ -21,6 +21,16 @@ public ProductModel save(ProductModel product) { return productJpaRepository.save(product); } + @Override + public int incrementLikeCount(Long productId) { + return productJpaRepository.incrementLikeCount(productId); + } + + @Override + public int decrementLikeCount(Long productId) { + return productJpaRepository.decrementLikeCount(productId); + } + @Override public Optional findById(Long id) { return productJpaRepository.findById(id); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java index 8fe913f02..b266b4ae4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java @@ -1,7 +1,5 @@ package com.loopers.domain.like; -import com.loopers.domain.product.Money; -import com.loopers.domain.product.ProductModel; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -21,50 +19,44 @@ void setUp() { likeToggleService = new LikeToggleService(); } - private ProductModel createProduct() { - return new ProductModel("에어맥스", "설명", new Money(129000), 1L); - } - @DisplayName("좋아요 토글 - like") @Nested class Like { - @DisplayName("좋아요가 없으면 새로 생성하고 likeCount를 증가시킨다") + @DisplayName("좋아요가 없으면 새로 생성하고 countChanged=true를 반환한다") @Test void createsNewLikeWhenNoneExists() { // given - ProductModel product = createProduct(); Optional existing = Optional.empty(); // when - Optional result = likeToggleService.like(existing, product, 1L, 100L); + LikeResult result = likeToggleService.like(existing, 1L, 100L); // then assertAll( - () -> assertThat(result).isPresent(), - () -> assertThat(result.get().userId()).isEqualTo(1L), - () -> assertThat(result.get().productId()).isEqualTo(100L), - () -> assertThat(product.likeCount()).isEqualTo(1) + () -> assertThat(result.newLike()).isPresent(), + () -> assertThat(result.newLike().get().userId()).isEqualTo(1L), + () -> assertThat(result.newLike().get().productId()).isEqualTo(100L), + () -> assertThat(result.countChanged()).isTrue() ); } - @DisplayName("삭제된 좋아요가 있으면 복구하고 likeCount를 증가시킨다") + @DisplayName("삭제된 좋아요가 있으면 복구하고 countChanged=true를 반환한다") @Test void restoresDeletedLike() { // given - ProductModel product = createProduct(); LikeModel deletedLike = new LikeModel(1L, 100L); deletedLike.delete(); Optional existing = Optional.of(deletedLike); // when - Optional result = likeToggleService.like(existing, product, 1L, 100L); + LikeResult result = likeToggleService.like(existing, 1L, 100L); // then assertAll( - () -> assertThat(result).isEmpty(), - () -> assertThat(deletedLike.getDeletedAt()).isNull(), - () -> assertThat(product.likeCount()).isEqualTo(1) + () -> assertThat(result.newLike()).isEmpty(), + () -> assertThat(result.countChanged()).isTrue(), + () -> assertThat(deletedLike.getDeletedAt()).isNull() ); } @@ -72,17 +64,16 @@ void restoresDeletedLike() { @Test void skipsWhenAlreadyActive() { // given - ProductModel product = createProduct(); LikeModel activeLike = new LikeModel(1L, 100L); Optional existing = Optional.of(activeLike); // when - Optional result = likeToggleService.like(existing, product, 1L, 100L); + LikeResult result = likeToggleService.like(existing, 1L, 100L); // then assertAll( - () -> assertThat(result).isEmpty(), - () -> assertThat(product.likeCount()).isEqualTo(0) + () -> assertThat(result.newLike()).isEmpty(), + () -> assertThat(result.countChanged()).isFalse() ); } } @@ -91,22 +82,17 @@ void skipsWhenAlreadyActive() { @Nested class Unlike { - @DisplayName("활성 좋아요를 삭제하고 likeCount를 감소시킨다") + @DisplayName("활성 좋아요를 삭제한다") @Test - void deletesLikeAndDecrementsCount() { + void deletesLike() { // given - ProductModel product = createProduct(); - product.incrementLikeCount(); LikeModel activeLike = new LikeModel(1L, 100L); // when - likeToggleService.unlike(activeLike, product); + likeToggleService.unlike(activeLike); // then - assertAll( - () -> assertThat(activeLike.getDeletedAt()).isNotNull(), - () -> assertThat(product.likeCount()).isEqualTo(0) - ); + assertThat(activeLike.getDeletedAt()).isNotNull(); } } } 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 index 81ca675e8..b428a95bf 100644 --- 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 @@ -87,46 +87,5 @@ void defaultsToZero() { // then assertThat(product.likeCount()).isEqualTo(0); } - - @DisplayName("incrementLikeCount()를 호출하면 likeCount가 1 증가한다") - @Test - void incrementsLikeCount() { - // given - ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); - - // when - product.incrementLikeCount(); - - // then - assertThat(product.likeCount()).isEqualTo(1); - } - - @DisplayName("decrementLikeCount()를 호출하면 likeCount가 1 감소한다") - @Test - void decrementsLikeCount() { - // given - ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); - product.incrementLikeCount(); - product.incrementLikeCount(); - - // when - product.decrementLikeCount(); - - // then - assertThat(product.likeCount()).isEqualTo(1); - } - - @DisplayName("likeCount가 0일 때 decrementLikeCount()를 호출하면 0을 유지한다") - @Test - void doesNotGoBelowZero() { - // given - ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); - - // when - product.decrementLikeCount(); - - // then - assertThat(product.likeCount()).isEqualTo(0); - } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java index d18c694cf..5b366f864 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -182,7 +182,7 @@ void returnsAllOrders() { signupMember(); placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); var response = testRestTemplate.exchange("/api-admin/v1/orders?page=0&size=20", - HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {}); assertTrue(response.getStatusCode().is2xxSuccessful()); } } @@ -197,7 +197,7 @@ void returnsOrderDetailForAdmin() { var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); Long orderId = orderResponse.getBody().data().orderId(); var response = testRestTemplate.exchange("/api-admin/v1/orders/" + orderId, - HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {}); assertTrue(response.getStatusCode().is2xxSuccessful()); } }