From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/15] 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 8f73709503ccd35478f0218ce2e5a712e4f33db5 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:33 +0900 Subject: [PATCH 02/15] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=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 - CLAUDE.md 추가 (프로젝트 컨텍스트 및 개발 규칙) - spring-security-crypto 의존성 추가 - ErrorType에 UNAUTHORIZED, USER_NOT_FOUND, PASSWORD_MISMATCH 추가 - MySqlTestContainersConfig에 MYSQL_ROOT_PASSWORD 환경변수 추가 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 132 ++++++++++++++++++ apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/support/error/ErrorType.java | 7 +- .../MySqlTestContainersConfig.java | 1 + 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a88a85fa1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +이 파일은 Claude Code가 프로젝트를 이해하는 데 필요한 컨텍스트를 제공합니다. + +## 프로젝트 개요 + +**프로젝트명**: loopers-java-spring-template +**그룹 ID**: com.loopers +**라이선스**: LICENSE 파일 참조 + +커머스 도메인을 위한 Java/Spring Boot 기반 멀티 모듈 백엔드 템플릿 프로젝트입니다. + +## 기술 스택 및 버전 + +| 기술 | 버전 | +|------|------| +| Java | 21 | +| Spring Boot | 3.4.4 | +| Spring Cloud Dependencies | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | +| Lombok | Spring Boot BOM | +| QueryDSL | Spring Boot BOM (Jakarta) | +| SpringDoc OpenAPI | 2.7.0 | +| Micrometer | Spring Boot BOM | +| Testcontainers | Spring Boot BOM | +| JUnit 5 | Spring Boot BOM | +| Mockito | 5.14.0 | +| SpringMockK | 4.0.2 | +| Instancio JUnit | 5.0.2 | +| Slack Appender | 1.6.1 | + +## 모듈 구조 + +``` +loopers-java-spring-template/ +├── apps/ # 실행 가능한 애플리케이션 (BootJar) +│ ├── commerce-api/ # REST API 서버 (Web, OpenAPI) +│ ├── commerce-streamer/ # Kafka 스트림 처리 서버 +│ └── commerce-batch/ # Spring Batch 애플리케이션 +│ +├── modules/ # 공유 라이브러리 모듈 +│ ├── jpa/ # JPA + QueryDSL + MySQL +│ ├── redis/ # Spring Data Redis +│ └── kafka/ # Spring Kafka +│ +├── supports/ # 횡단 관심사 지원 모듈 +│ ├── jackson/ # Jackson 직렬화 설정 +│ ├── logging/ # 로깅 + Slack Appender +│ └── monitoring/ # Prometheus + Micrometer +│ +├── docker/ # Docker 관련 설정 +└── http/ # HTTP 요청 파일 (IntelliJ HTTP Client) +``` + +### 모듈 의존성 관계 + +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring + +## 빌드 및 실행 + +```bash +# 전체 빌드 +./gradlew build + +# 특정 앱 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-streamer:bootRun +./gradlew :apps:commerce-batch:bootRun + +# 테스트 실행 +./gradlew test +``` + +## 테스트 환경 + +- 테스트 시 `Asia/Seoul` 타임존 사용 +- 테스트 프로파일: `test` +- Testcontainers 사용 (MySQL, Redis, Kafka) +- JaCoCo 코드 커버리지 리포트 생성 (XML 포맷) + +## 주요 설정 + +- **버전 관리**: Git 커밋 해시를 기본 버전으로 사용 +- **빌드 타입**: + - `apps/*` 모듈: BootJar (실행 가능한 JAR) + - `modules/*`, `supports/*` 모듈: 일반 JAR (라이브러리) + +## 코드 스타일 + +- Lombok 사용 +- Jackson JSR310 모듈로 Java Time API 직렬화 +- QueryDSL Jakarta 스펙 사용 + + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 +## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..6acd86062 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // security + implementation("org.springframework.security:spring-security-crypto") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") 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..d64c6b491 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 @@ -11,7 +11,12 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + /** 인증 관련 에러 */ + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "존재하지 않는 사용자입니다."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edacc..0495cb5b6 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -14,6 +14,7 @@ public class MySqlTestContainersConfig { .withDatabaseName("loopers") .withUsername("test") .withPassword("test") + .withEnv("MYSQL_ROOT_PASSWORD", "test") .withExposedPorts(3306) .withCommand( "--character-set-server=utf8mb4", From 9180d46950b2933dcc413c985e87236ef32a306d Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:47 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User 엔티티 (필드 검증, BCrypt 암호화, 이름 마스킹) - UserRepository 인터페이스 - UserService (회원가입, 조회, 인증, 비밀번호 변경) - UserTest 단위 테스트 47건 Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/user/User.java | 177 +++++++++ .../loopers/domain/user/UserRepository.java | 9 + .../com/loopers/domain/user/UserService.java | 46 +++ .../com/loopers/domain/user/UserTest.java | 364 ++++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 000000000..5ea7523e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,177 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.regex.Pattern; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣a-zA-Z]+$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*]+$"); + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("uuuuMMdd") + .withResolverStyle(ResolverStyle.STRICT); + + private static final int MAX_LOGIN_ID_BYTES = 30; + private static final int MAX_NAME_BYTES = 30; + private static final int MIN_PASSWORD_LENGTH = 8; + private static final int MAX_PASSWORD_LENGTH = 16; + private static final int BIRTH_DATE_SUBSTRING_LENGTH = 4; + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private String birthDate; + + @Column(name = "email", nullable = false) + private String email; + + protected User() {} + + public User(String loginId, String password, String name, String birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = PASSWORD_ENCODER.encode(password); + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + public boolean matchPassword(String rawPassword) { + return PASSWORD_ENCODER.matches(rawPassword, this.password); + } + + public void changePassword(String newPassword) { + if (matchPassword(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 다르게 설정해야 합니다."); + } + validatePassword(newPassword, this.birthDate); + this.password = PASSWORD_ENCODER.encode(newPassword); + } + + public String getMaskedName() { + if (name.length() <= 1) { + return name; + } + char first = name.charAt(0); + char last = name.charAt(name.length() - 1); + String middle = "*".repeat(name.length() - 2); + return first + middle + last; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 사용할 수 있습니다."); + } + if (loginId.getBytes(StandardCharsets.UTF_8).length > MAX_LOGIN_ID_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 30바이트를 초과할 수 없습니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + if (!NAME_PATTERN.matcher(name).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글과 영문만 사용할 수 있습니다."); + } + if (name.getBytes(StandardCharsets.UTF_8).length > MAX_NAME_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 30바이트를 초과할 수 없습니다."); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + if (birthDate.length() != 8 || !birthDate.matches("\\d{8}")) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 YYYYMMDD 형식이어야 합니다."); + } + try { + LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 날짜입니다."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 이메일 형식입니다."); + } + } + + private void validatePassword(String password, String birthDate) { + if (password == null || password.length() < MIN_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8자 이상이어야 합니다."); + } + if (password.length() > MAX_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 16자 이하이어야 합니다."); + } + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자(!@#$%^&*)만 사용할 수 있습니다."); + } + if (containsBirthDateSubstring(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일 정보를 포함할 수 없습니다."); + } + } + + private boolean containsBirthDateSubstring(String password, String birthDate) { + for (int i = 0; i <= birthDate.length() - BIRTH_DATE_SUBSTRING_LENGTH; i++) { + String substring = birthDate.substring(i, i + BIRTH_DATE_SUBSTRING_LENGTH); + if (password.contains(substring)) { + return true; + } + } + return false; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..15889936f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 000000000..5fb27a419 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.user; + +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 UserService { + + private final UserRepository userRepository; + + @Transactional + public User register(String loginId, String password, String name, String birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다."); + } + User user = new User(loginId, password, name, birthDate, email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUserByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "[loginId = " + loginId + "] 사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String password) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "존재하지 않는 사용자입니다.")); + + if (!user.matchPassword(password)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH, "비밀번호가 일치하지 않습니다."); + } + return user; + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = authenticate(loginId, currentPassword); + user.changePassword(newPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 000000000..427487637 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,364 @@ +package com.loopers.domain.user; + +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + + @DisplayName("User를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 입력이 주어지면, 정상적으로 생성된다.") + @Test + void createsUser_whenValidInputIsProvided() { + // arrange + String loginId = "testuser123"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User user = new User(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(loginId), + () -> assertThat(user.getName()).isEqualTo(name), + () -> assertThat(user.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(user.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("loginId가 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenLoginIdIsNullOrEmpty(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId가 영문+숫자 외 문자를 포함하면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test user", "테스트유저", "test_user"}) + void throwsBadRequestException_whenLoginIdContainsInvalidChars(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId가 30바이트를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenLoginIdExceeds30Bytes() { + // arrange + String loginId = "a".repeat(31); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenNameIsNullOrEmpty(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 한글+영문 외 문자를 포함하면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"홍길동123", "홍길동!", "홍 길동"}) + void throwsBadRequestException_whenNameContainsInvalidChars(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 30바이트를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameExceeds30Bytes() { + // arrange - 한글 1자 = 3바이트, 11자 = 33바이트 + String name = "가".repeat(11); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenBirthDateIsNullOrEmpty(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 YYYYMMDD 포맷이 아니면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"1990-01-01", "19901", "990101", "2000/01/01"}) + void throwsBadRequestException_whenBirthDateHasInvalidFormat(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 유효하지 않은 날짜면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"19901301", "19900132", "20000230", "19000001"}) + void throwsBadRequestException_whenBirthDateIsInvalidDate(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email이 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenEmailIsNullOrEmpty(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email이 유효하지 않은 형식이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"test", "test@", "@example.com", "test@.com", "test@com"}) + void throwsBadRequestException_whenEmailHasInvalidFormat(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordIsTooShort() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test12!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 16자를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordIsTooLong() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234567890123!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 허용되지 않은 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"Test1234~", "Test1234()", "Test1234<>"}) + void throwsBadRequestException_whenPasswordContainsInvalidChars(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 생년월일 4자리 이상 부분문자열이 포함되면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"Test1990!", "Test0101!", "Test9001!"}) + void throwsBadRequestException_whenPasswordContainsBirthDateSubstring(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호를 검증할 때,") + @Nested + class MatchPassword { + + @DisplayName("비밀번호가 일치하면, true를 반환한다.") + @Test + void returnsTrue_whenPasswordMatches() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("Test1234!"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("비밀번호가 일치하지 않으면, false를 반환한다.") + @Test + void returnsFalse_whenPasswordDoesNotMatch() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("WrongPass1!"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("유효한 새 비밀번호로 변경하면, 성공한다.") + @Test + void succeeds_whenNewPasswordIsValid() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + user.changePassword("NewPass12!"); + + // assert + assertThat(user.matchPassword("NewPass12!")).isTrue(); + } + + @DisplayName("현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewPasswordIsSameAsCurrent() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("Test1234!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 규칙을 위반하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewPasswordViolatesRules() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("short"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때,") + @Nested + class GetMaskedName { + + @DisplayName("2글자 이상이면, 첫 글자와 마지막 글자만 보이고 중간은 *로 마스킹된다.") + @Test + void masksMiddleCharacters_whenNameHasTwoOrMoreCharacters() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("홍*동"); + } + + @DisplayName("1글자이면, 그대로 반환된다.") + @Test + void returnsAsIs_whenNameHasOneCharacter() { + // arrange + User user = new User("testuser", "Test1234!", "김", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("김"); + } + + @DisplayName("영문 이름도 마스킹된다.") + @Test + void masksEnglishName() { + // arrange + User user = new User("testuser", "Test1234!", "John", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("J**n"); + } + } +} From 5e5db91f57ea36b72ce4dbf7560db941940e92f9 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:58 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20User=20Infrastructure=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserJpaRepository (Spring Data JPA) - UserRepositoryImpl (Repository 구현체) - UserServiceIntegrationTest 통합 테스트 9건 Co-Authored-By: Claude Opus 4.5 --- .../user/UserJpaRepository.java | 11 + .../user/UserRepositoryImpl.java | 30 +++ .../user/UserServiceIntegrationTest.java | 209 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..fb0e51c3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..9a9ed24a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..c87e0e65a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,209 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입할 때,") + @Nested + class Register { + + @DisplayName("유효한 정보로 가입하면, 사용자가 생성된다.") + @Test + void createsUser_whenValidInfoIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User result = userService.register(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflictException_whenLoginIdAlreadyExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.register(loginId, "Another12!", "김철수", "19950505", "another@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("사용자를 조회할 때,") + @Nested + class GetUserByLoginId { + + @DisplayName("존재하는 로그인 ID로 조회하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + User result = userService.getUserByLoginId(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("존재하지 않는 로그인 ID로 조회하면, USER_NOT_FOUND 예외가 발생한다.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserByLoginId(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + } + + @DisplayName("인증할 때,") + @Nested + class Authenticate { + + @DisplayName("올바른 로그인 ID와 비밀번호로 인증하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenCredentialsAreValid() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + userService.register(loginId, password, "홍길동", "19900101", "test@example.com"); + + // act + User result = userService.authenticate(loginId, password); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("존재하지 않는 로그인 ID로 인증하면, USER_NOT_FOUND 예외가 발생한다.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + String password = "Test1234!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, password); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + + @DisplayName("잘못된 비밀번호로 인증하면, PASSWORD_MISMATCH 예외가 발생한다.") + @Test + void throwsPasswordMismatchException_whenPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, "WrongPass1!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경하면, 성공한다.") + @Test + void succeeds_whenCurrentPasswordIsCorrectAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass12!"; + userService.register(loginId, currentPassword, "홍길동", "19900101", "test@example.com"); + + // act + userService.changePassword(loginId, currentPassword, newPassword); + + // assert + User updatedUser = userService.authenticate(loginId, newPassword); + assertThat(updatedUser).isNotNull(); + } + + @DisplayName("잘못된 현재 비밀번호로 변경하면, PASSWORD_MISMATCH 예외가 발생한다.") + @Test + void throwsPasswordMismatchException_whenCurrentPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(loginId, "WrongPass1!", "NewPass12!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } +} From 81ad178f60af5a7e67abf7bfd77adbd9ef69ec61 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:09 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20Application=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EB=B0=8F=20=ED=97=A4=EB=8D=94=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserFacade, UserInfo (Application 계층) - AuthenticatedUser, AuthenticatedUserArgumentResolver (헤더 인증) - WebMvcConfig (ArgumentResolver 등록) Co-Authored-By: Claude Opus 4.5 --- .../loopers/application/user/UserFacade.java | 27 ++++++++++++ .../loopers/application/user/UserInfo.java | 23 ++++++++++ .../java/com/loopers/config/WebMvcConfig.java | 21 ++++++++++ .../api/auth/AuthenticatedUser.java | 4 ++ .../AuthenticatedUserArgumentResolver.java | 42 +++++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.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/api/auth/AuthenticatedUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..56fdc56d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String password, String name, String birthDate, String email) { + User user = userService.register(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(String loginId, String password) { + User user = userService.authenticate(loginId, password); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentPassword, String newPassword) { + userService.changePassword(loginId, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 000000000..ab17729e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo( + Long id, + String loginId, + String name, + String maskedName, + String birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} 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..9fa63b863 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.auth.AuthenticatedUserArgumentResolver; +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 AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authenticatedUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java new file mode 100644 index 000000000..6933472f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.api.auth; + +public record AuthenticatedUser(String loginId, String password) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java new file mode 100644 index 000000000..a4a25634c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java @@ -0,0 +1,42 @@ +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 AuthenticatedUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(AuthenticatedUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String loginId = webRequest.getHeader(HEADER_LOGIN_ID); + String password = webRequest.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginId 헤더가 필요합니다."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginPw 헤더가 필요합니다."); + } + + return new AuthenticatedUser(loginId, password); + } +} From beaf1a117e5b44a9450c2b801a5e9767e6b9c724 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:20 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20User=20API=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserV1Controller (POST /users, GET /users/me, PATCH /users/me/password) - UserV1Dto (요청/응답 DTO) - UserV1ApiSpec (OpenAPI 스펙) - UserV1ApiE2ETest E2E 테스트 12건 - user-v1.http (IntelliJ HTTP Client) Co-Authored-By: Claude Opus 4.5 --- .../interfaces/api/user/UserV1ApiSpec.java | 34 +++ .../interfaces/api/user/UserV1Controller.java | 61 ++++ .../interfaces/api/user/UserV1Dto.java | 47 +++ .../interfaces/api/UserV1ApiE2ETest.java | 289 ++++++++++++++++++ http/commerce-api/user-v1.http | 27 ++ 5 files changed, 458 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java create mode 100644 http/commerce-api/user-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 000000000..944fb2944 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "사용자 관련 API입니다.") +public interface UserV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 사용자를 등록합니다." + ) + ApiResponse register(UserV1Dto.RegisterRequest request); + + @Operation( + summary = "내 정보 조회", + description = "현재 로그인한 사용자의 정보를 조회합니다." + ) + ApiResponse getMe( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser + ); + + @Operation( + summary = "비밀번호 변경", + description = "현재 로그인한 사용자의 비밀번호를 변경합니다." + ) + ApiResponse changePassword( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser, + UserV1Dto.ChangePasswordRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 000000000..05660b0a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo info = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.RegisterResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe(AuthenticatedUser authenticatedUser) { + UserInfo info = userFacade.getMyInfo( + authenticatedUser.loginId(), + authenticatedUser.password() + ); + return ApiResponse.success(UserV1Dto.MeResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + AuthenticatedUser authenticatedUser, + @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword( + authenticatedUser.loginId(), + request.currentPassword(), + request.newPassword() + ); + return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 000000000..67bc1be17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +public class UserV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + public record RegisterResponse(Long userId) { + public static RegisterResponse from(UserInfo info) { + return new RegisterResponse(info.id()); + } + } + + public record MeResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MeResponse from(UserInfo info) { + return new MeResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record ChangePasswordResponse(String message) { + public static ChangePasswordResponse success() { + return new ChangePasswordResponse("비밀번호가 성공적으로 변경되었습니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 000000000..47d86b790 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,289 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.user.UserV1Dto; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/me/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 201 CREATED와 userId를 반환한다.") + @Test + void returns201AndUserId_whenValidInfoIsProvided() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Test1234!", + "홍길동", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isNotNull() + ); + } + + @DisplayName("이미 존재하는 loginId로 회원가입하면, 409 CONFLICT를 반환한다.") + @Test + void returns409Conflict_whenLoginIdAlreadyExists() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Another12!", + "김철수", + "19950505", + "another@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("유효하지 않은 입력으로 회원가입하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenInputIsInvalid() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "test@user", // 잘못된 loginId + "Test1234!", + "홍길동", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMe { + + @DisplayName("유효한 인증 정보로 조회하면, 200 OK와 마스킹된 사용자 정보를 반환한다.") + @Test + void returns200AndMaskedUserInfo_whenCredentialsAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍*동"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("19900101"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("인증 헤더가 누락되면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenAuthHeaderIsMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + // 헤더 없음 + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 사용자로 조회하면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returns401Unauthorized_whenUserNotFound() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 틀리면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returns401Unauthorized_whenPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/v1/users/me/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 인증과 비밀번호로 변경하면, 200 OK와 성공 메시지를 반환한다.") + @Test + void returns200AndSuccessMessage_whenCredentialsAndPasswordAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().message()).isEqualTo("비밀번호가 성공적으로 변경되었습니다.") + ); + } + + @DisplayName("헤더 비밀번호와 body currentPassword가 다르면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returns401Unauthorized_whenCurrentPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 규칙을 위반하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenNewPasswordIsInvalid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "short"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 000000000..a392a8816 --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,27 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "19900101", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 비밀번호 변경 +PATCH {{commerce-api}}/api/v1/users/me/password +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass12!" +} From 61db53c8d9c6170dcdb32e55e7682281b6f0b6e1 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:31 +0900 Subject: [PATCH 07/15] =?UTF-8?q?chore:=20PR=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20Claude=20Code=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/commands/create-pr.md (PR 템플릿 기반 자동 생성 스킬) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/create-pr.md | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .claude/commands/create-pr.md diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md new file mode 100644 index 000000000..00b1102be --- /dev/null +++ b/.claude/commands/create-pr.md @@ -0,0 +1,49 @@ +현재 브랜치의 변경사항을 분석하여 `.github/pull_request_template.md` 양식에 맞는 PR을 자동 생성한다. + +## 수행 절차 + +### 1단계: 변경사항 분석 +아래 명령어를 **병렬로** 실행하여 정보를 수집한다: +- `git status` (변경된 파일 목록) +- `git log main..HEAD --oneline` (현재 브랜치의 커밋 내역) +- `git diff main...HEAD --stat` (변경된 파일 통계) +- `git diff main...HEAD` (전체 변경 내용) + +### 2단계: PR 본문 작성 +`.github/pull_request_template.md` 양식을 읽고, 수집한 정보를 기반으로 아래 규칙에 따라 본문을 작성한다. + +#### 📌 Summary +- **배경**: 이 변경이 필요한 이유 (기존 문제, 요구사항) +- **목표**: 이번 PR에서 달성하려는 것 +- **결과**: 변경 후 달라지는 점 + +#### 🧭 Context & Decision +- **문제 정의**: 현재 동작/제약, 문제(리스크), 성공 기준을 구체적으로 기술 +- **선택지와 결정**: 코드에서 실제 사용된 기술적 선택(패턴, 라이브러리, 구조)과 그 이유를 기술. 대안이 명확하지 않으면 "단일 접근" 으로 표기 + +#### 🏗️ Design Overview +- **변경 범위**: 실제 변경된 모듈/도메인, 신규 추가 파일, 제거/대체된 파일을 나열 +- **주요 컴포넌트 책임**: 변경된 주요 클래스/파일의 역할을 `ComponentName`: 설명 형태로 기술 + +#### 🔁 Flow Diagram +- **핵심 API 흐름마다** Mermaid `sequenceDiagram`을 작성한다 +- 참여자(participant)는 실제 클래스명을 사용한다 +- `autonumber`를 포함한다 +- 정상 흐름과 예외 흐름(alt/else)을 모두 포함한다 +- API가 여러 개면 각각 별도 다이어그램으로 작성한다 + +### 3단계: PR 생성 +- 브랜치가 리모트에 push되지 않았으면 `git push -u origin ` 실행 +- `gh pr create` 명령어로 PR 생성 +- PR 제목은 70자 이내, 변경의 핵심을 요약 +- PR 본문은 HEREDOC으로 전달 + +```bash +gh pr create --title "PR 제목" --body "$(cat <<'EOF' +... 작성된 PR 본문 ... +EOF +)" +``` + +### 4단계: 결과 보고 +- 생성된 PR URL을 사용자에게 반환한다 From 44bfc368022317b7ceb2990fabbd617848243cb5 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 15:33:13 +0900 Subject: [PATCH 08/15] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B6=84=EC=84=9D=20=EB=B0=8F=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20=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 - 01-requirements.md: 도메인별 필드/비즈니스 규칙, 유저 시나리오 - 02-sequence-diagrams.md: 주문/좋아요/브랜드 삭제 시퀀스 다이어그램 - 03-class-diagram.md: 계층별 클래스 구조 다이어그램 - 04-erd.md: 테이블 스키마, 인덱스, FK 정책 Co-Authored-By: Claude Opus 4.5 --- .claude/skills/requirements-analysis/SKILL.md | 77 ++ .docs/design/01-requirements.md | 431 +++++++++++ .docs/design/02-sequence-diagrams.md | 437 +++++++++++ .docs/design/03-class-diagram.md | 700 ++++++++++++++++++ .docs/design/04-erd.md | 419 +++++++++++ 5 files changed, 2064 insertions(+) create mode 100644 .claude/skills/requirements-analysis/SKILL.md create mode 100644 .docs/design/01-requirements.md create mode 100644 .docs/design/02-sequence-diagrams.md create mode 100644 .docs/design/03-class-diagram.md create mode 100644 .docs/design/04-erd.md diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..baa2e2eca --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,431 @@ +# 커머스 도메인 요구사항 정의서 + +## 1. 개요 + +### 1.1 문서 목적 +Java/Spring Boot 멀티 모듈 커머스 백엔드의 Brand, Product, ProductLike, Order, OrderItem 도메인에 대한 +상세 요구사항을 정의한다. + +### 1.2 기존 패턴 참조 +- 아키텍처: Layered Architecture (interfaces → application → domain → infrastructure) +- 인증: 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) +- 응답 형식: ApiResponse (meta + data) +- 예외 처리: CoreException + ErrorType enum + +### 1.3 액터 정의 + +| 액터 | 설명 | 인증 방식 | +|------|------|----------| +| 일반 사용자 | 상품 조회, 좋아요, 주문 가능 | X-Loopers-LoginId + X-Loopers-LoginPw | +| 어드민 | 브랜드/상품 CRUD 관리 | X-Loopers-Ldap: loopers.admin | + +--- + +## 2. 도메인별 상세 요구사항 + +### 2.1 Brand (브랜드) + +#### 2.1.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| name | String | Y | 1-100자, 공백 불가, 중복 불가 | +| description | String | N | 최대 500자 | +| logoUrl | String | N | URL 형식 검증, 최대 500자 | +| createdAt | ZonedDateTime | Y | 자동 생성 | +| updatedAt | ZonedDateTime | Y | 자동 갱신 | +| deletedAt | ZonedDateTime | N | Soft Delete | + +*브랜드 주소, 대표명, 브랜드 사이트 URL 같은 컬럼을 넣을지 말지 고민했지만 설계에 집중하고 싶어서 넣지 않았다.* + +#### 2.1.2 비즈니스 규칙 +- **BR-BRAND-001**: 브랜드명은 시스템 내 유일해야 한다 +- **BR-BRAND-002**: 브랜드 삭제 시 해당 브랜드의 모든 상품이 Cascade 삭제된다 +- **BR-BRAND-003**: 삭제된 브랜드는 조회되지 않는다 (Soft Delete) + +#### 2.1.3 검증 규칙 +``` +name 검증: +- null 또는 blank 불가 → "브랜드명은 필수입니다." +- 100자 초과 → "브랜드명은 100자를 초과할 수 없습니다." +- 중복 → "이미 존재하는 브랜드명입니다." (409 CONFLICT) + +description 검증: +- 500자 초과 → "브랜드 설명은 500자를 초과할 수 없습니다." + +logoUrl 검증: +- URL 형식 불일치 → "유효하지 않은 URL 형식입니다." +- 500자 초과 → "로고 URL은 500자를 초과할 수 없습니다." +``` + +--- + +### 2.2 Product (상품) + +#### 2.2.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| brandId | Long | Y | Brand FK, 존재 검증 | +| name | String | Y | 1-200자 | +| description | String | N | 최대 2000자 | +| price | Long | Y | 0 이상 | +| stock | Integer | Y | 0 이상 | +| imageUrl | String | N | URL 형식, 최대 500자 | +| createdAt | ZonedDateTime | Y | 자동 생성 | +| updatedAt | ZonedDateTime | Y | 자동 갱신 | +| deletedAt | ZonedDateTime | N | Soft Delete | + +#### 2.2.2 비즈니스 규칙 +- **BR-PRODUCT-001**: 상품은 반드시 하나의 브랜드에 속해야 한다 +- **BR-PRODUCT-002**: 상품 등록 후 브랜드 변경 불가 +- **BR-PRODUCT-003**: 상품 삭제 시 해당 상품의 모든 좋아요가 Cascade 삭제된다 +- **BR-PRODUCT-004**: 삭제된 상품은 목록 조회 시 제외된다 +- **BR-PRODUCT-005**: 재고가 0인 상품도 조회는 가능하다 + +#### 2.2.3 검증 규칙 +``` +name 검증: +- null 또는 blank 불가 → "상품명은 필수입니다." +- 200자 초과 → "상품명은 200자를 초과할 수 없습니다." + +price 검증: +- null 불가 → "가격은 필수입니다." +- 음수 → "가격은 0원 이상이어야 합니다." + +stock 검증: +- null 불가 → "재고는 필수입니다." +- 음수 → "재고는 0개 이상이어야 합니다." + +brandId 검증: +- 존재하지 않는 브랜드 → "존재하지 않는 브랜드입니다." (404 NOT_FOUND) +- 삭제된 브랜드 → "삭제된 브랜드입니다." (400 BAD_REQUEST) +``` + +--- + +### 2.3 ProductLike (상품 좋아요) + +#### 2.3.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| userId | Long | Y | User FK | +| productId | Long | Y | Product FK | +| createdAt | ZonedDateTime | Y | 자동 생성 | + +#### 2.3.2 비즈니스 규칙 +- **BR-LIKE-001**: 좋아요 등록 시 이미 좋아요가 존재하면 좋아요 취소 처리 (토글 방식) +- **BR-LIKE-002**: 좋아요 개수는 실시간 COUNT 집계 +- **BR-LIKE-003**: 존재하지 않는 좋아요 취소 시 멱등 처리 (에러 없이 성공 응답) +- **BR-LIKE-004**: 삭제된 상품에는 좋아요 불가 + +#### 2.3.3 검증 규칙 +``` +좋아요 등록: +- 삭제된 상품 → "삭제된 상품입니다." (400 BAD_REQUEST) +- 존재하지 않는 상품 → "존재하지 않는 상품입니다." (404 NOT_FOUND) +- 중복 좋아요 → 좋아요 취소 처리 (토글) + +좋아요 취소: +- 존재하지 않는 좋아요 → 멱등 처리 (성공 응답) +``` + +--- + +### 2.4 Order (주문) + +#### 2.4.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| userId | Long | Y | User FK | +| totalPrice | Long | Y | 0 이상, 계산된 값 | +| status | OrderStatus | Y | PENDING, COMPLETED, CANCELLED | +| createdAt | ZonedDateTime | Y | 자동 생성 | +| updatedAt | ZonedDateTime | Y | 자동 갱신 | + +#### 2.4.2 OrderStatus 상태 정의 +```java +public enum OrderStatus { + PENDING, // 주문 대기 + COMPLETED, // 주문 완료 + CANCELLED // 주문 취소 +} +``` + +#### 2.4.3 비즈니스 규칙 +- **BR-ORDER-001**: 다건 상품 주문 지원 (OrderItem 1:N 관계) +- **BR-ORDER-002**: 전체 실패 정책 - 하나라도 실패 시 전체 주문 롤백 +- **BR-ORDER-003**: 재고 검증 후 차감은 원자적으로 수행 (비관적 락) +- **BR-ORDER-004**: totalPrice는 OrderItem들의 (price * quantity) 합계 + +--- + +### 2.5 OrderItem (주문 항목) + +#### 2.5.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| orderId | Long | Y | Order FK | +| productId | Long | Y | Product FK | +| quantity | Integer | Y | 1 이상 | +| price | Long | Y | 주문 시점 상품 가격 (스냅샷) | +| createdAt | ZonedDateTime | Y | 자동 생성 | + +#### 2.5.2 비즈니스 규칙 +- **BR-ORDERITEM-001**: 주문 시점의 상품 가격을 스냅샷으로 저장 +- **BR-ORDERITEM-002**: 동일 주문 내 동일 상품 중복 불가 (orderId + productId UNIQUE) +- **BR-ORDERITEM-003**: 수량은 최소 1개 이상 + +--- + +## 3. 유저 시나리오 + +### 3.1 일반 사용자 시나리오 + +#### US-001: 브랜드 정보 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/brands/{brandId} 요청 +Then: 브랜드 정보(name, description, logoUrl) 반환 +``` + +#### US-002: 상품 목록 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/products 요청 (정렬/필터/페이징 옵션) +Then: 상품 목록과 좋아요 수 반환 + +정렬 옵션: +- latest (기본값): 최신순 +- price_asc: 가격 낮은순 +- like_desc: 좋아요 많은순 + +필터 옵션: +- brandId: 특정 브랜드 필터 + +페이징: +- page: 페이지 번호 (0부터 시작) +- size: 페이지 크기 (기본 20) +``` + +#### US-003: 상품 상세 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/products/{productId} 요청 +Then: 상품 상세 정보와 좋아요 수 반환 +``` + +#### US-004: 좋아요 등록/토글 +``` +Given: 사용자가 로그인 상태 +When: POST /api/v1/products/{productId}/likes 요청 +Then: + - 좋아요가 없으면 → 좋아요 등록 + - 좋아요가 있으면 → 좋아요 취소 (토글) +``` + +#### US-005: 좋아요 취소 +``` +Given: 사용자가 로그인 상태 +When: DELETE /api/v1/products/{productId}/likes 요청 +Then: 좋아요 삭제 (존재하지 않아도 성공) +``` + +#### US-006: 내가 좋아요한 상품 목록 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/users/{userId}/likes 요청 +Then: 좋아요한 상품 목록 반환 +``` + +#### US-007: 주문 생성 +``` +Given: 사용자가 로그인 상태, 주문할 상품들 선택 +When: POST /api/v1/orders 요청 (items: [{productId, quantity}]) +Then: + - 재고 검증 (모든 상품) + - 재고 차감 (원자적) + - 주문 생성 및 ID 반환 + - 실패 시 전체 롤백 +``` + +#### US-008: 주문 목록 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/orders 요청 (선택적 기간 필터) +Then: 본인의 주문 목록 반환 +``` + +#### US-009: 주문 상세 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/orders/{orderId} 요청 +Then: + - 본인 주문인 경우: 주문 상세 반환 + - 타인 주문인 경우: 403 FORBIDDEN +``` + +--- + +### 3.2 어드민 시나리오 + +#### AS-001: 브랜드 목록 조회 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: GET /api-admin/v1/brands 요청 +Then: 전체 브랜드 목록 반환 +``` + +#### AS-002: 브랜드 등록 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: POST /api-admin/v1/brands 요청 +Then: 브랜드 등록 및 ID 반환 +``` + +#### AS-003: 브랜드 수정 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: PUT /api-admin/v1/brands/{brandId} 요청 +Then: 브랜드 정보 수정 +``` + +#### AS-004: 브랜드 삭제 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: DELETE /api-admin/v1/brands/{brandId} 요청 +Then: + - 브랜드 Soft Delete + - 해당 브랜드의 모든 상품 Cascade Soft Delete + - 상품들의 좋아요 Cascade Hard Delete +``` + +#### AS-005: 상품 등록 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: POST /api-admin/v1/products 요청 +Then: 상품 등록 및 ID 반환 +``` + +#### AS-006: 상품 수정 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: PUT /api-admin/v1/products/{productId} 요청 +Then: + - brandId 제외 필드 수정 가능 + - brandId 변경 시도 시: "브랜드 변경은 불가능합니다." (400 BAD_REQUEST) +``` + +#### AS-007: 상품 삭제 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: DELETE /api-admin/v1/products/{productId} 요청 +Then: + - 상품 Soft Delete + - 좋아요 Cascade Hard Delete +``` + +--- + +## 4. API 명세 + +### 4.1 일반 사용자 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | /api/v1/brands/{brandId} | 브랜드 정보 조회 | +| GET | /api/v1/products | 상품 목록 조회 | +| GET | /api/v1/products/{productId} | 상품 상세 조회 | +| POST | /api/v1/products/{productId}/likes | 좋아요 등록 | +| DELETE | /api/v1/products/{productId}/likes | 좋아요 취소 | +| GET | /api/v1/users/{userId}/likes | 내가 좋아요한 상품 목록 | +| POST | /api/v1/orders | 주문 생성 | +| GET | /api/v1/orders | 주문 목록 조회 | +| GET | /api/v1/orders/{orderId} | 주문 상세 조회 | + +### 4.2 어드민 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | /api-admin/v1/brands | 브랜드 목록 조회 | +| GET | /api-admin/v1/brands/{brandId} | 브랜드 상세 조회 | +| POST | /api-admin/v1/brands | 브랜드 등록 | +| PUT | /api-admin/v1/brands/{brandId} | 브랜드 수정 | +| DELETE | /api-admin/v1/brands/{brandId} | 브랜드 삭제 | +| GET | /api-admin/v1/products | 상품 목록 조회 | +| GET | /api-admin/v1/products/{productId} | 상품 상세 조회 | +| POST | /api-admin/v1/products | 상품 등록 | +| PUT | /api-admin/v1/products/{productId} | 상품 수정 | +| DELETE | /api-admin/v1/products/{productId} | 상품 삭제 | + +--- + +## 5. 인증 체계 + +### 5.1 일반 사용자 인증 +``` +Headers: + X-Loopers-LoginId: {loginId} + X-Loopers-LoginPw: {password} + +처리: AuthenticatedUserArgumentResolver +대상: /api/v1/** 엔드포인트 +``` + +### 5.2 어드민 인증 +``` +Headers: + X-Loopers-Ldap: loopers.admin + +처리: AdminAuthInterceptor + AdminUserArgumentResolver +대상: /api-admin/v1/** 엔드포인트 +``` + +--- + +## 6. 에러 타입 정의 + +```java +// ErrorType.java에 추가할 타입 +BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "존재하지 않는 브랜드입니다."), +BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "이미 존재하는 브랜드명입니다."), +BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "삭제된 브랜드입니다."), +PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "존재하지 않는 상품입니다."), +PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "삭제된 상품입니다."), +INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."), +ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "존재하지 않는 주문입니다."), +ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "주문 접근 권한이 없습니다."), +ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "관리자 권한이 필요합니다."), +BRAND_CHANGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "BRAND_CHANGE_NOT_ALLOWED", "브랜드 변경은 불가능합니다."); +``` + +--- + +## 7. API 응답 형식 + +### 7.1 성공 응답 +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { ... } +} +``` + +### 7.2 실패 응답 +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "PRODUCT_NOT_FOUND", + "message": "존재하지 않는 상품입니다." + }, + "data": null +} +``` diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..faa32ccc6 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,437 @@ +# 시퀀스 다이어그램 + +## 다이어그램 목적 +시퀀스 다이어그램을 통해 다음을 검증한다: +- 책임 분리: 각 객체가 맡은 역할이 명확한가 +- 호출 순서: 비즈니스 로직의 흐름이 올바른가 +- 트랜잭션 경계: 원자성이 보장되는 범위가 적절한가 + +--- + +## 1. 주문 생성 시퀀스 + +### 1.1 정상 흐름 (다건 주문) + +**목적**: 재고 검증, 비관적 락, 트랜잭션 경계 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant OR as OrderRepository + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over C,Ctrl: Body: { items: [{productId, quantity}] } + + Ctrl->>+F: createOrder(userId, items) + + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional 시작 + + loop 각 주문 항목에 대해 + OS->>+PS: getProductForOrder(productId) + PS->>+PR: findByIdWithLock(productId) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: 재고 검증 (stock >= quantity) + + alt 재고 부족 + OS-->>F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: 전체 롤백 + end + + OS->>+PS: decreaseStock(productId, quantity) + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET stock = stock - quantity + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-OS: void + end + + OS->>OS: totalPrice 계산 + OS->>OS: Order 엔티티 생성 + OS->>OS: OrderItem 엔티티들 생성 (가격 스냅샷) + + OS->>+OR: save(order) + OR->>+DB: INSERT orders, order_items + DB-->>-OR: Order (with ID) + OR-->>-OS: Order + + Note over OS: @Transactional 커밋 + + OS-->>-F: Order + F->>F: OrderInfo.from(order) + F-->>-Ctrl: OrderInfo + + Ctrl->>Ctrl: OrderV1Dto.CreateResponse.from(info) + Ctrl-->>-C: 201 Created + { orderId, totalPrice } +``` + +**핵심 포인트:** +- `SELECT ... FOR UPDATE`로 비관적 락 획득 → 동시 주문 시 재고 경쟁 방지 +- 모든 상품 검증 후 차감 → 하나라도 실패 시 전체 롤백 +- OrderItem에 가격 스냅샷 저장 → 상품 가격 변경 시에도 주문 가격 유지 + +--- + +### 1.2 재고 부족 실패 흐름 + +**목적**: 전체 실패 정책, 롤백 동작 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: items: [{productId: 1, qty: 10}, {productId: 2, qty: 5}] + + Ctrl->>+F: createOrder(userId, items) + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional 시작 + + OS->>+PS: getProductForOrder(productId: 1) + PS->>+PR: findByIdWithLock(productId: 1) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 10) + PR-->>-PS: Product + PS-->>-OS: Product + OS->>OS: 재고 검증 통과 (10 >= 10) + OS->>PS: decreaseStock(1, 10) + + OS->>+PS: getProductForOrder(productId: 2) + PS->>+PR: findByIdWithLock(productId: 2) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 3) + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: 재고 검증 실패 (3 < 5) + + OS-->>-F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: 전체 롤백 (상품1 재고 복구) + + F-->>-Ctrl: throw CoreException + Ctrl-->>-C: 400 Bad Request + INSUFFICIENT_STOCK +``` + +**핵심 포인트:** +- 두 번째 상품 재고 부족 시 첫 번째 상품 재고 차감도 롤백 +- 트랜잭션 단위로 원자성 보장 + +--- + +## 2. 좋아요 등록 시퀀스 (토글 방식) + +### 2.1 신규 좋아요 등록 + +**목적**: 상품 유효성 검증 및 좋아요 등록 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: like(userId, productId) + + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>LS: 상품 삭제 여부 검증 + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: null (미존재) + LR-->>-LS: Optional (empty) + + LS->>LS: ProductLike 엔티티 생성 (신규 등록) + + LS->>+LR: save(productLike) + LR->>+DB: INSERT product_likes + DB-->>-LR: ProductLike + LR-->>-LS: ProductLike + + LS-->>-F: ProductLike + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "좋아요가 등록되었습니다." } +``` +**핵심 포인트:** + +- *좋아요 기능 설계 시 아래와 같은 두 가지 선택지가 있었다. 정렬 쿼리를 위해 Product 내부에 좋아요 필드를 두는 방법과 좋아요 테이블을 따로 두는 선택지 중 정합성을 높이는 방식을 선택했다.* + +*1. 비정규화: Product에 likeCount 필드를 두고 좋아요 등록/취소 시 동기 업데이트. 정렬 쿼리 성능 우수* + +*2. 실시간 집계(적용): 좋아요 테이블에서 COUNT 집계. 정합성 높으나 정렬 시 쿼리 비용 증가* + + + + +- 상품 존재 및 삭제 여부 먼저 검증 +- 기존 좋아요가 없으면 신규 등록 + +--- + +### 2.2 기존 좋아요 존재 시 (토글 - 취소 처리) + +**목적**: 토글 방식 동작 확인 - 이미 좋아요가 있으면 취소 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + + Ctrl->>+F: like(userId, productId) + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: ProductLike (존재) + LR-->>-LS: Optional (present) + + Note over LS: 이미 존재하므로 좋아요 취소 (토글) + + LS->>+LR: delete(productLike) + LR->>+DB: DELETE FROM product_likes + DB-->>-LR: OK + LR-->>-LS: void + + LS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "좋아요가 취소되었습니다." } +``` + +**핵심 포인트:** +- 좋아요가 이미 존재하면 삭제 (토글 방식) +- POST 요청 한 번으로 등록/취소 모두 처리 + +--- + +## 3. 브랜드 삭제 시퀀스 (Cascade 삭제) + +**목적**: Cascade 삭제 순서, 트랜잭션 경계 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant Int as AdminAuthInterceptor + participant Ctrl as BrandAdminV1Controller + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + participant LS as ProductLikeService + participant BR as BrandRepository + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Int: DELETE /api-admin/v1/brands/{brandId} + Note over C,Int: Headers: X-Loopers-Ldap: loopers.admin + + Int->>Int: Admin 권한 검증 + Int->>+Ctrl: 요청 전달 + + Ctrl->>+F: deleteBrand(brandId) + F->>+BS: deleteBrand(brandId) + + Note over BS: @Transactional 시작 + + BS->>+BR: findById(brandId) + BR->>+DB: SELECT FROM brands + DB-->>-BR: Brand + BR-->>-BS: Brand + + alt 브랜드 없음 + BS-->>F: throw CoreException(BRAND_NOT_FOUND) + end + + BS->>+PS: getProductsByBrandId(brandId) + PS->>+PR: findAllByBrandId(brandId) + PR->>+DB: SELECT FROM products WHERE brand_id = ? + DB-->>-PR: List + PR-->>-PS: List + PS-->>-BS: List + + loop 각 상품에 대해 + BS->>+LS: deleteAllByProductId(productId) + LS->>+LR: deleteAllByProductId(productId) + LR->>+DB: DELETE FROM product_likes WHERE product_id = ? + DB-->>-LR: OK + LR-->>-LS: void + LS-->>-BS: void + + BS->>+PS: deleteProduct(productId) + PS->>PS: product.delete() + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET deleted_at = NOW() + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-BS: void + end + + BS->>BS: brand.delete() + BS->>+BR: save(brand) + BR->>+DB: UPDATE brands SET deleted_at = NOW() + DB-->>-BR: OK + BR-->>-BS: Brand + + Note over BS: @Transactional 커밋 + + BS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "브랜드가 삭제되었습니다." } +``` + +**핵심 포인트:** +- 삭제 순서: 좋아요(Hard) → 상품(Soft) → 브랜드(Soft) +- 단일 트랜잭션으로 원자성 보장 +- 좋아요는 Hard Delete, 상품/브랜드는 Soft Delete + +--- + +## 4. 상품 목록 조회 시퀀스 (좋아요 수 포함) + +**목적**: 좋아요 실시간 집계, 정렬 옵션 처리 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductV1Controller + participant F as ProductFacade + participant PS as ProductService + participant LS as ProductLikeService + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: GET /api/v1/products?sort=like_desc&brandId=1&page=0&size=20 + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: getProducts(sort, brandId, pageable) + + F->>+PS: getProducts(sort, brandId, pageable) + + alt sort = like_desc (좋아요 많은순) + PS->>+PR: findAllOrderByLikeCountDesc(brandId, pageable) + PR->>+DB: SELECT p.*, COUNT(pl.id) as like_count
FROM products p
LEFT JOIN product_likes pl
GROUP BY p.id
ORDER BY like_count DESC + DB-->>-PR: Page + PR-->>-PS: Page + else sort = latest | price_asc + PS->>+PR: findAll(brandId, pageable, sort) + PR->>+DB: SELECT FROM products WHERE ... + DB-->>-PR: Page + PR-->>-PS: Page + + PS->>+LS: getLikeCounts(productIds) + LS->>+LR: countByProductIdIn(productIds) + LR->>+DB: SELECT product_id, COUNT(*)
FROM product_likes
WHERE product_id IN (...)
GROUP BY product_id + DB-->>-LR: Map + LR-->>-LS: Map + LS-->>-PS: Map + end + + PS-->>-F: Page + + F->>F: List.from(products) + F-->>-Ctrl: Page + + Ctrl->>Ctrl: ProductV1Dto.ListResponse.from(page) + Ctrl-->>-C: 200 OK + { products: [...], pageInfo: {...} } +``` + +**핵심 포인트:** +- `like_desc` 정렬 시 JOIN + COUNT로 한 번에 조회 +- 다른 정렬 시 상품 조회 후 좋아요 수 별도 조회 (N+1 방지를 위해 IN 쿼리 사용) +- *쿠팡과 오늘의 집에서 하듯이, 주문 목록 조회 시 기간(startAt, endAt)으로 조회하는 방안을 검토해 보았으나 설계에 집중하기 위해 넣지 않았다.* +--- + +## 5. 어드민 인증 흐름 + +**목적**: Interceptor + ArgumentResolver 조합 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant F as Filter Chain + participant Int as AdminAuthInterceptor + participant AR as AdminUserArgumentResolver + participant Ctrl as AdminController + + C->>+F: Request to /api-admin/v1/** + Note over C,F: Headers: X-Loopers-Ldap: loopers.admin + + F->>+Int: preHandle() + + Int->>Int: Extract X-Loopers-Ldap header + + alt Header missing + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header != "loopers.admin" + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header = "loopers.admin" + Int-->>-F: true (continue) + end + + F->>+AR: resolveArgument() + Note over AR: AdminUser 파라미터 존재 시 + AR->>AR: Create AdminUser object + AR-->>-F: AdminUser + + F->>+Ctrl: Controller method + Ctrl-->>-F: Response + F-->>-C: Response +``` + +**핵심 포인트:** +- Interceptor가 1차 방어선 (헤더 누락/불일치 시 401) +- ArgumentResolver는 컨트롤러에 AdminUser 객체 주입 +- 이중 안전장치로 보안성 강화 +- *Interceptor 방식은 컨트롤러에서 어드민 정보 접근 시 Request에서 다시 추출 필요하고 특정 메서드만 예외 처리하려면 추가 로직 필요한 문제* +- *ArgumentResolver는 모든 메서드에 @AdminAuth 파라미터 추가 필요하고, 실수로 어노테이션을 누락하면 보안 위험한 문제* + +*-> Interceptor + ArgumentResolver 조합으로 문제를 해결했다.* \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..09f1c4596 --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,700 @@ +# 클래스 다이어그램 + +## 다이어그램 목적 +클래스 다이어그램을 통해 다음을 검증한다: +- 도메인 책임: 각 도메인의 역할이 명확한가 +- 의존 방향: 상위 계층이 하위 계층에만 의존하는가 +- 응집도: 관련 기능이 적절히 그룹화되어 있는가 + +--- + +## 1. 전체 계층 구조 개요 + +```mermaid +classDiagram + direction TB + + namespace Interfaces { + class Controller + class ApiSpec + class Dto + class ArgumentResolver + class Interceptor + } + + namespace Application { + class Facade + class Info + } + + namespace Domain { + class Entity + class Service + class Repository + } + + namespace Infrastructure { + class RepositoryImpl + class JpaRepository + } + + Controller --> Facade : uses + Controller --> Dto : uses + Facade --> Service : uses + Facade --> Info : returns + Service --> Repository : uses + Service --> Entity : uses + RepositoryImpl ..|> Repository : implements + RepositoryImpl --> JpaRepository : uses +``` + +**계층별 책임:** +- **Interfaces**: HTTP 요청/응답 처리, DTO 변환, 인증 처리 +- **Application**: 유스케이스 조율, 도메인 ↔ 프레젠테이션 변환 +- **Domain**: 비즈니스 로직, 엔티티 검증, 도메인 규칙 +- **Infrastructure**: 데이터 접근, 외부 시스템 연동 + +--- + +## 2. Brand 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class BrandV1Controller { + -BrandFacade brandFacade + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + } + + class BrandAdminV1Controller { + -BrandFacade brandFacade + +getBrands(Pageable) ApiResponse~Page~ + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + +createBrand(CreateRequest) ApiResponse~CreateResponse~ + +updateBrand(Long, UpdateRequest) ApiResponse~Response~ + +deleteBrand(Long brandId) ApiResponse~Void~ + } + + class BrandV1Dto { + <> + } + class Response { + <> + +Long id + +String name + +String description + +String logoUrl + +from(BrandInfo) Response + } + class CreateRequest { + <> + +String name + +String description + +String logoUrl + } + class CreateResponse { + <> + +Long brandId + } + class UpdateRequest { + <> + +String name + +String description + +String logoUrl + } + + %% Application Layer + class BrandFacade { + -BrandService brandService + +getBrand(Long brandId) BrandInfo + +getBrands(Pageable) Page~BrandInfo~ + +createBrand(String, String, String) BrandInfo + +updateBrand(Long, String, String, String) BrandInfo + +deleteBrand(Long brandId) void + } + + class BrandInfo { + <> + +Long id + +String name + +String description + +String logoUrl + +from(Brand) BrandInfo + } + + %% Domain Layer + class Brand { + -String name + -String description + -String logoUrl + +Brand(String, String, String) + +update(String, String, String) void + #guard() void + } + + class BrandService { + -BrandRepository brandRepository + +getBrand(Long brandId) Brand + +getBrands(Pageable) Page~Brand~ + +createBrand(String, String, String) Brand + +updateBrand(Long, String, String, String) Brand + +deleteBrand(Long brandId) void + } + + class BrandRepository { + <> + +findById(Long) Optional~Brand~ + +findAll(Pageable) Page~Brand~ + +save(Brand) Brand + +existsByName(String) boolean + } + + %% Infrastructure Layer + class BrandRepositoryImpl { + -BrandJpaRepository brandJpaRepository + } + + class BrandJpaRepository { + <> + +findByName(String) Optional~Brand~ + +existsByNameAndDeletedAtIsNull(String) boolean + } + + %% Relationships + BrandV1Controller --> BrandFacade + BrandAdminV1Controller --> BrandFacade + BrandFacade --> BrandService + BrandFacade --> BrandInfo + BrandService --> BrandRepository + BrandService --> Brand + BrandRepositoryImpl ..|> BrandRepository + BrandRepositoryImpl --> BrandJpaRepository + Brand --|> BaseEntity + + BrandV1Dto ..> Response + BrandV1Dto ..> CreateRequest + BrandV1Dto ..> CreateResponse + BrandV1Dto ..> UpdateRequest +``` + +--- + +## 3. Product 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductV1Controller { + -ProductFacade productFacade + +getProducts(String sort, Long brandId, Pageable) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~DetailResponse~ + } + + class ProductAdminV1Controller { + -ProductFacade productFacade + +getProducts(Pageable, Long brandId) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~AdminDetailResponse~ + +createProduct(CreateRequest) ApiResponse~CreateResponse~ + +updateProduct(Long, UpdateRequest) ApiResponse~Response~ + +deleteProduct(Long productId) ApiResponse~Void~ + } + + %% Application Layer + class ProductFacade { + -ProductService productService + -ProductLikeService productLikeService + -BrandService brandService + +getProducts(String, Long, Pageable) Page~ProductInfo~ + +getProduct(Long productId) ProductInfo + +createProduct(Long, String, String, Long, Integer, String) ProductInfo + +updateProduct(Long, String, String, Long, Integer, String) ProductInfo + +deleteProduct(Long productId) void + } + + class ProductInfo { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +Long price + +Integer stock + +String imageUrl + +Long likeCount + +from(Product, Long) ProductInfo + } + + %% Domain Layer + class Product { + -Long brandId + -String name + -String description + -Long price + -Integer stock + -String imageUrl + +Product(Long, String, String, Long, Integer, String) + +update(String, String, Long, Integer, String) void + +decreaseStock(int quantity) void + +increaseStock(int quantity) void + #guard() void + } + + class ProductService { + -ProductRepository productRepository + -BrandRepository brandRepository + +getProduct(Long productId) Product + +getProductForOrder(Long productId) Product + +getProducts(String, Long, Pageable) Page~Product~ + +getProductsByBrandId(Long brandId) List~Product~ + +createProduct(...) Product + +updateProduct(...) Product + +deleteProduct(Long productId) void + +decreaseStock(Long productId, int quantity) void + } + + class ProductRepository { + <> + +findById(Long) Optional~Product~ + +findByIdWithLock(Long) Optional~Product~ + +findAll(String, Long, Pageable) Page~Product~ + +findAllByBrandId(Long) List~Product~ + +findAllOrderByLikeCountDesc(Long, Pageable) Page~Object[]~ + +save(Product) Product + } + + %% Infrastructure Layer + class ProductRepositoryImpl { + -ProductJpaRepository productJpaRepository + -JPAQueryFactory queryFactory + } + + class ProductJpaRepository { + <> + } + + %% Relationships + ProductV1Controller --> ProductFacade + ProductAdminV1Controller --> ProductFacade + ProductFacade --> ProductService + ProductFacade --> ProductInfo + ProductService --> ProductRepository + ProductService --> Product + ProductRepositoryImpl ..|> ProductRepository + ProductRepositoryImpl --> ProductJpaRepository + Product --|> BaseEntity +``` + +--- + +## 4. ProductLike 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductLikeV1Controller { + -ProductLikeFacade productLikeFacade + +like(AuthenticatedUser, Long productId) ApiResponse~Void~ + +unlike(AuthenticatedUser, Long productId) ApiResponse~Void~ + +getMyLikes(AuthenticatedUser, Long userId) ApiResponse~List~ + } + + %% Application Layer + class ProductLikeFacade { + -ProductLikeService productLikeService + -ProductService productService + -UserService userService + +like(Long userId, Long productId) void + +unlike(Long userId, Long productId) void + +getMyLikes(Long userId) List~ProductLikeInfo~ + } + + class ProductLikeInfo { + <> + +Long productId + +String productName + +Long price + +String imageUrl + +ZonedDateTime likedAt + } + + %% Domain Layer + class ProductLike { + -Long userId + -Long productId + +ProductLike(Long userId, Long productId) + +getUserId() Long + +getProductId() Long + } + + class ProductLikeService { + -ProductLikeRepository productLikeRepository + +like(Long userId, Long productId) ProductLike + +unlike(Long userId, Long productId) void + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long productId) Long + +getLikeCounts(List~Long~ productIds) Map~Long_Long~ + +getByUserId(Long userId) List~ProductLike~ + +deleteAllByProductId(Long productId) void + } + + class ProductLikeRepository { + <> + +findByUserIdAndProductId(Long, Long) Optional~ProductLike~ + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long) Long + +countByProductIdIn(List~Long~) List~Object[]~ + +findAllByUserId(Long) List~ProductLike~ + +deleteByUserIdAndProductId(Long, Long) void + +deleteAllByProductId(Long) void + +save(ProductLike) ProductLike + } + + %% Infrastructure Layer + class ProductLikeRepositoryImpl { + -ProductLikeJpaRepository productLikeJpaRepository + } + + class ProductLikeJpaRepository { + <> + } + + %% Relationships + ProductLikeV1Controller --> ProductLikeFacade + ProductLikeFacade --> ProductLikeService + ProductLikeFacade --> ProductLikeInfo + ProductLikeService --> ProductLikeRepository + ProductLikeService --> ProductLike + ProductLikeRepositoryImpl ..|> ProductLikeRepository + ProductLikeRepositoryImpl --> ProductLikeJpaRepository + ProductLike --|> BaseEntity +``` + +--- + +## 5. Order 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class OrderV1Controller { + -OrderFacade orderFacade + +createOrder(AuthenticatedUser, CreateRequest) ApiResponse~CreateResponse~ + +getOrders(AuthenticatedUser, LocalDate, LocalDate, Pageable) ApiResponse~Page~ + +getOrder(AuthenticatedUser, Long orderId) ApiResponse~DetailResponse~ + } + + class OrderV1Dto { + <> + } + + class CreateRequest { + <> + +List~OrderItemRequest~ items + } + + class OrderItemRequest { + <> + +Long productId + +Integer quantity + } + + class CreateResponse { + <> + +Long orderId + +Long totalPrice + } + + class ListResponse { + <> + +Long orderId + +Long totalPrice + +String status + +ZonedDateTime createdAt + } + + class DetailResponse { + <> + +Long orderId + +Long totalPrice + +String status + +List~OrderItemResponse~ items + +ZonedDateTime createdAt + } + + class OrderItemResponse { + <> + +Long productId + +String productName + +Integer quantity + +Long price + } + + %% Application Layer + class OrderFacade { + -OrderService orderService + -ProductService productService + -UserService userService + +createOrder(Long userId, List~OrderItemRequest~) OrderInfo + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~OrderInfo~ + +getOrder(Long userId, Long orderId) OrderInfo + } + + class OrderInfo { + <> + +Long id + +Long userId + +Long totalPrice + +OrderStatus status + +List~OrderItemInfo~ items + +ZonedDateTime createdAt + +from(Order) OrderInfo + } + + class OrderItemInfo { + <> + +Long productId + +String productName + +Integer quantity + +Long price + +from(OrderItem) OrderItemInfo + } + + %% Domain Layer + class Order { + -Long userId + -Long totalPrice + -OrderStatus status + -List~OrderItem~ orderItems + +Order(Long userId) + +addItem(OrderItem item) void + +calculateTotalPrice() void + +complete() void + +cancel() void + } + + class OrderItem { + -Order order + -Long productId + -String productName + -Integer quantity + -Long price + +OrderItem(Long, String, Integer, Long) + +setOrder(Order order) void + +getSubtotal() Long + } + + class OrderStatus { + <> + PENDING + COMPLETED + CANCELLED + } + + class OrderService { + -OrderRepository orderRepository + -ProductService productService + +createOrder(Long userId, List~OrderItemRequest~) Order + +getOrder(Long orderId) Order + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~Order~ + +validateOrderAccess(Long userId, Order order) void + } + + class OrderRepository { + <> + +findById(Long) Optional~Order~ + +findByUserId(Long, Pageable) Page~Order~ + +findByUserIdAndCreatedAtBetween(...) Page~Order~ + +save(Order) Order + } + + %% Infrastructure Layer + class OrderRepositoryImpl { + -OrderJpaRepository orderJpaRepository + } + + class OrderJpaRepository { + <> + } + + %% Relationships + OrderV1Controller --> OrderFacade + OrderFacade --> OrderService + OrderFacade --> OrderInfo + OrderService --> OrderRepository + OrderService --> Order + Order --> OrderItem + Order --> OrderStatus + OrderRepositoryImpl ..|> OrderRepository + OrderRepositoryImpl --> OrderJpaRepository + Order --|> BaseEntity + OrderItem --|> BaseEntity + + OrderV1Dto ..> CreateRequest + OrderV1Dto ..> OrderItemRequest + OrderV1Dto ..> CreateResponse + OrderV1Dto ..> ListResponse + OrderV1Dto ..> DetailResponse + OrderV1Dto ..> OrderItemResponse +``` + +--- + +## 6. 인증 관련 클래스 + +```mermaid +classDiagram + direction TB + + %% 일반 사용자 인증 (기존) + class AuthenticatedUser { + <> + +String loginId + +String password + } + + class AuthenticatedUserArgumentResolver { + -UserService userService + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% 어드민 인증 (신규) + class AdminUser { + <> + +String ldapId + } + + class AdminAuthInterceptor { + -String ADMIN_LDAP_HEADER + -String ADMIN_LDAP_VALUE + +preHandle(HttpServletRequest, HttpServletResponse, Object) boolean + } + + class AdminUserArgumentResolver { + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% WebMvcConfig + class WebMvcConfig { + -AuthenticatedUserArgumentResolver authResolver + -AdminUserArgumentResolver adminResolver + -AdminAuthInterceptor adminInterceptor + +addArgumentResolvers(List) void + +addInterceptors(InterceptorRegistry) void + } + + %% Interfaces + class HandlerMethodArgumentResolver { + <> + } + + class HandlerInterceptor { + <> + } + + %% Relationships + WebMvcConfig --> AuthenticatedUserArgumentResolver + WebMvcConfig --> AdminUserArgumentResolver + WebMvcConfig --> AdminAuthInterceptor + AuthenticatedUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminAuthInterceptor ..|> HandlerInterceptor +``` + +**핵심 포인트:** +- **AdminAuthInterceptor**: `/api-admin/**` 경로에 대해 헤더 검증 (1차 방어선) +- **AdminUserArgumentResolver**: 컨트롤러에 AdminUser 객체 주입 + +--- + +## 7. 공통 클래스 + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + #guard() void + +delete() void + +restore() void + +isDeleted() boolean + } + + class ApiResponse~T~ { + <> + +Metadata meta + +T data + +success() ApiResponse~Object~ + +success(T data) ApiResponse~T~ + +fail(String, String) ApiResponse~Object~ + } + + class Metadata { + <> + +Result result + +String errorCode + +String message + } + + class Result { + <> + SUCCESS + FAIL + } + + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + } + + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + UNAUTHORIZED + USER_NOT_FOUND + PASSWORD_MISMATCH + BRAND_NOT_FOUND + BRAND_ALREADY_EXISTS + BRAND_DELETED + PRODUCT_NOT_FOUND + PRODUCT_DELETED + INSUFFICIENT_STOCK + ORDER_NOT_FOUND + ORDER_ACCESS_DENIED + ADMIN_UNAUTHORIZED + BRAND_CHANGE_NOT_ALLOWED + -HttpStatus status + -String code + -String message + } + + ApiResponse --> Metadata + Metadata --> Result + CoreException --> ErrorType +``` + +**핵심 포인트:** +- **BaseEntity**: 모든 엔티티의 공통 필드 (id, timestamps, soft delete) +- **ApiResponse**: 통일된 API 응답 형식 +- **ErrorType**: 도메인별 에러 코드 정의 diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..50851256c --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,419 @@ +# ERD (Entity Relationship Diagram) + +## 다이어그램 목적 +ERD를 통해 다음을 검증한다: +- 영속성 구조: 데이터가 어떻게 저장되는가 +- 관계의 주인: FK가 어디에 위치하는가 +- 정규화 여부: 데이터 중복이 최소화되었는가 +- 정합성: 제약조건이 비즈니스 규칙을 반영하는가 + +--- + +## 1. 전체 ERD + +```mermaid +erDiagram + users ||--o{ orders : "places" + users ||--o{ product_likes : "likes" + brands ||--o{ products : "has" + products ||--o{ product_likes : "has" + products ||--o{ order_items : "ordered_in" + orders ||--|{ order_items : "contains" + + users { + bigint id PK "AUTO_INCREMENT" + varchar_30 login_id UK "NOT NULL" + varchar_255 password "NOT NULL, BCrypt" + varchar_30 name "NOT NULL" + varchar_8 birth_date "NOT NULL, YYYYMMDD" + varchar_100 email "NOT NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + brands { + bigint id PK "AUTO_INCREMENT" + varchar_100 name UK "NOT NULL" + varchar_500 description "NULL" + varchar_500 logo_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + products { + bigint id PK "AUTO_INCREMENT" + bigint brand_id FK "NOT NULL" + varchar_200 name "NOT NULL" + varchar_2000 description "NULL" + bigint price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar_500 image_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + product_likes { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + timestamp created_at "NOT NULL" + } + + orders { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint total_price "NOT NULL, >= 0" + varchar_20 status "NOT NULL, ENUM" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + } + + order_items { + bigint id PK "AUTO_INCREMENT" + bigint order_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + varchar_200 product_name "NOT NULL, 스냅샷" + int quantity "NOT NULL, >= 1" + bigint price "NOT NULL, 스냅샷" + timestamp created_at "NOT NULL" + } +``` + +--- + +## 2. 테이블 상세 스키마 + +### 2.1 users 테이블 (기존) + +```sql +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(30) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(30) NOT NULL, + birth_date VARCHAR(8) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_users_login_id UNIQUE (login_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +### 2.2 brands 테이블 + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) NULL, + logo_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_brands_name UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_brands_deleted_at ON brands (deleted_at); +CREATE INDEX idx_brands_created_at ON brands (created_at); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `uk_brands_name` | 브랜드명 중복 방지 | +| `idx_brands_deleted_at` | Soft Delete 필터링 최적화 | +| `idx_brands_created_at` | 최신순 정렬 최적화 | + +--- + +### 2.3 products 테이블 + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(2000) NULL, + price BIGINT NOT NULL, + stock INT NOT NULL DEFAULT 0, + image_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) + REFERENCES brands(id) ON DELETE RESTRICT, + + CONSTRAINT chk_products_price CHECK (price >= 0), + CONSTRAINT chk_products_stock CHECK (stock >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_products_brand_id ON products (brand_id); +CREATE INDEX idx_products_deleted_at ON products (deleted_at); +CREATE INDEX idx_products_price ON products (price); +CREATE INDEX idx_products_created_at ON products (created_at DESC); + +-- 복합 인덱스: 브랜드별 상품 조회 최적화 +CREATE INDEX idx_products_brand_deleted_created + ON products (brand_id, deleted_at, created_at DESC); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `fk_products_brand_id` | 브랜드-상품 관계 무결성 | +| `idx_products_brand_id` | 브랜드별 상품 조회 | +| `idx_products_deleted_at` | Soft Delete 필터링 | +| `idx_products_price` | 가격순 정렬 최적화 | +| `idx_products_created_at` | 최신순 정렬 최적화 | +| `idx_products_brand_deleted_created` | 브랜드 필터 + 삭제 필터 + 최신순 복합 쿼리 | + +--- + +### 2.4 product_likes 테이블 + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE CASCADE, + + CONSTRAINT uk_product_likes_user_product UNIQUE (user_id, product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_product_likes_product_id ON product_likes (product_id); +CREATE INDEX idx_product_likes_user_id ON product_likes (user_id); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `uk_product_likes_user_product` | 중복 좋아요 방지 + 특정 사용자의 특정 상품 좋아요 여부 조회 | +| `idx_product_likes_product_id` | 상품별 좋아요 수 COUNT 최적화 | +| `idx_product_likes_user_id` | 사용자별 좋아요 목록 조회 최적화 | + +**CASCADE 삭제:** +- User 삭제 시 해당 사용자의 좋아요 자동 삭제 +- Product 삭제 시 해당 상품의 좋아요 자동 삭제 + +--- + +### 2.5 orders 테이블 + +```sql +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + total_price BIGINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE RESTRICT, + + CONSTRAINT chk_orders_total_price CHECK (total_price >= 0), + CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_orders_user_id ON orders (user_id); +CREATE INDEX idx_orders_created_at ON orders (created_at DESC); +CREATE INDEX idx_orders_status ON orders (status); + +-- 복합 인덱스: 사용자별 기간 조회 최적화 +CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `idx_orders_user_id` | 사용자별 주문 조회 | +| `idx_orders_created_at` | 최신순 정렬 | +| `idx_orders_status` | 상태별 필터링 | +| `idx_orders_user_created` | 사용자의 주문 기간 조회 최적화 | + +--- + +### 2.6 order_items 테이블 + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL COMMENT '주문 시점 상품명 스냅샷', + quantity INT NOT NULL, + price BIGINT NOT NULL COMMENT '주문 시점 상품 가격 스냅샷', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) + REFERENCES orders(id) ON DELETE CASCADE, + CONSTRAINT fk_order_items_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE RESTRICT, + + CONSTRAINT uk_order_items_order_product UNIQUE (order_id, product_id), + CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), + CONSTRAINT chk_order_items_price CHECK (price >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_order_items_order_id ON order_items (order_id); +CREATE INDEX idx_order_items_product_id ON order_items (product_id); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `uk_order_items_order_product` | 동일 주문 내 동일 상품 중복 방지 | +| `idx_order_items_order_id` | 주문별 항목 조회 | +| `idx_order_items_product_id` | 상품별 주문 이력 조회 | + +**가격/상품명 스냅샷:** +- `price`, `product_name` 필드는 주문 시점의 값을 저장 +- 상품 가격/이름 변경 시에도 기존 주문의 정보는 유지 + +--- + +## 3. 관계 정의 + +### 3.1 관계 요약 + +| 관계 | 타입 | 설명 | +|------|------|------| +| users : orders | 1:N | 사용자는 여러 주문 가능 | +| users : product_likes | 1:N | 사용자는 여러 상품에 좋아요 가능 | +| brands : products | 1:N | 브랜드는 여러 상품 보유 | +| products : product_likes | 1:N | 상품은 여러 좋아요 보유 | +| products : order_items | 1:N | 상품은 여러 주문에 포함 가능 | +| orders : order_items | 1:N | 주문은 여러 주문 항목 포함 | + +### 3.2 FK 삭제 정책 + +| FK | 정책 | 이유 | +|----|------|------| +| products.brand_id | RESTRICT | 브랜드 삭제 시 애플리케이션에서 Cascade 처리 | +| product_likes.user_id | CASCADE | 사용자 삭제 시 좋아요 자동 삭제 | +| product_likes.product_id | CASCADE | 상품 삭제 시 좋아요 자동 삭제 | +| orders.user_id | RESTRICT | 주문 이력 보존 (사용자 삭제 불가) | +| order_items.order_id | CASCADE | 주문 삭제 시 항목 자동 삭제 | +| order_items.product_id | RESTRICT | 상품 삭제 시에도 주문 이력 보존 | + +--- + +## 4. 쿼리 최적화 가이드 + +### 4.1 상품 목록 조회 (좋아요순 정렬) + +```sql +-- 좋아요 많은순 정렬 (서브쿼리 방식) +SELECT + p.*, + COALESCE(like_counts.cnt, 0) as like_count +FROM products p +LEFT JOIN ( + SELECT product_id, COUNT(*) as cnt + FROM product_likes + GROUP BY product_id +) like_counts ON p.id = like_counts.product_id +WHERE p.deleted_at IS NULL + AND (p.brand_id = :brandId OR :brandId IS NULL) +ORDER BY like_count DESC, p.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +### 4.2 재고 차감 (비관적 락) + +```sql +-- 비관적 락으로 재고 조회 +SELECT * FROM products +WHERE id = :productId AND deleted_at IS NULL +FOR UPDATE; + +-- 재고 검증 후 차감 +UPDATE products +SET stock = stock - :quantity, updated_at = NOW() +WHERE id = :productId AND stock >= :quantity; +``` + +### 4.3 주문 상세 조회 (Fetch Join) + +```sql +SELECT o.*, oi.* +FROM orders o +JOIN order_items oi ON o.id = oi.order_id +WHERE o.id = :orderId AND o.user_id = :userId; +``` + +### 4.4 사용자별 좋아요 상품 목록 + +```sql +SELECT p.*, pl.created_at as liked_at +FROM product_likes pl +JOIN products p ON pl.product_id = p.id +WHERE pl.user_id = :userId AND p.deleted_at IS NULL +ORDER BY pl.created_at DESC; +``` + +### 4.5 사용자 주문 기간 조회 + +```sql +SELECT o.*, oi.* +FROM orders o +LEFT JOIN order_items oi ON o.id = oi.order_id +WHERE o.user_id = :userId + AND (:startAt IS NULL OR o.created_at >= :startAt) + AND (:endAt IS NULL OR o.created_at < :endAt + INTERVAL 1 DAY) +ORDER BY o.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +--- + +## 5. 데이터 마이그레이션 순서 + +``` +V1__Create_users_table.sql (기존) +V2__Create_brands_table.sql +V3__Create_products_table.sql +V4__Create_product_likes_table.sql +V5__Create_orders_table.sql +V6__Create_order_items_table.sql +V7__Add_indexes.sql +``` + +--- + +## 6. 데이터 정합성 고려사항 + +### 6.1 동시성 제어 +- **재고 차감**: `SELECT ... FOR UPDATE` 비관적 락 +- **좋아요 중복**: `UNIQUE (user_id, product_id)` 제약조건 +- **주문 항목 중복**: `UNIQUE (order_id, product_id)` 제약조건 + +### 6.2 Soft Delete 처리 +- brands, products, users: `deleted_at` 필드 사용 +- 조회 시 `WHERE deleted_at IS NULL` 조건 필수 +- product_likes, orders, order_items: Hard Delete + +### 6.3 스냅샷 데이터 +- order_items.price: 주문 시점 상품 가격 +- order_items.product_name: 주문 시점 상품명 +- 상품 정보 변경과 무관하게 주문 이력 보존 From 54860bede52f7c8d5eff1cc292a387d5e2b8d17b Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 15:50:39 +0900 Subject: [PATCH 09/15] =?UTF-8?q?Revert=20"docs:=20=EC=BB=A4=EB=A8=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B6=84=EC=84=9D=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=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 This reverts commit 44bfc368022317b7ceb2990fabbd617848243cb5. --- .claude/skills/requirements-analysis/SKILL.md | 77 -- .docs/design/01-requirements.md | 431 ----------- .docs/design/02-sequence-diagrams.md | 437 ----------- .docs/design/03-class-diagram.md | 700 ------------------ .docs/design/04-erd.md | 419 ----------- 5 files changed, 2064 deletions(-) delete mode 100644 .claude/skills/requirements-analysis/SKILL.md delete mode 100644 .docs/design/01-requirements.md delete mode 100644 .docs/design/02-sequence-diagrams.md delete mode 100644 .docs/design/03-class-diagram.md delete mode 100644 .docs/design/04-erd.md diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md deleted file mode 100644 index 3485a8af8..000000000 --- a/.claude/skills/requirements-analysis/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: requirements-analysis -description: - 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. - 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. - 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. ---- -요구사항을 분석할 때 반드시 다음 흐름을 따른다. -### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. -- 요구사항 문장을 정리하는 데서 끝내지 않는다. -- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. -- 다음 관점을 분리해서 정리한다: - - 사용자 관점 - - 비즈니스 관점 - - 시스템 관점 -> 예시 -> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" - -### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 -- 추측하거나 알아서 결정하지 않는다. -- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. - **다음 유형의 질문을 반드시 포함한다:** -- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 -- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 -- 확장 질문: 나중에 바뀔 가능성이 있는가 - -### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 -- 질문은 우선순위를 가진다 (중요한 것부터). -- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. -> 형식 예시: -- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 -- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 - -### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 -- 바로 코드나 기술 얘기로 들어가지 않는다. -- 먼저 다음을 정의한다: - - 액터 (사용자, 외부 시스템) - - 핵심 도메인 - - 보조/외부 시스템 -- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. - -### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 -**다이어그램을 그리기 전에 반드시 설명한다** -- 왜 이 다이어그램이 필요한지 -- 이 다이어그램으로 무엇을 검증하려는지 - -**다이어그램은 Mermaid 문법으로 작성한다** -사용 기준: -- **시퀀스 다이어그램** - - 책임 분리 - - 호출 순서 - - 트랜잭션 경계 확인 -- **클래스 다이어그램** - - 도메인 책임 - - 의존 방향 - - 응집도 확인 -- **ERD** - - 영속성 구조 - - 관계의 주인 - - 정규화 여부 - -### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 -- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. -- 설계 의도가 드러나도록 해석을 붙인다. - -### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 -- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. - - 트랜잭션 비대화 - - 도메인 간 결합도 증가 - - 정책 변경 시 영향 범위 확대 -- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. - -### 톤 & 스타일 가이드 -- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 -- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. -- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 -- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md deleted file mode 100644 index baa2e2eca..000000000 --- a/.docs/design/01-requirements.md +++ /dev/null @@ -1,431 +0,0 @@ -# 커머스 도메인 요구사항 정의서 - -## 1. 개요 - -### 1.1 문서 목적 -Java/Spring Boot 멀티 모듈 커머스 백엔드의 Brand, Product, ProductLike, Order, OrderItem 도메인에 대한 -상세 요구사항을 정의한다. - -### 1.2 기존 패턴 참조 -- 아키텍처: Layered Architecture (interfaces → application → domain → infrastructure) -- 인증: 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) -- 응답 형식: ApiResponse (meta + data) -- 예외 처리: CoreException + ErrorType enum - -### 1.3 액터 정의 - -| 액터 | 설명 | 인증 방식 | -|------|------|----------| -| 일반 사용자 | 상품 조회, 좋아요, 주문 가능 | X-Loopers-LoginId + X-Loopers-LoginPw | -| 어드민 | 브랜드/상품 CRUD 관리 | X-Loopers-Ldap: loopers.admin | - ---- - -## 2. 도메인별 상세 요구사항 - -### 2.1 Brand (브랜드) - -#### 2.1.1 필드 정의 -| 필드 | 타입 | 필수 | 제약조건 | -|------|------|------|----------| -| id | Long | Y | 자동 생성 (PK) | -| name | String | Y | 1-100자, 공백 불가, 중복 불가 | -| description | String | N | 최대 500자 | -| logoUrl | String | N | URL 형식 검증, 최대 500자 | -| createdAt | ZonedDateTime | Y | 자동 생성 | -| updatedAt | ZonedDateTime | Y | 자동 갱신 | -| deletedAt | ZonedDateTime | N | Soft Delete | - -*브랜드 주소, 대표명, 브랜드 사이트 URL 같은 컬럼을 넣을지 말지 고민했지만 설계에 집중하고 싶어서 넣지 않았다.* - -#### 2.1.2 비즈니스 규칙 -- **BR-BRAND-001**: 브랜드명은 시스템 내 유일해야 한다 -- **BR-BRAND-002**: 브랜드 삭제 시 해당 브랜드의 모든 상품이 Cascade 삭제된다 -- **BR-BRAND-003**: 삭제된 브랜드는 조회되지 않는다 (Soft Delete) - -#### 2.1.3 검증 규칙 -``` -name 검증: -- null 또는 blank 불가 → "브랜드명은 필수입니다." -- 100자 초과 → "브랜드명은 100자를 초과할 수 없습니다." -- 중복 → "이미 존재하는 브랜드명입니다." (409 CONFLICT) - -description 검증: -- 500자 초과 → "브랜드 설명은 500자를 초과할 수 없습니다." - -logoUrl 검증: -- URL 형식 불일치 → "유효하지 않은 URL 형식입니다." -- 500자 초과 → "로고 URL은 500자를 초과할 수 없습니다." -``` - ---- - -### 2.2 Product (상품) - -#### 2.2.1 필드 정의 -| 필드 | 타입 | 필수 | 제약조건 | -|------|------|------|----------| -| id | Long | Y | 자동 생성 (PK) | -| brandId | Long | Y | Brand FK, 존재 검증 | -| name | String | Y | 1-200자 | -| description | String | N | 최대 2000자 | -| price | Long | Y | 0 이상 | -| stock | Integer | Y | 0 이상 | -| imageUrl | String | N | URL 형식, 최대 500자 | -| createdAt | ZonedDateTime | Y | 자동 생성 | -| updatedAt | ZonedDateTime | Y | 자동 갱신 | -| deletedAt | ZonedDateTime | N | Soft Delete | - -#### 2.2.2 비즈니스 규칙 -- **BR-PRODUCT-001**: 상품은 반드시 하나의 브랜드에 속해야 한다 -- **BR-PRODUCT-002**: 상품 등록 후 브랜드 변경 불가 -- **BR-PRODUCT-003**: 상품 삭제 시 해당 상품의 모든 좋아요가 Cascade 삭제된다 -- **BR-PRODUCT-004**: 삭제된 상품은 목록 조회 시 제외된다 -- **BR-PRODUCT-005**: 재고가 0인 상품도 조회는 가능하다 - -#### 2.2.3 검증 규칙 -``` -name 검증: -- null 또는 blank 불가 → "상품명은 필수입니다." -- 200자 초과 → "상품명은 200자를 초과할 수 없습니다." - -price 검증: -- null 불가 → "가격은 필수입니다." -- 음수 → "가격은 0원 이상이어야 합니다." - -stock 검증: -- null 불가 → "재고는 필수입니다." -- 음수 → "재고는 0개 이상이어야 합니다." - -brandId 검증: -- 존재하지 않는 브랜드 → "존재하지 않는 브랜드입니다." (404 NOT_FOUND) -- 삭제된 브랜드 → "삭제된 브랜드입니다." (400 BAD_REQUEST) -``` - ---- - -### 2.3 ProductLike (상품 좋아요) - -#### 2.3.1 필드 정의 -| 필드 | 타입 | 필수 | 제약조건 | -|------|------|------|----------| -| id | Long | Y | 자동 생성 (PK) | -| userId | Long | Y | User FK | -| productId | Long | Y | Product FK | -| createdAt | ZonedDateTime | Y | 자동 생성 | - -#### 2.3.2 비즈니스 규칙 -- **BR-LIKE-001**: 좋아요 등록 시 이미 좋아요가 존재하면 좋아요 취소 처리 (토글 방식) -- **BR-LIKE-002**: 좋아요 개수는 실시간 COUNT 집계 -- **BR-LIKE-003**: 존재하지 않는 좋아요 취소 시 멱등 처리 (에러 없이 성공 응답) -- **BR-LIKE-004**: 삭제된 상품에는 좋아요 불가 - -#### 2.3.3 검증 규칙 -``` -좋아요 등록: -- 삭제된 상품 → "삭제된 상품입니다." (400 BAD_REQUEST) -- 존재하지 않는 상품 → "존재하지 않는 상품입니다." (404 NOT_FOUND) -- 중복 좋아요 → 좋아요 취소 처리 (토글) - -좋아요 취소: -- 존재하지 않는 좋아요 → 멱등 처리 (성공 응답) -``` - ---- - -### 2.4 Order (주문) - -#### 2.4.1 필드 정의 -| 필드 | 타입 | 필수 | 제약조건 | -|------|------|------|----------| -| id | Long | Y | 자동 생성 (PK) | -| userId | Long | Y | User FK | -| totalPrice | Long | Y | 0 이상, 계산된 값 | -| status | OrderStatus | Y | PENDING, COMPLETED, CANCELLED | -| createdAt | ZonedDateTime | Y | 자동 생성 | -| updatedAt | ZonedDateTime | Y | 자동 갱신 | - -#### 2.4.2 OrderStatus 상태 정의 -```java -public enum OrderStatus { - PENDING, // 주문 대기 - COMPLETED, // 주문 완료 - CANCELLED // 주문 취소 -} -``` - -#### 2.4.3 비즈니스 규칙 -- **BR-ORDER-001**: 다건 상품 주문 지원 (OrderItem 1:N 관계) -- **BR-ORDER-002**: 전체 실패 정책 - 하나라도 실패 시 전체 주문 롤백 -- **BR-ORDER-003**: 재고 검증 후 차감은 원자적으로 수행 (비관적 락) -- **BR-ORDER-004**: totalPrice는 OrderItem들의 (price * quantity) 합계 - ---- - -### 2.5 OrderItem (주문 항목) - -#### 2.5.1 필드 정의 -| 필드 | 타입 | 필수 | 제약조건 | -|------|------|------|----------| -| id | Long | Y | 자동 생성 (PK) | -| orderId | Long | Y | Order FK | -| productId | Long | Y | Product FK | -| quantity | Integer | Y | 1 이상 | -| price | Long | Y | 주문 시점 상품 가격 (스냅샷) | -| createdAt | ZonedDateTime | Y | 자동 생성 | - -#### 2.5.2 비즈니스 규칙 -- **BR-ORDERITEM-001**: 주문 시점의 상품 가격을 스냅샷으로 저장 -- **BR-ORDERITEM-002**: 동일 주문 내 동일 상품 중복 불가 (orderId + productId UNIQUE) -- **BR-ORDERITEM-003**: 수량은 최소 1개 이상 - ---- - -## 3. 유저 시나리오 - -### 3.1 일반 사용자 시나리오 - -#### US-001: 브랜드 정보 조회 -``` -Given: 사용자가 로그인 상태 -When: GET /api/v1/brands/{brandId} 요청 -Then: 브랜드 정보(name, description, logoUrl) 반환 -``` - -#### US-002: 상품 목록 조회 -``` -Given: 사용자가 로그인 상태 -When: GET /api/v1/products 요청 (정렬/필터/페이징 옵션) -Then: 상품 목록과 좋아요 수 반환 - -정렬 옵션: -- latest (기본값): 최신순 -- price_asc: 가격 낮은순 -- like_desc: 좋아요 많은순 - -필터 옵션: -- brandId: 특정 브랜드 필터 - -페이징: -- page: 페이지 번호 (0부터 시작) -- size: 페이지 크기 (기본 20) -``` - -#### US-003: 상품 상세 조회 -``` -Given: 사용자가 로그인 상태 -When: GET /api/v1/products/{productId} 요청 -Then: 상품 상세 정보와 좋아요 수 반환 -``` - -#### US-004: 좋아요 등록/토글 -``` -Given: 사용자가 로그인 상태 -When: POST /api/v1/products/{productId}/likes 요청 -Then: - - 좋아요가 없으면 → 좋아요 등록 - - 좋아요가 있으면 → 좋아요 취소 (토글) -``` - -#### US-005: 좋아요 취소 -``` -Given: 사용자가 로그인 상태 -When: DELETE /api/v1/products/{productId}/likes 요청 -Then: 좋아요 삭제 (존재하지 않아도 성공) -``` - -#### US-006: 내가 좋아요한 상품 목록 -``` -Given: 사용자가 로그인 상태 -When: GET /api/v1/users/{userId}/likes 요청 -Then: 좋아요한 상품 목록 반환 -``` - -#### US-007: 주문 생성 -``` -Given: 사용자가 로그인 상태, 주문할 상품들 선택 -When: POST /api/v1/orders 요청 (items: [{productId, quantity}]) -Then: - - 재고 검증 (모든 상품) - - 재고 차감 (원자적) - - 주문 생성 및 ID 반환 - - 실패 시 전체 롤백 -``` - -#### US-008: 주문 목록 조회 -``` -Given: 사용자가 로그인 상태 -When: GET /api/v1/orders 요청 (선택적 기간 필터) -Then: 본인의 주문 목록 반환 -``` - -#### US-009: 주문 상세 조회 -``` -Given: 사용자가 로그인 상태 -When: GET /api/v1/orders/{orderId} 요청 -Then: - - 본인 주문인 경우: 주문 상세 반환 - - 타인 주문인 경우: 403 FORBIDDEN -``` - ---- - -### 3.2 어드민 시나리오 - -#### AS-001: 브랜드 목록 조회 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: GET /api-admin/v1/brands 요청 -Then: 전체 브랜드 목록 반환 -``` - -#### AS-002: 브랜드 등록 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: POST /api-admin/v1/brands 요청 -Then: 브랜드 등록 및 ID 반환 -``` - -#### AS-003: 브랜드 수정 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: PUT /api-admin/v1/brands/{brandId} 요청 -Then: 브랜드 정보 수정 -``` - -#### AS-004: 브랜드 삭제 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: DELETE /api-admin/v1/brands/{brandId} 요청 -Then: - - 브랜드 Soft Delete - - 해당 브랜드의 모든 상품 Cascade Soft Delete - - 상품들의 좋아요 Cascade Hard Delete -``` - -#### AS-005: 상품 등록 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: POST /api-admin/v1/products 요청 -Then: 상품 등록 및 ID 반환 -``` - -#### AS-006: 상품 수정 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: PUT /api-admin/v1/products/{productId} 요청 -Then: - - brandId 제외 필드 수정 가능 - - brandId 변경 시도 시: "브랜드 변경은 불가능합니다." (400 BAD_REQUEST) -``` - -#### AS-007: 상품 삭제 (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin 헤더 -When: DELETE /api-admin/v1/products/{productId} 요청 -Then: - - 상품 Soft Delete - - 좋아요 Cascade Hard Delete -``` - ---- - -## 4. API 명세 - -### 4.1 일반 사용자 API - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | /api/v1/brands/{brandId} | 브랜드 정보 조회 | -| GET | /api/v1/products | 상품 목록 조회 | -| GET | /api/v1/products/{productId} | 상품 상세 조회 | -| POST | /api/v1/products/{productId}/likes | 좋아요 등록 | -| DELETE | /api/v1/products/{productId}/likes | 좋아요 취소 | -| GET | /api/v1/users/{userId}/likes | 내가 좋아요한 상품 목록 | -| POST | /api/v1/orders | 주문 생성 | -| GET | /api/v1/orders | 주문 목록 조회 | -| GET | /api/v1/orders/{orderId} | 주문 상세 조회 | - -### 4.2 어드민 API - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | /api-admin/v1/brands | 브랜드 목록 조회 | -| GET | /api-admin/v1/brands/{brandId} | 브랜드 상세 조회 | -| POST | /api-admin/v1/brands | 브랜드 등록 | -| PUT | /api-admin/v1/brands/{brandId} | 브랜드 수정 | -| DELETE | /api-admin/v1/brands/{brandId} | 브랜드 삭제 | -| GET | /api-admin/v1/products | 상품 목록 조회 | -| GET | /api-admin/v1/products/{productId} | 상품 상세 조회 | -| POST | /api-admin/v1/products | 상품 등록 | -| PUT | /api-admin/v1/products/{productId} | 상품 수정 | -| DELETE | /api-admin/v1/products/{productId} | 상품 삭제 | - ---- - -## 5. 인증 체계 - -### 5.1 일반 사용자 인증 -``` -Headers: - X-Loopers-LoginId: {loginId} - X-Loopers-LoginPw: {password} - -처리: AuthenticatedUserArgumentResolver -대상: /api/v1/** 엔드포인트 -``` - -### 5.2 어드민 인증 -``` -Headers: - X-Loopers-Ldap: loopers.admin - -처리: AdminAuthInterceptor + AdminUserArgumentResolver -대상: /api-admin/v1/** 엔드포인트 -``` - ---- - -## 6. 에러 타입 정의 - -```java -// ErrorType.java에 추가할 타입 -BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "존재하지 않는 브랜드입니다."), -BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "이미 존재하는 브랜드명입니다."), -BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "삭제된 브랜드입니다."), -PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "존재하지 않는 상품입니다."), -PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "삭제된 상품입니다."), -INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."), -ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "존재하지 않는 주문입니다."), -ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "주문 접근 권한이 없습니다."), -ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "관리자 권한이 필요합니다."), -BRAND_CHANGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "BRAND_CHANGE_NOT_ALLOWED", "브랜드 변경은 불가능합니다."); -``` - ---- - -## 7. API 응답 형식 - -### 7.1 성공 응답 -```json -{ - "meta": { - "result": "SUCCESS", - "errorCode": null, - "message": null - }, - "data": { ... } -} -``` - -### 7.2 실패 응답 -```json -{ - "meta": { - "result": "FAIL", - "errorCode": "PRODUCT_NOT_FOUND", - "message": "존재하지 않는 상품입니다." - }, - "data": null -} -``` diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md deleted file mode 100644 index faa32ccc6..000000000 --- a/.docs/design/02-sequence-diagrams.md +++ /dev/null @@ -1,437 +0,0 @@ -# 시퀀스 다이어그램 - -## 다이어그램 목적 -시퀀스 다이어그램을 통해 다음을 검증한다: -- 책임 분리: 각 객체가 맡은 역할이 명확한가 -- 호출 순서: 비즈니스 로직의 흐름이 올바른가 -- 트랜잭션 경계: 원자성이 보장되는 범위가 적절한가 - ---- - -## 1. 주문 생성 시퀀스 - -### 1.1 정상 흐름 (다건 주문) - -**목적**: 재고 검증, 비관적 락, 트랜잭션 경계 확인 - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as OrderV1Controller - participant F as OrderFacade - participant OS as OrderService - participant PS as ProductService - participant OR as OrderRepository - participant PR as ProductRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/orders - Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw - Note over C,Ctrl: Body: { items: [{productId, quantity}] } - - Ctrl->>+F: createOrder(userId, items) - - F->>+OS: createOrder(userId, items) - - Note over OS: @Transactional 시작 - - loop 각 주문 항목에 대해 - OS->>+PS: getProductForOrder(productId) - PS->>+PR: findByIdWithLock(productId) - PR->>+DB: SELECT ... FOR UPDATE - DB-->>-PR: Product - PR-->>-PS: Product - PS-->>-OS: Product - - OS->>OS: 재고 검증 (stock >= quantity) - - alt 재고 부족 - OS-->>F: throw CoreException(INSUFFICIENT_STOCK) - Note over OS,DB: 전체 롤백 - end - - OS->>+PS: decreaseStock(productId, quantity) - PS->>+PR: save(product) - PR->>+DB: UPDATE products SET stock = stock - quantity - DB-->>-PR: OK - PR-->>-PS: Product - PS-->>-OS: void - end - - OS->>OS: totalPrice 계산 - OS->>OS: Order 엔티티 생성 - OS->>OS: OrderItem 엔티티들 생성 (가격 스냅샷) - - OS->>+OR: save(order) - OR->>+DB: INSERT orders, order_items - DB-->>-OR: Order (with ID) - OR-->>-OS: Order - - Note over OS: @Transactional 커밋 - - OS-->>-F: Order - F->>F: OrderInfo.from(order) - F-->>-Ctrl: OrderInfo - - Ctrl->>Ctrl: OrderV1Dto.CreateResponse.from(info) - Ctrl-->>-C: 201 Created + { orderId, totalPrice } -``` - -**핵심 포인트:** -- `SELECT ... FOR UPDATE`로 비관적 락 획득 → 동시 주문 시 재고 경쟁 방지 -- 모든 상품 검증 후 차감 → 하나라도 실패 시 전체 롤백 -- OrderItem에 가격 스냅샷 저장 → 상품 가격 변경 시에도 주문 가격 유지 - ---- - -### 1.2 재고 부족 실패 흐름 - -**목적**: 전체 실패 정책, 롤백 동작 확인 - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as OrderV1Controller - participant F as OrderFacade - participant OS as OrderService - participant PS as ProductService - participant PR as ProductRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/orders - Note over C,Ctrl: items: [{productId: 1, qty: 10}, {productId: 2, qty: 5}] - - Ctrl->>+F: createOrder(userId, items) - F->>+OS: createOrder(userId, items) - - Note over OS: @Transactional 시작 - - OS->>+PS: getProductForOrder(productId: 1) - PS->>+PR: findByIdWithLock(productId: 1) - PR->>+DB: SELECT ... FOR UPDATE - DB-->>-PR: Product (stock: 10) - PR-->>-PS: Product - PS-->>-OS: Product - OS->>OS: 재고 검증 통과 (10 >= 10) - OS->>PS: decreaseStock(1, 10) - - OS->>+PS: getProductForOrder(productId: 2) - PS->>+PR: findByIdWithLock(productId: 2) - PR->>+DB: SELECT ... FOR UPDATE - DB-->>-PR: Product (stock: 3) - PR-->>-PS: Product - PS-->>-OS: Product - - OS->>OS: 재고 검증 실패 (3 < 5) - - OS-->>-F: throw CoreException(INSUFFICIENT_STOCK) - Note over OS,DB: 전체 롤백 (상품1 재고 복구) - - F-->>-Ctrl: throw CoreException - Ctrl-->>-C: 400 Bad Request + INSUFFICIENT_STOCK -``` - -**핵심 포인트:** -- 두 번째 상품 재고 부족 시 첫 번째 상품 재고 차감도 롤백 -- 트랜잭션 단위로 원자성 보장 - ---- - -## 2. 좋아요 등록 시퀀스 (토글 방식) - -### 2.1 신규 좋아요 등록 - -**목적**: 상품 유효성 검증 및 좋아요 등록 확인 - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as ProductLikeV1Controller - participant F as ProductLikeFacade - participant LS as ProductLikeService - participant PS as ProductService - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/products/{productId}/likes - Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw - - Ctrl->>+F: like(userId, productId) - - F->>+LS: like(userId, productId) - - LS->>+PS: getProduct(productId) - PS-->>-LS: Product - - LS->>LS: 상품 삭제 여부 검증 - - LS->>+LR: findByUserIdAndProductId(userId, productId) - LR->>+DB: SELECT FROM product_likes - DB-->>-LR: null (미존재) - LR-->>-LS: Optional (empty) - - LS->>LS: ProductLike 엔티티 생성 (신규 등록) - - LS->>+LR: save(productLike) - LR->>+DB: INSERT product_likes - DB-->>-LR: ProductLike - LR-->>-LS: ProductLike - - LS-->>-F: ProductLike - F-->>-Ctrl: void - - Ctrl-->>-C: 200 OK + { message: "좋아요가 등록되었습니다." } -``` -**핵심 포인트:** - -- *좋아요 기능 설계 시 아래와 같은 두 가지 선택지가 있었다. 정렬 쿼리를 위해 Product 내부에 좋아요 필드를 두는 방법과 좋아요 테이블을 따로 두는 선택지 중 정합성을 높이는 방식을 선택했다.* - -*1. 비정규화: Product에 likeCount 필드를 두고 좋아요 등록/취소 시 동기 업데이트. 정렬 쿼리 성능 우수* - -*2. 실시간 집계(적용): 좋아요 테이블에서 COUNT 집계. 정합성 높으나 정렬 시 쿼리 비용 증가* - - - - -- 상품 존재 및 삭제 여부 먼저 검증 -- 기존 좋아요가 없으면 신규 등록 - ---- - -### 2.2 기존 좋아요 존재 시 (토글 - 취소 처리) - -**목적**: 토글 방식 동작 확인 - 이미 좋아요가 있으면 취소 - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as ProductLikeV1Controller - participant F as ProductLikeFacade - participant LS as ProductLikeService - participant PS as ProductService - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/products/{productId}/likes - - Ctrl->>+F: like(userId, productId) - F->>+LS: like(userId, productId) - - LS->>+PS: getProduct(productId) - PS-->>-LS: Product - - LS->>+LR: findByUserIdAndProductId(userId, productId) - LR->>+DB: SELECT FROM product_likes - DB-->>-LR: ProductLike (존재) - LR-->>-LS: Optional (present) - - Note over LS: 이미 존재하므로 좋아요 취소 (토글) - - LS->>+LR: delete(productLike) - LR->>+DB: DELETE FROM product_likes - DB-->>-LR: OK - LR-->>-LS: void - - LS-->>-F: void - F-->>-Ctrl: void - - Ctrl-->>-C: 200 OK + { message: "좋아요가 취소되었습니다." } -``` - -**핵심 포인트:** -- 좋아요가 이미 존재하면 삭제 (토글 방식) -- POST 요청 한 번으로 등록/취소 모두 처리 - ---- - -## 3. 브랜드 삭제 시퀀스 (Cascade 삭제) - -**목적**: Cascade 삭제 순서, 트랜잭션 경계 확인 - -```mermaid -sequenceDiagram - autonumber - participant C as Admin Client - participant Int as AdminAuthInterceptor - participant Ctrl as BrandAdminV1Controller - participant F as BrandFacade - participant BS as BrandService - participant PS as ProductService - participant LS as ProductLikeService - participant BR as BrandRepository - participant PR as ProductRepository - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Int: DELETE /api-admin/v1/brands/{brandId} - Note over C,Int: Headers: X-Loopers-Ldap: loopers.admin - - Int->>Int: Admin 권한 검증 - Int->>+Ctrl: 요청 전달 - - Ctrl->>+F: deleteBrand(brandId) - F->>+BS: deleteBrand(brandId) - - Note over BS: @Transactional 시작 - - BS->>+BR: findById(brandId) - BR->>+DB: SELECT FROM brands - DB-->>-BR: Brand - BR-->>-BS: Brand - - alt 브랜드 없음 - BS-->>F: throw CoreException(BRAND_NOT_FOUND) - end - - BS->>+PS: getProductsByBrandId(brandId) - PS->>+PR: findAllByBrandId(brandId) - PR->>+DB: SELECT FROM products WHERE brand_id = ? - DB-->>-PR: List - PR-->>-PS: List - PS-->>-BS: List - - loop 각 상품에 대해 - BS->>+LS: deleteAllByProductId(productId) - LS->>+LR: deleteAllByProductId(productId) - LR->>+DB: DELETE FROM product_likes WHERE product_id = ? - DB-->>-LR: OK - LR-->>-LS: void - LS-->>-BS: void - - BS->>+PS: deleteProduct(productId) - PS->>PS: product.delete() - PS->>+PR: save(product) - PR->>+DB: UPDATE products SET deleted_at = NOW() - DB-->>-PR: OK - PR-->>-PS: Product - PS-->>-BS: void - end - - BS->>BS: brand.delete() - BS->>+BR: save(brand) - BR->>+DB: UPDATE brands SET deleted_at = NOW() - DB-->>-BR: OK - BR-->>-BS: Brand - - Note over BS: @Transactional 커밋 - - BS-->>-F: void - F-->>-Ctrl: void - - Ctrl-->>-C: 200 OK + { message: "브랜드가 삭제되었습니다." } -``` - -**핵심 포인트:** -- 삭제 순서: 좋아요(Hard) → 상품(Soft) → 브랜드(Soft) -- 단일 트랜잭션으로 원자성 보장 -- 좋아요는 Hard Delete, 상품/브랜드는 Soft Delete - ---- - -## 4. 상품 목록 조회 시퀀스 (좋아요 수 포함) - -**목적**: 좋아요 실시간 집계, 정렬 옵션 처리 확인 - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as ProductV1Controller - participant F as ProductFacade - participant PS as ProductService - participant LS as ProductLikeService - participant PR as ProductRepository - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Ctrl: GET /api/v1/products?sort=like_desc&brandId=1&page=0&size=20 - Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw - - Ctrl->>+F: getProducts(sort, brandId, pageable) - - F->>+PS: getProducts(sort, brandId, pageable) - - alt sort = like_desc (좋아요 많은순) - PS->>+PR: findAllOrderByLikeCountDesc(brandId, pageable) - PR->>+DB: SELECT p.*, COUNT(pl.id) as like_count
FROM products p
LEFT JOIN product_likes pl
GROUP BY p.id
ORDER BY like_count DESC - DB-->>-PR: Page - PR-->>-PS: Page - else sort = latest | price_asc - PS->>+PR: findAll(brandId, pageable, sort) - PR->>+DB: SELECT FROM products WHERE ... - DB-->>-PR: Page - PR-->>-PS: Page - - PS->>+LS: getLikeCounts(productIds) - LS->>+LR: countByProductIdIn(productIds) - LR->>+DB: SELECT product_id, COUNT(*)
FROM product_likes
WHERE product_id IN (...)
GROUP BY product_id - DB-->>-LR: Map - LR-->>-LS: Map - LS-->>-PS: Map - end - - PS-->>-F: Page - - F->>F: List.from(products) - F-->>-Ctrl: Page - - Ctrl->>Ctrl: ProductV1Dto.ListResponse.from(page) - Ctrl-->>-C: 200 OK + { products: [...], pageInfo: {...} } -``` - -**핵심 포인트:** -- `like_desc` 정렬 시 JOIN + COUNT로 한 번에 조회 -- 다른 정렬 시 상품 조회 후 좋아요 수 별도 조회 (N+1 방지를 위해 IN 쿼리 사용) -- *쿠팡과 오늘의 집에서 하듯이, 주문 목록 조회 시 기간(startAt, endAt)으로 조회하는 방안을 검토해 보았으나 설계에 집중하기 위해 넣지 않았다.* ---- - -## 5. 어드민 인증 흐름 - -**목적**: Interceptor + ArgumentResolver 조합 확인 - -```mermaid -sequenceDiagram - autonumber - participant C as Admin Client - participant F as Filter Chain - participant Int as AdminAuthInterceptor - participant AR as AdminUserArgumentResolver - participant Ctrl as AdminController - - C->>+F: Request to /api-admin/v1/** - Note over C,F: Headers: X-Loopers-Ldap: loopers.admin - - F->>+Int: preHandle() - - Int->>Int: Extract X-Loopers-Ldap header - - alt Header missing - Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED - else Header != "loopers.admin" - Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED - else Header = "loopers.admin" - Int-->>-F: true (continue) - end - - F->>+AR: resolveArgument() - Note over AR: AdminUser 파라미터 존재 시 - AR->>AR: Create AdminUser object - AR-->>-F: AdminUser - - F->>+Ctrl: Controller method - Ctrl-->>-F: Response - F-->>-C: Response -``` - -**핵심 포인트:** -- Interceptor가 1차 방어선 (헤더 누락/불일치 시 401) -- ArgumentResolver는 컨트롤러에 AdminUser 객체 주입 -- 이중 안전장치로 보안성 강화 -- *Interceptor 방식은 컨트롤러에서 어드민 정보 접근 시 Request에서 다시 추출 필요하고 특정 메서드만 예외 처리하려면 추가 로직 필요한 문제* -- *ArgumentResolver는 모든 메서드에 @AdminAuth 파라미터 추가 필요하고, 실수로 어노테이션을 누락하면 보안 위험한 문제* - -*-> Interceptor + ArgumentResolver 조합으로 문제를 해결했다.* \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md deleted file mode 100644 index 09f1c4596..000000000 --- a/.docs/design/03-class-diagram.md +++ /dev/null @@ -1,700 +0,0 @@ -# 클래스 다이어그램 - -## 다이어그램 목적 -클래스 다이어그램을 통해 다음을 검증한다: -- 도메인 책임: 각 도메인의 역할이 명확한가 -- 의존 방향: 상위 계층이 하위 계층에만 의존하는가 -- 응집도: 관련 기능이 적절히 그룹화되어 있는가 - ---- - -## 1. 전체 계층 구조 개요 - -```mermaid -classDiagram - direction TB - - namespace Interfaces { - class Controller - class ApiSpec - class Dto - class ArgumentResolver - class Interceptor - } - - namespace Application { - class Facade - class Info - } - - namespace Domain { - class Entity - class Service - class Repository - } - - namespace Infrastructure { - class RepositoryImpl - class JpaRepository - } - - Controller --> Facade : uses - Controller --> Dto : uses - Facade --> Service : uses - Facade --> Info : returns - Service --> Repository : uses - Service --> Entity : uses - RepositoryImpl ..|> Repository : implements - RepositoryImpl --> JpaRepository : uses -``` - -**계층별 책임:** -- **Interfaces**: HTTP 요청/응답 처리, DTO 변환, 인증 처리 -- **Application**: 유스케이스 조율, 도메인 ↔ 프레젠테이션 변환 -- **Domain**: 비즈니스 로직, 엔티티 검증, 도메인 규칙 -- **Infrastructure**: 데이터 접근, 외부 시스템 연동 - ---- - -## 2. Brand 도메인 클래스 - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class BrandV1Controller { - -BrandFacade brandFacade - +getBrand(Long brandId) ApiResponse~BrandDto.Response~ - } - - class BrandAdminV1Controller { - -BrandFacade brandFacade - +getBrands(Pageable) ApiResponse~Page~ - +getBrand(Long brandId) ApiResponse~BrandDto.Response~ - +createBrand(CreateRequest) ApiResponse~CreateResponse~ - +updateBrand(Long, UpdateRequest) ApiResponse~Response~ - +deleteBrand(Long brandId) ApiResponse~Void~ - } - - class BrandV1Dto { - <> - } - class Response { - <> - +Long id - +String name - +String description - +String logoUrl - +from(BrandInfo) Response - } - class CreateRequest { - <> - +String name - +String description - +String logoUrl - } - class CreateResponse { - <> - +Long brandId - } - class UpdateRequest { - <> - +String name - +String description - +String logoUrl - } - - %% Application Layer - class BrandFacade { - -BrandService brandService - +getBrand(Long brandId) BrandInfo - +getBrands(Pageable) Page~BrandInfo~ - +createBrand(String, String, String) BrandInfo - +updateBrand(Long, String, String, String) BrandInfo - +deleteBrand(Long brandId) void - } - - class BrandInfo { - <> - +Long id - +String name - +String description - +String logoUrl - +from(Brand) BrandInfo - } - - %% Domain Layer - class Brand { - -String name - -String description - -String logoUrl - +Brand(String, String, String) - +update(String, String, String) void - #guard() void - } - - class BrandService { - -BrandRepository brandRepository - +getBrand(Long brandId) Brand - +getBrands(Pageable) Page~Brand~ - +createBrand(String, String, String) Brand - +updateBrand(Long, String, String, String) Brand - +deleteBrand(Long brandId) void - } - - class BrandRepository { - <> - +findById(Long) Optional~Brand~ - +findAll(Pageable) Page~Brand~ - +save(Brand) Brand - +existsByName(String) boolean - } - - %% Infrastructure Layer - class BrandRepositoryImpl { - -BrandJpaRepository brandJpaRepository - } - - class BrandJpaRepository { - <> - +findByName(String) Optional~Brand~ - +existsByNameAndDeletedAtIsNull(String) boolean - } - - %% Relationships - BrandV1Controller --> BrandFacade - BrandAdminV1Controller --> BrandFacade - BrandFacade --> BrandService - BrandFacade --> BrandInfo - BrandService --> BrandRepository - BrandService --> Brand - BrandRepositoryImpl ..|> BrandRepository - BrandRepositoryImpl --> BrandJpaRepository - Brand --|> BaseEntity - - BrandV1Dto ..> Response - BrandV1Dto ..> CreateRequest - BrandV1Dto ..> CreateResponse - BrandV1Dto ..> UpdateRequest -``` - ---- - -## 3. Product 도메인 클래스 - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class ProductV1Controller { - -ProductFacade productFacade - +getProducts(String sort, Long brandId, Pageable) ApiResponse~Page~ - +getProduct(Long productId) ApiResponse~DetailResponse~ - } - - class ProductAdminV1Controller { - -ProductFacade productFacade - +getProducts(Pageable, Long brandId) ApiResponse~Page~ - +getProduct(Long productId) ApiResponse~AdminDetailResponse~ - +createProduct(CreateRequest) ApiResponse~CreateResponse~ - +updateProduct(Long, UpdateRequest) ApiResponse~Response~ - +deleteProduct(Long productId) ApiResponse~Void~ - } - - %% Application Layer - class ProductFacade { - -ProductService productService - -ProductLikeService productLikeService - -BrandService brandService - +getProducts(String, Long, Pageable) Page~ProductInfo~ - +getProduct(Long productId) ProductInfo - +createProduct(Long, String, String, Long, Integer, String) ProductInfo - +updateProduct(Long, String, String, Long, Integer, String) ProductInfo - +deleteProduct(Long productId) void - } - - class ProductInfo { - <> - +Long id - +Long brandId - +String brandName - +String name - +String description - +Long price - +Integer stock - +String imageUrl - +Long likeCount - +from(Product, Long) ProductInfo - } - - %% Domain Layer - class Product { - -Long brandId - -String name - -String description - -Long price - -Integer stock - -String imageUrl - +Product(Long, String, String, Long, Integer, String) - +update(String, String, Long, Integer, String) void - +decreaseStock(int quantity) void - +increaseStock(int quantity) void - #guard() void - } - - class ProductService { - -ProductRepository productRepository - -BrandRepository brandRepository - +getProduct(Long productId) Product - +getProductForOrder(Long productId) Product - +getProducts(String, Long, Pageable) Page~Product~ - +getProductsByBrandId(Long brandId) List~Product~ - +createProduct(...) Product - +updateProduct(...) Product - +deleteProduct(Long productId) void - +decreaseStock(Long productId, int quantity) void - } - - class ProductRepository { - <> - +findById(Long) Optional~Product~ - +findByIdWithLock(Long) Optional~Product~ - +findAll(String, Long, Pageable) Page~Product~ - +findAllByBrandId(Long) List~Product~ - +findAllOrderByLikeCountDesc(Long, Pageable) Page~Object[]~ - +save(Product) Product - } - - %% Infrastructure Layer - class ProductRepositoryImpl { - -ProductJpaRepository productJpaRepository - -JPAQueryFactory queryFactory - } - - class ProductJpaRepository { - <> - } - - %% Relationships - ProductV1Controller --> ProductFacade - ProductAdminV1Controller --> ProductFacade - ProductFacade --> ProductService - ProductFacade --> ProductInfo - ProductService --> ProductRepository - ProductService --> Product - ProductRepositoryImpl ..|> ProductRepository - ProductRepositoryImpl --> ProductJpaRepository - Product --|> BaseEntity -``` - ---- - -## 4. ProductLike 도메인 클래스 - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class ProductLikeV1Controller { - -ProductLikeFacade productLikeFacade - +like(AuthenticatedUser, Long productId) ApiResponse~Void~ - +unlike(AuthenticatedUser, Long productId) ApiResponse~Void~ - +getMyLikes(AuthenticatedUser, Long userId) ApiResponse~List~ - } - - %% Application Layer - class ProductLikeFacade { - -ProductLikeService productLikeService - -ProductService productService - -UserService userService - +like(Long userId, Long productId) void - +unlike(Long userId, Long productId) void - +getMyLikes(Long userId) List~ProductLikeInfo~ - } - - class ProductLikeInfo { - <> - +Long productId - +String productName - +Long price - +String imageUrl - +ZonedDateTime likedAt - } - - %% Domain Layer - class ProductLike { - -Long userId - -Long productId - +ProductLike(Long userId, Long productId) - +getUserId() Long - +getProductId() Long - } - - class ProductLikeService { - -ProductLikeRepository productLikeRepository - +like(Long userId, Long productId) ProductLike - +unlike(Long userId, Long productId) void - +existsByUserIdAndProductId(Long, Long) boolean - +countByProductId(Long productId) Long - +getLikeCounts(List~Long~ productIds) Map~Long_Long~ - +getByUserId(Long userId) List~ProductLike~ - +deleteAllByProductId(Long productId) void - } - - class ProductLikeRepository { - <> - +findByUserIdAndProductId(Long, Long) Optional~ProductLike~ - +existsByUserIdAndProductId(Long, Long) boolean - +countByProductId(Long) Long - +countByProductIdIn(List~Long~) List~Object[]~ - +findAllByUserId(Long) List~ProductLike~ - +deleteByUserIdAndProductId(Long, Long) void - +deleteAllByProductId(Long) void - +save(ProductLike) ProductLike - } - - %% Infrastructure Layer - class ProductLikeRepositoryImpl { - -ProductLikeJpaRepository productLikeJpaRepository - } - - class ProductLikeJpaRepository { - <> - } - - %% Relationships - ProductLikeV1Controller --> ProductLikeFacade - ProductLikeFacade --> ProductLikeService - ProductLikeFacade --> ProductLikeInfo - ProductLikeService --> ProductLikeRepository - ProductLikeService --> ProductLike - ProductLikeRepositoryImpl ..|> ProductLikeRepository - ProductLikeRepositoryImpl --> ProductLikeJpaRepository - ProductLike --|> BaseEntity -``` - ---- - -## 5. Order 도메인 클래스 - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class OrderV1Controller { - -OrderFacade orderFacade - +createOrder(AuthenticatedUser, CreateRequest) ApiResponse~CreateResponse~ - +getOrders(AuthenticatedUser, LocalDate, LocalDate, Pageable) ApiResponse~Page~ - +getOrder(AuthenticatedUser, Long orderId) ApiResponse~DetailResponse~ - } - - class OrderV1Dto { - <> - } - - class CreateRequest { - <> - +List~OrderItemRequest~ items - } - - class OrderItemRequest { - <> - +Long productId - +Integer quantity - } - - class CreateResponse { - <> - +Long orderId - +Long totalPrice - } - - class ListResponse { - <> - +Long orderId - +Long totalPrice - +String status - +ZonedDateTime createdAt - } - - class DetailResponse { - <> - +Long orderId - +Long totalPrice - +String status - +List~OrderItemResponse~ items - +ZonedDateTime createdAt - } - - class OrderItemResponse { - <> - +Long productId - +String productName - +Integer quantity - +Long price - } - - %% Application Layer - class OrderFacade { - -OrderService orderService - -ProductService productService - -UserService userService - +createOrder(Long userId, List~OrderItemRequest~) OrderInfo - +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~OrderInfo~ - +getOrder(Long userId, Long orderId) OrderInfo - } - - class OrderInfo { - <> - +Long id - +Long userId - +Long totalPrice - +OrderStatus status - +List~OrderItemInfo~ items - +ZonedDateTime createdAt - +from(Order) OrderInfo - } - - class OrderItemInfo { - <> - +Long productId - +String productName - +Integer quantity - +Long price - +from(OrderItem) OrderItemInfo - } - - %% Domain Layer - class Order { - -Long userId - -Long totalPrice - -OrderStatus status - -List~OrderItem~ orderItems - +Order(Long userId) - +addItem(OrderItem item) void - +calculateTotalPrice() void - +complete() void - +cancel() void - } - - class OrderItem { - -Order order - -Long productId - -String productName - -Integer quantity - -Long price - +OrderItem(Long, String, Integer, Long) - +setOrder(Order order) void - +getSubtotal() Long - } - - class OrderStatus { - <> - PENDING - COMPLETED - CANCELLED - } - - class OrderService { - -OrderRepository orderRepository - -ProductService productService - +createOrder(Long userId, List~OrderItemRequest~) Order - +getOrder(Long orderId) Order - +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~Order~ - +validateOrderAccess(Long userId, Order order) void - } - - class OrderRepository { - <> - +findById(Long) Optional~Order~ - +findByUserId(Long, Pageable) Page~Order~ - +findByUserIdAndCreatedAtBetween(...) Page~Order~ - +save(Order) Order - } - - %% Infrastructure Layer - class OrderRepositoryImpl { - -OrderJpaRepository orderJpaRepository - } - - class OrderJpaRepository { - <> - } - - %% Relationships - OrderV1Controller --> OrderFacade - OrderFacade --> OrderService - OrderFacade --> OrderInfo - OrderService --> OrderRepository - OrderService --> Order - Order --> OrderItem - Order --> OrderStatus - OrderRepositoryImpl ..|> OrderRepository - OrderRepositoryImpl --> OrderJpaRepository - Order --|> BaseEntity - OrderItem --|> BaseEntity - - OrderV1Dto ..> CreateRequest - OrderV1Dto ..> OrderItemRequest - OrderV1Dto ..> CreateResponse - OrderV1Dto ..> ListResponse - OrderV1Dto ..> DetailResponse - OrderV1Dto ..> OrderItemResponse -``` - ---- - -## 6. 인증 관련 클래스 - -```mermaid -classDiagram - direction TB - - %% 일반 사용자 인증 (기존) - class AuthenticatedUser { - <> - +String loginId - +String password - } - - class AuthenticatedUserArgumentResolver { - -UserService userService - +supportsParameter(MethodParameter) boolean - +resolveArgument(...) Object - } - - %% 어드민 인증 (신규) - class AdminUser { - <> - +String ldapId - } - - class AdminAuthInterceptor { - -String ADMIN_LDAP_HEADER - -String ADMIN_LDAP_VALUE - +preHandle(HttpServletRequest, HttpServletResponse, Object) boolean - } - - class AdminUserArgumentResolver { - +supportsParameter(MethodParameter) boolean - +resolveArgument(...) Object - } - - %% WebMvcConfig - class WebMvcConfig { - -AuthenticatedUserArgumentResolver authResolver - -AdminUserArgumentResolver adminResolver - -AdminAuthInterceptor adminInterceptor - +addArgumentResolvers(List) void - +addInterceptors(InterceptorRegistry) void - } - - %% Interfaces - class HandlerMethodArgumentResolver { - <> - } - - class HandlerInterceptor { - <> - } - - %% Relationships - WebMvcConfig --> AuthenticatedUserArgumentResolver - WebMvcConfig --> AdminUserArgumentResolver - WebMvcConfig --> AdminAuthInterceptor - AuthenticatedUserArgumentResolver ..|> HandlerMethodArgumentResolver - AdminUserArgumentResolver ..|> HandlerMethodArgumentResolver - AdminAuthInterceptor ..|> HandlerInterceptor -``` - -**핵심 포인트:** -- **AdminAuthInterceptor**: `/api-admin/**` 경로에 대해 헤더 검증 (1차 방어선) -- **AdminUserArgumentResolver**: 컨트롤러에 AdminUser 객체 주입 - ---- - -## 7. 공통 클래스 - -```mermaid -classDiagram - direction TB - - class BaseEntity { - <> - #Long id - #ZonedDateTime createdAt - #ZonedDateTime updatedAt - #ZonedDateTime deletedAt - #guard() void - +delete() void - +restore() void - +isDeleted() boolean - } - - class ApiResponse~T~ { - <> - +Metadata meta - +T data - +success() ApiResponse~Object~ - +success(T data) ApiResponse~T~ - +fail(String, String) ApiResponse~Object~ - } - - class Metadata { - <> - +Result result - +String errorCode - +String message - } - - class Result { - <> - SUCCESS - FAIL - } - - class CoreException { - -ErrorType errorType - -String customMessage - +CoreException(ErrorType) - +CoreException(ErrorType, String) - +getErrorType() ErrorType - } - - class ErrorType { - <> - INTERNAL_ERROR - BAD_REQUEST - NOT_FOUND - CONFLICT - UNAUTHORIZED - USER_NOT_FOUND - PASSWORD_MISMATCH - BRAND_NOT_FOUND - BRAND_ALREADY_EXISTS - BRAND_DELETED - PRODUCT_NOT_FOUND - PRODUCT_DELETED - INSUFFICIENT_STOCK - ORDER_NOT_FOUND - ORDER_ACCESS_DENIED - ADMIN_UNAUTHORIZED - BRAND_CHANGE_NOT_ALLOWED - -HttpStatus status - -String code - -String message - } - - ApiResponse --> Metadata - Metadata --> Result - CoreException --> ErrorType -``` - -**핵심 포인트:** -- **BaseEntity**: 모든 엔티티의 공통 필드 (id, timestamps, soft delete) -- **ApiResponse**: 통일된 API 응답 형식 -- **ErrorType**: 도메인별 에러 코드 정의 diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md deleted file mode 100644 index 50851256c..000000000 --- a/.docs/design/04-erd.md +++ /dev/null @@ -1,419 +0,0 @@ -# ERD (Entity Relationship Diagram) - -## 다이어그램 목적 -ERD를 통해 다음을 검증한다: -- 영속성 구조: 데이터가 어떻게 저장되는가 -- 관계의 주인: FK가 어디에 위치하는가 -- 정규화 여부: 데이터 중복이 최소화되었는가 -- 정합성: 제약조건이 비즈니스 규칙을 반영하는가 - ---- - -## 1. 전체 ERD - -```mermaid -erDiagram - users ||--o{ orders : "places" - users ||--o{ product_likes : "likes" - brands ||--o{ products : "has" - products ||--o{ product_likes : "has" - products ||--o{ order_items : "ordered_in" - orders ||--|{ order_items : "contains" - - users { - bigint id PK "AUTO_INCREMENT" - varchar_30 login_id UK "NOT NULL" - varchar_255 password "NOT NULL, BCrypt" - varchar_30 name "NOT NULL" - varchar_8 birth_date "NOT NULL, YYYYMMDD" - varchar_100 email "NOT NULL" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - timestamp deleted_at "NULL, Soft Delete" - } - - brands { - bigint id PK "AUTO_INCREMENT" - varchar_100 name UK "NOT NULL" - varchar_500 description "NULL" - varchar_500 logo_url "NULL" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - timestamp deleted_at "NULL, Soft Delete" - } - - products { - bigint id PK "AUTO_INCREMENT" - bigint brand_id FK "NOT NULL" - varchar_200 name "NOT NULL" - varchar_2000 description "NULL" - bigint price "NOT NULL, >= 0" - int stock "NOT NULL, >= 0" - varchar_500 image_url "NULL" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - timestamp deleted_at "NULL, Soft Delete" - } - - product_likes { - bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL" - bigint product_id FK "NOT NULL" - timestamp created_at "NOT NULL" - } - - orders { - bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL" - bigint total_price "NOT NULL, >= 0" - varchar_20 status "NOT NULL, ENUM" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - } - - order_items { - bigint id PK "AUTO_INCREMENT" - bigint order_id FK "NOT NULL" - bigint product_id FK "NOT NULL" - varchar_200 product_name "NOT NULL, 스냅샷" - int quantity "NOT NULL, >= 1" - bigint price "NOT NULL, 스냅샷" - timestamp created_at "NOT NULL" - } -``` - ---- - -## 2. 테이블 상세 스키마 - -### 2.1 users 테이블 (기존) - -```sql -CREATE TABLE users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - login_id VARCHAR(30) NOT NULL, - password VARCHAR(255) NOT NULL, - name VARCHAR(30) NOT NULL, - birth_date VARCHAR(8) NOT NULL, - email VARCHAR(100) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - CONSTRAINT uk_users_login_id UNIQUE (login_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - ---- - -### 2.2 brands 테이블 - -```sql -CREATE TABLE brands ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description VARCHAR(500) NULL, - logo_url VARCHAR(500) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - CONSTRAINT uk_brands_name UNIQUE (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 인덱스 -CREATE INDEX idx_brands_deleted_at ON brands (deleted_at); -CREATE INDEX idx_brands_created_at ON brands (created_at); -``` - -**인덱스 설계 의도:** -| 인덱스 | 용도 | -|--------|------| -| `uk_brands_name` | 브랜드명 중복 방지 | -| `idx_brands_deleted_at` | Soft Delete 필터링 최적화 | -| `idx_brands_created_at` | 최신순 정렬 최적화 | - ---- - -### 2.3 products 테이블 - -```sql -CREATE TABLE products ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - brand_id BIGINT NOT NULL, - name VARCHAR(200) NOT NULL, - description VARCHAR(2000) NULL, - price BIGINT NOT NULL, - stock INT NOT NULL DEFAULT 0, - image_url VARCHAR(500) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) - REFERENCES brands(id) ON DELETE RESTRICT, - - CONSTRAINT chk_products_price CHECK (price >= 0), - CONSTRAINT chk_products_stock CHECK (stock >= 0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 인덱스 -CREATE INDEX idx_products_brand_id ON products (brand_id); -CREATE INDEX idx_products_deleted_at ON products (deleted_at); -CREATE INDEX idx_products_price ON products (price); -CREATE INDEX idx_products_created_at ON products (created_at DESC); - --- 복합 인덱스: 브랜드별 상품 조회 최적화 -CREATE INDEX idx_products_brand_deleted_created - ON products (brand_id, deleted_at, created_at DESC); -``` - -**인덱스 설계 의도:** -| 인덱스 | 용도 | -|--------|------| -| `fk_products_brand_id` | 브랜드-상품 관계 무결성 | -| `idx_products_brand_id` | 브랜드별 상품 조회 | -| `idx_products_deleted_at` | Soft Delete 필터링 | -| `idx_products_price` | 가격순 정렬 최적화 | -| `idx_products_created_at` | 최신순 정렬 최적화 | -| `idx_products_brand_deleted_created` | 브랜드 필터 + 삭제 필터 + 최신순 복합 쿼리 | - ---- - -### 2.4 product_likes 테이블 - -```sql -CREATE TABLE product_likes ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) - REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) - REFERENCES products(id) ON DELETE CASCADE, - - CONSTRAINT uk_product_likes_user_product UNIQUE (user_id, product_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 인덱스 -CREATE INDEX idx_product_likes_product_id ON product_likes (product_id); -CREATE INDEX idx_product_likes_user_id ON product_likes (user_id); -``` - -**인덱스 설계 의도:** -| 인덱스 | 용도 | -|--------|------| -| `uk_product_likes_user_product` | 중복 좋아요 방지 + 특정 사용자의 특정 상품 좋아요 여부 조회 | -| `idx_product_likes_product_id` | 상품별 좋아요 수 COUNT 최적화 | -| `idx_product_likes_user_id` | 사용자별 좋아요 목록 조회 최적화 | - -**CASCADE 삭제:** -- User 삭제 시 해당 사용자의 좋아요 자동 삭제 -- Product 삭제 시 해당 상품의 좋아요 자동 삭제 - ---- - -### 2.5 orders 테이블 - -```sql -CREATE TABLE orders ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - total_price BIGINT NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) - REFERENCES users(id) ON DELETE RESTRICT, - - CONSTRAINT chk_orders_total_price CHECK (total_price >= 0), - CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 인덱스 -CREATE INDEX idx_orders_user_id ON orders (user_id); -CREATE INDEX idx_orders_created_at ON orders (created_at DESC); -CREATE INDEX idx_orders_status ON orders (status); - --- 복합 인덱스: 사용자별 기간 조회 최적화 -CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); -``` - -**인덱스 설계 의도:** -| 인덱스 | 용도 | -|--------|------| -| `idx_orders_user_id` | 사용자별 주문 조회 | -| `idx_orders_created_at` | 최신순 정렬 | -| `idx_orders_status` | 상태별 필터링 | -| `idx_orders_user_created` | 사용자의 주문 기간 조회 최적화 | - ---- - -### 2.6 order_items 테이블 - -```sql -CREATE TABLE order_items ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - order_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - product_name VARCHAR(200) NOT NULL COMMENT '주문 시점 상품명 스냅샷', - quantity INT NOT NULL, - price BIGINT NOT NULL COMMENT '주문 시점 상품 가격 스냅샷', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) - REFERENCES orders(id) ON DELETE CASCADE, - CONSTRAINT fk_order_items_product_id FOREIGN KEY (product_id) - REFERENCES products(id) ON DELETE RESTRICT, - - CONSTRAINT uk_order_items_order_product UNIQUE (order_id, product_id), - CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), - CONSTRAINT chk_order_items_price CHECK (price >= 0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 인덱스 -CREATE INDEX idx_order_items_order_id ON order_items (order_id); -CREATE INDEX idx_order_items_product_id ON order_items (product_id); -``` - -**인덱스 설계 의도:** -| 인덱스 | 용도 | -|--------|------| -| `uk_order_items_order_product` | 동일 주문 내 동일 상품 중복 방지 | -| `idx_order_items_order_id` | 주문별 항목 조회 | -| `idx_order_items_product_id` | 상품별 주문 이력 조회 | - -**가격/상품명 스냅샷:** -- `price`, `product_name` 필드는 주문 시점의 값을 저장 -- 상품 가격/이름 변경 시에도 기존 주문의 정보는 유지 - ---- - -## 3. 관계 정의 - -### 3.1 관계 요약 - -| 관계 | 타입 | 설명 | -|------|------|------| -| users : orders | 1:N | 사용자는 여러 주문 가능 | -| users : product_likes | 1:N | 사용자는 여러 상품에 좋아요 가능 | -| brands : products | 1:N | 브랜드는 여러 상품 보유 | -| products : product_likes | 1:N | 상품은 여러 좋아요 보유 | -| products : order_items | 1:N | 상품은 여러 주문에 포함 가능 | -| orders : order_items | 1:N | 주문은 여러 주문 항목 포함 | - -### 3.2 FK 삭제 정책 - -| FK | 정책 | 이유 | -|----|------|------| -| products.brand_id | RESTRICT | 브랜드 삭제 시 애플리케이션에서 Cascade 처리 | -| product_likes.user_id | CASCADE | 사용자 삭제 시 좋아요 자동 삭제 | -| product_likes.product_id | CASCADE | 상품 삭제 시 좋아요 자동 삭제 | -| orders.user_id | RESTRICT | 주문 이력 보존 (사용자 삭제 불가) | -| order_items.order_id | CASCADE | 주문 삭제 시 항목 자동 삭제 | -| order_items.product_id | RESTRICT | 상품 삭제 시에도 주문 이력 보존 | - ---- - -## 4. 쿼리 최적화 가이드 - -### 4.1 상품 목록 조회 (좋아요순 정렬) - -```sql --- 좋아요 많은순 정렬 (서브쿼리 방식) -SELECT - p.*, - COALESCE(like_counts.cnt, 0) as like_count -FROM products p -LEFT JOIN ( - SELECT product_id, COUNT(*) as cnt - FROM product_likes - GROUP BY product_id -) like_counts ON p.id = like_counts.product_id -WHERE p.deleted_at IS NULL - AND (p.brand_id = :brandId OR :brandId IS NULL) -ORDER BY like_count DESC, p.created_at DESC -LIMIT :limit OFFSET :offset; -``` - -### 4.2 재고 차감 (비관적 락) - -```sql --- 비관적 락으로 재고 조회 -SELECT * FROM products -WHERE id = :productId AND deleted_at IS NULL -FOR UPDATE; - --- 재고 검증 후 차감 -UPDATE products -SET stock = stock - :quantity, updated_at = NOW() -WHERE id = :productId AND stock >= :quantity; -``` - -### 4.3 주문 상세 조회 (Fetch Join) - -```sql -SELECT o.*, oi.* -FROM orders o -JOIN order_items oi ON o.id = oi.order_id -WHERE o.id = :orderId AND o.user_id = :userId; -``` - -### 4.4 사용자별 좋아요 상품 목록 - -```sql -SELECT p.*, pl.created_at as liked_at -FROM product_likes pl -JOIN products p ON pl.product_id = p.id -WHERE pl.user_id = :userId AND p.deleted_at IS NULL -ORDER BY pl.created_at DESC; -``` - -### 4.5 사용자 주문 기간 조회 - -```sql -SELECT o.*, oi.* -FROM orders o -LEFT JOIN order_items oi ON o.id = oi.order_id -WHERE o.user_id = :userId - AND (:startAt IS NULL OR o.created_at >= :startAt) - AND (:endAt IS NULL OR o.created_at < :endAt + INTERVAL 1 DAY) -ORDER BY o.created_at DESC -LIMIT :limit OFFSET :offset; -``` - ---- - -## 5. 데이터 마이그레이션 순서 - -``` -V1__Create_users_table.sql (기존) -V2__Create_brands_table.sql -V3__Create_products_table.sql -V4__Create_product_likes_table.sql -V5__Create_orders_table.sql -V6__Create_order_items_table.sql -V7__Add_indexes.sql -``` - ---- - -## 6. 데이터 정합성 고려사항 - -### 6.1 동시성 제어 -- **재고 차감**: `SELECT ... FOR UPDATE` 비관적 락 -- **좋아요 중복**: `UNIQUE (user_id, product_id)` 제약조건 -- **주문 항목 중복**: `UNIQUE (order_id, product_id)` 제약조건 - -### 6.2 Soft Delete 처리 -- brands, products, users: `deleted_at` 필드 사용 -- 조회 시 `WHERE deleted_at IS NULL` 조건 필수 -- product_likes, orders, order_items: Hard Delete - -### 6.3 스냅샷 데이터 -- order_items.price: 주문 시점 상품 가격 -- order_items.product_name: 주문 시점 상품명 -- 상품 정보 변경과 무관하게 주문 이력 보존 From 356b6ec32d08e6b9a85019e507f30ea502712a5b Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:03:51 +0900 Subject: [PATCH 10/15] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=AA=85=EC=84=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 - Brand/Product/ProductLike/Order/OrderItem 도메인 필드 정의 - 비즈니스 규칙 (BR-*) 및 검증 규칙 정의 - 유저 시나리오 9개 (US-001~009), 어드민 시나리오 7개 (AS-001~007) - API 명세 및 에러 타입 정의 Co-Authored-By: Claude Opus 4.5 --- .docs/design/01-requirements.md | 431 ++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 .docs/design/01-requirements.md diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..87009de6e --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,431 @@ +# 커머스 도메인 요구사항 정의서 + +## 1. 개요 + +### 1.1 문서 목적 +Java/Spring Boot 멀티 모듈 커머스 백엔드의 Brand, Product, ProductLike, Order, OrderItem 도메인에 대한 +상세 요구사항을 정의한다. + +### 1.2 기존 패턴 참조 +- 아키텍처: Layered Architecture (interfaces → application → domain → infrastructure) +- 인증: 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) +- 응답 형식: ApiResponse (meta + data) +- 예외 처리: CoreException + ErrorType enum + +### 1.3 액터 정의 + +| 액터 | 설명 | 인증 방식 | +|------|------|----------| +| 일반 사용자 | 상품 조회, 좋아요, 주문 가능 | X-Loopers-LoginId + X-Loopers-LoginPw | +| 어드민 | 브랜드/상품 CRUD 관리 | X-Loopers-Ldap: loopers.admin | + +--- + +## 2. 도메인별 상세 요구사항 + +### 2.1 Brand (브랜드) + +#### 2.1.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| name | String | Y | 1-100자, 공백 불가, 중복 불가 | +| description | String | N | 최대 500자 | +| logoUrl | String | N | URL 형식 검증, 최대 500자 | +| createdAt | ZonedDateTime | Y | 자동 생성 | +| updatedAt | ZonedDateTime | Y | 자동 갱신 | +| deletedAt | ZonedDateTime | N | Soft Delete | + +*브랜드 주소, 대표명, 브랜드 사이트 URL 같은 컬럼을 넣을지 말지 고민했지만 설계에 집중하고 싶어서 넣지 않았다.* + +#### 2.1.2 비즈니스 규칙 +- **BR-BRAND-001**: 브랜드명은 시스템 내 유일해야 한다 +- **BR-BRAND-002**: 브랜드 삭제 시 해당 브랜드의 모든 상품이 Cascade 삭제된다 +- **BR-BRAND-003**: 삭제된 브랜드는 조회되지 않는다 (Soft Delete) + +#### 2.1.3 검증 규칙 +``` +name 검증: +- null 또는 blank 불가 → "브랜드명은 필수입니다." +- 100자 초과 → "브랜드명은 100자를 초과할 수 없습니다." +- 중복 → "이미 존재하는 브랜드명입니다." (409 CONFLICT) + +description 검증: +- 500자 초과 → "브랜드 설명은 500자를 초과할 수 없습니다." + +logoUrl 검증: +- URL 형식 불일치 → "유효하지 않은 URL 형식입니다." +- 500자 초과 → "로고 URL은 500자를 초과할 수 없습니다." +``` + +--- + +### 2.2 Product (상품) + +#### 2.2.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| brandId | Long | Y | Brand FK, 존재 검증 | +| name | String | Y | 1-200자 | +| description | String | N | 최대 2000자 | +| price | Long | Y | 0 이상 | +| stock | Integer | Y | 0 이상 | +| imageUrl | String | N | URL 형식, 최대 500자 | +| createdAt | ZonedDateTime | Y | 자동 생성 | +| updatedAt | ZonedDateTime | Y | 자동 갱신 | +| deletedAt | ZonedDateTime | N | Soft Delete | + +#### 2.2.2 비즈니스 규칙 +- **BR-PRODUCT-001**: 상품은 반드시 하나의 브랜드에 속해야 한다 +- **BR-PRODUCT-002**: 상품 등록 후 브랜드 변경 불가 +- **BR-PRODUCT-003**: 상품 삭제 시 해당 상품의 모든 좋아요가 Cascade 삭제된다 +- **BR-PRODUCT-004**: 삭제된 상품은 목록 조회 시 제외된다 +- **BR-PRODUCT-005**: 재고가 0인 상품도 조회는 가능하다 + +#### 2.2.3 검증 규칙 +``` +name 검증: +- null 또는 blank 불가 → "상품명은 필수입니다." +- 200자 초과 → "상품명은 200자를 초과할 수 없습니다." + +price 검증: +- null 불가 → "가격은 필수입니다." +- 음수 → "가격은 0원 이상이어야 합니다." + +stock 검증: +- null 불가 → "재고는 필수입니다." +- 음수 → "재고는 0개 이상이어야 합니다." + +brandId 검증: +- 존재하지 않는 브랜드 → "존재하지 않는 브랜드입니다." (404 NOT_FOUND) +- 삭제된 브랜드 → "삭제된 브랜드입니다." (400 BAD_REQUEST) +``` + +--- + +### 2.3 ProductLike (상품 좋아요) + +#### 2.3.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| userId | Long | Y | User FK | +| productId | Long | Y | Product FK | +| createdAt | ZonedDateTime | Y | 자동 생성 | + +#### 2.3.2 비즈니스 규칙 +- **BR-LIKE-001**: 좋아요 등록 시 이미 좋아요가 존재하면 좋아요 취소 처리 (토글 방식) +- **BR-LIKE-002**: 좋아요 개수는 실시간 COUNT 집계 +- **BR-LIKE-003**: 존재하지 않는 좋아요 취소 시 멱등 처리 (에러 없이 성공 응답) +- **BR-LIKE-004**: 삭제된 상품에는 좋아요 불가 + +#### 2.3.3 검증 규칙 +``` +좋아요 등록: +- 삭제된 상품 → "삭제된 상품입니다." (400 BAD_REQUEST) +- 존재하지 않는 상품 → "존재하지 않는 상품입니다." (404 NOT_FOUND) +- 중복 좋아요 → 좋아요 취소 처리 (토글) + +좋아요 취소: +- 존재하지 않는 좋아요 → 멱등 처리 (성공 응답) +``` + +--- + +### 2.4 Order (주문) + +#### 2.4.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| userId | Long | Y | User FK | +| totalPrice | Long | Y | 0 이상, 계산된 값 | +| status | OrderStatus | Y | PENDING, COMPLETED, CANCELLED | +| createdAt | ZonedDateTime | Y | 자동 생성 | +| updatedAt | ZonedDateTime | Y | 자동 갱신 | + +#### 2.4.2 OrderStatus 상태 정의 +```java +public enum OrderStatus { + PENDING, // 주문 대기 + COMPLETED, // 주문 완료 + CANCELLED // 주문 취소 +} +``` + +#### 2.4.3 비즈니스 규칙 +- **BR-ORDER-001**: 다건 상품 주문 지원 (OrderItem 1:N 관계) +- **BR-ORDER-002**: 전체 실패 정책 - 하나라도 실패 시 전체 주문 롤백 +- **BR-ORDER-003**: 재고 검증 후 차감은 원자적으로 수행 (비관적 락) +- **BR-ORDER-004**: totalPrice는 OrderItem들의 (price * quantity) 합계 + +--- + +### 2.5 OrderItem (주문 항목) + +#### 2.5.1 필드 정의 +| 필드 | 타입 | 필수 | 제약조건 | +|------|------|------|----------| +| id | Long | Y | 자동 생성 (PK) | +| orderId | Long | Y | Order FK | +| productId | Long | Y | Product FK | +| quantity | Integer | Y | 1 이상 | +| price | Long | Y | 주문 시점 상품 가격 (스냅샷) | +| createdAt | ZonedDateTime | Y | 자동 생성 | + +#### 2.5.2 비즈니스 규칙 +- **BR-ORDERITEM-001**: 주문 시점의 상품 가격을 스냅샷으로 저장 +- **BR-ORDERITEM-002**: 동일 주문 내 동일 상품 중복 불가 (orderId + productId UNIQUE) +- **BR-ORDERITEM-003**: 수량은 최소 1개 이상 + +--- + +## 3. 유저 시나리오 + +### 3.1 일반 사용자 시나리오 + +#### US-001: 브랜드 정보 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/brands/{brandId} 요청 +Then: 브랜드 정보(name, description, logoUrl) 반환 +``` + +#### US-002: 상품 목록 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/products 요청 (정렬/필터/페이징 옵션) +Then: 상품 목록과 좋아요 수 반환 + +정렬 옵션: +- latest (기본값): 최신순 +- price_asc: 가격 낮은순 +- like_desc: 좋아요 많은순 + +필터 옵션: +- brandId: 특정 브랜드 필터 + +페이징: +- page: 페이지 번호 (0부터 시작) +- size: 페이지 크기 (기본 20) +``` + +#### US-003: 상품 상세 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/products/{productId} 요청 +Then: 상품 상세 정보와 좋아요 수 반환 +``` + +#### US-004: 좋아요 등록/토글 +``` +Given: 사용자가 로그인 상태 +When: POST /api/v1/products/{productId}/likes 요청 +Then: + - 좋아요가 없으면 → 좋아요 등록 + - 좋아요가 있으면 → 좋아요 취소 (토글) +``` + +#### US-005: 좋아요 취소 +``` +Given: 사용자가 로그인 상태 +When: DELETE /api/v1/products/{productId}/likes 요청 +Then: 좋아요 삭제 (존재하지 않아도 성공) +``` + +#### US-006: 내가 좋아요한 상품 목록 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/users/{userId}/likes 요청 +Then: 좋아요한 상품 목록 반환 +``` + +#### US-007: 주문 생성 +``` +Given: 사용자가 로그인 상태, 주문할 상품들 선택 +When: POST /api/v1/orders 요청 (items: [{productId, quantity}]) +Then: + - 재고 검증 (모든 상품) + - 재고 차감 (원자적) + - 주문 생성 및 ID 반환 + - 실패 시 전체 롤백 +``` + +#### US-008: 주문 목록 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/orders 요청 (선택적 기간 필터) +Then: 본인의 주문 목록 반환 +``` + +#### US-009: 주문 상세 조회 +``` +Given: 사용자가 로그인 상태 +When: GET /api/v1/orders/{orderId} 요청 +Then: + - 본인 주문인 경우: 주문 상세 반환 + - 타인 주문인 경우: 403 FORBIDDEN +``` + +--- + +### 3.2 어드민 시나리오 + +#### AS-001: 브랜드 목록 조회 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: GET /api-admin/v1/brands 요청 +Then: 전체 브랜드 목록 반환 +``` + +#### AS-002: 브랜드 등록 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: POST /api-admin/v1/brands 요청 +Then: 브랜드 등록 및 ID 반환 +``` + +#### AS-003: 브랜드 수정 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: PUT /api-admin/v1/brands/{brandId} 요청 +Then: 브랜드 정보 수정 +``` + +#### AS-004: 브랜드 삭제 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: DELETE /api-admin/v1/brands/{brandId} 요청 +Then: + - 브랜드 Soft Delete + - 해당 브랜드의 모든 상품 Cascade Soft Delete + - 상품들의 좋아요 Cascade Hard Delete +``` + +#### AS-005: 상품 등록 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: POST /api-admin/v1/products 요청 +Then: 상품 등록 및 ID 반환 +``` + +#### AS-006: 상품 수정 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: PUT /api-admin/v1/products/{productId} 요청 +Then: + - brandId 제외 필드 수정 가능 + - brandId 변경 시도 시: "브랜드 변경은 불가능합니다." (400 BAD_REQUEST) +``` + +#### AS-007: 상품 삭제 (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin 헤더 +When: DELETE /api-admin/v1/products/{productId} 요청 +Then: + - 상품 Soft Delete + - 좋아요 Cascade Hard Delete +``` + +--- + +## 4. API 명세 + +### 4.1 일반 사용자 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | /api/v1/brands/{brandId} | 브랜드 정보 조회 | +| GET | /api/v1/products | 상품 목록 조회 | +| GET | /api/v1/products/{productId} | 상품 상세 조회 | +| POST | /api/v1/products/{productId}/likes | 좋아요 등록 | +| DELETE | /api/v1/products/{productId}/likes | 좋아요 취소 | +| GET | /api/v1/users/{userId}/likes | 내가 좋아요한 상품 목록 | +| POST | /api/v1/orders | 주문 생성 | +| GET | /api/v1/orders | 주문 목록 조회 | +| GET | /api/v1/orders/{orderId} | 주문 상세 조회 | + +### 4.2 어드민 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | /api-admin/v1/brands | 브랜드 목록 조회 | +| GET | /api-admin/v1/brands/{brandId} | 브랜드 상세 조회 | +| POST | /api-admin/v1/brands | 브랜드 등록 | +| PUT | /api-admin/v1/brands/{brandId} | 브랜드 수정 | +| DELETE | /api-admin/v1/brands/{brandId} | 브랜드 삭제 | +| GET | /api-admin/v1/products | 상품 목록 조회 | +| GET | /api-admin/v1/products/{productId} | 상품 상세 조회 | +| POST | /api-admin/v1/products | 상품 등록 | +| PUT | /api-admin/v1/products/{productId} | 상품 수정 | +| DELETE | /api-admin/v1/products/{productId} | 상품 삭제 | + +--- + +## 5. 인증 체계 + +### 5.1 일반 사용자 인증 +``` +Headers: + X-Loopers-LoginId: {loginId} + X-Loopers-LoginPw: {password} + +처리: AuthenticatedUserArgumentResolver +대상: /api/v1/** 엔드포인트 +``` + +### 5.2 어드민 인증 +``` +Headers: + X-Loopers-Ldap: loopers.admin + +처리: AdminAuthInterceptor + AdminUserArgumentResolver +대상: /api-admin/v1/** 엔드포인트 +``` + +--- + +## 6. 에러 타입 정의 + +```java +// ErrorType.java에 추가할 타입 +BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "존재하지 않는 브랜드입니다."), +BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "이미 존재하는 브랜드명입니다."), +BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "삭제된 브랜드입니다."), +PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "존재하지 않는 상품입니다."), +PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "삭제된 상품입니다."), +INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."), +ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "존재하지 않는 주문입니다."), +ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "주문 접근 권한이 없습니다."), +ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "관리자 권한이 필요합니다."), +BRAND_CHANGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "BRAND_CHANGE_NOT_ALLOWED", "브랜드 변경은 불가능합니다."); +``` + +--- + +## 7. API 응답 형식 + +### 7.1 성공 응답 +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { ... } +} +``` + +### 7.2 실패 응답 +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "PRODUCT_NOT_FOUND", + "message": "존재하지 않는 상품입니다." + }, + "data": null +} +``` \ No newline at end of file From 5d86f4eac31388992c08971e71c0e8b7d2cb6458 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:16 +0900 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=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 - 주문 생성 시퀀스 (정상/재고 부족 플로우) - 좋아요 등록 시퀀스 (토글 방식: 신규/취소) - 브랜드 삭제 시퀀스 (Cascade 삭제) - 상품 목록 조회 시퀀스 (좋아요 수 포함) - 어드민 인증 플로우 (Interceptor + ArgumentResolver) Co-Authored-By: Claude Opus 4.5 --- .docs/design/02-sequence-diagrams.md | 437 +++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 .docs/design/02-sequence-diagrams.md diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..faa32ccc6 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,437 @@ +# 시퀀스 다이어그램 + +## 다이어그램 목적 +시퀀스 다이어그램을 통해 다음을 검증한다: +- 책임 분리: 각 객체가 맡은 역할이 명확한가 +- 호출 순서: 비즈니스 로직의 흐름이 올바른가 +- 트랜잭션 경계: 원자성이 보장되는 범위가 적절한가 + +--- + +## 1. 주문 생성 시퀀스 + +### 1.1 정상 흐름 (다건 주문) + +**목적**: 재고 검증, 비관적 락, 트랜잭션 경계 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant OR as OrderRepository + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over C,Ctrl: Body: { items: [{productId, quantity}] } + + Ctrl->>+F: createOrder(userId, items) + + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional 시작 + + loop 각 주문 항목에 대해 + OS->>+PS: getProductForOrder(productId) + PS->>+PR: findByIdWithLock(productId) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: 재고 검증 (stock >= quantity) + + alt 재고 부족 + OS-->>F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: 전체 롤백 + end + + OS->>+PS: decreaseStock(productId, quantity) + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET stock = stock - quantity + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-OS: void + end + + OS->>OS: totalPrice 계산 + OS->>OS: Order 엔티티 생성 + OS->>OS: OrderItem 엔티티들 생성 (가격 스냅샷) + + OS->>+OR: save(order) + OR->>+DB: INSERT orders, order_items + DB-->>-OR: Order (with ID) + OR-->>-OS: Order + + Note over OS: @Transactional 커밋 + + OS-->>-F: Order + F->>F: OrderInfo.from(order) + F-->>-Ctrl: OrderInfo + + Ctrl->>Ctrl: OrderV1Dto.CreateResponse.from(info) + Ctrl-->>-C: 201 Created + { orderId, totalPrice } +``` + +**핵심 포인트:** +- `SELECT ... FOR UPDATE`로 비관적 락 획득 → 동시 주문 시 재고 경쟁 방지 +- 모든 상품 검증 후 차감 → 하나라도 실패 시 전체 롤백 +- OrderItem에 가격 스냅샷 저장 → 상품 가격 변경 시에도 주문 가격 유지 + +--- + +### 1.2 재고 부족 실패 흐름 + +**목적**: 전체 실패 정책, 롤백 동작 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: items: [{productId: 1, qty: 10}, {productId: 2, qty: 5}] + + Ctrl->>+F: createOrder(userId, items) + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional 시작 + + OS->>+PS: getProductForOrder(productId: 1) + PS->>+PR: findByIdWithLock(productId: 1) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 10) + PR-->>-PS: Product + PS-->>-OS: Product + OS->>OS: 재고 검증 통과 (10 >= 10) + OS->>PS: decreaseStock(1, 10) + + OS->>+PS: getProductForOrder(productId: 2) + PS->>+PR: findByIdWithLock(productId: 2) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 3) + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: 재고 검증 실패 (3 < 5) + + OS-->>-F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: 전체 롤백 (상품1 재고 복구) + + F-->>-Ctrl: throw CoreException + Ctrl-->>-C: 400 Bad Request + INSUFFICIENT_STOCK +``` + +**핵심 포인트:** +- 두 번째 상품 재고 부족 시 첫 번째 상품 재고 차감도 롤백 +- 트랜잭션 단위로 원자성 보장 + +--- + +## 2. 좋아요 등록 시퀀스 (토글 방식) + +### 2.1 신규 좋아요 등록 + +**목적**: 상품 유효성 검증 및 좋아요 등록 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: like(userId, productId) + + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>LS: 상품 삭제 여부 검증 + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: null (미존재) + LR-->>-LS: Optional (empty) + + LS->>LS: ProductLike 엔티티 생성 (신규 등록) + + LS->>+LR: save(productLike) + LR->>+DB: INSERT product_likes + DB-->>-LR: ProductLike + LR-->>-LS: ProductLike + + LS-->>-F: ProductLike + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "좋아요가 등록되었습니다." } +``` +**핵심 포인트:** + +- *좋아요 기능 설계 시 아래와 같은 두 가지 선택지가 있었다. 정렬 쿼리를 위해 Product 내부에 좋아요 필드를 두는 방법과 좋아요 테이블을 따로 두는 선택지 중 정합성을 높이는 방식을 선택했다.* + +*1. 비정규화: Product에 likeCount 필드를 두고 좋아요 등록/취소 시 동기 업데이트. 정렬 쿼리 성능 우수* + +*2. 실시간 집계(적용): 좋아요 테이블에서 COUNT 집계. 정합성 높으나 정렬 시 쿼리 비용 증가* + + + + +- 상품 존재 및 삭제 여부 먼저 검증 +- 기존 좋아요가 없으면 신규 등록 + +--- + +### 2.2 기존 좋아요 존재 시 (토글 - 취소 처리) + +**목적**: 토글 방식 동작 확인 - 이미 좋아요가 있으면 취소 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + + Ctrl->>+F: like(userId, productId) + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: ProductLike (존재) + LR-->>-LS: Optional (present) + + Note over LS: 이미 존재하므로 좋아요 취소 (토글) + + LS->>+LR: delete(productLike) + LR->>+DB: DELETE FROM product_likes + DB-->>-LR: OK + LR-->>-LS: void + + LS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "좋아요가 취소되었습니다." } +``` + +**핵심 포인트:** +- 좋아요가 이미 존재하면 삭제 (토글 방식) +- POST 요청 한 번으로 등록/취소 모두 처리 + +--- + +## 3. 브랜드 삭제 시퀀스 (Cascade 삭제) + +**목적**: Cascade 삭제 순서, 트랜잭션 경계 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant Int as AdminAuthInterceptor + participant Ctrl as BrandAdminV1Controller + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + participant LS as ProductLikeService + participant BR as BrandRepository + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Int: DELETE /api-admin/v1/brands/{brandId} + Note over C,Int: Headers: X-Loopers-Ldap: loopers.admin + + Int->>Int: Admin 권한 검증 + Int->>+Ctrl: 요청 전달 + + Ctrl->>+F: deleteBrand(brandId) + F->>+BS: deleteBrand(brandId) + + Note over BS: @Transactional 시작 + + BS->>+BR: findById(brandId) + BR->>+DB: SELECT FROM brands + DB-->>-BR: Brand + BR-->>-BS: Brand + + alt 브랜드 없음 + BS-->>F: throw CoreException(BRAND_NOT_FOUND) + end + + BS->>+PS: getProductsByBrandId(brandId) + PS->>+PR: findAllByBrandId(brandId) + PR->>+DB: SELECT FROM products WHERE brand_id = ? + DB-->>-PR: List + PR-->>-PS: List + PS-->>-BS: List + + loop 각 상품에 대해 + BS->>+LS: deleteAllByProductId(productId) + LS->>+LR: deleteAllByProductId(productId) + LR->>+DB: DELETE FROM product_likes WHERE product_id = ? + DB-->>-LR: OK + LR-->>-LS: void + LS-->>-BS: void + + BS->>+PS: deleteProduct(productId) + PS->>PS: product.delete() + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET deleted_at = NOW() + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-BS: void + end + + BS->>BS: brand.delete() + BS->>+BR: save(brand) + BR->>+DB: UPDATE brands SET deleted_at = NOW() + DB-->>-BR: OK + BR-->>-BS: Brand + + Note over BS: @Transactional 커밋 + + BS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "브랜드가 삭제되었습니다." } +``` + +**핵심 포인트:** +- 삭제 순서: 좋아요(Hard) → 상품(Soft) → 브랜드(Soft) +- 단일 트랜잭션으로 원자성 보장 +- 좋아요는 Hard Delete, 상품/브랜드는 Soft Delete + +--- + +## 4. 상품 목록 조회 시퀀스 (좋아요 수 포함) + +**목적**: 좋아요 실시간 집계, 정렬 옵션 처리 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductV1Controller + participant F as ProductFacade + participant PS as ProductService + participant LS as ProductLikeService + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: GET /api/v1/products?sort=like_desc&brandId=1&page=0&size=20 + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: getProducts(sort, brandId, pageable) + + F->>+PS: getProducts(sort, brandId, pageable) + + alt sort = like_desc (좋아요 많은순) + PS->>+PR: findAllOrderByLikeCountDesc(brandId, pageable) + PR->>+DB: SELECT p.*, COUNT(pl.id) as like_count
FROM products p
LEFT JOIN product_likes pl
GROUP BY p.id
ORDER BY like_count DESC + DB-->>-PR: Page + PR-->>-PS: Page + else sort = latest | price_asc + PS->>+PR: findAll(brandId, pageable, sort) + PR->>+DB: SELECT FROM products WHERE ... + DB-->>-PR: Page + PR-->>-PS: Page + + PS->>+LS: getLikeCounts(productIds) + LS->>+LR: countByProductIdIn(productIds) + LR->>+DB: SELECT product_id, COUNT(*)
FROM product_likes
WHERE product_id IN (...)
GROUP BY product_id + DB-->>-LR: Map + LR-->>-LS: Map + LS-->>-PS: Map + end + + PS-->>-F: Page + + F->>F: List.from(products) + F-->>-Ctrl: Page + + Ctrl->>Ctrl: ProductV1Dto.ListResponse.from(page) + Ctrl-->>-C: 200 OK + { products: [...], pageInfo: {...} } +``` + +**핵심 포인트:** +- `like_desc` 정렬 시 JOIN + COUNT로 한 번에 조회 +- 다른 정렬 시 상품 조회 후 좋아요 수 별도 조회 (N+1 방지를 위해 IN 쿼리 사용) +- *쿠팡과 오늘의 집에서 하듯이, 주문 목록 조회 시 기간(startAt, endAt)으로 조회하는 방안을 검토해 보았으나 설계에 집중하기 위해 넣지 않았다.* +--- + +## 5. 어드민 인증 흐름 + +**목적**: Interceptor + ArgumentResolver 조합 확인 + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant F as Filter Chain + participant Int as AdminAuthInterceptor + participant AR as AdminUserArgumentResolver + participant Ctrl as AdminController + + C->>+F: Request to /api-admin/v1/** + Note over C,F: Headers: X-Loopers-Ldap: loopers.admin + + F->>+Int: preHandle() + + Int->>Int: Extract X-Loopers-Ldap header + + alt Header missing + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header != "loopers.admin" + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header = "loopers.admin" + Int-->>-F: true (continue) + end + + F->>+AR: resolveArgument() + Note over AR: AdminUser 파라미터 존재 시 + AR->>AR: Create AdminUser object + AR-->>-F: AdminUser + + F->>+Ctrl: Controller method + Ctrl-->>-F: Response + F-->>-C: Response +``` + +**핵심 포인트:** +- Interceptor가 1차 방어선 (헤더 누락/불일치 시 401) +- ArgumentResolver는 컨트롤러에 AdminUser 객체 주입 +- 이중 안전장치로 보안성 강화 +- *Interceptor 방식은 컨트롤러에서 어드민 정보 접근 시 Request에서 다시 추출 필요하고 특정 메서드만 예외 처리하려면 추가 로직 필요한 문제* +- *ArgumentResolver는 모든 메서드에 @AdminAuth 파라미터 추가 필요하고, 실수로 어노테이션을 누락하면 보안 위험한 문제* + +*-> Interceptor + ArgumentResolver 조합으로 문제를 해결했다.* \ No newline at end of file From 12f94c3d56f280b8b00af936a0f67c3bda9a0981 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:29 +0900 Subject: [PATCH 12/15] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=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 - 전체 계층 구조 개요 (Layered Architecture) - Brand/Product/ProductLike/Order 도메인 클래스 - 인증 관련 클래스 (AdminAuthInterceptor, AdminUserArgumentResolver) - 공통 클래스 (BaseEntity, ApiResponse, CoreException, ErrorType) Co-Authored-By: Claude Opus 4.5 --- .docs/design/03-class-diagram.md | 700 +++++++++++++++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 .docs/design/03-class-diagram.md diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..bb4152a67 --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,700 @@ +# 클래스 다이어그램 + +## 다이어그램 목적 +클래스 다이어그램을 통해 다음을 검증한다: +- 도메인 책임: 각 도메인의 역할이 명확한가 +- 의존 방향: 상위 계층이 하위 계층에만 의존하는가 +- 응집도: 관련 기능이 적절히 그룹화되어 있는가 + +--- + +## 1. 전체 계층 구조 개요 + +```mermaid +classDiagram + direction TB + + namespace Interfaces { + class Controller + class ApiSpec + class Dto + class ArgumentResolver + class Interceptor + } + + namespace Application { + class Facade + class Info + } + + namespace Domain { + class Entity + class Service + class Repository + } + + namespace Infrastructure { + class RepositoryImpl + class JpaRepository + } + + Controller --> Facade : uses + Controller --> Dto : uses + Facade --> Service : uses + Facade --> Info : returns + Service --> Repository : uses + Service --> Entity : uses + RepositoryImpl ..|> Repository : implements + RepositoryImpl --> JpaRepository : uses +``` + +**계층별 책임:** +- **Interfaces**: HTTP 요청/응답 처리, DTO 변환, 인증 처리 +- **Application**: 유스케이스 조율, 도메인 ↔ 프레젠테이션 변환 +- **Domain**: 비즈니스 로직, 엔티티 검증, 도메인 규칙 +- **Infrastructure**: 데이터 접근, 외부 시스템 연동 + +--- + +## 2. Brand 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class BrandV1Controller { + -BrandFacade brandFacade + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + } + + class BrandAdminV1Controller { + -BrandFacade brandFacade + +getBrands(Pageable) ApiResponse~Page~ + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + +createBrand(CreateRequest) ApiResponse~CreateResponse~ + +updateBrand(Long, UpdateRequest) ApiResponse~Response~ + +deleteBrand(Long brandId) ApiResponse~Void~ + } + + class BrandV1Dto { + <> + } + class Response { + <> + +Long id + +String name + +String description + +String logoUrl + +from(BrandInfo) Response + } + class CreateRequest { + <> + +String name + +String description + +String logoUrl + } + class CreateResponse { + <> + +Long brandId + } + class UpdateRequest { + <> + +String name + +String description + +String logoUrl + } + + %% Application Layer + class BrandFacade { + -BrandService brandService + +getBrand(Long brandId) BrandInfo + +getBrands(Pageable) Page~BrandInfo~ + +createBrand(String, String, String) BrandInfo + +updateBrand(Long, String, String, String) BrandInfo + +deleteBrand(Long brandId) void + } + + class BrandInfo { + <> + +Long id + +String name + +String description + +String logoUrl + +from(Brand) BrandInfo + } + + %% Domain Layer + class Brand { + -String name + -String description + -String logoUrl + +Brand(String, String, String) + +update(String, String, String) void + #guard() void + } + + class BrandService { + -BrandRepository brandRepository + +getBrand(Long brandId) Brand + +getBrands(Pageable) Page~Brand~ + +createBrand(String, String, String) Brand + +updateBrand(Long, String, String, String) Brand + +deleteBrand(Long brandId) void + } + + class BrandRepository { + <> + +findById(Long) Optional~Brand~ + +findAll(Pageable) Page~Brand~ + +save(Brand) Brand + +existsByName(String) boolean + } + + %% Infrastructure Layer + class BrandRepositoryImpl { + -BrandJpaRepository brandJpaRepository + } + + class BrandJpaRepository { + <> + +findByName(String) Optional~Brand~ + +existsByNameAndDeletedAtIsNull(String) boolean + } + + %% Relationships + BrandV1Controller --> BrandFacade + BrandAdminV1Controller --> BrandFacade + BrandFacade --> BrandService + BrandFacade --> BrandInfo + BrandService --> BrandRepository + BrandService --> Brand + BrandRepositoryImpl ..|> BrandRepository + BrandRepositoryImpl --> BrandJpaRepository + Brand --|> BaseEntity + + BrandV1Dto ..> Response + BrandV1Dto ..> CreateRequest + BrandV1Dto ..> CreateResponse + BrandV1Dto ..> UpdateRequest +``` + +--- + +## 3. Product 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductV1Controller { + -ProductFacade productFacade + +getProducts(String sort, Long brandId, Pageable) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~DetailResponse~ + } + + class ProductAdminV1Controller { + -ProductFacade productFacade + +getProducts(Pageable, Long brandId) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~AdminDetailResponse~ + +createProduct(CreateRequest) ApiResponse~CreateResponse~ + +updateProduct(Long, UpdateRequest) ApiResponse~Response~ + +deleteProduct(Long productId) ApiResponse~Void~ + } + + %% Application Layer + class ProductFacade { + -ProductService productService + -ProductLikeService productLikeService + -BrandService brandService + +getProducts(String, Long, Pageable) Page~ProductInfo~ + +getProduct(Long productId) ProductInfo + +createProduct(Long, String, String, Long, Integer, String) ProductInfo + +updateProduct(Long, String, String, Long, Integer, String) ProductInfo + +deleteProduct(Long productId) void + } + + class ProductInfo { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +Long price + +Integer stock + +String imageUrl + +Long likeCount + +from(Product, Long) ProductInfo + } + + %% Domain Layer + class Product { + -Long brandId + -String name + -String description + -Long price + -Integer stock + -String imageUrl + +Product(Long, String, String, Long, Integer, String) + +update(String, String, Long, Integer, String) void + +decreaseStock(int quantity) void + +increaseStock(int quantity) void + #guard() void + } + + class ProductService { + -ProductRepository productRepository + -BrandRepository brandRepository + +getProduct(Long productId) Product + +getProductForOrder(Long productId) Product + +getProducts(String, Long, Pageable) Page~Product~ + +getProductsByBrandId(Long brandId) List~Product~ + +createProduct(...) Product + +updateProduct(...) Product + +deleteProduct(Long productId) void + +decreaseStock(Long productId, int quantity) void + } + + class ProductRepository { + <> + +findById(Long) Optional~Product~ + +findByIdWithLock(Long) Optional~Product~ + +findAll(String, Long, Pageable) Page~Product~ + +findAllByBrandId(Long) List~Product~ + +findAllOrderByLikeCountDesc(Long, Pageable) Page~Object[]~ + +save(Product) Product + } + + %% Infrastructure Layer + class ProductRepositoryImpl { + -ProductJpaRepository productJpaRepository + -JPAQueryFactory queryFactory + } + + class ProductJpaRepository { + <> + } + + %% Relationships + ProductV1Controller --> ProductFacade + ProductAdminV1Controller --> ProductFacade + ProductFacade --> ProductService + ProductFacade --> ProductInfo + ProductService --> ProductRepository + ProductService --> Product + ProductRepositoryImpl ..|> ProductRepository + ProductRepositoryImpl --> ProductJpaRepository + Product --|> BaseEntity +``` + +--- + +## 4. ProductLike 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductLikeV1Controller { + -ProductLikeFacade productLikeFacade + +like(AuthenticatedUser, Long productId) ApiResponse~Void~ + +unlike(AuthenticatedUser, Long productId) ApiResponse~Void~ + +getMyLikes(AuthenticatedUser, Long userId) ApiResponse~List~ + } + + %% Application Layer + class ProductLikeFacade { + -ProductLikeService productLikeService + -ProductService productService + -UserService userService + +like(Long userId, Long productId) void + +unlike(Long userId, Long productId) void + +getMyLikes(Long userId) List~ProductLikeInfo~ + } + + class ProductLikeInfo { + <> + +Long productId + +String productName + +Long price + +String imageUrl + +ZonedDateTime likedAt + } + + %% Domain Layer + class ProductLike { + -Long userId + -Long productId + +ProductLike(Long userId, Long productId) + +getUserId() Long + +getProductId() Long + } + + class ProductLikeService { + -ProductLikeRepository productLikeRepository + +like(Long userId, Long productId) ProductLike + +unlike(Long userId, Long productId) void + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long productId) Long + +getLikeCounts(List~Long~ productIds) Map~Long_Long~ + +getByUserId(Long userId) List~ProductLike~ + +deleteAllByProductId(Long productId) void + } + + class ProductLikeRepository { + <> + +findByUserIdAndProductId(Long, Long) Optional~ProductLike~ + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long) Long + +countByProductIdIn(List~Long~) List~Object[]~ + +findAllByUserId(Long) List~ProductLike~ + +deleteByUserIdAndProductId(Long, Long) void + +deleteAllByProductId(Long) void + +save(ProductLike) ProductLike + } + + %% Infrastructure Layer + class ProductLikeRepositoryImpl { + -ProductLikeJpaRepository productLikeJpaRepository + } + + class ProductLikeJpaRepository { + <> + } + + %% Relationships + ProductLikeV1Controller --> ProductLikeFacade + ProductLikeFacade --> ProductLikeService + ProductLikeFacade --> ProductLikeInfo + ProductLikeService --> ProductLikeRepository + ProductLikeService --> ProductLike + ProductLikeRepositoryImpl ..|> ProductLikeRepository + ProductLikeRepositoryImpl --> ProductLikeJpaRepository + ProductLike --|> BaseEntity +``` + +--- + +## 5. Order 도메인 클래스 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class OrderV1Controller { + -OrderFacade orderFacade + +createOrder(AuthenticatedUser, CreateRequest) ApiResponse~CreateResponse~ + +getOrders(AuthenticatedUser, LocalDate, LocalDate, Pageable) ApiResponse~Page~ + +getOrder(AuthenticatedUser, Long orderId) ApiResponse~DetailResponse~ + } + + class OrderV1Dto { + <> + } + + class CreateRequest { + <> + +List~OrderItemRequest~ items + } + + class OrderItemRequest { + <> + +Long productId + +Integer quantity + } + + class CreateResponse { + <> + +Long orderId + +Long totalPrice + } + + class ListResponse { + <> + +Long orderId + +Long totalPrice + +String status + +ZonedDateTime createdAt + } + + class DetailResponse { + <> + +Long orderId + +Long totalPrice + +String status + +List~OrderItemResponse~ items + +ZonedDateTime createdAt + } + + class OrderItemResponse { + <> + +Long productId + +String productName + +Integer quantity + +Long price + } + + %% Application Layer + class OrderFacade { + -OrderService orderService + -ProductService productService + -UserService userService + +createOrder(Long userId, List~OrderItemRequest~) OrderInfo + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~OrderInfo~ + +getOrder(Long userId, Long orderId) OrderInfo + } + + class OrderInfo { + <> + +Long id + +Long userId + +Long totalPrice + +OrderStatus status + +List~OrderItemInfo~ items + +ZonedDateTime createdAt + +from(Order) OrderInfo + } + + class OrderItemInfo { + <> + +Long productId + +String productName + +Integer quantity + +Long price + +from(OrderItem) OrderItemInfo + } + + %% Domain Layer + class Order { + -Long userId + -Long totalPrice + -OrderStatus status + -List~OrderItem~ orderItems + +Order(Long userId) + +addItem(OrderItem item) void + +calculateTotalPrice() void + +complete() void + +cancel() void + } + + class OrderItem { + -Order order + -Long productId + -String productName + -Integer quantity + -Long price + +OrderItem(Long, String, Integer, Long) + +setOrder(Order order) void + +getSubtotal() Long + } + + class OrderStatus { + <> + PENDING + COMPLETED + CANCELLED + } + + class OrderService { + -OrderRepository orderRepository + -ProductService productService + +createOrder(Long userId, List~OrderItemRequest~) Order + +getOrder(Long orderId) Order + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~Order~ + +validateOrderAccess(Long userId, Order order) void + } + + class OrderRepository { + <> + +findById(Long) Optional~Order~ + +findByUserId(Long, Pageable) Page~Order~ + +findByUserIdAndCreatedAtBetween(...) Page~Order~ + +save(Order) Order + } + + %% Infrastructure Layer + class OrderRepositoryImpl { + -OrderJpaRepository orderJpaRepository + } + + class OrderJpaRepository { + <> + } + + %% Relationships + OrderV1Controller --> OrderFacade + OrderFacade --> OrderService + OrderFacade --> OrderInfo + OrderService --> OrderRepository + OrderService --> Order + Order --> OrderItem + Order --> OrderStatus + OrderRepositoryImpl ..|> OrderRepository + OrderRepositoryImpl --> OrderJpaRepository + Order --|> BaseEntity + OrderItem --|> BaseEntity + + OrderV1Dto ..> CreateRequest + OrderV1Dto ..> OrderItemRequest + OrderV1Dto ..> CreateResponse + OrderV1Dto ..> ListResponse + OrderV1Dto ..> DetailResponse + OrderV1Dto ..> OrderItemResponse +``` + +--- + +## 6. 인증 관련 클래스 + +```mermaid +classDiagram + direction TB + + %% 일반 사용자 인증 (기존) + class AuthenticatedUser { + <> + +String loginId + +String password + } + + class AuthenticatedUserArgumentResolver { + -UserService userService + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% 어드민 인증 (신규) + class AdminUser { + <> + +String ldapId + } + + class AdminAuthInterceptor { + -String ADMIN_LDAP_HEADER + -String ADMIN_LDAP_VALUE + +preHandle(HttpServletRequest, HttpServletResponse, Object) boolean + } + + class AdminUserArgumentResolver { + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% WebMvcConfig + class WebMvcConfig { + -AuthenticatedUserArgumentResolver authResolver + -AdminUserArgumentResolver adminResolver + -AdminAuthInterceptor adminInterceptor + +addArgumentResolvers(List) void + +addInterceptors(InterceptorRegistry) void + } + + %% Interfaces + class HandlerMethodArgumentResolver { + <> + } + + class HandlerInterceptor { + <> + } + + %% Relationships + WebMvcConfig --> AuthenticatedUserArgumentResolver + WebMvcConfig --> AdminUserArgumentResolver + WebMvcConfig --> AdminAuthInterceptor + AuthenticatedUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminAuthInterceptor ..|> HandlerInterceptor +``` + +**핵심 포인트:** +- **AdminAuthInterceptor**: `/api-admin/**` 경로에 대해 헤더 검증 (1차 방어선) +- **AdminUserArgumentResolver**: 컨트롤러에 AdminUser 객체 주입 + +--- + +## 7. 공통 클래스 + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + #guard() void + +delete() void + +restore() void + +isDeleted() boolean + } + + class ApiResponse~T~ { + <> + +Metadata meta + +T data + +success() ApiResponse~Object~ + +success(T data) ApiResponse~T~ + +fail(String, String) ApiResponse~Object~ + } + + class Metadata { + <> + +Result result + +String errorCode + +String message + } + + class Result { + <> + SUCCESS + FAIL + } + + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + } + + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + UNAUTHORIZED + USER_NOT_FOUND + PASSWORD_MISMATCH + BRAND_NOT_FOUND + BRAND_ALREADY_EXISTS + BRAND_DELETED + PRODUCT_NOT_FOUND + PRODUCT_DELETED + INSUFFICIENT_STOCK + ORDER_NOT_FOUND + ORDER_ACCESS_DENIED + ADMIN_UNAUTHORIZED + BRAND_CHANGE_NOT_ALLOWED + -HttpStatus status + -String code + -String message + } + + ApiResponse --> Metadata + Metadata --> Result + CoreException --> ErrorType +``` + +**핵심 포인트:** +- **BaseEntity**: 모든 엔티티의 공통 필드 (id, timestamps, soft delete) +- **ApiResponse**: 통일된 API 응답 형식 +- **ErrorType**: 도메인별 에러 코드 정의 \ No newline at end of file From b3b74aa599732826043d3f6742e34f680b0e7381 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:43 +0900 Subject: [PATCH 13/15] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20ERD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6개 테이블 스키마 (users, brands, products, product_likes, orders, order_items) - 인덱스 설계 및 FK 삭제 정책 - 쿼리 최적화 가이드 (좋아요순 정렬, 비관적 락) Co-Authored-By: Claude Opus 4.5 --- .docs/design/04-erd.md | 419 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 .docs/design/04-erd.md diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..d4cf61030 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,419 @@ +# ERD (Entity Relationship Diagram) + +## 다이어그램 목적 +ERD를 통해 다음을 검증한다: +- 영속성 구조: 데이터가 어떻게 저장되는가 +- 관계의 주인: FK가 어디에 위치하는가 +- 정규화 여부: 데이터 중복이 최소화되었는가 +- 정합성: 제약조건이 비즈니스 규칙을 반영하는가 + +--- + +## 1. 전체 ERD + +```mermaid +erDiagram + users ||--o{ orders : "places" + users ||--o{ product_likes : "likes" + brands ||--o{ products : "has" + products ||--o{ product_likes : "has" + products ||--o{ order_items : "ordered_in" + orders ||--|{ order_items : "contains" + + users { + bigint id PK "AUTO_INCREMENT" + varchar_30 login_id UK "NOT NULL" + varchar_255 password "NOT NULL, BCrypt" + varchar_30 name "NOT NULL" + varchar_8 birth_date "NOT NULL, YYYYMMDD" + varchar_100 email "NOT NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + brands { + bigint id PK "AUTO_INCREMENT" + varchar_100 name UK "NOT NULL" + varchar_500 description "NULL" + varchar_500 logo_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + products { + bigint id PK "AUTO_INCREMENT" + bigint brand_id FK "NOT NULL" + varchar_200 name "NOT NULL" + varchar_2000 description "NULL" + bigint price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar_500 image_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + product_likes { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + timestamp created_at "NOT NULL" + } + + orders { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint total_price "NOT NULL, >= 0" + varchar_20 status "NOT NULL, ENUM" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + } + + order_items { + bigint id PK "AUTO_INCREMENT" + bigint order_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + varchar_200 product_name "NOT NULL, 스냅샷" + int quantity "NOT NULL, >= 1" + bigint price "NOT NULL, 스냅샷" + timestamp created_at "NOT NULL" + } +``` + +--- + +## 2. 테이블 상세 스키마 + +### 2.1 users 테이블 (기존) + +```sql +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(30) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(30) NOT NULL, + birth_date VARCHAR(8) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_users_login_id UNIQUE (login_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +### 2.2 brands 테이블 + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) NULL, + logo_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_brands_name UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_brands_deleted_at ON brands (deleted_at); +CREATE INDEX idx_brands_created_at ON brands (created_at); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `uk_brands_name` | 브랜드명 중복 방지 | +| `idx_brands_deleted_at` | Soft Delete 필터링 최적화 | +| `idx_brands_created_at` | 최신순 정렬 최적화 | + +--- + +### 2.3 products 테이블 + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(2000) NULL, + price BIGINT NOT NULL, + stock INT NOT NULL DEFAULT 0, + image_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) + REFERENCES brands(id) ON DELETE RESTRICT, + + CONSTRAINT chk_products_price CHECK (price >= 0), + CONSTRAINT chk_products_stock CHECK (stock >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_products_brand_id ON products (brand_id); +CREATE INDEX idx_products_deleted_at ON products (deleted_at); +CREATE INDEX idx_products_price ON products (price); +CREATE INDEX idx_products_created_at ON products (created_at DESC); + +-- 복합 인덱스: 브랜드별 상품 조회 최적화 +CREATE INDEX idx_products_brand_deleted_created + ON products (brand_id, deleted_at, created_at DESC); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `fk_products_brand_id` | 브랜드-상품 관계 무결성 | +| `idx_products_brand_id` | 브랜드별 상품 조회 | +| `idx_products_deleted_at` | Soft Delete 필터링 | +| `idx_products_price` | 가격순 정렬 최적화 | +| `idx_products_created_at` | 최신순 정렬 최적화 | +| `idx_products_brand_deleted_created` | 브랜드 필터 + 삭제 필터 + 최신순 복합 쿼리 | + +--- + +### 2.4 product_likes 테이블 + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE CASCADE, + + CONSTRAINT uk_product_likes_user_product UNIQUE (user_id, product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_product_likes_product_id ON product_likes (product_id); +CREATE INDEX idx_product_likes_user_id ON product_likes (user_id); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `uk_product_likes_user_product` | 중복 좋아요 방지 + 특정 사용자의 특정 상품 좋아요 여부 조회 | +| `idx_product_likes_product_id` | 상품별 좋아요 수 COUNT 최적화 | +| `idx_product_likes_user_id` | 사용자별 좋아요 목록 조회 최적화 | + +**CASCADE 삭제:** +- User 삭제 시 해당 사용자의 좋아요 자동 삭제 +- Product 삭제 시 해당 상품의 좋아요 자동 삭제 + +--- + +### 2.5 orders 테이블 + +```sql +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + total_price BIGINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE RESTRICT, + + CONSTRAINT chk_orders_total_price CHECK (total_price >= 0), + CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_orders_user_id ON orders (user_id); +CREATE INDEX idx_orders_created_at ON orders (created_at DESC); +CREATE INDEX idx_orders_status ON orders (status); + +-- 복합 인덱스: 사용자별 기간 조회 최적화 +CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `idx_orders_user_id` | 사용자별 주문 조회 | +| `idx_orders_created_at` | 최신순 정렬 | +| `idx_orders_status` | 상태별 필터링 | +| `idx_orders_user_created` | 사용자의 주문 기간 조회 최적화 | + +--- + +### 2.6 order_items 테이블 + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL COMMENT '주문 시점 상품명 스냅샷', + quantity INT NOT NULL, + price BIGINT NOT NULL COMMENT '주문 시점 상품 가격 스냅샷', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) + REFERENCES orders(id) ON DELETE CASCADE, + CONSTRAINT fk_order_items_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE RESTRICT, + + CONSTRAINT uk_order_items_order_product UNIQUE (order_id, product_id), + CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), + CONSTRAINT chk_order_items_price CHECK (price >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 인덱스 +CREATE INDEX idx_order_items_order_id ON order_items (order_id); +CREATE INDEX idx_order_items_product_id ON order_items (product_id); +``` + +**인덱스 설계 의도:** +| 인덱스 | 용도 | +|--------|------| +| `uk_order_items_order_product` | 동일 주문 내 동일 상품 중복 방지 | +| `idx_order_items_order_id` | 주문별 항목 조회 | +| `idx_order_items_product_id` | 상품별 주문 이력 조회 | + +**가격/상품명 스냅샷:** +- `price`, `product_name` 필드는 주문 시점의 값을 저장 +- 상품 가격/이름 변경 시에도 기존 주문의 정보는 유지 + +--- + +## 3. 관계 정의 + +### 3.1 관계 요약 + +| 관계 | 타입 | 설명 | +|------|------|------| +| users : orders | 1:N | 사용자는 여러 주문 가능 | +| users : product_likes | 1:N | 사용자는 여러 상품에 좋아요 가능 | +| brands : products | 1:N | 브랜드는 여러 상품 보유 | +| products : product_likes | 1:N | 상품은 여러 좋아요 보유 | +| products : order_items | 1:N | 상품은 여러 주문에 포함 가능 | +| orders : order_items | 1:N | 주문은 여러 주문 항목 포함 | + +### 3.2 FK 삭제 정책 + +| FK | 정책 | 이유 | +|----|------|------| +| products.brand_id | RESTRICT | 브랜드 삭제 시 애플리케이션에서 Cascade 처리 | +| product_likes.user_id | CASCADE | 사용자 삭제 시 좋아요 자동 삭제 | +| product_likes.product_id | CASCADE | 상품 삭제 시 좋아요 자동 삭제 | +| orders.user_id | RESTRICT | 주문 이력 보존 (사용자 삭제 불가) | +| order_items.order_id | CASCADE | 주문 삭제 시 항목 자동 삭제 | +| order_items.product_id | RESTRICT | 상품 삭제 시에도 주문 이력 보존 | + +--- + +## 4. 쿼리 최적화 가이드 + +### 4.1 상품 목록 조회 (좋아요순 정렬) + +```sql +-- 좋아요 많은순 정렬 (서브쿼리 방식) +SELECT + p.*, + COALESCE(like_counts.cnt, 0) as like_count +FROM products p +LEFT JOIN ( + SELECT product_id, COUNT(*) as cnt + FROM product_likes + GROUP BY product_id +) like_counts ON p.id = like_counts.product_id +WHERE p.deleted_at IS NULL + AND (p.brand_id = :brandId OR :brandId IS NULL) +ORDER BY like_count DESC, p.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +### 4.2 재고 차감 (비관적 락) + +```sql +-- 비관적 락으로 재고 조회 +SELECT * FROM products +WHERE id = :productId AND deleted_at IS NULL +FOR UPDATE; + +-- 재고 검증 후 차감 +UPDATE products +SET stock = stock - :quantity, updated_at = NOW() +WHERE id = :productId AND stock >= :quantity; +``` + +### 4.3 주문 상세 조회 (Fetch Join) + +```sql +SELECT o.*, oi.* +FROM orders o +JOIN order_items oi ON o.id = oi.order_id +WHERE o.id = :orderId AND o.user_id = :userId; +``` + +### 4.4 사용자별 좋아요 상품 목록 + +```sql +SELECT p.*, pl.created_at as liked_at +FROM product_likes pl +JOIN products p ON pl.product_id = p.id +WHERE pl.user_id = :userId AND p.deleted_at IS NULL +ORDER BY pl.created_at DESC; +``` + +### 4.5 사용자 주문 기간 조회 + +```sql +SELECT o.*, oi.* +FROM orders o +LEFT JOIN order_items oi ON o.id = oi.order_id +WHERE o.user_id = :userId + AND (:startAt IS NULL OR o.created_at >= :startAt) + AND (:endAt IS NULL OR o.created_at < :endAt + INTERVAL 1 DAY) +ORDER BY o.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +--- + +## 5. 데이터 마이그레이션 순서 + +``` +V1__Create_users_table.sql (기존) +V2__Create_brands_table.sql +V3__Create_products_table.sql +V4__Create_product_likes_table.sql +V5__Create_orders_table.sql +V6__Create_order_items_table.sql +V7__Add_indexes.sql +``` + +--- + +## 6. 데이터 정합성 고려사항 + +### 6.1 동시성 제어 +- **재고 차감**: `SELECT ... FOR UPDATE` 비관적 락 +- **좋아요 중복**: `UNIQUE (user_id, product_id)` 제약조건 +- **주문 항목 중복**: `UNIQUE (order_id, product_id)` 제약조건 + +### 6.2 Soft Delete 처리 +- brands, products, users: `deleted_at` 필드 사용 +- 조회 시 `WHERE deleted_at IS NULL` 조건 필수 +- product_likes, orders, order_items: Hard Delete + +### 6.3 스냅샷 데이터 +- order_items.price: 주문 시점 상품 가격 +- order_items.product_name: 주문 시점 상품명 +- 상품 정보 변경과 무관하게 주문 이력 보존 \ No newline at end of file From 5100a9a08a5470ce50885db3e8501de169cb0246 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:52 +0900 Subject: [PATCH 14/15] =?UTF-8?q?chore:=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B6=84=EC=84=9D=20=EC=8A=A4=ED=82=AC=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 - requirements-analysis 스킬 정의 - 요구사항 분석 워크플로우 가이드라인 Co-Authored-By: Claude Opus 4.5 --- .claude/skills/SKILL.md | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .claude/skills/SKILL.md diff --git a/.claude/skills/SKILL.md b/.claude/skills/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file From 7fcdc06c9b1dec922290cf4312ece163c98f6a6a Mon Sep 17 00:00:00 2001 From: dame2 Date: Thu, 26 Feb 2026 23:06:36 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=EC=88=9C=EC=88=98=20DDD=20?= =?UTF-8?q?=EC=BB=A4=EB=A8=B8=EC=8A=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=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 - Domain Layer: 순수 Java 엔티티 (Brand, Product, Like, Order) - Value Objects: Money, Stock, Quantity, ProductSort - Domain Services: 비즈니스 규칙 캡슐화 - Application Services: 트랜잭션 경계 및 BC 조합 - Infrastructure Layer: JPA 엔티티, Mapper, Repository 구현체 - Fake Repository: 단위 테스트용 in-memory 구현 - Interface Layer: REST API 컨트롤러 Co-Authored-By: Claude Opus 4.5 --- .docs/prompt/domain-implements-task.md | 0 .../application/brand/BrandResult.java | 25 ++ .../application/brand/BrandService.java | 52 ++++ .../like/LikeApplicationService.java | 54 ++++ .../loopers/application/like/LikeResult.java | 24 ++ .../order/OrderApplicationService.java | 112 +++++++ .../application/order/OrderItemRequest.java | 10 + .../application/order/OrderItemResult.java | 26 ++ .../application/order/OrderResult.java | 34 ++ .../application/product/ProductResult.java | 31 ++ .../application/product/ProductService.java | 70 +++++ .../java/com/loopers/domain/brand/Brand.java | 115 +++++++ .../domain/brand/BrandDomainService.java | 44 +++ .../com/loopers/domain/brand/BrandInfo.java | 53 ++++ .../loopers/domain/brand/BrandRepository.java | 24 ++ .../loopers/domain/brand/BrandValidator.java | 25 ++ .../java/com/loopers/domain/common/Money.java | 43 +++ .../com/loopers/domain/common/Quantity.java | 17 + .../java/com/loopers/domain/like/Like.java | 58 ++++ .../domain/like/LikeDomainService.java | 69 +++++ .../java/com/loopers/domain/like/LikeId.java | 15 + .../loopers/domain/like/LikeRepository.java | 25 ++ .../java/com/loopers/domain/order/Order.java | 92 ++++++ .../com/loopers/domain/order/OrderItem.java | 88 ++++++ .../loopers/domain/order/OrderRepository.java | 22 ++ .../com/loopers/domain/order/OrderStatus.java | 31 ++ .../com/loopers/domain/product/Product.java | 153 +++++++++ .../domain/product/ProductDomainService.java | 87 ++++++ .../loopers/domain/product/ProductInfo.java | 83 +++++ .../domain/product/ProductRepository.java | 32 ++ .../loopers/domain/product/ProductSort.java | 22 ++ .../domain/product/ProductValidator.java | 24 ++ .../com/loopers/domain/product/Stock.java | 31 ++ .../persistence/jpa/brand/BrandJpaEntity.java | 56 ++++ .../jpa/brand/BrandJpaRepository.java | 21 ++ .../persistence/jpa/brand/BrandMapper.java | 55 ++++ .../jpa/brand/BrandRepositoryImpl.java | 68 ++++ .../persistence/jpa/like/LikeJpaEntity.java | 71 +++++ .../jpa/like/LikeJpaRepository.java | 26 ++ .../persistence/jpa/like/LikeMapper.java | 39 +++ .../jpa/like/LikeRepositoryImpl.java | 69 +++++ .../jpa/order/OrderItemJpaEntity.java | 79 +++++ .../persistence/jpa/order/OrderJpaEntity.java | 95 ++++++ .../jpa/order/OrderJpaRepository.java | 26 ++ .../persistence/jpa/order/OrderMapper.java | 61 ++++ .../jpa/order/OrderRepositoryImpl.java | 57 ++++ .../jpa/product/ProductJpaEntity.java | 93 ++++++ .../jpa/product/ProductJpaRepository.java | 37 +++ .../jpa/product/ProductMapper.java | 66 ++++ .../jpa/product/ProductRepositoryImpl.java | 111 +++++++ .../api/brand/BrandAdminV1ApiSpec.java | 41 +++ .../api/brand/BrandAdminV1Controller.java | 69 +++++ .../interfaces/api/brand/BrandV1ApiSpec.java | 15 + .../api/brand/BrandV1Controller.java | 25 ++ .../interfaces/api/brand/BrandV1Dto.java | 63 ++++ .../api/product/ProductAdminV1ApiSpec.java | 41 +++ .../api/product/ProductAdminV1Controller.java | 73 +++++ .../api/product/ProductV1ApiSpec.java | 23 ++ .../api/product/ProductV1Controller.java | 39 +++ .../interfaces/api/product/ProductV1Dto.java | 93 ++++++ .../brand/BrandServiceIntegrationTest.java | 291 ++++++++++++++++++ .../like/LikeApplicationServiceTest.java | 120 ++++++++ .../order/OrderApplicationServiceTest.java | 253 +++++++++++++++ .../loopers/domain/brand/BrandInfoTest.java | 179 +++++++++++ .../com/loopers/domain/brand/BrandTest.java | 160 ++++++++++ .../com/loopers/domain/common/MoneyTest.java | 194 ++++++++++++ .../loopers/domain/common/QuantityTest.java | 74 +++++ .../domain/like/LikeDomainServiceTest.java | 195 ++++++++++++ .../com/loopers/domain/like/LikeTest.java | 67 ++++ .../loopers/domain/order/OrderItemTest.java | 129 ++++++++ .../com/loopers/domain/order/OrderTest.java | 144 +++++++++ .../loopers/domain/product/ProductTest.java | 258 ++++++++++++++++ .../com/loopers/domain/product/StockTest.java | 197 ++++++++++++ .../com/loopers/fake/FakeBrandRepository.java | 87 ++++++ .../com/loopers/fake/FakeLikeRepository.java | 84 +++++ .../com/loopers/fake/FakeOrderRepository.java | 96 ++++++ .../loopers/fake/FakeProductRepository.java | 134 ++++++++ http/brand-admin-api.http | 36 +++ http/brand-api.http | 5 + http/product-admin-api.http | 42 +++ http/product-api.http | 17 + 81 files changed, 5865 insertions(+) create mode 100644 .docs/prompt/domain-implements-task.md create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.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/application/product/ProductResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.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/BrandValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.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/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.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/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.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/ProductSort.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java 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/BrandV1ApiSpec.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/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/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 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java create mode 100644 http/brand-admin-api.http create mode 100644 http/brand-api.http create mode 100644 http/product-admin-api.http create mode 100644 http/product-api.http diff --git a/.docs/prompt/domain-implements-task.md b/.docs/prompt/domain-implements-task.md new file mode 100644 index 000000000..e69de29bb diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java new file mode 100644 index 000000000..6300f81de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java @@ -0,0 +1,25 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +import java.time.ZonedDateTime; + +public record BrandResult( + Long id, + String name, + String description, + String logoUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static BrandResult from(Brand brand) { + return new BrandResult( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLogoUrl(), + brand.getCreatedAt(), + brand.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..b9cd90415 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,52 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.brand.BrandInfo; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandDomainService brandDomainService; + private final ProductDomainService productDomainService; + + @Transactional(readOnly = true) + public BrandResult findById(Long id) { + Brand brand = brandDomainService.findById(id); + return BrandResult.from(brand); + } + + @Transactional(readOnly = true) + public List findAll() { + return brandDomainService.findAll().stream() + .map(BrandResult::from) + .toList(); + } + + @Transactional + public BrandResult create(BrandInfo info) { + Brand brand = brandDomainService.create(info); + return BrandResult.from(brand); + } + + @Transactional + public BrandResult update(Long id, BrandInfo info) { + Brand brand = brandDomainService.update(id, info); + return BrandResult.from(brand); + } + + @Transactional + public void delete(Long brandId) { + // 해당 브랜드의 모든 상품 soft delete (다른 BC) + productDomainService.deleteAllByBrandId(brandId); + // Brand soft delete + brandDomainService.delete(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java new file mode 100644 index 000000000..dcf340db3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 좋아요 Application Service. + * 여러 BC 조합 및 트랜잭션 경계 담당. + */ +@Service +@RequiredArgsConstructor +public class LikeApplicationService { + + private final LikeDomainService likeDomainService; + private final ProductRepository productRepository; + + /** + * 좋아요 등록. + * 상품 존재 여부 검증 후 도메인 서비스 호출. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 생성된 좋아요 결과 + */ + @Transactional + public LikeResult like(Long userId, Long productId) { + validateProductExists(productId); + Like like = likeDomainService.like(userId, productId); + return LikeResult.from(like); + } + + /** + * 좋아요 취소. + * 멱등하게 동작 - 존재하지 않아도 예외 없이 처리. + * + * @param userId 사용자 ID + * @param productId 상품 ID + */ + @Transactional + public void unlike(Long userId, Long productId) { + likeDomainService.unlike(userId, productId); + } + + private void validateProductExists(Long productId) { + productRepository.findByIdActive(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java new file mode 100644 index 000000000..6e209e0a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java @@ -0,0 +1,24 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; + +import java.time.ZonedDateTime; + +/** + * 좋아요 응답 DTO. + */ +public record LikeResult( + Long id, + Long userId, + Long productId, + ZonedDateTime createdAt +) { + public static LikeResult from(Like like) { + return new LikeResult( + like.getId(), + like.getUserId(), + like.getProductId(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java new file mode 100644 index 000000000..43a9c9128 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -0,0 +1,112 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 주문 Application Service. + * 여러 BC 조합 및 트랜잭션 경계 담당. + */ +@Service +@RequiredArgsConstructor +public class OrderApplicationService { + + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + /** + * 주문 생성. + * 1. 상품 조회 (비관적 락) + * 2. 재고 차감 + * 3. 주문 생성 + * + * @param userId 사용자 ID + * @param items 주문 항목 요청 목록 + * @return 생성된 주문 결과 + */ + @Transactional + public OrderResult placeOrder(Long userId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다."); + } + + List orderItems = new ArrayList<>(); + + for (OrderItemRequest req : items) { + // 1) 비관적 락으로 상품 조회 + Product product = productRepository.findByIdWithLock(req.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + // 2) 재고 차감 (도메인 규칙) + product.decreaseStock(req.quantity()); + productRepository.save(product); + + // 3) OrderItem 생성 (가격 스냅샷) + orderItems.add(OrderItem.create( + product.getId(), + product.getName(), + req.quantity(), + product.getPrice() + )); + } + + // 4) Order 생성/저장 + Order order = Order.create(userId, orderItems); + Order saved = orderRepository.save(order); + + return OrderResult.from(saved); + } + + /** + * 주문 조회. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @return 주문 결과 + */ + @Transactional(readOnly = true) + public OrderResult getOrder(Long orderId, Long userId) { + Order order = orderRepository.findByIdAndUserId(orderId, userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + return OrderResult.from(order); + } + + /** + * 사용자의 주문 목록 조회. + * + * @param userId 사용자 ID + * @param offset 시작 위치 + * @param limit 조회 개수 + * @return 주문 결과 목록 + */ + @Transactional(readOnly = true) + public List getOrders(Long userId, int offset, int limit) { + return orderRepository.findAllByUserId(userId, offset, limit).stream() + .map(OrderResult::from) + .toList(); + } + + /** + * 사용자의 주문 수 조회. + * + * @param userId 사용자 ID + * @return 주문 수 + */ + @Transactional(readOnly = true) + public long countOrders(Long userId) { + return orderRepository.countByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java new file mode 100644 index 000000000..3180995f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java @@ -0,0 +1,10 @@ +package com.loopers.application.order; + +/** + * 주문 항목 요청 DTO. + */ +public record OrderItemRequest( + Long productId, + int quantity +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java new file mode 100644 index 000000000..5169e6623 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java @@ -0,0 +1,26 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +/** + * 주문 항목 응답 DTO. + */ +public record OrderItemResult( + Long id, + Long productId, + String productName, + int quantity, + Long priceSnapshot, + Long subtotal +) { + public static OrderItemResult from(OrderItem item) { + return new OrderItemResult( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPriceSnapshot().amount(), + item.getSubtotal().amount() + ); + } +} 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..d56cf66ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java @@ -0,0 +1,34 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.time.ZonedDateTime; +import java.util.List; + +/** + * 주문 응답 DTO. + */ +public record OrderResult( + Long id, + Long userId, + List items, + Long totalPrice, + OrderStatus status, + ZonedDateTime createdAt +) { + public static OrderResult from(Order order) { + List itemResults = order.getItems().stream() + .map(OrderItemResult::from) + .toList(); + + return new OrderResult( + order.getId(), + order.getUserId(), + itemResults, + order.getTotalPrice().amount(), + order.getStatus(), + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java new file mode 100644 index 000000000..3c5a22006 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java @@ -0,0 +1,31 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductResult( + Long id, + Long brandId, + String name, + String description, + Long price, + Integer stock, + String imageUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static ProductResult from(Product product) { + return new ProductResult( + product.getId(), + product.getBrandId(), + product.getName(), + product.getDescription(), + product.getPrice().amount(), + product.getStock().quantity(), + product.getImageUrl(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} 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 new file mode 100644 index 000000000..d91ed0c0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,70 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductInfo; +import com.loopers.domain.product.ProductSort; +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.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductDomainService productDomainService; + + @Transactional(readOnly = true) + public ProductResult findById(Long id) { + Product product = productDomainService.findById(id); + return ProductResult.from(product); + } + + @Transactional(readOnly = true) + public Page findAll(Long brandId, Pageable pageable) { + // 기본 정렬은 LATEST + ProductSort sort = ProductSort.LATEST; + + int offset = (int) pageable.getOffset(); + int limit = pageable.getPageSize(); + + List products; + long total; + + if (brandId != null) { + products = productDomainService.findAllByBrandId(brandId, sort, offset, limit); + total = productDomainService.countByBrandId(brandId); + } else { + products = productDomainService.findAll(sort, offset, limit); + total = productDomainService.countAll(); + } + + List results = products.stream() + .map(ProductResult::from) + .toList(); + + return new PageImpl<>(results, pageable, total); + } + + @Transactional + public ProductResult create(ProductInfo info) { + Product product = productDomainService.create(info); + return ProductResult.from(product); + } + + @Transactional + public ProductResult update(Long id, ProductInfo info) { + Product product = productDomainService.update(id, info); + return ProductResult.from(product); + } + + @Transactional + public void delete(Long id) { + productDomainService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..a29022a50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,115 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * 브랜드 도메인 엔티티. + * 순수 Java 객체로 JPA/Spring 의존성 없음. + */ +public class Brand { + + private Long id; + private String name; + private String description; + private String logoUrl; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + private ZonedDateTime deletedAt; + + private Brand() {} + + /** + * 새 브랜드 생성. + */ + public static Brand create(String name, String description, String logoUrl) { + Brand brand = new Brand(); + brand.name = name; + brand.description = description; + brand.logoUrl = logoUrl; + ZonedDateTime now = ZonedDateTime.now(); + brand.createdAt = now; + brand.updatedAt = now; + return brand; + } + + /** + * DB에서 복원 (Infrastructure에서 사용). + */ + public static Brand reconstitute(Long id, String name, String description, + String logoUrl, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + Brand brand = new Brand(); + brand.id = id; + brand.name = name; + brand.description = description; + brand.logoUrl = logoUrl; + brand.createdAt = createdAt; + brand.updatedAt = updatedAt; + brand.deletedAt = deletedAt; + return brand; + } + + /** + * 브랜드 정보 수정. + * + * @throws CoreException 삭제된 브랜드인 경우 + */ + public void update(String name, String description, String logoUrl) { + guardDeleted(); + this.name = name; + this.description = description; + this.logoUrl = logoUrl; + this.updatedAt = ZonedDateTime.now(); + } + + /** + * 브랜드 삭제 (Soft Delete). + * 멱등하게 동작한다. + */ + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } + } + + public boolean isDeleted() { + return deletedAt != null; + } + + private void guardDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.BRAND_DELETED); + } + } + + // Getters + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLogoUrl() { + return logoUrl; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } + + public ZonedDateTime getUpdatedAt() { + return updatedAt; + } + + public ZonedDateTime getDeletedAt() { + return deletedAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java new file mode 100644 index 000000000..09054a07e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java @@ -0,0 +1,44 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BrandDomainService { + + private final BrandRepository brandRepository; + private final BrandValidator brandValidator; + + public Brand create(BrandInfo info) { + brandValidator.validateNameNotDuplicated(info.name()); + Brand brand = Brand.create(info.name(), info.description(), info.logoUrl()); + return brandRepository.save(brand); + } + + public Brand update(Long id, BrandInfo info) { + Brand brand = findById(id); + brandValidator.validateNameNotDuplicatedExcept(info.name(), id); + brand.update(info.name(), info.description(), info.logoUrl()); + return brandRepository.save(brand); + } + + public Brand findById(Long id) { + return brandRepository.findByIdActive(id) + .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + } + + public List findAll() { + return brandRepository.findAllActive(); + } + + public void delete(Long id) { + Brand brand = findById(id); + brand.delete(); + brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java new file mode 100644 index 000000000..d2cbf3032 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java @@ -0,0 +1,53 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record BrandInfo( + String name, + String description, + String logoUrl +) { + private static final int MAX_NAME_LENGTH = 100; + private static final int MAX_DESCRIPTION_LENGTH = 500; + private static final int MAX_LOGO_URL_LENGTH = 500; + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); + + public BrandInfo { + validateName(name); + validateDescription(description); + validateLogoUrl(logoUrl); + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수입니다."); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("브랜드명은 %d자를 초과할 수 없습니다.", MAX_NAME_LENGTH)); + } + } + + private void validateDescription(String description) { + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("브랜드 설명은 %d자를 초과할 수 없습니다.", MAX_DESCRIPTION_LENGTH)); + } + } + + private void validateLogoUrl(String logoUrl) { + if (logoUrl == null) { + return; + } + if (!URL_PATTERN.matcher(logoUrl).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로고 URL은 http 또는 https로 시작해야 합니다."); + } + if (logoUrl.length() > MAX_LOGO_URL_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("로고 URL은 %d자를 초과할 수 없습니다.", MAX_LOGO_URL_LENGTH)); + } + } +} 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..1dc2d0dd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,24 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; + +/** + * 브랜드 Repository 인터페이스. + * 순수 Java 인터페이스로 Spring/JPA 의존성 없음. + * 구현체는 Infrastructure Layer에 위치. + */ +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + Optional findByIdActive(Long id); + + List findAllActive(); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java new file mode 100644 index 000000000..774046ff2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java @@ -0,0 +1,25 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BrandValidator { + + private final BrandRepository brandRepository; + + public void validateNameNotDuplicated(String name) { + if (brandRepository.existsByName(name)) { + throw new CoreException(ErrorType.BRAND_ALREADY_EXISTS); + } + } + + public void validateNameNotDuplicatedExcept(String name, Long excludeId) { + if (brandRepository.existsByNameAndIdNot(name, excludeId)) { + throw new CoreException(ErrorType.BRAND_ALREADY_EXISTS); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..70f90feb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,43 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 금액을 나타내는 Value Object. + * 불변 객체로 모든 연산은 새로운 Money 인스턴스를 반환한다. + */ +public record Money(long amount) { + + public static final Money ZERO = new Money(0); + + public Money { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + } + + /** + * 두 금액을 더한다. + * + * @param other 더할 금액 + * @return 합산된 금액 + */ + public Money add(Money other) { + return new Money(this.amount + other.amount); + } + + /** + * 금액에 수량을 곱한다. + * + * @param quantity 곱할 수량 (1 이상) + * @return 곱해진 금액 + * @throws CoreException 수량이 0 이하인 경우 + */ + public Money multiply(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + return new Money(this.amount * quantity); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java new file mode 100644 index 000000000..c7764ecee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -0,0 +1,17 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 수량을 나타내는 Value Object. + * 수량은 항상 1 이상이어야 한다. + */ +public record Quantity(int value) { + + public Quantity { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..0a64cd8c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,58 @@ +package com.loopers.domain.like; + +import java.time.ZonedDateTime; + +/** + * 좋아요 도메인 엔티티. + * 사용자-상품 관계를 나타낸다. + * 순수 Java 객체로 JPA/Spring 의존성 없음. + */ +public class Like { + + private Long id; + private Long userId; + private Long productId; + private ZonedDateTime createdAt; + + private Like() {} + + /** + * 새 좋아요 생성. + */ + public static Like create(Long userId, Long productId) { + Like like = new Like(); + like.userId = userId; + like.productId = productId; + like.createdAt = ZonedDateTime.now(); + return like; + } + + /** + * DB에서 복원 (Infrastructure에서 사용). + */ + public static Like reconstitute(Long id, Long userId, Long productId, ZonedDateTime createdAt) { + Like like = new Like(); + like.id = id; + like.userId = userId; + like.productId = productId; + like.createdAt = createdAt; + return like; + } + + // Getters + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java new file mode 100644 index 000000000..6bac34be1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java @@ -0,0 +1,69 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 좋아요 도메인 서비스. + * 좋아요 등록/취소 정책을 캡슐화. + * 상태 없이 입력/출력이 명확한 "함수의 객체화". + */ +@Component +@RequiredArgsConstructor +public class LikeDomainService { + + private final LikeRepository likeRepository; + + /** + * 좋아요 등록. + * 중복 좋아요 시 CONFLICT 예외 발생. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 생성된 Like + * @throws CoreException 이미 좋아요한 경우 + */ + public Like like(Long userId, Long productId) { + if (likeRepository.exists(userId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + } + return likeRepository.save(Like.create(userId, productId)); + } + + /** + * 좋아요 취소. + * 멱등하게 동작 - 존재하지 않아도 예외 없이 처리. + * + * @param userId 사용자 ID + * @param productId 상품 ID + */ + public void unlike(Long userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(likeRepository::delete); + } + + /** + * 상품의 좋아요 수 조회. + * + * @param productId 상품 ID + * @return 좋아요 수 + */ + public long countByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } + + /** + * 여러 상품의 좋아요 수 일괄 조회. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID → 좋아요 수 Map + */ + public Map countByProductIds(List productIds) { + return likeRepository.countByProductIds(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java new file mode 100644 index 000000000..663c9da88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java @@ -0,0 +1,15 @@ +package com.loopers.domain.like; + +import java.util.Objects; + +/** + * 좋아요 복합키 Value Object. + * userId와 productId의 조합으로 유일성을 보장. + */ +public record LikeId(Long userId, Long productId) { + + public LikeId { + Objects.requireNonNull(userId, "userId는 필수입니다."); + Objects.requireNonNull(productId, "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..ba1ca305d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,25 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 좋아요 Repository 인터페이스. + * 순수 Java 인터페이스로 Spring/JPA 의존성 없음. + * 구현체는 Infrastructure Layer에 위치. + */ +public interface LikeRepository { + + Like save(Like like); + + void delete(Like like); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + boolean exists(Long userId, Long productId); + + long countByProductId(Long productId); + + Map countByProductIds(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..bf6eb968e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,92 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 주문 도메인 엔티티 (Aggregate Root). + * 순수 Java 객체로 JPA/Spring 의존성 없음. + */ +public class Order { + + private Long id; + private Long userId; + private List items; + private Money totalPrice; + private OrderStatus status; + private ZonedDateTime createdAt; + + private Order() {} + + /** + * 새 주문 생성. + * + * @param userId 사용자 ID + * @param items 주문 항목 목록 (1개 이상) + * @return 생성된 Order + * @throws CoreException 주문 항목이 비어있는 경우 + */ + public static Order create(Long userId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다."); + } + + Money total = items.stream() + .map(OrderItem::getSubtotal) + .reduce(Money.ZERO, Money::add); + + Order order = new Order(); + order.userId = userId; + order.items = new ArrayList<>(items); + order.totalPrice = total; + order.status = OrderStatus.CREATED; + order.createdAt = ZonedDateTime.now(); + return order; + } + + /** + * DB에서 복원 (Infrastructure에서 사용). + */ + public static Order reconstitute(Long id, Long userId, List items, + Money totalPrice, OrderStatus status, ZonedDateTime createdAt) { + Order order = new Order(); + order.id = id; + order.userId = userId; + order.items = new ArrayList<>(items); + order.totalPrice = totalPrice; + order.status = status; + order.createdAt = createdAt; + return order; + } + + // Getters + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public List getItems() { + return Collections.unmodifiableList(items); + } + + public Money getTotalPrice() { + return totalPrice; + } + + public OrderStatus getStatus() { + return status; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..dfd93958b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,88 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 주문 항목 도메인 엔티티. + * 주문 시점의 상품 정보 스냅샷을 포함. + * 순수 Java 객체로 JPA/Spring 의존성 없음. + */ +public class OrderItem { + + private Long id; + private Long productId; + private String productName; // 스냅샷 + private int quantity; + private Money priceSnapshot; // 주문 시점 단가 + + private OrderItem() {} + + /** + * 새 주문 항목 생성. + * + * @param productId 상품 ID + * @param productName 상품명 (스냅샷) + * @param quantity 수량 (1 이상) + * @param price 단가 (스냅샷) + * @return 생성된 OrderItem + * @throws CoreException 수량이 0 이하인 경우 + */ + public static OrderItem create(Long productId, String productName, int quantity, Money price) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + + OrderItem item = new OrderItem(); + item.productId = productId; + item.productName = productName; + item.quantity = quantity; + item.priceSnapshot = price; + return item; + } + + /** + * DB에서 복원 (Infrastructure에서 사용). + */ + public static OrderItem reconstitute(Long id, Long productId, String productName, + int quantity, Money priceSnapshot) { + OrderItem item = new OrderItem(); + item.id = id; + item.productId = productId; + item.productName = productName; + item.quantity = quantity; + item.priceSnapshot = priceSnapshot; + return item; + } + + /** + * 소계 계산 (단가 × 수량). + * + * @return 소계 금액 + */ + public Money getSubtotal() { + return priceSnapshot.multiply(quantity); + } + + // Getters + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public int getQuantity() { + return quantity; + } + + public Money getPriceSnapshot() { + return priceSnapshot; + } +} 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..0e1f3fba6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +/** + * 주문 Repository 인터페이스. + * 순수 Java 인터페이스로 Spring/JPA 의존성 없음. + * 구현체는 Infrastructure Layer에 위치. + */ +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserId(Long userId, int offset, int limit); + + long countByUserId(Long userId); +} 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..777a36c86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,31 @@ +package com.loopers.domain.order; + +/** + * 주문 상태. + */ +public enum OrderStatus { + /** + * 주문 생성됨 + */ + CREATED, + + /** + * 결제 완료 + */ + PAID, + + /** + * 배송중 + */ + SHIPPED, + + /** + * 배송 완료 + */ + DELIVERED, + + /** + * 취소됨 + */ + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..58903f2fd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,153 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * 상품 도메인 엔티티. + * 순수 Java 객체로 JPA/Spring 의존성 없음. + */ +public class Product { + + private Long id; + private Long brandId; + private String name; + private String description; + private Money price; + private Stock stock; + private String imageUrl; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + private ZonedDateTime deletedAt; + + private Product() {} + + /** + * 새 상품 생성. + */ + public static Product create(Long brandId, String name, String description, + Money price, Stock stock, String imageUrl) { + Product product = new Product(); + product.brandId = brandId; + product.name = name; + product.description = description; + product.price = price; + product.stock = stock; + product.imageUrl = imageUrl; + ZonedDateTime now = ZonedDateTime.now(); + product.createdAt = now; + product.updatedAt = now; + return product; + } + + /** + * DB에서 복원 (Infrastructure에서 사용). + */ + public static Product reconstitute(Long id, Long brandId, String name, String description, + Money price, Stock stock, String imageUrl, + ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + Product product = new Product(); + product.id = id; + product.brandId = brandId; + product.name = name; + product.description = description; + product.price = price; + product.stock = stock; + product.imageUrl = imageUrl; + product.createdAt = createdAt; + product.updatedAt = updatedAt; + product.deletedAt = deletedAt; + return product; + } + + /** + * 재고 차감. + * + * @param quantity 차감할 수량 + * @throws CoreException 삭제된 상품이거나 재고가 부족한 경우 + */ + public void decreaseStock(int quantity) { + guardDeleted(); + this.stock = this.stock.decrease(quantity); + this.updatedAt = ZonedDateTime.now(); + } + + /** + * 상품 정보 수정. + * + * @throws CoreException 삭제된 상품인 경우 + */ + public void update(String name, String description, Money price, Stock stock, String imageUrl) { + guardDeleted(); + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.imageUrl = imageUrl; + this.updatedAt = ZonedDateTime.now(); + } + + /** + * 상품 삭제 (Soft Delete). + * 멱등하게 동작한다. + */ + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } + } + + public boolean isDeleted() { + return deletedAt != null; + } + + private void guardDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.PRODUCT_DELETED); + } + } + + // Getters + public Long getId() { + return id; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Money getPrice() { + return price; + } + + public Stock getStock() { + return stock; + } + + public String getImageUrl() { + return imageUrl; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } + + public ZonedDateTime getUpdatedAt() { + return updatedAt; + } + + public ZonedDateTime getDeletedAt() { + return deletedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java new file mode 100644 index 000000000..429c20361 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -0,0 +1,87 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ProductDomainService { + + private final ProductRepository productRepository; + private final ProductValidator productValidator; + + public Product create(ProductInfo info) { + productValidator.validateBrandExists(info.brandId()); + Product product = Product.create( + info.brandId(), + info.name(), + info.description(), + new Money(info.price()), + new Stock(info.stock()), + info.imageUrl() + ); + return productRepository.save(product); + } + + public Product update(Long id, ProductInfo info) { + Product product = findById(id); + product.update( + info.name(), + info.description(), + new Money(info.price()), + new Stock(info.stock()), + info.imageUrl() + ); + return productRepository.save(product); + } + + public Product findById(Long id) { + return productRepository.findByIdActive(id) + .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); + } + + public Product findByIdWithLock(Long id) { + return productRepository.findByIdWithLock(id) + .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); + } + + public List findAll(ProductSort sort, int offset, int limit) { + return productRepository.findAllActive(sort, offset, limit); + } + + public List findAllByBrandId(Long brandId, ProductSort sort, int offset, int limit) { + return productRepository.findAllByBrandIdActive(brandId, sort, offset, limit); + } + + public long countAll() { + return productRepository.countActive(); + } + + public long countByBrandId(Long brandId) { + return productRepository.countByBrandIdActive(brandId); + } + + public void decreaseStock(Product product, int quantity) { + product.decreaseStock(quantity); + productRepository.save(product); + } + + public void delete(Long id) { + Product product = findById(id); + product.delete(); + productRepository.save(product); + } + + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandIdActive(brandId); + products.forEach(product -> { + product.delete(); + productRepository.save(product); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java new file mode 100644 index 000000000..1936572d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java @@ -0,0 +1,83 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record ProductInfo( + Long brandId, + String name, + String description, + Long price, + Integer stock, + String imageUrl +) { + private static final int MAX_NAME_LENGTH = 200; + private static final int MAX_DESCRIPTION_LENGTH = 2000; + private static final int MAX_IMAGE_URL_LENGTH = 500; + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); + + public ProductInfo { + validateBrandId(brandId); + validateName(name); + validateDescription(description); + validatePrice(price); + validateStock(stock); + validateImageUrl(imageUrl); + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다."); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("상품명은 %d자를 초과할 수 없습니다.", MAX_NAME_LENGTH)); + } + } + + private void validateDescription(String description) { + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("상품 설명은 %d자를 초과할 수 없습니다.", MAX_DESCRIPTION_LENGTH)); + } + } + + private void validatePrice(Long price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 필수입니다."); + } + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private void validateStock(Integer stock) { + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 필수입니다."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + private void validateImageUrl(String imageUrl) { + if (imageUrl == null) { + return; + } + if (!URL_PATTERN.matcher(imageUrl).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 URL은 http 또는 https로 시작해야 합니다."); + } + if (imageUrl.length() > MAX_IMAGE_URL_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("이미지 URL은 %d자를 초과할 수 없습니다.", MAX_IMAGE_URL_LENGTH)); + } + } +} 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..0ae63b4c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,32 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +/** + * 상품 Repository 인터페이스. + * 순수 Java 인터페이스로 Spring/JPA 의존성 없음. + * 구현체는 Infrastructure Layer에 위치. + */ +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + Optional findByIdActive(Long id); + + Optional findByIdWithLock(Long id); + + List findAllActive(ProductSort sort, int offset, int limit); + + List findAllByBrandIdActive(Long brandId, ProductSort sort, int offset, int limit); + + List findAllByBrandIdActive(Long brandId); + + List findAllByIds(List ids); + + long countActive(); + + long countByBrandIdActive(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java new file mode 100644 index 000000000..afb60c7b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java @@ -0,0 +1,22 @@ +package com.loopers.domain.product; + +/** + * 상품 목록 정렬 조건. + */ +public enum ProductSort { + /** + * 최신순 (createdAt DESC) + */ + LATEST, + + /** + * 가격 낮은 순 (price ASC) + */ + PRICE_ASC, + + /** + * 좋아요 많은 순 (likeCount DESC) + * Application Layer에서 Like BC 데이터와 조합하여 처리 + */ + LIKES_DESC +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java new file mode 100644 index 000000000..d2aa28960 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java @@ -0,0 +1,24 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductValidator { + + private final BrandRepository brandRepository; + + public void validateBrandExists(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.BRAND_DELETED); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java new file mode 100644 index 000000000..5e7a6f820 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Stock(int quantity) { + + public Stock { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + public Stock decrease(int amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + if (this.quantity < amount) { + throw new CoreException(ErrorType.INSUFFICIENT_STOCK, + String.format("재고가 부족합니다. (현재: %d, 요청: %d)", this.quantity, amount)); + } + return new Stock(this.quantity - amount); + } + + public Stock increase(int amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "증가 수량은 1 이상이어야 합니다."); + } + return new Stock(this.quantity + amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java new file mode 100644 index 000000000..88751a3b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +/** + * 브랜드 JPA 엔티티. + * Infrastructure Layer에 위치하며 영속성을 담당. + */ +@Entity +@Table(name = "brands") +public class BrandJpaEntity extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "logo_url", length = 500) + private String logoUrl; + + protected BrandJpaEntity() {} + + public BrandJpaEntity(String name, String description, String logoUrl) { + this.name = name; + this.description = description; + this.logoUrl = logoUrl; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLogoUrl() { + return logoUrl; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java new file mode 100644 index 000000000..8ff316720 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Brand JPA Repository. + * Spring Data JPA를 사용한 영속성 계층. + */ +public interface BrandJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); + + List findAllByDeletedAtIsNull(); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java new file mode 100644 index 000000000..d83b73954 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import com.loopers.domain.brand.Brand; + +/** + * Brand 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class BrandMapper { + + private BrandMapper() {} + + /** + * JPA 엔티티를 도메인 객체로 변환. + */ + public static Brand toDomain(BrandJpaEntity entity) { + if (entity == null) { + return null; + } + return Brand.reconstitute( + entity.getId(), + entity.getName(), + entity.getDescription(), + entity.getLogoUrl(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } + + /** + * 도메인 객체를 새 JPA 엔티티로 변환 (신규 저장용). + */ + public static BrandJpaEntity toJpaEntity(Brand domain) { + if (domain == null) { + return null; + } + return new BrandJpaEntity( + domain.getName(), + domain.getDescription(), + domain.getLogoUrl() + ); + } + + /** + * 기존 JPA 엔티티를 도메인 객체로 업데이트. + */ + public static void updateJpaEntity(BrandJpaEntity entity, Brand domain) { + entity.setName(domain.getName()); + entity.setDescription(domain.getDescription()); + entity.setLogoUrl(domain.getLogoUrl()); + if (domain.isDeleted() && entity.getDeletedAt() == null) { + entity.delete(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..2d466046d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * BrandRepository 구현체. + * JPA를 사용하여 Brand 도메인 객체를 영속화. + * Domain ↔ JPA Entity 변환은 BrandMapper를 통해 수행. + */ +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository jpaRepository; + + @Override + public Brand save(Brand brand) { + BrandJpaEntity entity; + + if (brand.getId() == null) { + // 신규 생성 + entity = BrandMapper.toJpaEntity(brand); + } else { + // 기존 엔티티 업데이트 + entity = jpaRepository.findById(brand.getId()) + .orElseGet(() -> BrandMapper.toJpaEntity(brand)); + BrandMapper.updateJpaEntity(entity, brand); + } + + BrandJpaEntity saved = jpaRepository.save(entity); + return BrandMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(BrandMapper::toDomain); + } + + @Override + public Optional findByIdActive(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id) + .map(BrandMapper::toDomain); + } + + @Override + public List findAllActive() { + return jpaRepository.findAllByDeletedAtIsNull().stream() + .map(BrandMapper::toDomain) + .toList(); + } + + @Override + public boolean existsByName(String name) { + return jpaRepository.existsByName(name); + } + + @Override + public boolean existsByNameAndIdNot(String name, Long id) { + return jpaRepository.existsByNameAndIdNot(name, id); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java new file mode 100644 index 000000000..f69a2ddf1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.ZonedDateTime; + +/** + * 좋아요 JPA 엔티티. + * Infrastructure Layer에 위치하며 영속성을 담당. + */ +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) + }, + indexes = { + @Index(name = "idx_likes_product_id", columnList = "product_id") + } +) +public class LikeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected LikeJpaEntity() {} + + public LikeJpaEntity(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java new file mode 100644 index 000000000..5f5a05a42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Like JPA Repository. + * Spring Data JPA를 사용한 영속성 계층. + */ +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + long countByProductId(Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM LikeJpaEntity l " + + "WHERE l.productId IN :productIds " + + "GROUP BY l.productId") + List countByProductIdIn(@Param("productIds") List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java new file mode 100644 index 000000000..cc11e05f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import com.loopers.domain.like.Like; + +/** + * Like 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class LikeMapper { + + private LikeMapper() {} + + /** + * JPA 엔티티를 도메인 객체로 변환. + */ + public static Like toDomain(LikeJpaEntity entity) { + if (entity == null) { + return null; + } + return Like.reconstitute( + entity.getId(), + entity.getUserId(), + entity.getProductId(), + entity.getCreatedAt() + ); + } + + /** + * 도메인 객체를 JPA 엔티티로 변환 (신규 저장용). + */ + public static LikeJpaEntity toJpaEntity(Like domain) { + if (domain == null) { + return null; + } + return new LikeJpaEntity( + domain.getUserId(), + domain.getProductId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..48bc1ca35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * LikeRepository 구현체. + * JPA를 사용하여 Like 도메인 객체를 영속화. + * Domain ↔ JPA Entity 변환은 LikeMapper를 통해 수행. + */ +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository jpaRepository; + + @Override + public Like save(Like like) { + LikeJpaEntity entity = LikeMapper.toJpaEntity(like); + LikeJpaEntity saved = jpaRepository.save(entity); + return LikeMapper.toDomain(saved); + } + + @Override + public void delete(Like like) { + jpaRepository.deleteById(like.getId()); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return jpaRepository.findByUserIdAndProductId(userId, productId) + .map(LikeMapper::toDomain); + } + + @Override + public boolean exists(Long userId, Long productId) { + return jpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public long countByProductId(Long productId) { + return jpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + + List results = jpaRepository.countByProductIdIn(productIds); + Map countMap = new HashMap<>(); + + for (Object[] row : results) { + Long productId = (Long) row[0]; + Long count = (Long) row[1]; + countMap.put(productId, count); + } + + return countMap; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java new file mode 100644 index 000000000..9589df92b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +/** + * 주문 항목 JPA 엔티티. + * Infrastructure Layer에 위치하며 영속성을 담당. + */ +@Entity +@Table(name = "order_items") +public class OrderItemJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderJpaEntity order; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "price_snapshot", nullable = false) + private Long priceSnapshot; + + protected OrderItemJpaEntity() {} + + public OrderItemJpaEntity(OrderJpaEntity order, Long productId, String productName, + Integer quantity, Long priceSnapshot) { + this.order = order; + this.productId = productId; + this.productName = productName; + this.quantity = quantity; + this.priceSnapshot = priceSnapshot; + } + + public Long getId() { + return id; + } + + public OrderJpaEntity getOrder() { + return order; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public Integer getQuantity() { + return quantity; + } + + public Long getPriceSnapshot() { + return priceSnapshot; + } + + void setOrder(OrderJpaEntity order) { + this.order = order; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java new file mode 100644 index 000000000..85c896859 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java @@ -0,0 +1,95 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import com.loopers.domain.order.OrderStatus; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 주문 JPA 엔티티. + * Infrastructure Layer에 위치하며 영속성을 담당. + */ +@Entity +@Table( + name = "orders", + indexes = { + @Index(name = "idx_orders_user_id", columnList = "user_id") + } +) +public class OrderJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + @Column(name = "total_price", nullable = false) + private Long totalPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected OrderJpaEntity() {} + + public OrderJpaEntity(Long userId, Long totalPrice, OrderStatus status) { + this.userId = userId; + this.totalPrice = totalPrice; + this.status = status; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public void addItem(OrderItemJpaEntity item) { + items.add(item); + item.setOrder(this); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public List getItems() { + return items; + } + + public Long getTotalPrice() { + return totalPrice; + } + + public OrderStatus getStatus() { + return status; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java new file mode 100644 index 000000000..246f10815 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +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; + +/** + * Order JPA Repository. + * Spring Data JPA 인터페이스. + */ +public interface OrderJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OrderJpaEntity o LEFT JOIN FETCH o.items WHERE o.id = :id") + Optional findByIdWithItems(@Param("id") Long id); + + @Query("SELECT o FROM OrderJpaEntity o LEFT JOIN FETCH o.items WHERE o.id = :id AND o.userId = :userId") + Optional findByIdAndUserIdWithItems(@Param("id") Long id, @Param("userId") Long userId); + + Page findAllByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + long countByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java new file mode 100644 index 000000000..856921b00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.util.List; + +/** + * Order Domain ↔ JPA Entity 변환 Mapper. + */ +public class OrderMapper { + + private OrderMapper() {} + + public static Order toDomain(OrderJpaEntity entity) { + List items = entity.getItems().stream() + .map(OrderMapper::toOrderItemDomain) + .toList(); + + return Order.reconstitute( + entity.getId(), + entity.getUserId(), + items, + new Money(entity.getTotalPrice()), + entity.getStatus(), + entity.getCreatedAt() + ); + } + + public static OrderItem toOrderItemDomain(OrderItemJpaEntity entity) { + return OrderItem.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getProductName(), + entity.getQuantity(), + new Money(entity.getPriceSnapshot()) + ); + } + + public static OrderJpaEntity toJpaEntity(Order domain) { + OrderJpaEntity entity = new OrderJpaEntity( + domain.getUserId(), + domain.getTotalPrice().amount(), + domain.getStatus() + ); + + for (OrderItem item : domain.getItems()) { + OrderItemJpaEntity itemEntity = new OrderItemJpaEntity( + entity, + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPriceSnapshot().amount() + ); + entity.addItem(itemEntity); + } + + return entity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..d7f57df51 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java @@ -0,0 +1,57 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * OrderRepository 구현체 (Adapter). + * Domain Repository 인터페이스를 JPA로 구현. + */ +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository jpaRepository; + + public OrderRepositoryImpl(OrderJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Order save(Order order) { + OrderJpaEntity entity = OrderMapper.toJpaEntity(order); + OrderJpaEntity saved = jpaRepository.save(entity); + return OrderMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findByIdWithItems(id) + .map(OrderMapper::toDomain); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return jpaRepository.findByIdAndUserIdWithItems(id, userId) + .map(OrderMapper::toDomain); + } + + @Override + public List findAllByUserId(Long userId, int offset, int limit) { + int page = offset / limit; + return jpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, limit)) + .getContent() + .stream() + .map(OrderMapper::toDomain) + .toList(); + } + + @Override + public long countByUserId(Long userId) { + return jpaRepository.countByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java new file mode 100644 index 000000000..e90f2cdbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java @@ -0,0 +1,93 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +/** + * 상품 JPA 엔티티. + * Infrastructure Layer에 위치하며 영속성을 담당. + */ +@Entity +@Table(name = "products") +public class ProductJpaEntity extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "description", length = 2000) + private String description; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + protected ProductJpaEntity() {} + + public ProductJpaEntity(Long brandId, String name, String description, + Long price, Integer stock, String imageUrl) { + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.imageUrl = imageUrl; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Long getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setBrandId(Long brandId) { + this.brandId = brandId; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setPrice(Long price) { + this.price = price; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java new file mode 100644 index 000000000..2090c5a69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import jakarta.persistence.LockModeType; +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.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Product JPA Repository. + * Spring Data JPA를 사용한 영속성 계층. + */ +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductJpaEntity p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findByIdWithLock(@Param("id") Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + List findAllByIdIn(List ids); + + long countByDeletedAtIsNull(); + + long countByBrandIdAndDeletedAtIsNull(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java new file mode 100644 index 000000000..dde389264 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; + +/** + * Product 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class ProductMapper { + + private ProductMapper() {} + + /** + * JPA 엔티티를 도메인 객체로 변환. + */ + public static Product toDomain(ProductJpaEntity entity) { + if (entity == null) { + return null; + } + return Product.reconstitute( + entity.getId(), + entity.getBrandId(), + entity.getName(), + entity.getDescription(), + new Money(entity.getPrice()), + new Stock(entity.getStock()), + entity.getImageUrl(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } + + /** + * 도메인 객체를 새 JPA 엔티티로 변환 (신규 저장용). + */ + public static ProductJpaEntity toJpaEntity(Product domain) { + if (domain == null) { + return null; + } + return new ProductJpaEntity( + domain.getBrandId(), + domain.getName(), + domain.getDescription(), + domain.getPrice().amount(), + domain.getStock().quantity(), + domain.getImageUrl() + ); + } + + /** + * 기존 JPA 엔티티를 도메인 객체로 업데이트. + */ + public static void updateJpaEntity(ProductJpaEntity entity, Product domain) { + entity.setBrandId(domain.getBrandId()); + entity.setName(domain.getName()); + entity.setDescription(domain.getDescription()); + entity.setPrice(domain.getPrice().amount()); + entity.setStock(domain.getStock().quantity()); + entity.setImageUrl(domain.getImageUrl()); + if (domain.isDeleted() && entity.getDeletedAt() == null) { + entity.delete(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..9efa9aba5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java @@ -0,0 +1,111 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSort; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRepository 구현체. + * JPA를 사용하여 Product 도메인 객체를 영속화. + * Domain ↔ JPA Entity 변환은 ProductMapper를 통해 수행. + */ +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository jpaRepository; + + @Override + public Product save(Product product) { + ProductJpaEntity entity; + + if (product.getId() == null) { + // 신규 생성 + entity = ProductMapper.toJpaEntity(product); + } else { + // 기존 엔티티 업데이트 + entity = jpaRepository.findById(product.getId()) + .orElseGet(() -> ProductMapper.toJpaEntity(product)); + ProductMapper.updateJpaEntity(entity, product); + } + + ProductJpaEntity saved = jpaRepository.save(entity); + return ProductMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(ProductMapper::toDomain); + } + + @Override + public Optional findByIdActive(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id) + .map(ProductMapper::toDomain); + } + + @Override + public Optional findByIdWithLock(Long id) { + return jpaRepository.findByIdWithLock(id) + .map(ProductMapper::toDomain); + } + + @Override + public List findAllActive(ProductSort sort, int offset, int limit) { + Pageable pageable = createPageable(sort, offset, limit); + return jpaRepository.findAllByDeletedAtIsNull(pageable).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId, ProductSort sort, int offset, int limit) { + Pageable pageable = createPageable(sort, offset, limit); + return jpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId) { + return jpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public List findAllByIds(List ids) { + return jpaRepository.findAllByIdIn(ids).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public long countActive() { + return jpaRepository.countByDeletedAtIsNull(); + } + + @Override + public long countByBrandIdActive(Long brandId) { + return jpaRepository.countByBrandIdAndDeletedAtIsNull(brandId); + } + + private Pageable createPageable(ProductSort sort, int offset, int limit) { + int page = offset / limit; + Sort jpaSort = switch (sort) { + case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); // likes_desc는 Application에서 처리 + }; + return PageRequest.of(page, limit, jpaSort); + } +} 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..1c513ca01 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +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 java.util.List; + +@Tag(name = "Brand Admin V1 API", description = "브랜드 관리 API입니다.") +public interface BrandAdminV1ApiSpec { + + @Operation( + summary = "브랜드 목록 조회", + description = "모든 브랜드 목록을 조회합니다." + ) + ApiResponse> getAllBrands(); + + @Operation( + summary = "브랜드 상세 조회", + description = "브랜드 ID로 브랜드 정보를 조회합니다." + ) + ApiResponse getBrand(Long brandId); + + @Operation( + summary = "브랜드 등록", + description = "새로운 브랜드를 등록합니다." + ) + ApiResponse createBrand(BrandV1Dto.BrandCreateRequest request); + + @Operation( + summary = "브랜드 수정", + description = "브랜드 정보를 수정합니다." + ) + ApiResponse updateBrand(Long brandId, BrandV1Dto.BrandUpdateRequest request); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다. (Soft Delete)" + ) + ApiResponse deleteBrand(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..5a3b34a0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandResult; +import com.loopers.application.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private final BrandService brandService; + + @GetMapping + @Override + public ApiResponse> getAllBrands() { + List results = brandService.findAll(); + List responses = results.stream() + .map(BrandV1Dto.BrandResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandResult result = brandService.findById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createBrand(@Valid @RequestBody BrandV1Dto.BrandCreateRequest request) { + BrandResult result = brandService.create(request.toInfo()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse updateBrand( + @PathVariable Long brandId, + @Valid @RequestBody BrandV1Dto.BrandUpdateRequest request + ) { + BrandResult result = brandService.update(brandId, request.toInfo()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandService.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..ee20a2ce7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,15 @@ +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; + +@Tag(name = "Brand V1 API", description = "브랜드 관련 API입니다.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 상세 조회", + description = "브랜드 ID로 브랜드 정보를 조회합니다." + ) + ApiResponse getBrand(Long brandId); +} 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..5211a7c32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandResult; +import com.loopers.application.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 implements BrandV1ApiSpec { + + private final BrandService brandService; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandResult result = brandService.findById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } +} 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..15f96a564 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandResult; +import com.loopers.domain.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.ZonedDateTime; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description, + String logoUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static BrandResponse from(BrandResult result) { + return new BrandResponse( + result.id(), + result.name(), + result.description(), + result.logoUrl(), + result.createdAt(), + result.updatedAt() + ); + } + } + + public record BrandCreateRequest( + @NotBlank(message = "브랜드명은 필수입니다.") + @Size(max = 100, message = "브랜드명은 100자를 초과할 수 없습니다.") + String name, + + @Size(max = 500, message = "브랜드 설명은 500자를 초과할 수 없습니다.") + String description, + + @Size(max = 500, message = "로고 URL은 500자를 초과할 수 없습니다.") + String logoUrl + ) { + public BrandInfo toInfo() { + return new BrandInfo(name, description, logoUrl); + } + } + + public record BrandUpdateRequest( + @NotBlank(message = "브랜드명은 필수입니다.") + @Size(max = 100, message = "브랜드명은 100자를 초과할 수 없습니다.") + String name, + + @Size(max = 500, message = "브랜드 설명은 500자를 초과할 수 없습니다.") + String description, + + @Size(max = 500, message = "로고 URL은 500자를 초과할 수 없습니다.") + String logoUrl + ) { + public BrandInfo toInfo() { + return new BrandInfo(name, description, logoUrl); + } + } +} 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..f7e7d9a44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +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> getAllProducts(Long brandId, Pageable pageable); + + @Operation( + summary = "상품 상세 조회", + description = "상품 ID로 상품 정보를 조회합니다." + ) + ApiResponse getProduct(Long productId); + + @Operation( + summary = "상품 등록", + description = "새로운 상품을 등록합니다." + ) + ApiResponse createProduct(ProductV1Dto.ProductCreateRequest request); + + @Operation( + summary = "상품 수정", + description = "상품 정보를 수정합니다." + ) + ApiResponse updateProduct(Long productId, ProductV1Dto.ProductUpdateRequest request); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다. (Soft Delete)" + ) + ApiResponse deleteProduct(Long productId); +} 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..71e8456c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductResult; +import com.loopers.application.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private final ProductService productService; + + @GetMapping + @Override + public ApiResponse> getAllProducts( + @RequestParam(required = false) Long brandId, + Pageable pageable + ) { + Page results = productService.findAll(brandId, pageable); + Page responses = results.map(ProductV1Dto.ProductResponse::from); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + ProductResult result = productService.findById(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createProduct( + @Valid @RequestBody ProductV1Dto.ProductCreateRequest request + ) { + ProductResult result = productService.create(request.toInfo()); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductV1Dto.ProductUpdateRequest request + ) { + ProductResult result = productService.update(productId, request.toInfo()); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse deleteProduct(@PathVariable Long productId) { + productService.delete(productId); + return ApiResponse.success(); + } +} 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..7ca75aae6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,23 @@ +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 = "상품 목록을 조회합니다. brandId로 필터링할 수 있습니다." + ) + ApiResponse> getProducts(Long brandId, Pageable pageable); + + @Operation( + summary = "상품 상세 조회", + description = "상품 ID로 상품 정보를 조회합니다." + ) + ApiResponse getProduct(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..876633f99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductResult; +import com.loopers.application.product.ProductService; +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 ProductService productService; + + @GetMapping + @Override + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + Pageable pageable + ) { + Page results = productService.findAll(brandId, pageable); + Page responses = results.map(ProductV1Dto.ProductResponse::from); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + ProductResult result = productService.findById(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } +} 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..3aac721c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,93 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductResult; +import com.loopers.domain.product.ProductInfo; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.ZonedDateTime; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + Long brandId, + String name, + String description, + Long price, + Integer stock, + String imageUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(ProductResult result) { + return new ProductResponse( + result.id(), + result.brandId(), + result.name(), + result.description(), + result.price(), + result.stock(), + result.imageUrl(), + result.createdAt(), + result.updatedAt() + ); + } + } + + public record ProductCreateRequest( + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다.") + @Size(max = 200, message = "상품명은 200자를 초과할 수 없습니다.") + String name, + + @Size(max = 2000, message = "상품 설명은 2000자를 초과할 수 없습니다.") + String description, + + @NotNull(message = "가격은 필수입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + Long price, + + @NotNull(message = "재고는 필수입니다.") + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + Integer stock, + + @Size(max = 500, message = "이미지 URL은 500자를 초과할 수 없습니다.") + String imageUrl + ) { + public ProductInfo toInfo() { + return new ProductInfo(brandId, name, description, price, stock, imageUrl); + } + } + + public record ProductUpdateRequest( + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다.") + @Size(max = 200, message = "상품명은 200자를 초과할 수 없습니다.") + String name, + + @Size(max = 2000, message = "상품 설명은 2000자를 초과할 수 없습니다.") + String description, + + @NotNull(message = "가격은 필수입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + Long price, + + @NotNull(message = "재고는 필수입니다.") + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + Integer stock, + + @Size(max = 500, message = "이미지 URL은 500자를 초과할 수 없습니다.") + String imageUrl + ) { + public ProductInfo toInfo() { + return new ProductInfo(brandId, name, description, price, stock, imageUrl); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..e99a87f0a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,291 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandInfo; +import com.loopers.infrastructure.persistence.jpa.brand.BrandJpaRepository; +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 BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, 브랜드가 저장된다.") + @Test + void createsBrand_whenValidInfoIsProvided() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", "https://example.com/nike.png"); + + // act + BrandResult result = brandService.create(info); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.description()).isEqualTo("Just Do It"), + () -> assertThat(result.logoUrl()).isEqualTo("https://example.com/nike.png"), + () -> assertThat(result.createdAt()).isNotNull(), + () -> assertThat(result.updatedAt()).isNotNull() + ); + } + + @DisplayName("이미 존재하는 브랜드명으로 생성하면, BRAND_ALREADY_EXISTS 예외가 발생한다.") + @Test + void throwsBrandAlreadyExistsException_whenNameAlreadyExists() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", null); + brandService.create(info); + + BrandInfo duplicateInfo = new BrandInfo("Nike", "Another description", null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.create(duplicateInfo); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_ALREADY_EXISTS); + } + } + + @DisplayName("브랜드를 조회할 때,") + @Nested + class FindById { + + @DisplayName("존재하는 ID로 조회하면, 브랜드 정보를 반환한다.") + @Test + void returnsBrand_whenIdExists() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", "https://example.com/nike.png"); + BrandResult created = brandService.create(info); + + // act + BrandResult result = brandService.findById(created.id()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isEqualTo(created.id()), + () -> assertThat(result.name()).isEqualTo("Nike") + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, BRAND_NOT_FOUND 예외가 발생한다.") + @Test + void throwsBrandNotFoundException_whenIdDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.findById(nonExistentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + + @DisplayName("삭제된 브랜드를 조회하면, BRAND_NOT_FOUND 예외가 발생한다.") + @Test + void throwsBrandNotFoundException_whenBrandIsDeleted() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", null); + BrandResult created = brandService.create(info); + brandService.delete(created.id()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.findById(created.id()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + } + + @DisplayName("브랜드 목록을 조회할 때,") + @Nested + class FindAll { + + @DisplayName("브랜드가 존재하면, 목록을 반환한다.") + @Test + void returnsBrandList_whenBrandsExist() { + // arrange + brandService.create(new BrandInfo("Nike", null, null)); + brandService.create(new BrandInfo("Adidas", null, null)); + brandService.create(new BrandInfo("Puma", null, null)); + + // act + List result = brandService.findAll(); + + // assert + assertThat(result).hasSize(3); + } + + @DisplayName("삭제된 브랜드는 목록에서 제외된다.") + @Test + void excludesDeletedBrands_fromList() { + // arrange + BrandResult nike = brandService.create(new BrandInfo("Nike", null, null)); + brandService.create(new BrandInfo("Adidas", null, null)); + brandService.delete(nike.id()); + + // act + List result = brandService.findAll(); + + // assert + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).name()).isEqualTo("Adidas") + ); + } + } + + @DisplayName("브랜드를 수정할 때,") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 브랜드가 업데이트된다.") + @Test + void updatesBrand_whenValidInfoIsProvided() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Original", null); + BrandResult created = brandService.create(info); + + BrandInfo updateInfo = new BrandInfo("Nike Updated", "Updated description", "https://example.com/updated.png"); + + // act + BrandResult result = brandService.update(created.id(), updateInfo); + + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("Nike Updated"), + () -> assertThat(result.description()).isEqualTo("Updated description"), + () -> assertThat(result.logoUrl()).isEqualTo("https://example.com/updated.png") + ); + } + + @DisplayName("이미 존재하는 다른 브랜드명으로 수정하면, BRAND_ALREADY_EXISTS 예외가 발생한다.") + @Test + void throwsBrandAlreadyExistsException_whenUpdatingToExistingName() { + // arrange + brandService.create(new BrandInfo("Nike", null, null)); + BrandResult adidas = brandService.create(new BrandInfo("Adidas", null, null)); + + BrandInfo updateInfo = new BrandInfo("Nike", null, null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(adidas.id(), updateInfo); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_ALREADY_EXISTS); + } + + @DisplayName("같은 이름으로 수정하면, 정상적으로 업데이트된다.") + @Test + void updatesBrand_whenUpdatingWithSameName() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Original", null); + BrandResult created = brandService.create(info); + + BrandInfo updateInfo = new BrandInfo("Nike", "Updated description", null); + + // act + BrandResult result = brandService.update(created.id(), updateInfo); + + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.description()).isEqualTo("Updated description") + ); + } + + @DisplayName("삭제된 브랜드를 수정하면, BRAND_NOT_FOUND 예외가 발생한다.") + @Test + void throwsBrandNotFoundException_whenUpdatingDeletedBrand() { + // arrange + BrandInfo info = new BrandInfo("Nike", null, null); + BrandResult created = brandService.create(info); + brandService.delete(created.id()); + + BrandInfo updateInfo = new BrandInfo("Nike Updated", null, null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(created.id(), updateInfo); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + } + + @DisplayName("브랜드를 삭제할 때,") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면, soft delete 처리된다.") + @Test + void softDeletesBrand_whenBrandExists() { + // arrange + BrandInfo info = new BrandInfo("Nike", null, null); + BrandResult created = brandService.create(info); + + // act + brandService.delete(created.id()); + + // assert + assertThat(brandJpaRepository.findById(created.id())) + .isPresent() + .hasValueSatisfying(brand -> assertThat(brand.getDeletedAt()).isNotNull()); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면, BRAND_NOT_FOUND 예외가 발생한다.") + @Test + void throwsBrandNotFoundException_whenBrandDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.delete(nonExistentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java new file mode 100644 index 000000000..4a8dbe945 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java @@ -0,0 +1,120 @@ +package com.loopers.application.like; + +import com.loopers.domain.common.Money; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("LikeApplicationService 테스트") +class LikeApplicationServiceTest { + + private FakeLikeRepository fakeLikeRepository; + private FakeProductRepository fakeProductRepository; + private LikeDomainService likeDomainService; + private LikeApplicationService likeApplicationService; + + @BeforeEach + void setUp() { + fakeLikeRepository = new FakeLikeRepository(); + fakeProductRepository = new FakeProductRepository(); + likeDomainService = new LikeDomainService(fakeLikeRepository); + likeApplicationService = new LikeApplicationService(likeDomainService, fakeProductRepository); + } + + private Product createAndSaveProduct() { + Product product = Product.create(1L, "테스트 상품", "설명", + new Money(10000), new Stock(100), "http://image.url"); + return fakeProductRepository.save(product); + } + + @Nested + @DisplayName("좋아요 등록") + class Like { + + @Test + @DisplayName("성공 - 상품이 존재하고 처음 좋아요하는 경우") + void 좋아요_등록_성공() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + + // Act + LikeResult result = likeApplicationService.like(userId, product.getId()); + + // Assert + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.productId()).isEqualTo(product.getId()); + assertThat(result.createdAt()).isNotNull(); + } + + @Test + @DisplayName("실패 - 상품이 존재하지 않는 경우") + void 상품_미존재_예외() { + // Arrange + Long userId = 1L; + Long nonExistentProductId = 999L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> likeApplicationService.like(userId, nonExistentProductId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("실패 - 이미 좋아요한 상품인 경우") + void 중복_좋아요_예외() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + likeApplicationService.like(userId, product.getId()); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> likeApplicationService.like(userId, product.getId())); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @Nested + @DisplayName("좋아요 취소") + class Unlike { + + @Test + @DisplayName("성공 - 좋아요가 존재하는 경우") + void 좋아요_취소_성공() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + likeApplicationService.like(userId, product.getId()); + + // Act + likeApplicationService.unlike(userId, product.getId()); + + // Assert + assertThat(fakeLikeRepository.exists(userId, product.getId())).isFalse(); + } + + @Test + @DisplayName("성공 - 좋아요가 존재하지 않아도 멱등하게 동작") + void 좋아요_미존재_멱등성() { + // Arrange + Long userId = 1L; + Long productId = 999L; + + // Act & Assert - 예외 없이 성공 + assertDoesNotThrow(() -> likeApplicationService.unlike(userId, productId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java new file mode 100644 index 000000000..e64b91ccd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java @@ -0,0 +1,253 @@ +package com.loopers.application.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.fake.FakeOrderRepository; +import com.loopers.fake.FakeProductRepository; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("OrderApplicationService 테스트") +class OrderApplicationServiceTest { + + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + private OrderApplicationService orderApplicationService; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + orderApplicationService = new OrderApplicationService(fakeProductRepository, fakeOrderRepository); + } + + private Product createAndSaveProduct(String name, long price, int stock) { + Product product = Product.create(1L, name, "설명", + new Money(price), new Stock(stock), "http://image.url"); + return fakeProductRepository.save(product); + } + + @Nested + @DisplayName("주문 생성") + class PlaceOrder { + + @Test + @DisplayName("성공 - 단일 상품 주문") + void 단일_상품_주문_성공() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product.getId(), 2) + ); + + // Act + OrderResult result = orderApplicationService.placeOrder(userId, items); + + // Assert + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productId()).isEqualTo(product.getId()); + assertThat(result.items().get(0).quantity()).isEqualTo(2); + assertThat(result.items().get(0).priceSnapshot()).isEqualTo(10000); + assertThat(result.totalPrice()).isEqualTo(20000); // 10000 * 2 + assertThat(result.status()).isEqualTo(OrderStatus.CREATED); + + // 재고 차감 확인 + Product updatedProduct = fakeProductRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getStock().quantity()).isEqualTo(98); // 100 - 2 + } + + @Test + @DisplayName("성공 - 복수 상품 주문") + void 복수_상품_주문_성공() { + // Arrange + Product product1 = createAndSaveProduct("상품1", 10000, 100); + Product product2 = createAndSaveProduct("상품2", 20000, 50); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product1.getId(), 2), + new OrderItemRequest(product2.getId(), 1) + ); + + // Act + OrderResult result = orderApplicationService.placeOrder(userId, items); + + // Assert + assertThat(result.items()).hasSize(2); + assertThat(result.totalPrice()).isEqualTo(40000); // 10000*2 + 20000*1 + } + + @Test + @DisplayName("실패 - 주문 항목이 비어있는 경우") + void 주문항목_비어있음_예외() { + // Arrange + Long userId = 1L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, List.of())); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("실패 - 주문 항목이 null인 경우") + void 주문항목_null_예외() { + // Arrange + Long userId = 1L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, null)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("실패 - 상품이 존재하지 않는 경우") + void 상품_미존재_예외() { + // Arrange + Long userId = 1L; + List items = List.of( + new OrderItemRequest(999L, 1) + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, items)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("실패 - 재고 부족") + void 재고_부족_예외() { + // Arrange + Product product = createAndSaveProduct("재고 적은 상품", 10000, 5); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product.getId(), 10) // 재고 5인데 10개 주문 + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, items)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + } + + @Nested + @DisplayName("주문 조회") + class GetOrder { + + @Test + @DisplayName("성공 - 주문 ID와 사용자 ID로 조회") + void 주문_조회_성공() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + List items = List.of(new OrderItemRequest(product.getId(), 2)); + OrderResult created = orderApplicationService.placeOrder(userId, items); + + // Act + OrderResult result = orderApplicationService.getOrder(created.id(), userId); + + // Assert + assertThat(result.id()).isEqualTo(created.id()); + assertThat(result.userId()).isEqualTo(userId); + } + + @Test + @DisplayName("실패 - 주문이 존재하지 않는 경우") + void 주문_미존재_예외() { + // Arrange + Long userId = 1L; + Long nonExistentOrderId = 999L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.getOrder(nonExistentOrderId, userId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("실패 - 다른 사용자의 주문 조회 시") + void 타인_주문_조회_예외() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + Long otherUserId = 2L; + List items = List.of(new OrderItemRequest(product.getId(), 2)); + OrderResult created = orderApplicationService.placeOrder(userId, items); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.getOrder(created.id(), otherUserId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("주문 목록 조회") + class GetOrders { + + @Test + @DisplayName("성공 - 사용자의 주문 목록 조회") + void 주문_목록_조회_성공() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 1))); + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 2))); + + // Act + List results = orderApplicationService.getOrders(userId, 0, 10); + + // Assert + assertThat(results).hasSize(2); + } + + @Test + @DisplayName("성공 - 주문이 없는 경우 빈 목록 반환") + void 주문_없음_빈목록() { + // Arrange + Long userId = 1L; + + // Act + List results = orderApplicationService.getOrders(userId, 0, 10); + + // Assert + assertThat(results).isEmpty(); + } + } + + @Nested + @DisplayName("주문 수 조회") + class CountOrders { + + @Test + @DisplayName("성공 - 사용자의 주문 수 조회") + void 주문_수_조회_성공() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 1))); + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 2))); + + // Act + long count = orderApplicationService.countOrders(userId); + + // Assert + assertThat(count).isEqualTo(2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java new file mode 100644 index 000000000..0afb7e7b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java @@ -0,0 +1,179 @@ +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandInfoTest { + + @DisplayName("BrandInfo를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 입력이 주어지면, 정상적으로 생성된다.") + @Test + void createsBrandInfo_whenValidInputIsProvided() { + // arrange + String name = "Nike"; + String description = "Just Do It"; + String logoUrl = "https://example.com/logo.png"; + + // act + BrandInfo brandInfo = new BrandInfo(name, description, logoUrl); + + // assert + assertAll( + () -> assertThat(brandInfo.name()).isEqualTo(name), + () -> assertThat(brandInfo.description()).isEqualTo(description), + () -> assertThat(brandInfo.logoUrl()).isEqualTo(logoUrl) + ); + } + + @DisplayName("description과 logoUrl이 null이어도, 정상적으로 생성된다.") + @Test + void createsBrandInfo_whenOptionalFieldsAreNull() { + // arrange + String name = "Nike"; + + // act + BrandInfo brandInfo = new BrandInfo(name, null, null); + + // assert + assertAll( + () -> assertThat(brandInfo.name()).isEqualTo(name), + () -> assertThat(brandInfo.description()).isNull(), + () -> assertThat(brandInfo.logoUrl()).isNull() + ); + } + + @DisplayName("name이 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenNameIsNullOrEmpty(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo(name, "description", "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 공백 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {" ", "\t", "\n"}) + void throwsBadRequestException_whenNameIsBlank(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo(name, "description", "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 100자를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameExceeds100Characters() { + // arrange + String name = "a".repeat(101); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo(name, "description", "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 정확히 100자면, 정상적으로 생성된다.") + @Test + void createsBrandInfo_whenNameIsExactly100Characters() { + // arrange + String name = "a".repeat(100); + + // act + BrandInfo brandInfo = new BrandInfo(name, null, null); + + // assert + assertThat(brandInfo.name()).isEqualTo(name); + } + + @DisplayName("description이 500자를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenDescriptionExceeds500Characters() { + // arrange + String description = "a".repeat(501); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo("Nike", description, "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("description이 정확히 500자면, 정상적으로 생성된다.") + @Test + void createsBrandInfo_whenDescriptionIsExactly500Characters() { + // arrange + String description = "a".repeat(500); + + // act + BrandInfo brandInfo = new BrandInfo("Nike", description, null); + + // assert + assertThat(brandInfo.description()).isEqualTo(description); + } + + @DisplayName("logoUrl이 URL 형식이 아니면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"not-a-url", "ftp://example.com", "example.com/logo.png"}) + void throwsBadRequestException_whenLogoUrlIsNotValidUrl(String logoUrl) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo("Nike", "description", logoUrl); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("logoUrl이 http 또는 https로 시작하면, 정상적으로 생성된다.") + @ParameterizedTest + @ValueSource(strings = {"http://example.com/logo.png", "https://example.com/logo.png"}) + void createsBrandInfo_whenLogoUrlStartsWithHttpOrHttps(String logoUrl) { + // act + BrandInfo brandInfo = new BrandInfo("Nike", "description", logoUrl); + + // assert + assertThat(brandInfo.logoUrl()).isEqualTo(logoUrl); + } + + @DisplayName("logoUrl이 500자를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenLogoUrlExceeds500Characters() { + // arrange + String logoUrl = "https://example.com/" + "a".repeat(481); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo("Nike", "description", logoUrl); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..077d6819f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,160 @@ +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 java.time.ZonedDateTime; + +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 BrandTest { + + @DisplayName("Brand를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, 정상적으로 생성된다.") + @Test + void createsBrand_whenInfoIsValid() { + // arrange & act + Brand brand = Brand.create("Nike", "스포츠 브랜드", "https://example.com/nike.png"); + + // assert + assertThat(brand.getName()).isEqualTo("Nike"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + assertThat(brand.getLogoUrl()).isEqualTo("https://example.com/nike.png"); + assertThat(brand.getCreatedAt()).isNotNull(); + assertThat(brand.isDeleted()).isFalse(); + } + + @DisplayName("ID는 null로 생성된다.") + @Test + void createsWithNullId() { + // act + Brand brand = Brand.create("Nike", "스포츠 브랜드", "https://example.com/nike.png"); + + // assert + assertThat(brand.getId()).isNull(); + } + } + + @DisplayName("Brand를 복원할 때,") + @Nested + class Reconstitute { + + @DisplayName("모든 필드가 복원된다.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + String name = "Nike"; + String description = "스포츠 브랜드"; + String logoUrl = "https://example.com/nike.png"; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + ZonedDateTime updatedAt = ZonedDateTime.now(); + ZonedDateTime deletedAt = null; + + // act + Brand brand = Brand.reconstitute(id, name, description, logoUrl, createdAt, updatedAt, deletedAt); + + // assert + assertThat(brand.getId()).isEqualTo(id); + assertThat(brand.getName()).isEqualTo(name); + assertThat(brand.getDescription()).isEqualTo(description); + assertThat(brand.getLogoUrl()).isEqualTo(logoUrl); + assertThat(brand.getCreatedAt()).isEqualTo(createdAt); + assertThat(brand.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(brand.getDeletedAt()).isNull(); + } + + @DisplayName("삭제된 브랜드도 복원된다.") + @Test + void reconstitutesDeletedBrand() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime deletedAt = now; + + // act + Brand brand = Brand.reconstitute(1L, "Nike", "설명", "url", now, now, deletedAt); + + // assert + assertThat(brand.isDeleted()).isTrue(); + assertThat(brand.getDeletedAt()).isEqualTo(deletedAt); + } + } + + @DisplayName("Brand를 수정할 때,") + @Nested + class Update { + + @DisplayName("활성 브랜드는 정상적으로 수정된다.") + @Test + void updatesBrand_whenBrandIsActive() { + // arrange + Brand brand = Brand.create("Nike", "스포츠 브랜드", "https://example.com/nike.png"); + + // act + brand.update("Adidas", "독일 스포츠 브랜드", "https://example.com/adidas.png"); + + // assert + assertThat(brand.getName()).isEqualTo("Adidas"); + assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"); + assertThat(brand.getLogoUrl()).isEqualTo("https://example.com/adidas.png"); + } + + @DisplayName("삭제된 브랜드는 수정할 수 없다.") + @Test + void throwsException_whenBrandIsDeleted() { + // arrange + Brand brand = Brand.create("Nike", "스포츠 브랜드", "https://example.com/nike.png"); + brand.delete(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brand.update("Adidas", "설명", "url"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_DELETED); + } + } + + @DisplayName("Brand를 삭제할 때,") + @Nested + class Delete { + + @DisplayName("활성 브랜드는 삭제된다.") + @Test + void deletesBrand_whenBrandIsActive() { + // arrange + Brand brand = Brand.create("Nike", "스포츠 브랜드", "https://example.com/nike.png"); + + // act + brand.delete(); + + // assert + assertThat(brand.isDeleted()).isTrue(); + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("이미 삭제된 브랜드를 삭제해도 멱등하게 동작한다.") + @Test + void isIdempotent_whenBrandIsAlreadyDeleted() { + // arrange + Brand brand = Brand.create("Nike", "스포츠 브랜드", "https://example.com/nike.png"); + brand.delete(); + ZonedDateTime firstDeletedAt = brand.getDeletedAt(); + + // act + assertDoesNotThrow(() -> brand.delete()); + + // assert + assertThat(brand.getDeletedAt()).isEqualTo(firstDeletedAt); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java new file mode 100644 index 000000000..e6a0b6370 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java @@ -0,0 +1,194 @@ +package com.loopers.domain.common; + +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.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MoneyTest { + + @DisplayName("Money를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 금액으로 생성하면, 정상적으로 생성된다.") + @Test + void createsMoney_whenAmountIsValid() { + // arrange + long amount = 10000; + + // act + Money money = new Money(amount); + + // assert + assertThat(money.amount()).isEqualTo(10000); + } + + @DisplayName("금액이 0이면, 정상적으로 생성된다.") + @Test + void createsMoney_whenAmountIsZero() { + // arrange + long amount = 0; + + // act + Money money = new Money(amount); + + // assert + assertThat(money.amount()).isEqualTo(0); + } + + @DisplayName("금액이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // arrange + long amount = -1; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Money(amount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("금액을 더할 때,") + @Nested + class Add { + + @DisplayName("두 금액을 더하면, 합산된 금액이 반환된다.") + @Test + void returnsAddedMoney_whenAddingTwoAmounts() { + // arrange + Money money1 = new Money(10000); + Money money2 = new Money(5000); + + // act + Money result = money1.add(money2); + + // assert + assertThat(result.amount()).isEqualTo(15000); + } + + @DisplayName("ZERO에 금액을 더하면, 해당 금액이 반환된다.") + @Test + void returnsOriginalMoney_whenAddingToZero() { + // arrange + Money money = new Money(10000); + + // act + Money result = Money.ZERO.add(money); + + // assert + assertThat(result.amount()).isEqualTo(10000); + } + + @DisplayName("원본 Money는 변경되지 않는다. (불변성)") + @Test + void originalMoneyRemainsUnchanged() { + // arrange + Money original = new Money(10000); + + // act + Money added = original.add(new Money(5000)); + + // assert + assertThat(original.amount()).isEqualTo(10000); + assertThat(added.amount()).isEqualTo(15000); + } + } + + @DisplayName("금액에 수량을 곱할 때,") + @Nested + class Multiply { + + @DisplayName("유효한 수량을 곱하면, 곱해진 금액이 반환된다.") + @Test + void returnsMultipliedMoney_whenQuantityIsValid() { + // arrange + Money money = new Money(10000); + int quantity = 3; + + // act + Money result = money.multiply(quantity); + + // assert + assertThat(result.amount()).isEqualTo(30000); + } + + @DisplayName("수량이 1이면, 원래 금액이 반환된다.") + @Test + void returnsSameMoney_whenQuantityIsOne() { + // arrange + Money money = new Money(10000); + + // act + Money result = money.multiply(1); + + // assert + assertThat(result.amount()).isEqualTo(10000); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsZero() { + // arrange + Money money = new Money(10000); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + money.multiply(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // arrange + Money money = new Money(10000); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + money.multiply(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("원본 Money는 변경되지 않는다. (불변성)") + @Test + void originalMoneyRemainsUnchanged() { + // arrange + Money original = new Money(10000); + + // act + Money multiplied = original.multiply(3); + + // assert + assertThat(original.amount()).isEqualTo(10000); + assertThat(multiplied.amount()).isEqualTo(30000); + } + } + + @DisplayName("ZERO 상수는,") + @Nested + class ZeroConstant { + + @DisplayName("금액이 0인 Money이다.") + @Test + void hasZeroAmount() { + // assert + assertThat(Money.ZERO.amount()).isEqualTo(0); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java new file mode 100644 index 000000000..a9bfb8f0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.common; + +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.assertThrows; + +class QuantityTest { + + @DisplayName("Quantity를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 수량으로 생성하면, 정상적으로 생성된다.") + @Test + void createsQuantity_whenValueIsValid() { + // arrange + int value = 10; + + // act + Quantity quantity = new Quantity(value); + + // assert + assertThat(quantity.value()).isEqualTo(10); + } + + @DisplayName("수량이 1이면, 정상적으로 생성된다.") + @Test + void createsQuantity_whenValueIsOne() { + // arrange + int value = 1; + + // act + Quantity quantity = new Quantity(value); + + // assert + assertThat(quantity.value()).isEqualTo(1); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenValueIsZero() { + // arrange + int value = 0; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Quantity(value); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenValueIsNegative() { + // arrange + int value = -1; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Quantity(value); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java new file mode 100644 index 000000000..ef3ea5810 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java @@ -0,0 +1,195 @@ +package com.loopers.domain.like; + +import com.loopers.fake.FakeLikeRepository; +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 java.util.List; +import java.util.Map; + +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 LikeDomainServiceTest { + + private FakeLikeRepository fakeRepository; + private LikeDomainService service; + + @BeforeEach + void setUp() { + fakeRepository = new FakeLikeRepository(); + service = new LikeDomainService(fakeRepository); + } + + @DisplayName("좋아요를 등록할 때,") + @Nested + class LikeMethod { + + @DisplayName("새로운 좋아요면, 정상적으로 등록된다.") + @Test + void registersLike_whenLikeIsNew() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like like = service.like(userId, productId); + + // assert + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getId()).isNotNull(); + assertThat(fakeRepository.exists(userId, productId)).isTrue(); + } + + @DisplayName("이미 좋아요한 상품이면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflictException_whenLikeAlreadyExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + service.like(userId, productId); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + service.like(userId, productId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("다른 사용자가 같은 상품에 좋아요하면, 정상적으로 등록된다.") + @Test + void registersLike_whenDifferentUserLikesSameProduct() { + // arrange + service.like(1L, 100L); + + // act + Like like = service.like(2L, 100L); + + // assert + assertThat(like.getUserId()).isEqualTo(2L); + assertThat(fakeRepository.exists(2L, 100L)).isTrue(); + } + + @DisplayName("같은 사용자가 다른 상품에 좋아요하면, 정상적으로 등록된다.") + @Test + void registersLike_whenSameUserLikesDifferentProduct() { + // arrange + service.like(1L, 100L); + + // act + Like like = service.like(1L, 200L); + + // assert + assertThat(like.getProductId()).isEqualTo(200L); + assertThat(fakeRepository.exists(1L, 200L)).isTrue(); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class Unlike { + + @DisplayName("존재하는 좋아요면, 정상적으로 삭제된다.") + @Test + void deletesLike_whenLikeExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + service.like(userId, productId); + assertThat(fakeRepository.exists(userId, productId)).isTrue(); + + // act + service.unlike(userId, productId); + + // assert + assertThat(fakeRepository.exists(userId, productId)).isFalse(); + } + + @DisplayName("존재하지 않는 좋아요를 취소해도 예외가 발생하지 않는다. (멱등)") + @Test + void doesNotThrowException_whenLikeDoesNotExist() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act & assert + assertDoesNotThrow(() -> service.unlike(userId, productId)); + } + + @DisplayName("여러 번 취소해도 멱등하게 동작한다.") + @Test + void isIdempotent_whenUnlikingMultipleTimes() { + // arrange + service.like(1L, 100L); + + // act + service.unlike(1L, 100L); + service.unlike(1L, 100L); + service.unlike(1L, 100L); + + // assert + assertThat(fakeRepository.exists(1L, 100L)).isFalse(); + } + } + + @DisplayName("좋아요 수를 조회할 때,") + @Nested + class CountByProductId { + + @DisplayName("상품에 좋아요가 있으면, 개수를 반환한다.") + @Test + void returnsCount_whenLikesExist() { + // arrange + Long productId = 100L; + service.like(1L, productId); + service.like(2L, productId); + service.like(3L, productId); + + // act + long count = service.countByProductId(productId); + + // assert + assertThat(count).isEqualTo(3); + } + + @DisplayName("상품에 좋아요가 없으면, 0을 반환한다.") + @Test + void returnsZero_whenNoLikesExist() { + // act + long count = service.countByProductId(100L); + + // assert + assertThat(count).isEqualTo(0); + } + } + + @DisplayName("여러 상품의 좋아요 수를 조회할 때,") + @Nested + class CountByProductIds { + + @DisplayName("각 상품별 좋아요 수가 Map으로 반환된다.") + @Test + void returnsCountMap_forEachProduct() { + // arrange + service.like(1L, 100L); + service.like(2L, 100L); + service.like(1L, 200L); + + // act + Map counts = service.countByProductIds(List.of(100L, 200L, 300L)); + + // assert + assertThat(counts.get(100L)).isEqualTo(2); + assertThat(counts.get(200L)).isEqualTo(1); + assertThat(counts.get(300L)).isNull(); // 좋아요 없는 상품 + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..c8e3538de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeTest { + + @DisplayName("Like를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, 정상적으로 생성된다.") + @Test + void createsLike_whenInfoIsValid() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like like = Like.create(userId, productId); + + // assert + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getCreatedAt()).isNotNull(); + } + + @DisplayName("ID는 null로 생성된다.") + @Test + void createsWithNullId() { + // act + Like like = Like.create(1L, 100L); + + // assert + assertThat(like.getId()).isNull(); + } + } + + @DisplayName("Like를 복원할 때,") + @Nested + class Reconstitute { + + @DisplayName("모든 필드가 복원된다.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long userId = 10L; + Long productId = 100L; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + + // act + Like like = Like.reconstitute(id, userId, productId, createdAt); + + // assert + assertThat(like.getId()).isEqualTo(id); + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getCreatedAt()).isEqualTo(createdAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..3cef4b88d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,129 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.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.assertThrows; + +class OrderItemTest { + + @DisplayName("OrderItem을 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, 정상적으로 생성된다.") + @Test + void createsOrderItem_whenInfoIsValid() { + // arrange + Long productId = 100L; + String productName = "Air Max 90"; + int quantity = 2; + Money price = new Money(150000); + + // act + OrderItem item = OrderItem.create(productId, productName, quantity, price); + + // assert + assertThat(item.getProductId()).isEqualTo(productId); + assertThat(item.getProductName()).isEqualTo(productName); + assertThat(item.getQuantity()).isEqualTo(quantity); + assertThat(item.getPriceSnapshot()).isEqualTo(price); + } + + @DisplayName("ID는 null로 생성된다.") + @Test + void createsWithNullId() { + // act + OrderItem item = OrderItem.create(100L, "상품", 1, new Money(10000)); + + // assert + assertThat(item.getId()).isNull(); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsZero() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(100L, "상품", 0, new Money(10000)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(100L, "상품", -1, new Money(10000)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소계(subtotal)를 계산할 때,") + @Nested + class GetSubtotal { + + @DisplayName("단가 × 수량이 반환된다.") + @Test + void returnsMultipliedPrice() { + // arrange + OrderItem item = OrderItem.create(100L, "상품", 3, new Money(10000)); + + // act + Money subtotal = item.getSubtotal(); + + // assert + assertThat(subtotal.amount()).isEqualTo(30000); + } + + @DisplayName("수량이 1이면, 단가가 반환된다.") + @Test + void returnsPriceWhenQuantityIsOne() { + // arrange + OrderItem item = OrderItem.create(100L, "상품", 1, new Money(10000)); + + // act + Money subtotal = item.getSubtotal(); + + // assert + assertThat(subtotal.amount()).isEqualTo(10000); + } + } + + @DisplayName("OrderItem을 복원할 때,") + @Nested + class Reconstitute { + + @DisplayName("모든 필드가 복원된다.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long productId = 100L; + String productName = "Air Max 90"; + int quantity = 2; + Money priceSnapshot = new Money(150000); + + // act + OrderItem item = OrderItem.reconstitute(id, productId, productName, quantity, priceSnapshot); + + // assert + assertThat(item.getId()).isEqualTo(id); + assertThat(item.getProductId()).isEqualTo(productId); + assertThat(item.getProductName()).isEqualTo(productName); + assertThat(item.getQuantity()).isEqualTo(quantity); + assertThat(item.getPriceSnapshot()).isEqualTo(priceSnapshot); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..b6918a5b5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,144 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.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.ZonedDateTime; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("Order를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, 정상적으로 생성된다.") + @Test + void createsOrder_whenInfoIsValid() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.create(100L, "상품1", 2, new Money(10000)), + OrderItem.create(200L, "상품2", 1, new Money(20000)) + ); + + // act + Order order = Order.create(userId, items); + + // assert + assertThat(order.getUserId()).isEqualTo(userId); + assertThat(order.getItems()).hasSize(2); + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(order.getCreatedAt()).isNotNull(); + } + + @DisplayName("ID는 null로 생성된다.") + @Test + void createsWithNullId() { + // arrange + List items = List.of( + OrderItem.create(100L, "상품", 1, new Money(10000)) + ); + + // act + Order order = Order.create(1L, items); + + // assert + assertThat(order.getId()).isNull(); + } + + @DisplayName("총액이 올바르게 계산된다.") + @Test + void calculatesTotalPriceCorrectly() { + // arrange + List items = List.of( + OrderItem.create(100L, "상품1", 2, new Money(10000)), // 20000 + OrderItem.create(200L, "상품2", 1, new Money(20000)), // 20000 + OrderItem.create(300L, "상품3", 3, new Money(5000)) // 15000 + ); + + // act + Order order = Order.create(1L, items); + + // assert + assertThat(order.getTotalPrice().amount()).isEqualTo(55000); + } + + @DisplayName("주문 항목이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenItemsIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.create(1L, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenItemsIsEmpty() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.create(1L, Collections.emptyList()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 불변 리스트로 보호된다.") + @Test + void itemsAreImmutable() { + // arrange + List items = List.of( + OrderItem.create(100L, "상품", 1, new Money(10000)) + ); + Order order = Order.create(1L, items); + + // act & assert + assertThrows(UnsupportedOperationException.class, () -> { + order.getItems().add(OrderItem.create(200L, "추가상품", 1, new Money(20000))); + }); + } + } + + @DisplayName("Order를 복원할 때,") + @Nested + class Reconstitute { + + @DisplayName("모든 필드가 복원된다.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long userId = 10L; + List items = List.of( + OrderItem.reconstitute(1L, 100L, "상품", 2, new Money(10000)) + ); + Money totalPrice = new Money(20000); + OrderStatus status = OrderStatus.PAID; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + + // act + Order order = Order.reconstitute(id, userId, items, totalPrice, status, createdAt); + + // assert + assertThat(order.getId()).isEqualTo(id); + assertThat(order.getUserId()).isEqualTo(userId); + assertThat(order.getItems()).hasSize(1); + assertThat(order.getTotalPrice()).isEqualTo(totalPrice); + assertThat(order.getStatus()).isEqualTo(status); + assertThat(order.getCreatedAt()).isEqualTo(createdAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..0bf2ff3b8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,258 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.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.ZonedDateTime; + +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 ProductTest { + + @DisplayName("Product를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, 정상적으로 생성된다.") + @Test + void createsProduct_whenInfoIsValid() { + // arrange & act + Product product = Product.create( + 1L, + "Air Max 90", + "클래식 스니커즈", + new Money(150000), + new Stock(100), + "https://example.com/airmax.png" + ); + + // assert + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName()).isEqualTo("Air Max 90"); + assertThat(product.getDescription()).isEqualTo("클래식 스니커즈"); + assertThat(product.getPrice().amount()).isEqualTo(150000); + assertThat(product.getStock().quantity()).isEqualTo(100); + assertThat(product.getImageUrl()).isEqualTo("https://example.com/airmax.png"); + assertThat(product.getCreatedAt()).isNotNull(); + assertThat(product.isDeleted()).isFalse(); + } + + @DisplayName("ID는 null로 생성된다.") + @Test + void createsWithNullId() { + // act + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(10), "url"); + + // assert + assertThat(product.getId()).isNull(); + } + } + + @DisplayName("Product를 복원할 때,") + @Nested + class Reconstitute { + + @DisplayName("모든 필드가 복원된다.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long brandId = 10L; + String name = "Air Max 90"; + String description = "클래식 스니커즈"; + Money price = new Money(150000); + Stock stock = new Stock(100); + String imageUrl = "https://example.com/airmax.png"; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + ZonedDateTime updatedAt = ZonedDateTime.now(); + ZonedDateTime deletedAt = null; + + // act + Product product = Product.reconstitute( + id, brandId, name, description, price, stock, imageUrl, createdAt, updatedAt, deletedAt + ); + + // assert + assertThat(product.getId()).isEqualTo(id); + assertThat(product.getBrandId()).isEqualTo(brandId); + assertThat(product.getName()).isEqualTo(name); + assertThat(product.getDescription()).isEqualTo(description); + assertThat(product.getPrice()).isEqualTo(price); + assertThat(product.getStock()).isEqualTo(stock); + assertThat(product.getImageUrl()).isEqualTo(imageUrl); + assertThat(product.getCreatedAt()).isEqualTo(createdAt); + assertThat(product.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(product.getDeletedAt()).isNull(); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + + @DisplayName("충분한 재고가 있으면, 재고가 차감된다.") + @Test + void decreasesStock_whenStockIsSufficient() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + + // act + product.decreaseStock(30); + + // assert + assertThat(product.getStock().quantity()).isEqualTo(70); + } + + @DisplayName("재고와 정확히 같은 수량을 차감하면, 재고가 0이 된다.") + @Test + void decreasesStockToZero_whenDecreasingExactAmount() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(50), "url"); + + // act + product.decreaseStock(50); + + // assert + assertThat(product.getStock().quantity()).isEqualTo(0); + } + + @DisplayName("재고가 부족하면, INSUFFICIENT_STOCK 예외가 발생한다.") + @Test + void throwsInsufficientStockException_whenStockIsInsufficient() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(10), "url"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(20); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("차감 수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsZero() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("차감 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(-10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("삭제된 상품의 재고를 차감하면, PRODUCT_DELETED 예외가 발생한다.") + @Test + void throwsProductDeletedException_whenProductIsDeleted() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + product.delete(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PRODUCT_DELETED); + } + } + + @DisplayName("Product를 수정할 때,") + @Nested + class Update { + + @DisplayName("활성 상품은 정상적으로 수정된다.") + @Test + void updatesProduct_whenProductIsActive() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + + // act + product.update("수정된 상품", "수정된 설명", new Money(20000), new Stock(50), "new_url"); + + // assert + assertThat(product.getName()).isEqualTo("수정된 상품"); + assertThat(product.getDescription()).isEqualTo("수정된 설명"); + assertThat(product.getPrice().amount()).isEqualTo(20000); + assertThat(product.getStock().quantity()).isEqualTo(50); + assertThat(product.getImageUrl()).isEqualTo("new_url"); + } + + @DisplayName("삭제된 상품은 수정할 수 없다.") + @Test + void throwsException_whenProductIsDeleted() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + product.delete(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update("수정", "설명", new Money(20000), new Stock(50), "url"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PRODUCT_DELETED); + } + } + + @DisplayName("Product를 삭제할 때,") + @Nested + class Delete { + + @DisplayName("활성 상품은 삭제된다.") + @Test + void deletesProduct_whenProductIsActive() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + + // act + product.delete(); + + // assert + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } + + @DisplayName("이미 삭제된 상품을 삭제해도 멱등하게 동작한다.") + @Test + void isIdempotent_whenProductIsAlreadyDeleted() { + // arrange + Product product = Product.create(1L, "상품", "설명", new Money(10000), new Stock(100), "url"); + product.delete(); + ZonedDateTime firstDeletedAt = product.getDeletedAt(); + + // act + assertDoesNotThrow(() -> product.delete()); + + // assert + assertThat(product.getDeletedAt()).isEqualTo(firstDeletedAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java new file mode 100644 index 000000000..176bb1f7a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java @@ -0,0 +1,197 @@ +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.assertThrows; + +class StockTest { + + @DisplayName("Stock을 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 수량으로 생성하면, 정상적으로 생성된다.") + @Test + void createsStock_whenQuantityIsValid() { + // arrange + int quantity = 100; + + // act + Stock stock = new Stock(quantity); + + // assert + assertThat(stock.quantity()).isEqualTo(100); + } + + @DisplayName("수량이 0이면, 정상적으로 생성된다.") + @Test + void createsStock_whenQuantityIsZero() { + // arrange + int quantity = 0; + + // act + Stock stock = new Stock(quantity); + + // assert + assertThat(stock.quantity()).isEqualTo(0); + } + + @DisplayName("수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // arrange + int quantity = -1; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Stock(quantity); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class Decrease { + + @DisplayName("충분한 재고가 있으면, 차감된 Stock을 반환한다.") + @Test + void returnsDecreasedStock_whenStockIsSufficient() { + // arrange + Stock stock = new Stock(100); + + // act + Stock result = stock.decrease(30); + + // assert + assertThat(result.quantity()).isEqualTo(70); + } + + @DisplayName("재고와 정확히 같은 수량을 차감하면, 0이 된다.") + @Test + void returnsZeroStock_whenDecreasingExactAmount() { + // arrange + Stock stock = new Stock(50); + + // act + Stock result = stock.decrease(50); + + // assert + assertThat(result.quantity()).isEqualTo(0); + } + + @DisplayName("재고가 부족하면, INSUFFICIENT_STOCK 예외가 발생한다.") + @Test + void throwsInsufficientStockException_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.decrease(20); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("차감 수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenAmountIsZero() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.decrease(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("차감 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.decrease(-10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("원본 Stock은 변경되지 않는다. (불변성)") + @Test + void originalStockRemainsUnchanged() { + // arrange + Stock original = new Stock(100); + + // act + Stock decreased = original.decrease(30); + + // assert + assertThat(original.quantity()).isEqualTo(100); + assertThat(decreased.quantity()).isEqualTo(70); + } + } + + @DisplayName("재고를 증가시킬 때,") + @Nested + class Increase { + + @DisplayName("유효한 수량으로 증가시키면, 증가된 Stock을 반환한다.") + @Test + void returnsIncreasedStock_whenAmountIsValid() { + // arrange + Stock stock = new Stock(100); + + // act + Stock result = stock.increase(50); + + // assert + assertThat(result.quantity()).isEqualTo(150); + } + + @DisplayName("증가 수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenAmountIsZero() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.increase(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("증가 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.increase(-10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java new file mode 100644 index 000000000..112faad39 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java @@ -0,0 +1,87 @@ +package com.loopers.fake; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake BrandRepository. + * Map 기반 in-memory 구현. + */ +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Brand save(Brand brand) { + Long id = brand.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + // reconstitute를 통해 ID가 할당된 새 객체 생성 + brand = Brand.reconstitute( + id, + brand.getName(), + brand.getDescription(), + brand.getLogoUrl(), + brand.getCreatedAt(), + brand.getUpdatedAt(), + brand.getDeletedAt() + ); + } + store.put(id, brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdActive(Long id) { + return findById(id).filter(brand -> !brand.isDeleted()); + } + + @Override + public List findAllActive() { + return store.values().stream() + .filter(brand -> !brand.isDeleted()) + .toList(); + } + + @Override + public boolean existsByName(String name) { + return store.values().stream() + .filter(brand -> !brand.isDeleted()) + .anyMatch(brand -> brand.getName().equals(name)); + } + + @Override + public boolean existsByNameAndIdNot(String name, Long id) { + return store.values().stream() + .filter(brand -> !brand.isDeleted()) + .filter(brand -> !brand.getId().equals(id)) + .anyMatch(brand -> brand.getName().equals(name)); + } + + /** + * 테스트용: 저장소 초기화 + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } + + /** + * 테스트용: 저장된 브랜드 수 조회 + */ + public int size() { + return store.size(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java new file mode 100644 index 000000000..8f5eeb4d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java @@ -0,0 +1,84 @@ +package com.loopers.fake; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * 테스트용 Fake LikeRepository. + * Map 기반 in-memory 구현. + */ +public class FakeLikeRepository implements LikeRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Like save(Like like) { + Long id = idGenerator.getAndIncrement(); + Like saved = Like.reconstitute( + id, + like.getUserId(), + like.getProductId(), + like.getCreatedAt() + ); + store.put(id, saved); + return saved; + } + + @Override + public void delete(Like like) { + store.remove(like.getId()); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.values().stream() + .filter(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public boolean exists(Long userId, Long productId) { + return store.values().stream() + .anyMatch(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)); + } + + @Override + public long countByProductId(Long productId) { + return store.values().stream() + .filter(l -> l.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(List productIds) { + return store.values().stream() + .filter(l -> productIds.contains(l.getProductId())) + .collect(Collectors.groupingBy( + Like::getProductId, + Collectors.counting() + )); + } + + /** + * 테스트용: 저장소 초기화 + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } + + /** + * 테스트용: 저장된 좋아요 수 조회 + */ + public int size() { + return store.size(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java new file mode 100644 index 000000000..fe7ed0b2f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java @@ -0,0 +1,96 @@ +package com.loopers.fake; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake OrderRepository. + * Map 기반 in-memory 구현. + */ +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + private final AtomicLong itemIdGenerator = new AtomicLong(1); + + @Override + public Order save(Order order) { + Long orderId = order.getId(); + if (orderId == null) { + orderId = idGenerator.getAndIncrement(); + // OrderItem에도 ID 부여 + List itemsWithIds = order.getItems().stream() + .map(item -> OrderItem.reconstitute( + itemIdGenerator.getAndIncrement(), + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPriceSnapshot() + )) + .toList(); + + order = Order.reconstitute( + orderId, + order.getUserId(), + itemsWithIds, + order.getTotalPrice(), + order.getStatus(), + order.getCreatedAt() + ); + } + store.put(orderId, order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return findById(id) + .filter(order -> order.getUserId().equals(userId)); + } + + @Override + public List findAllByUserId(Long userId, int offset, int limit) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .sorted(Comparator.comparing(Order::getCreatedAt).reversed()) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public long countByUserId(Long userId) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .count(); + } + + /** + * 테스트용: 저장소 초기화 + */ + public void clear() { + store.clear(); + idGenerator.set(1); + itemIdGenerator.set(1); + } + + /** + * 테스트용: 저장된 주문 수 조회 + */ + public int size() { + return store.size(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java new file mode 100644 index 000000000..92228ac6c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java @@ -0,0 +1,134 @@ +package com.loopers.fake; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSort; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake ProductRepository. + * Map 기반 in-memory 구현. + */ +public class FakeProductRepository implements ProductRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Product save(Product product) { + Long id = product.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + product = Product.reconstitute( + id, + product.getBrandId(), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStock(), + product.getImageUrl(), + product.getCreatedAt(), + product.getUpdatedAt(), + product.getDeletedAt() + ); + } + store.put(id, product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdActive(Long id) { + return findById(id).filter(product -> !product.isDeleted()); + } + + @Override + public Optional findByIdWithLock(Long id) { + // Fake에서는 락 없이 동일하게 동작 + return findByIdActive(id); + } + + @Override + public List findAllActive(ProductSort sort, int offset, int limit) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .sorted(getComparator(sort)) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId, ProductSort sort, int offset, int limit) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .filter(product -> product.getBrandId().equals(brandId)) + .sorted(getComparator(sort)) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .filter(product -> product.getBrandId().equals(brandId)) + .toList(); + } + + @Override + public List findAllByIds(List ids) { + return store.values().stream() + .filter(product -> ids.contains(product.getId())) + .toList(); + } + + @Override + public long countActive() { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .count(); + } + + @Override + public long countByBrandIdActive(Long brandId) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .filter(product -> product.getBrandId().equals(brandId)) + .count(); + } + + private Comparator getComparator(ProductSort sort) { + return switch (sort) { + case LATEST -> Comparator.comparing(Product::getCreatedAt).reversed(); + case PRICE_ASC -> Comparator.comparing(p -> p.getPrice().amount()); + case LIKES_DESC -> Comparator.comparing(Product::getCreatedAt).reversed(); // Application에서 처리 + }; + } + + /** + * 테스트용: 저장소 초기화 + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } + + /** + * 테스트용: 저장된 상품 수 조회 + */ + public int size() { + return store.size(); + } +} diff --git a/http/brand-admin-api.http b/http/brand-admin-api.http new file mode 100644 index 000000000..2f1edfd63 --- /dev/null +++ b/http/brand-admin-api.http @@ -0,0 +1,36 @@ +### 브랜드 목록 조회 (Admin) +GET http://localhost:8080/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 (Admin) +GET http://localhost:8080/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### 브랜드 등록 (Admin) +POST http://localhost:8080/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "Nike", + "description": "Just Do It", + "logoUrl": "https://example.com/nike.png" +} + +### 브랜드 수정 (Admin) +PUT http://localhost:8080/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "Nike Updated", + "description": "Updated description", + "logoUrl": "https://example.com/nike-updated.png" +} + +### 브랜드 삭제 (Admin) +DELETE http://localhost:8080/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin diff --git a/http/brand-api.http b/http/brand-api.http new file mode 100644 index 000000000..f21596f54 --- /dev/null +++ b/http/brand-api.http @@ -0,0 +1,5 @@ +### 브랜드 상세 조회 +GET http://localhost:8080/api/v1/brands/1 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! diff --git a/http/product-admin-api.http b/http/product-admin-api.http new file mode 100644 index 000000000..0e97dd5ba --- /dev/null +++ b/http/product-admin-api.http @@ -0,0 +1,42 @@ +### 상품 목록 조회 (Admin) +GET http://localhost:8080/api-admin/v1/products?page=0&size=10 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 (Admin) +GET http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### 상품 등록 (Admin) +POST http://localhost:8080/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "Air Max 90", + "description": "Classic sneakers", + "price": 150000, + "stock": 100, + "imageUrl": "https://example.com/airmax90.png" +} + +### 상품 수정 (Admin) +PUT http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "Air Max 90 Updated", + "description": "Updated description", + "price": 160000, + "stock": 50, + "imageUrl": "https://example.com/airmax90-updated.png" +} + +### 상품 삭제 (Admin) +DELETE http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin diff --git a/http/product-api.http b/http/product-api.http new file mode 100644 index 000000000..a51643b32 --- /dev/null +++ b/http/product-api.http @@ -0,0 +1,17 @@ +### 상품 목록 조회 +GET http://localhost:8080/api/v1/products?page=0&size=10 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 상품 목록 조회 (브랜드 필터) +GET http://localhost:8080/api/v1/products?brandId=1&page=0&size=10 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 상품 상세 조회 +GET http://localhost:8080/api/v1/products/1 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234!