diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..39912a6d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,705 @@ +# Loopers Commerce Platform - 개발 가이드 + +## 프로젝트 개요 + +Loopers에서 제공하는 Spring Boot 기반의 멀티모듈 커머스 플랫폼입니다. + +### 주요 기술 스택 및 버전 + +#### Core +- **Java**: 21 (LTS) +- **Spring Boot**: 3.4.4 +- **Spring Cloud**: 2024.0.1 +- **Gradle**: 8.x (Kotlin DSL) + +#### Framework & Libraries +- **Spring Data JPA**: 3.4.4 (with QueryDSL) +- **Spring Security**: Crypto 모듈 (BCrypt 암호화) +- **Spring Batch**: 5.x +- **Spring Kafka**: 3.x +- **Redis**: Lettuce 기반 +- **MySQL**: 8.x (Production), TestContainers (Test) + +#### API & Documentation +- **SpringDoc OpenAPI**: 2.7.0 (Swagger UI) +- **Jakarta Validation**: Bean Validation 3.0 + +#### Testing +- **JUnit 5**: Jupiter +- **AssertJ**: Fluent Assertions +- **Mockito**: 5.14.0 +- **SpringMockK**: 4.0.2 (Kotlin Mock 지원) +- **Instancio**: 5.0.2 (Test Fixture 생성) +- **TestContainers**: MySQL, Redis + +#### Monitoring & Logging +- **Spring Actuator**: Health Check, Metrics +- **Prometheus**: Metrics 수집 +- **Grafana**: 시각화 대시보드 +- **Logback**: 구조화된 로깅 (JSON/Plain) +- **Slack Appender**: 1.6.1 (알림) + +#### Build & Code Quality +- **Jacoco**: 코드 커버리지 +- **Lombok**: 보일러플레이트 제거 + +--- + +## 모듈 구조 + +### 전체 구조 +``` +Root +├── apps (실행 가능한 Spring Boot 애플리케이션) +│ ├── commerce-api # REST API 서버 +│ ├── commerce-batch # 배치 작업 +│ └── commerce-streamer # Kafka 스트리밍 +├── modules (재사용 가능한 인프라 설정) +│ ├── jpa # JPA, QueryDSL, DataSource 설정 +│ ├── redis # Redis Cluster 설정 +│ └── kafka # Kafka Producer/Consumer 설정 +└── supports (부가 기능 모듈) + ├── jackson # JSON 직렬화 설정 + ├── logging # Logback 설정 (JSON/Plain/Slack) + └── monitoring # Actuator, Prometheus 설정 +``` + +### 모듈 원칙 +- **apps**: 각 모듈은 독립적으로 실행 가능한 SpringBootApplication +- **modules**: 도메인에 의존하지 않는 재사용 가능한 인프라 설정 +- **supports**: 로깅, 모니터링 등 부가 기능 제공 + +### 의존성 규칙 +- apps → modules, supports (의존 가능) +- modules ↔ modules (상호 의존 금지) +- supports ↔ supports (상호 의존 금지) +- modules, supports → apps (의존 불가) + +--- + +## 도메인 & 객체 설계 전략 +- Entity는 상태와 행위를 함께 가진다. +- VO는 불변이며, 값 동등성으로 비교한다. +- Domain Service는 상태를 가지지 않는다. +- Application Layer는 흐름을 조율하고 도메인에 위임한다. +- Repository Interface는 Domain에 위치한다. + +## 아키텍처 전략 +- Layered Architecture + DIP 적용 +- Application → Domain ← Infrastructure +- Domain은 다른 계층에 의존하지 않는다. + +--- + +## 아키텍처 및 레이어 구조 + +### 패키지 구조 (commerce-api 기준) +``` +com.loopers +├── domain # 도메인 레이어 +│ └── {domain-name} +│ ├── {Domain}.java # JPA Entity (도메인 모델) +│ ├── {Domain}Service.java # 도메인 비즈니스 로직 +│ ├── {Domain}Repository.java # Repository 인터페이스 +│ └── {ValueObject}.java # Value Object (record) +├── application # 애플리케이션 레이어 +│ └── {domain-name} +│ ├── {Domain}Facade.java # 여러 도메인 서비스 조합 +│ └── {Domain}Info.java # 애플리케이션 DTO +├── infrastructure # 인프라 레이어 +│ ├── {domain-name} +│ │ ├── {Domain}JpaRepository.java # Spring Data JPA +│ │ └── {Domain}RepositoryImpl.java # Repository 구현체 +│ ├── jpa/converter +│ │ └── {ValueObject}Converter.java # JPA AttributeConverter +│ ├── security +│ │ └── BCryptPasswordHasher.java # 암호화 구현체 +│ └── config +│ └── SecurityConfig.java # 설정 +├── interfaces # 인터페이스 레이어 +│ └── api +│ ├── {domain-name} +│ │ ├── {Domain}V1Controller.java # REST Controller +│ │ ├── {Domain}V1ApiSpec.java # OpenAPI 명세 (interface) +│ │ └── {Domain}V1Dto.java # API DTO (record) +│ ├── ApiResponse.java # 공통 응답 래퍼 +│ └── ApiControllerAdvice.java # 전역 예외 처리 +└── support # 공통 지원 + └── error + ├── CoreException.java # 도메인 예외 + └── ErrorType.java # 에러 타입 enum +``` + +### 레이어별 역할 + +#### 1. Domain Layer (도메인 레이어) +- **책임**: 핵심 비즈니스 로직과 규칙 +- **구성요소**: + - `{Domain}`: JPA Entity, BaseEntity 상속, 도메인 객체 + - `{Domain}Service`: 도메인 비즈니스 로직, 트랜잭션 관리 + - `{Domain}Repository`: 인터페이스 (구현체는 Infrastructure) + - Value Objects: record 타입, 불변 객체, 생성자에서 검증 + +#### 2. Application Layer (애플리케이션 레이어) +- **책임**: 유스케이스 조합, 여러 도메인 서비스 조율 +- **구성요소**: + - `{Domain}Facade`: 여러 도메인 서비스를 조합한 유스케이스 + - `{Domain}Info`: 애플리케이션 레벨 DTO + +#### 3. Infrastructure Layer (인프라 레이어) +- **책임**: 외부 시스템 연동, 기술적 구현 +- **구성요소**: + - `{Domain}RepositoryImpl`: Repository 인터페이스 구현 + - `{Domain}JpaRepository`: Spring Data JPA 인터페이스 + - JPA Converter: Value Object ↔ DB 컬럼 변환 + - 외부 API 클라이언트, 암호화 구현체 등 + +#### 4. Interfaces Layer (인터페이스 레이어) +- **책임**: 외부와의 통신 (REST API, gRPC 등) +- **구성요소**: + - `{Domain}V1Controller`: REST API 엔드포인트 + - `{Domain}V1ApiSpec`: OpenAPI 명세 인터페이스 (Swagger 어노테이션) + - `{Domain}V1Dto`: API 요청/응답 DTO (record) + - `ApiResponse`: 공통 응답 래퍼 (meta + data) + - `ApiControllerAdvice`: 전역 예외 처리 + +--- + +## 코드 컨벤션 + +### 1. 네이밍 규칙 + +#### 클래스/인터페이스 +- **Entity**: `{Domain}` (예: `Member`, `Order`) +- **Service**: `{Domain}Service` (예: `MemberService`) +- **Repository Interface**: `{Domain}Repository` (예: `MemberRepository`) +- **Repository Impl**: `{Domain}RepositoryImpl` (예: `MemberRepositoryImpl`) +- **JPA Repository**: `{Domain}JpaRepository` (예: `MemberJpaRepository`) +- **Controller**: `{Domain}V{version}Controller` (예: `MemberV1Controller`) +- **API Spec**: `{Domain}V{version}ApiSpec` (예: `MemberV1ApiSpec`) +- **DTO**: `{Domain}V{version}Dto` (예: `MemberV1Dto`) +- **Value Object**: `{Name}` (예: `MemberId`, `Email`, `BirthDate`) +- **Facade**: `{Domain}Facade` (예: `MemberFacade`) +- **Exception**: `{Name}Exception` (예: `CoreException`) + +#### 메서드 +- **조회**: `get{Entity}By{Condition}` (예: `getMemberByMemberId`) +- **저장**: `save`, `register`, `create` +- **수정**: `update`, `modify` +- **삭제**: `delete`, `remove` +- **존재 확인**: `existsBy{Condition}` (예: `existsByMemberId`) +- **검증**: `validate{Target}` (예: `validatePassword`) + +#### 변수 +- **상수**: `UPPER_SNAKE_CASE` (예: `VALID_MEMBER_ID`, `PASSWORD_PATTERN`) +- **일반 변수**: `camelCase` (예: `memberId`, `rawPassword`) + +### 2. 타입 사용 규칙 + +#### Value Object +- **타입**: `record` 사용 (Java 17+) +- **검증**: Compact Constructor에서 수행 +- **불변성**: 모든 필드 final (record 기본) +- **예시**: +```java +public record MemberId(String value) { + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + public MemberId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "memberId가 비어 있습니다"); + } + value = value.trim(); + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "memberId는 영문+숫자, 1~10자로 이루어져야 합니다"); + } + } +} +``` + +#### DTO (Data Transfer Object) +- **타입**: `record` 사용 +- **검증**: Jakarta Validation 어노테이션 사용 +- **변환**: 정적 팩토리 메서드 `from()` 제공 +- **예시**: +```java +public class MemberV1Dto { + public record RegisterRequest( + @NotBlank String memberId, + @NotBlank String password, + @NotBlank String email, + @NotBlank String birthDate, + @NotBlank String name, + @NotNull Gender gender + ) {} + + public record MemberResponse( + Long id, + String memberId, + String email, + String birthDate, + String name, + Gender gender + ) { + public static MemberResponse from(Member member) { + return new MemberResponse( + member.getId(), + member.getMemberId().value(), + member.getEmail().address(), + member.getBirthDate().asString(), + member.getName().value(), + member.getGender() + ); + } + } +} +``` + +#### Entity +- **타입**: `class` (JPA Entity) +- **상속**: `BaseEntity` 상속 (id, createdAt, updatedAt, deletedAt) +- **생성자**: protected 기본 생성자 + public 생성자 체이닝 +- **필드**: private, @Getter 사용 +- **예시**: +```java +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + @Getter + @Convert(converter = MemberIdConverter.class) + @Column(nullable = false, unique = true, length = 10) + private MemberId memberId; + + protected Member() {} + + public Member(String memberId, String password) { + this.memberId = new MemberId(memberId); + this.password = password; + } +} +``` + +### 3. 예외 처리 + +#### CoreException +- **용도**: 도메인 예외 표현 +- **구조**: `ErrorType` + 커스텀 메시지 +- **예시**: +```java +throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입된 ID 입니다."); +``` + +#### ErrorType +- **타입**: enum +- **필드**: `HttpStatus status`, `String code`, `String message` +- **종류**: `INTERNAL_ERROR`, `BAD_REQUEST`, `NOT_FOUND`, `CONFLICT` + +#### 전역 예외 처리 +- **클래스**: `ApiControllerAdvice` (@RestControllerAdvice) +- **처리 대상**: + - `CoreException`: 도메인 예외 + - `MethodArgumentNotValidException`: Validation 실패 + - `HttpMessageNotReadableException`: JSON 파싱 실패 + - `NoResourceFoundException`: 404 Not Found + - `Throwable`: 예상치 못한 예외 + +### 4. API 응답 구조 + +#### ApiResponse +```java +public record ApiResponse(Metadata meta, T data) { + public record Metadata(Result result, String errorCode, String message) { + public enum Result { SUCCESS, FAIL } + } +} +``` + +#### 성공 응답 +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { + "id": 1, + "memberId": "testuser1", + "email": "test@example.com" + } +} +``` + +#### 실패 응답 +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "Bad Request", + "message": "이미 가입된 ID 입니다." + }, + "data": null +} +``` + +### 5. JPA 관련 + +#### BaseEntity +- **필드**: `id`, `createdAt`, `updatedAt`, `deletedAt` +- **기능**: + - `@PrePersist`: createdAt, updatedAt 자동 설정, guard() 호출 + - `@PreUpdate`: updatedAt 자동 갱신, guard() 호출 + - `delete()`: Soft Delete (멱등성 보장) + - `restore()`: 삭제 취소 (멱등성 보장) + - `guard()`: 엔티티 검증 (하위 클래스에서 오버라이드) + +#### JPA Converter +- **용도**: Value Object ↔ DB 컬럼 변환 +- **어노테이션**: `@Converter(autoApply = false)` (명시적 적용) +- **null-safety**: null 체크 필수 +- **예시**: +```java +@Converter(autoApply = false) +public class MemberIdConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(MemberId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public MemberId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new MemberId(dbData); + } +} +``` + +### 6. 의존성 주입 +- **방식**: 생성자 주입 (Constructor Injection) +- **어노테이션**: `@RequiredArgsConstructor` (Lombok) +- **필드**: `private final` 사용 + +### 7. 트랜잭션 +- **Service 레이어**: `@Transactional` 사용 +- **읽기 전용**: `@Transactional(readOnly = true)` +- **쓰기**: `@Transactional` (기본) + +--- + +## 테스트 전략 + +### 테스트 피라미드 (역할 분리) + +| 레벨 | 대상 | 환경 | 목적 | +|------|------|------|------| +| **Unit** | 도메인 모델, VO | Spring 없이 JVM | 순수 로직/규칙 검증 | +| **Integration** | Service, Facade | @SpringBootTest + TestContainers | 비즈니스 흐름 검증 | +| **E2E** | REST API | TestRestTemplate | HTTP 요청/응답 시나리오 | + +### 테스트 레벨 + +#### 1. 단위 테스트 (Unit Test) +- **대상**: Value Object, 도메인 로직 (Spring 없이 순수 JVM) +- **명명**: `{ClassName}UnitTest` +- **어노테이션**: `@Test`, `@DisplayName`, `@Nested` +- **패턴**: 3A (Arrange - Act - Assert) +- **예시**: +```java +@DisplayName("회원을 생성할 때, ") +@Nested +class Create { + @DisplayName("ID 가 영문 및 숫자 10자 이내 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createsMember_whenIdIsInvalid() { + // arrange + String memberId = "invalid_id!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(memberId, "password123")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } +} +``` + +#### 2. 통합 테스트 (Integration Test) +- **대상**: Service, Facade (여러 컴포넌트 연결 상태에서 비즈니스 흐름 검증) +- **명명**: `{ClassName}IntegrationTest` +- **어노테이션**: `@SpringBootTest` +- **인프라**: TestContainers (MySQL, Redis) +- **격리**: `DatabaseCleanUp.truncateAllTables()` (@AfterEach) +- **예시**: +```java +@SpringBootTest +class MemberServiceIntegrationTest { + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + void register_withValidInfo_createsMember() { + // arrange + String memberId = "testuser1"; + + // act + Member result = memberService.register(...); + + // assert + assertThat(result.getMemberId().value()).isEqualTo(memberId); + } +} +``` + +#### 3. E2E 테스트 (End-to-End Test) +- **대상**: REST API (Controller → Service → DB 전체 흐름) +- **명명**: `{ClassName}E2ETest` +- **어노테이션**: `@SpringBootTest(webEnvironment = RANDOM_PORT)` +- **클라이언트**: `TestRestTemplate` +- **예시**: +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class MemberV1ApiE2ETest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Test + void register_withValidRequest_returnsCreatedMember() { + // arrange + var request = new MemberV1Dto.RegisterRequest("testuser1", "Pass1234!", ...); + + // act + var response = testRestTemplate.postForEntity("/api/v1/members/register", request, ApiResponse.class); + + // assert + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} +``` + +### 테스트 원칙 +1. **3A 패턴 준수**: Arrange - Act - Assert +2. **@DisplayName 필수**: 한글로 명확한 테스트 의도 표현 +3. **@Nested 활용**: 테스트 그룹화 (예: Create, Get, Update, Delete) +4. **AssertJ 사용**: `assertThat()`, `assertAll()` 활용 +5. **테스트 격리**: 각 테스트는 독립적으로 실행 가능해야 함 +6. **실제 동작 검증**: Mock 최소화, 실제 DB/API 호출 우선 + +--- + +## 개발 규칙 + +### 핵심 원칙: 속도보다 통제 +- AI는 코드의 의도, 변경영향, 책임에 대한 컨텍스트를 지속 유지할 수 없음 +- 개발자가 의도를 정의하고, AI가 승인된 범위 내에서만 구현 + +### Claude 역할 제한 +| 허용 | 금지 | +|------|------| +| 제안, 대안 제시 | 임의 설계 결정 | +| 승인된 범위 내 구현 | 요구사항 확장/범위 초과 | +| 테스트 작성 | 테스트 삭제, @Disabled, assertion 약화 | +| 승인 후 리팩토링 | 동작 변경, 기능 추가 | + +### 진행 Workflow - 증강 코딩 +- **대원칙**: 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행 +- **중간 결과 보고**: AI가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입 +- **설계 주도권 유지**: AI가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행 + +--- + +### TDD Workflow (Red → Green → Refactor) + +> 테스트는 구현 검증이 아니라 **설계 단위 검증**이다. + +모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) + +#### 🔴 Red Phase: 실패하는 테스트 먼저 작성 +- 요구사항을 테스트 케이스로 정의 +- **반드시 실패 확인** (컴파일 에러가 아닌 Assertion 실패) +- 프로덕션 코드 없으면 최소 껍데기만 생성 +- **이 단계에서 로직 구현 금지** + +#### 🟢 Green Phase: 테스트를 통과하는 최소 코드 작성 +- Red의 테스트가 **딱** 통과하는 코드만 작성 +- **오버엔지니어링 금지**: 미래 요구사항 예측 구현 금지 +- 기존 테스트도 모두 통과해야 함 + +#### 🔵 Refactor Phase: 코드 품질 개선 (동작 변경 없이) +- 중복 제거, 네이밍 개선, unused import 제거 +- **모든 테스트 케이스가 통과해야 함** +- **새 기능 추가 금지** (새 기능은 다시 Red부터) + +--- + +## 주의사항 + +### 1. Never Do (절대 금지) +- ❌ **실제 동작하지 않는 코드 작성 금지** + - Mock 데이터로만 동작하는 구현 금지 + - 실제 DB, API 호출 없이 가짜 응답 반환 금지 +- ❌ **null-safety 위반 금지** + - Java의 경우 `Optional` 활용 필수 + - Value Object는 생성자에서 null 검증 + - JPA Converter에서 null 체크 +- ❌ **println 코드 남기지 말 것** + - 디버깅용 `System.out.println()` 제거 + - 로깅이 필요하면 `@Slf4j` 사용 +- ❌ **테스트 임의 삭제/수정 금지** + - 실패하는 테스트를 삭제하지 말 것 + - `@Disabled`, `@Ignore` 사용 금지 + - 테스트를 통과시키기 위해 assertion 약화 금지 + +### 2. Recommendation (권장사항) +- ✅ **실제 API를 호출해 확인하는 E2E 테스트 코드 작성** + - TestRestTemplate 사용 + - 실제 HTTP 요청/응답 검증 +- ✅ **재사용 가능한 객체 설계** + - Value Object 활용 + - 불변 객체 우선 + - 정적 팩토리 메서드 제공 +- ✅ **성능 최적화에 대한 대안 및 제안** + - N+1 문제 해결 (Fetch Join, Batch Size) + - 인덱스 설계 + - 캐싱 전략 (Redis) +- ✅ **개발 완료된 API의 경우, `.http/**.http`에 분류해 작성** + - IntelliJ HTTP Client 파일 작성 + - 환경별 변수 관리 (`http-client.env.json`) + +### 3. Priority (우선순위) +1. **실제 동작하는 해결책만 고려** + - 이론적 해결책보다 실제 동작하는 코드 우선 +2. **null-safety, thread-safety 고려** + - Optional 활용 + - 불변 객체 사용 + - 동시성 이슈 고려 +3. **테스트 가능한 구조로 설계** + - 의존성 주입 + - 인터페이스 분리 + - Spy 패턴 활용 +4. **기존 코드 패턴 분석 후 일관성 유지** + - 네이밍 규칙 준수 + - 레이어 구조 준수 + - 기존 코드 스타일 따르기 + +--- + +## 도메인 분석 (User) - Week 1 범위 + +### 필드 검증 +- **loginId**: 영문+숫자만 허용 +- **email**: 이메일 형식 검증 +- **birthDate**: yyyy-MM-dd 형식 +- **name**: 1~50자 (조회 시 마지막 글자 `*` 마스킹) + +### 비즈니스 규칙 +- **비밀번호**: 8~16자, 영문 대소문자+숫자+특수문자 모두 포함 +- **비밀번호 제약**: 생년월일 포함 불가 +- **중복 가입 방지**: loginId 중복 체크 +- **암호화**: BCrypt + +### API 엔드포인트 (Week 1) +| API | 설명 | 인증 | +|-----|------|------| +| `POST /api/v1/users/register` | 회원가입 | 없음 | +| `GET /api/v1/users/me` | 내 정보 조회 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| `PATCH /api/v1/users/me/password` | 비밀번호 수정 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | + +--- + +## 환경 설정 + +### 프로파일 +- **local**: 로컬 개발 환경 +- **test**: 테스트 환경 (TestContainers) +- **dev**: 개발 서버 +- **qa**: QA 서버 +- **prd**: 운영 서버 + +### 인프라 실행 +```bash +# MySQL, Redis, Kafka 실행 +docker-compose -f ./docker/infra-compose.yml up + +# Prometheus, Grafana 실행 +docker-compose -f ./docker/monitoring-compose.yml up +``` + +### Swagger UI +- **URL**: http://localhost:8080/swagger-ui.html +- **활성화**: local, test 프로파일에서만 + +### Grafana +- **URL**: http://localhost:3000 +- **계정**: admin / admin + +--- + +## 참고사항 + +### Lombok 사용 +- `@Getter`: 필드별 적용 (클래스 레벨 지양) +- `@RequiredArgsConstructor`: 생성자 주입 +- `@Slf4j`: 로깅 + +### QueryDSL +- Q-Type 자동 생성 +- `build/generated/sources/annotationProcessor` 경로 + +### TestFixtures +- `modules:jpa`: `DatabaseCleanUp`, `MySqlTestContainersConfig` +- `modules:redis`: `RedisCleanUp`, `RedisTestContainersConfig` + +### Jacoco +- 테스트 커버리지 측정 +- XML 리포트 생성 (CI/CD 연동) + +--- + +## 추가 리소스 + +### 프로젝트 파일 +- `README.md`: 프로젝트 개요 및 시작 가이드 +- `.codeguide/loopers-1-week.md`: 1주차 구현 퀘스트 +- `http/commerce-api/example-v1.http`: API 테스트 예시 + +### 설정 파일 +- `gradle.properties`: 버전 관리 +- `build.gradle.kts`: 빌드 설정 +- `settings.gradle.kts`: 멀티모듈 설정 +- `application.yml`: 애플리케이션 설정 +- `jpa.yml`, `redis.yml`, `kafka.yml`: 모듈별 설정 + +--- + +## 버전 관리 + +### Git 전략 +- 버전: Git Hash 기반 (`getGitHash()`) +- 브랜치: feature, develop, main + +### 빌드 +```bash +# 전체 빌드 +./gradlew build + +# 특정 모듈 빌드 +./gradlew :apps:commerce-api:build + +# 테스트 실행 +./gradlew test + +# 커버리지 리포트 +./gradlew jacocoTestReport +``` + +--- + +이 문서는 프로젝트의 코드베이스를 분석하여 작성되었으며, 실제 구현된 패턴과 규칙을 반영합니다. diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..cada3c06c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +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.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductRepository productRepository; + + public LikeInfo like(Long userId, Long productId) { + validateProductExists(productId); + Like like = likeService.like(userId, productId); + return LikeInfo.from(like); + } + + public void unlike(Long userId, Long productId) { + validateProductExists(productId); + likeService.unlike(userId, productId); + } + + public List getLikesByUserId(Long userId) { + return likeService.findByUserId(userId).stream() + .map(LikeInfo::from) + .toList(); + } + + private void validateProductExists(Long productId) { + if (!productRepository.existsById(productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..54d31ddaa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; + +public record LikeInfo(Long likeId, Long userId, Long productId) { + + public static LikeInfo from(Like like) { + return new LikeInfo( + like.getId(), + like.getUserId(), + like.getProductId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..4787e3bac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,57 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductRepository productRepository; + private final UserRepository userRepository; + + @Transactional + public OrderInfo createOrder(Long userId, List itemRequests) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다")); + + List orderItems = new ArrayList<>(); + for (OrderItemRequest req : itemRequests) { + Product product = productRepository.findById(req.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다")); + + product.decreaseStock(req.quantity()); + productRepository.save(product); + + OrderItem orderItem = new OrderItem( + product.getId(), + product.getName(), + product.getPrice(), + req.quantity() + ); + orderItems.add(orderItem); + } + + Order order = new Order(userId, orderItems); + user.deductPoint(order.getTotalPrice()); + userRepository.save(user); + + return OrderInfo.from(orderService.createOrder(order)); + } + + public record OrderItemRequest(Long productId, Integer quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..a204df783 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.util.List; + +public record OrderInfo(Long orderId, Long userId, Long totalPrice, List items) { + + public record OrderItemInfo(Long productId, String productName, Long productPrice, Integer quantity) { + + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getProductName(), + item.getProductPrice(), + item.getQuantity() + ); + } + } + + public static OrderInfo from(Order order) { + List items = order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalPrice(), + items + ); + } +} 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..e10cc6c4b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,24 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brands") +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true, length = 50) + private BrandName name; + + protected Brand() {} + + public Brand(String name) { + this.name = new BrandName(name); + } + + public BrandName getName() { + return name; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java new file mode 100644 index 000000000..cfdd6ade9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java @@ -0,0 +1,16 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record BrandName(String value) { + public BrandName { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어 있을 수 없습니다"); + } + value = value.trim(); + if (value.length() > 50) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 1~50자여야 합니다"); + } + } +} 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..d3d180dc4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + List findAll(); + boolean existsByName(BrandName name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..48a241094 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,22 @@ +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; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + public Brand register(String name) { + BrandName brandName = new BrandName(name); + if (brandRepository.existsByName(brandName)) { + throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드 이름입니다"); + } + Brand brand = new Brand(name); + return brandRepository.save(brand); + } +} 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..387df406e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,40 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "likes") +public class Like extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected Like() {} + + public Like(Long userId, Long productId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다"); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다"); + } + this.userId = userId; + this.productId = productId; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..21fab1ac8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findByUserId(Long userId); + + void delete(Like like); + + long countByProductId(Long productId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..d48966a56 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,38 @@ +package com.loopers.domain.like; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + @Transactional + public Like like(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId) + .orElseGet(() -> { + Like like = new Like(userId, productId); + return likeRepository.save(like); + }); + } + + @Transactional + public void unlike(Long userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(likeRepository::delete); + } + + @Transactional(readOnly = true) + public List findByUserId(Long userId) { + return likeRepository.findByUserId(userId); + } + + @Transactional(readOnly = true) + public long countByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } +} 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..3c6ccb7e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,59 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "total_price", nullable = false) + private Long totalPrice; + + @Transient + private List orderItems = new ArrayList<>(); + + protected Order() {} + + public Order(Long userId, List orderItems) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다"); + } + if (orderItems == null || orderItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다"); + } + this.userId = userId; + this.orderItems = new ArrayList<>(orderItems); + this.totalPrice = calculateTotalPrice(); + } + + private long calculateTotalPrice() { + return orderItems.stream() + .mapToLong(OrderItem::totalPrice) + .sum(); + } + + public Long getUserId() { + return userId; + } + + public Long getTotalPrice() { + return totalPrice; + } + + public List getOrderItems() { + return Collections.unmodifiableList(orderItems); + } +} 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..224c0bc3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,77 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_items") +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false, length = 100) + private String productName; + + @Column(name = "product_price", nullable = false) + private Long productPrice; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + protected OrderItem() {} + + public OrderItem(Long productId, String productName, Long productPrice, Integer quantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다"); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다"); + } + if (productPrice == null || productPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0 이상이어야 합니다"); + } + if (quantity == null || quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1 이상이어야 합니다"); + } + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.quantity = quantity; + } + + public long totalPrice() { + return productPrice * quantity; + } + + void assignOrderId(Long orderId) { + this.orderId = orderId; + } + + public Long getOrderId() { + return orderId; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public Long getProductPrice() { + return productPrice; + } + + public Integer getQuantity() { + return quantity; + } +} 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..787d88ebc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +public interface OrderRepository { + + Order save(Order order); + + OrderItem saveItem(OrderItem orderItem); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..e029392e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,20 @@ +package com.loopers.domain.order; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + + public Order createOrder(Order order) { + Order savedOrder = orderRepository.save(order); + for (OrderItem item : order.getOrderItems()) { + item.assignOrderId(savedOrder.getId()); + orderRepository.saveItem(item); + } + return savedOrder; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Price.java new file mode 100644 index 000000000..d14401663 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Price.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record Price(Long value) { +} 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..f3ce4205e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,69 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "products") +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "stock_quantity", nullable = false) + private StockQuantity stockQuantity; + + protected Product() {} + + public Product(Long brandId, String name, Long price, String description, Integer stockQuantity) { + this.brandId = brandId; + this.name = name; + this.price = price; + this.description = description; + this.stockQuantity = new StockQuantity(stockQuantity); + } + + public void decreaseStock(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다"); + } + int current = this.stockQuantity.value(); + if (current < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); + } + this.stockQuantity = new StockQuantity(current - quantity); + } + + public void increaseStock(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "복원 수량은 1 이상이어야 합니다"); + } + int current = this.stockQuantity.value(); + this.stockQuantity = new StockQuantity(current + quantity); + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public StockQuantity getStockQuantity() { + return stockQuantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductName.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductName.java new file mode 100644 index 000000000..02df083b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductName.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record ProductName(String value) { +} 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..5818e1fff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.product; + +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + boolean existsById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/StockQuantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockQuantity.java new file mode 100644 index 000000000..6d6628e67 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockQuantity.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record StockQuantity(Integer value) { +} 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..de252d4a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,50 @@ +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; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "point", nullable = false) + private Long point; + + protected User() {} + + public User(String name, Long point) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 이름은 필수입니다"); + } + if (point == null || point < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다"); + } + this.name = name; + this.point = point; + } + + public void deductPoint(long amount) { + if (amount < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 포인트는 1 이상이어야 합니다"); + } + if (this.point < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다"); + } + this.point -= amount; + } + + public String getName() { + return name; + } + + public Long getPoint() { + return point; + } +} 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..3dcb06e0e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + Optional findById(Long id); + + User save(User user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..fbbcf992b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandName; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + boolean existsByName(BrandName name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..7731ac5d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public List findAll() { + return brandJpaRepository.findAll(); + } + + @Override + public boolean existsByName(BrandName name) { + return brandJpaRepository.existsByName(name); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java new file mode 100644 index 000000000..3638bdb77 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.brand.BrandName; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class BrandNameConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(BrandName attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public BrandName convertToEntityAttribute(String dbData) { + return dbData == null ? null : new BrandName(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java new file mode 100644 index 000000000..7f9a5c630 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.StockQuantity; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class StockQuantityConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(StockQuantity attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public StockQuantity convertToEntityAttribute(Integer dbData) { + return dbData == null ? null : new StockQuantity(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..d4aebda55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findByUserId(Long userId); + + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..9759532c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findByUserId(Long userId) { + return likeJpaRepository.findByUserId(userId); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..9d82b0259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..f2ee62050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..5f5033991 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public OrderItem saveItem(OrderItem orderItem) { + return orderItemJpaRepository.save(orderItem); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..0375b7ca7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..7d51d5912 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public boolean existsById(Long id) { + return productJpaRepository.existsById(id); + } +} 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..278fa657a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { +} 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..b9f408cdc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,25 @@ +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 Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..f721b03f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse like( + @PathVariable Long productId, + @RequestHeader("X-Loopers-UserId") Long userId + ) { + LikeInfo info = likeFacade.like(userId, productId); + return ApiResponse.success(LikeV1Dto.LikeResponse.from(info)); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse unlike( + @PathVariable Long productId, + @RequestHeader("X-Loopers-UserId") Long userId + ) { + likeFacade.unlike(userId, productId); + return ApiResponse.success(); + } + + @GetMapping("/api/v1/users/{userId}/likes") + public ApiResponse> getLikesByUser( + @PathVariable Long userId + ) { + List responses = likeFacade.getLikesByUserId(userId).stream() + .map(LikeV1Dto.LikeResponse::from) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..889bd42df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; + +public class LikeV1Dto { + + public record LikeResponse(Long likeId, Long userId, Long productId) { + + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.likeId(), + info.userId(), + info.productId() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..f2adc3227 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-Loopers-UserId") Long userId, + @RequestBody @Valid OrderV1Dto.CreateRequest request + ) { + List itemRequests = request.items().stream() + .map(OrderV1Dto.OrderItemRequest::toFacadeRequest) + .toList(); + OrderInfo info = orderFacade.createOrder(userId, itemRequests); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..caecc4d3a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest( + @NotEmpty List<@Valid OrderItemRequest> items + ) {} + + public record OrderItemRequest( + @NotNull Long productId, + @NotNull @Min(1) Integer quantity + ) { + public OrderFacade.OrderItemRequest toFacadeRequest() { + return new OrderFacade.OrderItemRequest(productId, quantity); + } + } + + public record OrderResponse(Long orderId, Long userId, Long totalPrice, List items) { + + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse(info.orderId(), info.userId(), info.totalPrice(), items); + } + } + + public record OrderItemResponse(Long productId, String productName, Long productPrice, Integer quantity) { + + public static OrderItemResponse from(OrderInfo.OrderItemInfo info) { + return new OrderItemResponse( + info.productId(), + info.productName(), + info.productPrice(), + info.quantity() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/application/order/FakeProductRepository.java new file mode 100644 index 000000000..6facb5285 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/FakeProductRepository.java @@ -0,0 +1,39 @@ +package com.loopers.application.order; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +class FakeProductRepository implements ProductRepository { + + private final Map store = new HashMap<>(); + private long sequence = 1L; + + public Product addProduct(Long brandId, String name, Long price, String description, Integer stock) { + Product product = new Product(brandId, name, price, description, stock); + store.put(sequence++, product); + return product; + } + + public void addProductWithId(Long id, Product product) { + store.put(id, product); + } + + @Override + public Product save(Product product) { + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public boolean existsById(Long id) { + return store.containsKey(id); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/FakeUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/application/order/FakeUserRepository.java new file mode 100644 index 000000000..0fecaa731 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/FakeUserRepository.java @@ -0,0 +1,27 @@ +package com.loopers.application.order; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +class FakeUserRepository implements UserRepository { + + private final Map store = new HashMap<>(); + + public void addUserWithId(Long id, User user) { + store.put(id, user); + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public User save(User user) { + return user; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeUnitTest.java new file mode 100644 index 000000000..d503cfa88 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeUnitTest.java @@ -0,0 +1,120 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderFacadeUnitTest { + + private OrderFacade orderFacade; + private FakeProductRepository fakeProductRepository; + private FakeUserRepository fakeUserRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeUserRepository = new FakeUserRepository(); + // OrderService에는 FakeOrderRepository를 주입하지만, Facade 테스트이므로 + // OrderService 내부 저장 로직보다 Facade의 조율 로직을 검증한다. + com.loopers.domain.order.OrderRepository fakeOrderRepository = + new com.loopers.domain.order.FakeOrderRepository(); + OrderService orderService = new OrderService(fakeOrderRepository); + orderFacade = new OrderFacade(orderService, fakeProductRepository, fakeUserRepository); + } + + @DisplayName("주문을 생성할 때,") + @Nested + class CreateOrder { + + @DisplayName("정상 요청이면, 주문이 생성되고 재고/포인트가 차감된다.") + @Test + void createsOrder_whenValidRequest() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 10); + fakeProductRepository.addProductWithId(1L, product); + + User user = new User("테스트유저", 100000L); + fakeUserRepository.addUserWithId(1L, user); + + List items = List.of( + new OrderFacade.OrderItemRequest(1L, 2) + ); + + // act + OrderInfo result = orderFacade.createOrder(1L, items); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.userId()).isEqualTo(1L), + () -> assertThat(result.totalPrice()).isEqualTo(20000L), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("상품A"), + () -> assertThat(result.items().get(0).quantity()).isEqualTo(2) + ); + + // 재고 차감 확인 + assertThat(product.getStockQuantity().value()).isEqualTo(8); + // 포인트 차감 확인 + assertThat(user.getPoint()).isEqualTo(80000L); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenStockInsufficient() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 1); + fakeProductRepository.addProductWithId(1L, product); + + User user = new User("테스트유저", 100000L); + fakeUserRepository.addUserWithId(1L, user); + + List items = List.of( + new OrderFacade.OrderItemRequest(1L, 5) + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.createOrder(1L, items)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("재고"); + } + + @DisplayName("포인트가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenPointInsufficient() { + // arrange + Product product = new Product(1L, "상품A", 50000L, "설명", 10); + fakeProductRepository.addProductWithId(1L, product); + + User user = new User("테스트유저", 10000L); + fakeUserRepository.addUserWithId(1L, user); + + List items = List.of( + new OrderFacade.OrderItemRequest(1L, 2) + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.createOrder(1L, items)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("포인트"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameUnitTest.java new file mode 100644 index 000000000..ce8a0f42a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameUnitTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandNameUnitTest { + + @DisplayName("브랜드 이름을 생성할 때,") + @Nested + class Create { + + @DisplayName("1~50자 정상 입력이면, 브랜드 이름이 생성된다.") + @Test + void createsBrandName_whenValueIsValid() { + // arrange & act + BrandName brandName = new BrandName("나이키"); + + // assert + assertThat(brandName.value()).isEqualTo("나이키"); + } + + @DisplayName("null 또는 공백 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenValueIsNullOrBlank() { + // act & assert + CoreException nullResult = assertThrows(CoreException.class, () -> + new BrandName(null)); + assertThat(nullResult.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + CoreException blankResult = assertThrows(CoreException.class, () -> + new BrandName(" ")); + assertThat(blankResult.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("51자 이상이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenValueExceeds50Characters() { + // arrange + String longName = "a".repeat(51); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new BrandName(longName)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceUnitTest.java new file mode 100644 index 000000000..1c530a904 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceUnitTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandServiceUnitTest { + + private BrandService brandService; + private FakeBrandRepository fakeBrandRepository; + + @BeforeEach + void setUp() { + fakeBrandRepository = new FakeBrandRepository(); + brandService = new BrandService(fakeBrandRepository); + } + + @DisplayName("브랜드를 등록할 때,") + @Nested + class Register { + + @DisplayName("정상 이름이면, 브랜드가 저장된다.") + @Test + void registersBrand_whenNameIsValid() { + // arrange + String name = "나이키"; + + // act + Brand result = brandService.register(name); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getName().value()).isEqualTo("나이키"); + } + + @DisplayName("동일한 이름의 브랜드가 이미 존재하면, CONFLICT 예외가 발생한다.") + @Test + void throwsException_whenNameAlreadyExists() { + // arrange + brandService.register("나이키"); + + // act + CoreException result = assertThrows(CoreException.class, () -> + brandService.register("나이키")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandUnitTest.java new file mode 100644 index 000000000..65a8cbc9a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandUnitTest.java @@ -0,0 +1,39 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandUnitTest { + + @DisplayName("브랜드를 생성할 때,") + @Nested + class Create { + + @DisplayName("정상 BrandName이면, Brand가 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + // arrange & act + Brand brand = new Brand("나이키"); + + // assert + assertThat(brand.getName().value()).isEqualTo("나이키"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java new file mode 100644 index 000000000..3e931eaaa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java @@ -0,0 +1,35 @@ +package com.loopers.domain.brand; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +class FakeBrandRepository implements BrandRepository { + + private final Map store = new HashMap<>(); + private long sequence = 1L; + + @Override + public Brand save(Brand brand) { + store.put(sequence++, brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public boolean existsByName(BrandName name) { + return store.values().stream() + .anyMatch(brand -> brand.getName().equals(name)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeLikeRepository.java new file mode 100644 index 000000000..7af8e118c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeLikeRepository.java @@ -0,0 +1,42 @@ +package com.loopers.domain.like; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +class FakeLikeRepository implements LikeRepository { + + private final List store = new ArrayList<>(); + + @Override + public Like save(Like like) { + store.add(like); + return like; + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.stream() + .filter(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public List findByUserId(Long userId) { + return store.stream() + .filter(l -> l.getUserId().equals(userId)) + .toList(); + } + + @Override + public void delete(Like like) { + store.remove(like); + } + + @Override + public long countByProductId(Long productId) { + return store.stream() + .filter(l -> l.getProductId().equals(productId)) + .count(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..b59f03b4c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.like; + +import com.loopers.infrastructure.like.LikeJpaRepository; +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private LikeJpaRepository likeJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요를 등록할 때,") + @Nested + class LikeRegister { + + @DisplayName("처음 좋아요하면, DB에 저장된다.") + @Test + void savesLike_whenFirstTime() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like result = likeService.like(userId, productId); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getUserId()).isEqualTo(1L), + () -> assertThat(result.getProductId()).isEqualTo(100L) + ); + + Optional saved = likeJpaRepository.findByUserIdAndProductId(userId, productId); + assertThat(saved).isPresent(); + } + + @DisplayName("이미 좋아요한 상태에서 다시 등록하면, 예외 없이 기존 Like를 반환한다.") + @Test + void returnsExistingLike_whenAlreadyLiked() { + // arrange + Long userId = 1L; + Long productId = 100L; + Like first = likeService.like(userId, productId); + + // act + Like second = likeService.like(userId, productId); + + // assert + assertThat(second.getId()).isEqualTo(first.getId()); + assertThat(likeJpaRepository.countByProductId(productId)).isEqualTo(1); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class Unlike { + + @DisplayName("좋아요가 존재하면, DB에서 삭제된다.") + @Test + void deletesLike_whenExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + likeService.like(userId, productId); + + // act + likeService.unlike(userId, productId); + + // assert + Optional found = likeJpaRepository.findByUserIdAndProductId(userId, productId); + assertThat(found).isEmpty(); + } + } + + @DisplayName("상품별 좋아요 수를 조회할 때,") + @Nested + class CountByProduct { + + @DisplayName("여러 사용자가 좋아요하면, 정확한 수를 반환한다.") + @Test + void returnsCorrectCount_whenMultipleUsersLiked() { + // arrange + Long productId = 100L; + likeService.like(1L, productId); + likeService.like(2L, productId); + likeService.like(3L, productId); + + // act + long count = likeService.countByProductId(productId); + + // assert + assertThat(count).isEqualTo(3); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceUnitTest.java new file mode 100644 index 000000000..052ead9ea --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceUnitTest.java @@ -0,0 +1,124 @@ +package com.loopers.domain.like; + +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; + +class LikeServiceUnitTest { + + private LikeService likeService; + private FakeLikeRepository fakeLikeRepository; + + @BeforeEach + void setUp() { + fakeLikeRepository = new FakeLikeRepository(); + likeService = new LikeService(fakeLikeRepository); + } + + @DisplayName("좋아요를 등록할 때,") + @Nested + class LikeRegister { + + @DisplayName("처음 좋아요하면, Like가 저장된다.") + @Test + void savesLike_whenFirstTime() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like result = likeService.like(userId, productId); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getProductId()).isEqualTo(100L); + } + + @DisplayName("이미 좋아요한 상태에서 다시 등록하면, 예외 없이 기존 Like를 반환한다.") + @Test + void returnsExistingLike_whenAlreadyLiked() { + // arrange + Long userId = 1L; + Long productId = 100L; + likeService.like(userId, productId); + + // act + Like result = likeService.like(userId, productId); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getProductId()).isEqualTo(100L); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class Unlike { + + @DisplayName("좋아요가 존재하면, 삭제된다.") + @Test + void deletesLike_whenExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + likeService.like(userId, productId); + + // act + likeService.unlike(userId, productId); + + // assert + long count = fakeLikeRepository.countByProductId(productId); + assertThat(count).isZero(); + } + + @DisplayName("좋아요가 없는 상태에서 취소하면, 예외 없이 성공한다.") + @Test + void doesNothing_whenNotLiked() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act & assert (예외가 발생하지 않으면 성공) + likeService.unlike(userId, productId); + + long count = fakeLikeRepository.countByProductId(productId); + assertThat(count).isZero(); + } + } + + @DisplayName("상품별 좋아요 수를 조회할 때,") + @Nested + class CountByProduct { + + @DisplayName("좋아요가 여러 개 있으면, 정확한 수를 반환한다.") + @Test + void returnsCorrectCount_whenMultipleLikesExist() { + // arrange + Long productId = 100L; + likeService.like(1L, productId); + likeService.like(2L, productId); + likeService.like(3L, productId); + + // act + long count = likeService.countByProductId(productId); + + // assert + assertThat(count).isEqualTo(3); + } + + @DisplayName("좋아요가 없으면, 0을 반환한다.") + @Test + void returnsZero_whenNoLikesExist() { + // act + long count = likeService.countByProductId(999L); + + // assert + assertThat(count).isZero(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeUnitTest.java new file mode 100644 index 000000000..e4e22c9e4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeUnitTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeUnitTest { + + @DisplayName("좋아요를 생성할 때,") + @Nested + class Create { + + @DisplayName("userId와 productId가 정상이면, Like가 생성된다.") + @Test + void createsLike_whenUserIdAndProductIdAreValid() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like like = new Like(userId, productId); + + // assert + assertThat(like.getUserId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(100L); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenUserIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Like(null, 100L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenProductIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Like(1L, null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java new file mode 100644 index 000000000..204f1e94f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java @@ -0,0 +1,32 @@ +package com.loopers.domain.order; + +import java.util.ArrayList; +import java.util.List; + +public class FakeOrderRepository implements OrderRepository { + + private final List orders = new ArrayList<>(); + private final List items = new ArrayList<>(); + private long orderSequence = 1L; + private long itemSequence = 1L; + + @Override + public Order save(Order order) { + orders.add(order); + return order; + } + + @Override + public OrderItem saveItem(OrderItem orderItem) { + items.add(orderItem); + return orderItem; + } + + public List getOrders() { + return orders; + } + + public List getItems() { + return items; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductUnitTest.java new file mode 100644 index 000000000..63656f9a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductUnitTest.java @@ -0,0 +1,92 @@ +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 ProductUnitTest { + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + + @DisplayName("요청 수량이 재고보다 적으면, 재고가 차감된다.") + @Test + void decreasesStock_whenQuantityIsLessThanStock() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 10); + + // act + product.decreaseStock(3); + + // assert + assertThat(product.getStockQuantity().value()).isEqualTo(7); + } + + @DisplayName("요청 수량이 재고와 같으면, 재고가 0이 된다.") + @Test + void decreasesStockToZero_whenQuantityEqualsStock() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 5); + + // act + product.decreaseStock(5); + + // assert + assertThat(product.getStockQuantity().value()).isEqualTo(0); + } + + @DisplayName("요청 수량이 재고보다 많으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenQuantityExceedsStock() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 3); + + // act + CoreException result = assertThrows(CoreException.class, () -> + product.decreaseStock(5)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("요청 수량이 1 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenQuantityIsLessThanOne() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 10); + + // act & assert + CoreException zeroResult = assertThrows(CoreException.class, () -> + product.decreaseStock(0)); + assertThat(zeroResult.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + CoreException negativeResult = assertThrows(CoreException.class, () -> + product.decreaseStock(-1)); + assertThat(negativeResult.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 복원할 때,") + @Nested + class IncreaseStock { + + @DisplayName("요청 수량이 1 이상이면, 재고가 증가한다.") + @Test + void increasesStock_whenQuantityIsValid() { + // arrange + Product product = new Product(1L, "상품A", 10000L, "설명", 5); + + // act + product.increaseStock(3); + + // assert + assertThat(product.getStockQuantity().value()).isEqualTo(8); + } + } +} diff --git a/apps/commerce-api/src/test/resources/docker-java.properties b/apps/commerce-api/src/test/resources/docker-java.properties new file mode 100644 index 000000000..d06ebb926 --- /dev/null +++ b/apps/commerce-api/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..4a634f214 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,8 @@ subprojects { } } + extra["testcontainers.version"] = "1.21.0" + dependencies { // Web runtimeOnly("org.springframework.boot:spring-boot-starter-validation") @@ -65,8 +67,8 @@ subprojects { testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}") // Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") - testImplementation("org.testcontainers:testcontainers") - testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:testcontainers:1.21.0") + testImplementation("org.testcontainers:junit-jupiter:1.21.0") } tasks.withType(Jar::class) { enabled = true } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..92d846d80 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,234 @@ +# Commerce API - 아키텍처 문서 + +## 프로젝트 개요 + +Spring Boot 기반의 멀티모듈 커머스 플랫폼으로, Layered Architecture + DIP를 적용한 4계층 구조를 채택했다. Domain 계층이 어떤 외부 계층에도 의존하지 않도록 설계했으며, 비즈니스 규칙은 Entity 내부에, 유스케이스 조합은 Application 계층의 Facade에서 담당한다. 현재 Brand, Product, User, Like, Order 5개 도메인이 구현되어 있다. + +--- + +## 4계층 구조 + +``` +Interfaces (API) → Application (Facade) → Domain (Entity/Service/Repository IF) + ↑ + Infrastructure (Repository Impl) +``` + +| 계층 | 패키지 | 역할 | +|---|---|---| +| **Interfaces** | `interfaces.api` | HTTP 요청/응답 처리, DTO 변환 | +| **Application** | `application` | 여러 도메인 서비스 조합, 유스케이스 조율 | +| **Domain** | `domain` | 핵심 비즈니스 규칙, Entity, VO, Repository 인터페이스 | +| **Infrastructure** | `infrastructure` | Repository 구현체, JPA 연동 | +| **Support** | `support.error` | 공통 예외 처리 (모든 계층에서 참조 가능) | + +--- + +## 레이어별 책임 + +### Interfaces Layer + +Controller는 요청을 받아 Facade에 위임하고, 응답 DTO로 변환하여 반환한다. 비즈니스 로직을 포함하지 않는다. + +- `ApiResponse`: 공통 응답 래퍼 (SUCCESS/FAIL + data) +- `ApiControllerAdvice`: 전역 예외 처리 (CoreException, Validation, 404 등) +- `{Domain}V1Controller`: REST 엔드포인트 (Facade 호출 → DTO 변환) +- `{Domain}V1Dto`: 요청/응답 DTO (record, Jakarta Validation 적용) + +### Application Layer + +여러 도메인 서비스를 조합하여 하나의 유스케이스를 완성한다. 도메인 로직을 직접 구현하지 않고 위임만 한다. + +- `{Domain}Facade`: 유스케이스 조율자 (예: OrderFacade는 User 조회 → 재고 차감 → 포인트 차감 → 주문 생성) +- `{Domain}Info`: Application DTO (Entity → Info 변환, `from()` 팩토리 메서드) + +### Domain Layer + +핵심 비즈니스 규칙을 가진다. 다른 계층에 의존하지 않는다. + +- `{Domain}` (Entity): JPA Entity, BaseEntity 상속, 도메인 로직 포함 (예: `Product.decreaseStock()`, `User.deductPoint()`) +- `{ValueObject}` (record): 불변 값 객체, Compact Constructor에서 검증 (예: `BrandName`) +- `{Domain}Service`: 상태 없는 도메인 서비스, Repository 호출 + 간단한 비즈니스 규칙 조율 +- `{Domain}Repository`: 인터페이스만 정의 (구현은 Infrastructure) + +### Infrastructure Layer + +Domain Repository 인터페이스의 구현체를 제공한다. Spring Data JPA에 위임한다. + +- `{Domain}JpaRepository`: Spring Data JPA 인터페이스 +- `{Domain}RepositoryImpl`: Domain Repository 구현체 (`@Component`, JpaRepository에 위임) + +--- + +## 레이어별 주요 클래스 목록 + +### Domain + +| 도메인 | Entity | Value Object | Service | Repository IF | +|---|---|---|---|---| +| Brand | `Brand` | `BrandName` | `BrandService` | `BrandRepository` | +| Product | `Product` | `StockQuantity` | - | `ProductRepository` | +| User | `User` | - | - | `UserRepository` | +| Like | `Like` | - | `LikeService` | `LikeRepository` | +| Order | `Order`, `OrderItem` | - | `OrderService` | `OrderRepository` | + +### Application + +| 도메인 | Facade | Info DTO | +|---|---|---| +| Like | `LikeFacade` | `LikeInfo` | +| Order | `OrderFacade` | `OrderInfo` (+ `OrderItemInfo`) | +| Example | `ExampleFacade` | `ExampleInfo` | + +### Interfaces + +| 도메인 | Controller | DTO | API | +|---|---|---|---| +| Like | `LikeV1Controller` | `LikeV1Dto` | POST/DELETE /products/{id}/likes, GET /users/{id}/likes | +| Order | `OrderV1Controller` | `OrderV1Dto` | POST /api/v1/orders | +| Example | `ExampleV1Controller` | `ExampleV1Dto` | GET /api/v1/examples/{id} | + +### Infrastructure + +| 도메인 | JpaRepository | RepositoryImpl | +|---|---|---| +| Product | `ProductJpaRepository` | `ProductRepositoryImpl` | +| User | `UserJpaRepository` | `UserRepositoryImpl` | +| Like | `LikeJpaRepository` | `LikeRepositoryImpl` | +| Order | `OrderJpaRepository`, `OrderItemJpaRepository` | `OrderRepositoryImpl` | +| Example | `ExampleJpaRepository` | `ExampleRepositoryImpl` | + +--- + +## 의존 방향 + +### 원칙 + +``` +Interfaces → Application → Domain ← Infrastructure +``` + +- Domain은 어떤 외부 계층에도 의존하지 않는다. +- Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP). +- Application은 Domain의 Service와 Repository 인터페이스만 참조한다. +- Interfaces는 Application의 Facade와 Info DTO만 참조한다. + +### DIP 검증 결과 + +전체 47개 파일의 import를 검증한 결과: + +| 검증 항목 | 결과 | +|---|---| +| Domain → Infrastructure | **참조 없음** | +| Domain → Application | **참조 없음** | +| Domain → Interfaces | **참조 없음** | +| Application → Infrastructure | **참조 없음** | +| Infrastructure → Domain | **정상** (구현체가 인터페이스 구현) | + +### Mermaid 다이어그램 + +```mermaid +graph TB + subgraph Interfaces + Controller["Controllers"] + Dto["V1Dto (Request/Response)"] + Advice["ApiControllerAdvice"] + end + + subgraph Application + Facade["Facades"] + Info["Info DTOs"] + end + + subgraph Domain + Entity["Entities
(Brand, Product, User, Like, Order)"] + Service["Services
(Brand, Like, Order, Example)"] + RepoIF["Repository Interfaces"] + VO["Value Objects
(BrandName, StockQuantity)"] + end + + subgraph Infrastructure + RepoImpl["RepositoryImpl"] + JpaRepo["JpaRepository"] + end + + Controller -->|호출| Facade + Dto -->|변환| Info + Facade -->|조율| Service + Facade -->|조회| RepoIF + Service -->|위임| RepoIF + RepoImpl -.->|구현| RepoIF + RepoImpl -->|위임| JpaRepo + Entity -->|포함| VO +``` + +--- + +## 설계 의도 및 선택한 원칙 + +### 1. 도메인 로직은 Entity 내부에 위치 + +```java +// Product.java — 재고 차감은 Entity가 직접 수행 +public void decreaseStock(int quantity) { + if (this.stockQuantity.value() < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); + } + this.stockQuantity = new StockQuantity(current - quantity); +} +``` + +Service에 비즈니스 규칙을 두지 않고, Entity가 자신의 상태 변경과 불변조건을 책임진다. + +### 2. Service는 상태 없는 조율자 + +Service는 Repository 호출과 간단한 흐름 제어만 담당한다. 예를 들어 `LikeService.like()`는 기존 좋아요 존재 여부를 확인하고 없으면 생성하는 조율만 수행한다. + +### 3. Application Facade로 유스케이스 조합 + +여러 도메인을 걸치는 흐름은 Facade에서 조합한다. + +```java +// OrderFacade.createOrder() 흐름 +User 조회 → Product 조회 + 재고 차감 → OrderItem 스냅샷 생성 → Order 생성 → 포인트 차감 +``` + +### 4. DTO 변환 체인 + +``` +Entity → Info (Application DTO) → Response (API DTO) +``` + +각 계층 경계에서 `from()` 정적 팩토리 메서드로 변환하여, 계층 간 결합도를 낮춘다. + +### 5. 멱등성 정책 (Like) + +좋아요 등록/취소는 예외를 발생시키지 않고 멱등하게 동작한다. 이미 좋아요한 상태에서 재요청 시 기존 데이터를 반환하고, 좋아요가 없는 상태에서 취소 시 아무 동작도 하지 않는다. + +--- + +## 현재 구조의 한계 및 개선 포인트 + +### 1. Order-OrderItem 관계 매핑 (높음) + +`Order.orderItems`가 `@Transient`로 선언되어 있어 생성 직후에만 항목 접근이 가능하다. DB에서 Order를 다시 조회하면 items가 빈 리스트가 된다. + +> 개선: `@OneToMany(mappedBy = "orderId")` JPA 연관관계 매핑 또는 조회 시 별도 조립 로직 추가 + +### 2. 트랜잭션 경계 중복 (중간) + +`OrderFacade.createOrder()`과 `OrderService.createOrder()` 모두 `@Transactional`이 선언되어 있다. 현재는 중첩 트랜잭션(REQUIRED)으로 동작하지만 경계가 불명확하다. + +> 개선: Facade 또는 Service 한 곳에서만 트랜잭션 관리 + +### 3. BrandRepositoryImpl 미구현 (중간) + +`BrandRepository` 인터페이스는 존재하지만 Infrastructure에 구현체(`BrandRepositoryImpl`)가 아직 없다. + +> 개선: 다른 도메인과 동일한 패턴으로 구현체 추가 + +### 4. 미사용 Value Object (낮음) + +`ProductName`, `Price` VO가 정의되어 있지만 Product 엔티티에서 사용하지 않고 있다 (String, Long으로 직접 사용 중). + +> 개선: VO로 감싸서 검증 로직 추가하거나, 사용하지 않는다면 제거 diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..c3577f232 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,510 @@ +# 01. 요구사항 정의서 + +> 이커머스 도메인(Brand, Product, ProductLike, Order, OrderItem) 설계를 위한 요구사항 정의 +> base branch: main + +--- + +## 목차 + +1. [개요](#1-개요) + - 1.1 목적 + - 1.2 액터 + - 1.3 도메인 개념 모델 + - 1.4 유비쿼터스 언어 +2. [Brand 도메인](#2-brand-도메인) + - 2.1 API 목록 + - 2.2 필드 정의 + - 2.3 유스케이스 + - 2.4 에러 케이스 +3. [Product 도메인](#3-product-도메인) + - 3.1 API 목록 + - 3.2 필드 정의 + - 3.3 유스케이스 + - 3.4 에러 케이스 +4. [ProductLike 도메인](#4-productlike-도메인) + - 4.1 API 목록 + - 4.2 필드 정의 + - 4.3 유스케이스 + - 4.4 에러 케이스 +5. [Order / OrderItem 도메인](#5-order--orderitem-도메인) + - 5.1 API 목록 + - 5.2 필드 정의 + - 5.3 유스케이스 + - 5.4 에러 케이스 +6. [공통 정책](#6-공통-정책) +7. [설계 결정 사항](#7-설계-결정-사항) + +--- + +## 1. 개요 + +### 1.1 목적 + +사용자가 상품을 조회하고, 좋아요를 누르고, 주문할 수 있는 이커머스 플랫폼의 핵심 도메인을 정의한다. +1주차에 구현된 User 도메인(회원가입, 인증)을 기반으로, 상품/주문 흐름을 설계한다. + +### 1.2 액터 + +| 액터 | 설명 | 인증 | +|------|------|------| +| 비회원 | 상품/브랜드 조회만 가능 | 불필요 | +| 회원 (User) | 좋아요, 주문 등 모든 기능 사용 | `X-Loopers-LoginId` + `X-Loopers-LoginPw` 헤더 | + +### 1.3 도메인 개념 모델 + +``` +User (1주차 완료, 참조만) + ├── 1:N → Order (회원이 주문을 생성한다) + └── 1:N → ProductLike (회원이 상품에 좋아요를 누른다) + +Brand + └── 1:N → Product (브랜드가 여러 상품을 가진다) + +Product + ├── 1:N → ProductLike (상품에 여러 좋아요가 달린다) + └── 1:N → OrderItem (상품이 여러 주문 항목에 포함된다) + +Order + └── 1:N → OrderItem (주문은 여러 주문 항목을 가진다) +``` + +### 1.4 유비쿼터스 언어 + +| 용어 | 의미 | 비고 | +|------|------|------| +| Brand | 상품을 공급하는 브랜드 | | +| Product | 판매 상품 | Brand에 소속 | +| ProductLike | 회원의 상품 좋아요 | User-Product 간 관계 | +| Order | 주문 | 회원이 생성 | +| OrderItem | 주문 내 개별 상품 항목 | Order-Product 간 관계 | +| User | 회원 | 1주차 구현 완료, FK 참조 | + +--- + +## 2. Brand 도메인 + +### 2.1 API 목록 + +| Method | Path | 설명 | 인증 | +|--------|------|------|------| +| POST | `/api/v1/brands` | 브랜드 등록 | 필요 | +| GET | `/api/v1/brands/{brandId}` | 브랜드 단건 조회 | 불필요 | +| GET | `/api/v1/brands` | 브랜드 목록 조회 | 불필요 | + +### 2.2 필드 정의 + +| 필드명 | 타입 | 제약조건 | 검증 규칙 | +|--------|------|----------|-----------| +| id | Long | PK, AUTO_INCREMENT | BaseEntity | +| name | String | NOT NULL, UNIQUE | 1~50자, 공백 불가 | +| createdAt | ZonedDateTime | NOT NULL | BaseEntity | +| updatedAt | ZonedDateTime | NOT NULL | BaseEntity | +| deletedAt | ZonedDateTime | nullable | BaseEntity (Soft Delete) | + +### 2.3 유스케이스 + +#### UC-B01: 브랜드 등록 + +**Main Flow** +1. 회원이 브랜드 이름을 입력하여 등록을 요청한다. +2. 시스템은 브랜드 이름의 형식을 검증한다. +3. 시스템은 동일한 이름의 브랜드가 존재하는지 확인한다. +4. 브랜드를 저장하고 생성된 정보를 응답한다. + +**Alternate Flow** +- 없음 + +**Exception Flow** +- E1: 브랜드 이름이 비어 있거나 50자 초과 → `400 Bad Request` +- E2: 동일한 이름의 브랜드가 이미 존재 → `409 Conflict` +- E3: 인증 실패 (헤더 누락 또는 불일치) → `401 Unauthorized` + +#### UC-B02: 브랜드 단건 조회 + +**Main Flow** +1. 사용자가 brandId로 브랜드 정보를 조회한다. +2. 시스템은 해당 브랜드를 반환한다. + +**Exception Flow** +- E1: 해당 brandId의 브랜드가 존재하지 않음 → `404 Not Found` + +#### UC-B03: 브랜드 목록 조회 + +**Main Flow** +1. 사용자가 브랜드 목록을 조회한다. +2. 시스템은 전체 브랜드 목록을 반환한다. + +**Alternate Flow** +- A1: 등록된 브랜드가 없으면 빈 리스트를 반환한다. + +### 2.4 에러 케이스 + +| 상황 | HTTP Status | errorCode | message | +|------|-------------|-----------|---------| +| 이름 누락/형식 오류 | 400 | Bad Request | 브랜드 이름은 1~50자여야 합니다 | +| 이름 중복 | 409 | Conflict | 이미 등록된 브랜드 이름입니다 | +| 브랜드 미존재 | 404 | Not Found | 해당 브랜드를 찾을 수 없습니다 | +| 인증 실패 | 401 | Unauthorized | 인증 정보가 유효하지 않습니다 | + +--- + +## 3. Product 도메인 + +### 3.1 API 목록 + +| Method | Path | 설명 | 인증 | +|--------|------|------|------| +| POST | `/api/v1/products` | 상품 등록 | 필요 | +| GET | `/api/v1/products/{productId}` | 상품 단건 조회 | 불필요 | +| GET | `/api/v1/products` | 상품 목록 조회 | 불필요 | + +### 3.2 필드 정의 + +| 필드명 | 타입 | 제약조건 | 검증 규칙 | +|--------|------|----------|-----------| +| id | Long | PK, AUTO_INCREMENT | BaseEntity | +| brandId | Long | FK(brand.id), NOT NULL | 존재하는 Brand 참조 | +| name | String | NOT NULL | 1~100자 | +| price | Long | NOT NULL | 0 이상 | +| description | String | nullable | 최대 500자 | +| stockQuantity | Integer | NOT NULL | 0 이상 | +| createdAt | ZonedDateTime | NOT NULL | BaseEntity | +| updatedAt | ZonedDateTime | NOT NULL | BaseEntity | +| deletedAt | ZonedDateTime | nullable | BaseEntity (Soft Delete) | + +### 3.3 유스케이스 + +#### UC-P01: 상품 등록 + +**Main Flow** +1. 회원이 브랜드 ID, 상품명, 가격, 설명, 재고 수량을 입력하여 등록을 요청한다. +2. 시스템은 필드 형식을 검증한다. +3. 시스템은 brandId에 해당하는 브랜드가 존재하는지 확인한다. +4. 상품을 저장하고 생성된 정보를 응답한다. + +**Exception Flow** +- E1: 필수 필드 누락 또는 형식 오류 → `400 Bad Request` +- E2: 가격이 음수 → `400 Bad Request` +- E3: 존재하지 않는 brandId → `404 Not Found` +- E4: 인증 실패 → `401 Unauthorized` + +#### UC-P02: 상품 단건 조회 + +**Main Flow** +1. 사용자가 productId로 상품 정보를 조회한다. +2. 시스템은 상품 정보 + 소속 브랜드 이름을 함께 응답한다. + +**Exception Flow** +- E1: 해당 productId의 상품이 존재하지 않음 → `404 Not Found` + +#### UC-P03: 상품 목록 조회 + +**Main Flow** +1. 사용자가 상품 목록을 조회한다. +2. 시스템은 상품 목록을 반환한다. + +**Alternate Flow** +- A1: 등록된 상품이 없으면 빈 리스트를 반환한다. + +### 3.4 에러 케이스 + +| 상황 | HTTP Status | errorCode | message | +|------|-------------|-----------|---------| +| 필수 필드 누락 | 400 | Bad Request | 필수 입력값입니다 | +| 가격 음수 | 400 | Bad Request | 가격은 0 이상이어야 합니다 | +| 재고 음수 | 400 | Bad Request | 재고 수량은 0 이상이어야 합니다 | +| 브랜드 미존재 | 404 | Not Found | 해당 브랜드를 찾을 수 없습니다 | +| 상품 미존재 | 404 | Not Found | 해당 상품을 찾을 수 없습니다 | +| 인증 실패 | 401 | Unauthorized | 인증 정보가 유효하지 않습니다 | + +--- + +## 4. ProductLike 도메인 + +### 4.1 API 목록 + +| Method | Path | 설명 | 인증 | +|--------|------|------|------| +| POST | `/api/v1/products/{productId}/likes` | 상품 좋아요 | 필요 | +| DELETE | `/api/v1/products/{productId}/likes` | 상품 좋아요 취소 | 필요 | + +### 4.2 필드 정의 + +| 필드명 | 타입 | 제약조건 | 검증 규칙 | +|--------|------|----------|-----------| +| id | Long | PK, AUTO_INCREMENT | BaseEntity | +| userId | Long | FK(user.id), NOT NULL | 존재하는 User 참조 | +| productId | Long | FK(product.id), NOT NULL | 존재하는 Product 참조 | +| createdAt | ZonedDateTime | NOT NULL | BaseEntity | +| updatedAt | ZonedDateTime | NOT NULL | BaseEntity | + +> **참고**: Hard Delete 정책(Q-L01) 적용으로 `deletedAt` 컬럼은 사용하지 않는다. BaseEntity를 상속하므로 JPA 엔티티에는 필드가 존재하나, 비즈니스적으로 미사용. + +**UNIQUE 제약**: (userId, productId) 복합 유니크 — 동일 사용자가 같은 상품에 중복 좋아요 불가 + +### 4.3 유스케이스 + +#### UC-L01: 상품 좋아요 + +**Main Flow** +1. 회원이 특정 상품에 좋아요를 요청한다. +2. 시스템은 해당 상품이 존재하는지 확인한다. +3. 시스템은 이미 좋아요한 상태인지 확인한다. +4. 좋아요를 저장하고 결과를 응답한다. + +**Exception Flow** +- E1: 상품이 존재하지 않음 → `404 Not Found` +- E2: 이미 좋아요한 상품 → `409 Conflict` +- E3: 인증 실패 → `401 Unauthorized` + +#### UC-L02: 상품 좋아요 취소 + +**Main Flow** +1. 회원이 특정 상품의 좋아요 취소를 요청한다. +2. 시스템은 해당 좋아요 기록이 존재하는지 확인한다. +3. 좋아요를 물리 삭제(Hard Delete)하고 결과를 응답한다. + +**Exception Flow** +- E1: 좋아요 기록이 존재하지 않음 → `404 Not Found` +- E2: 인증 실패 → `401 Unauthorized` + +### 4.4 에러 케이스 + +| 상황 | HTTP Status | errorCode | message | +|------|-------------|-----------|---------| +| 상품 미존재 | 404 | Not Found | 해당 상품을 찾을 수 없습니다 | +| 이미 좋아요함 | 409 | Conflict | 이미 좋아요한 상품입니다 | +| 좋아요 기록 없음 | 404 | Not Found | 좋아요 기록을 찾을 수 없습니다 | +| 인증 실패 | 401 | Unauthorized | 인증 정보가 유효하지 않습니다 | + +--- + +## 5. Order / OrderItem 도메인 + +### 5.1 API 목록 + +| Method | Path | 설명 | 인증 | +|--------|------|------|------| +| POST | `/api/v1/orders` | 주문 생성 | 필요 | +| GET | `/api/v1/orders/{orderId}` | 주문 단건 조회 | 필요 | +| GET | `/api/v1/orders/me` | 내 주문 목록 조회 | 필요 | +| PATCH | `/api/v1/orders/{orderId}/cancel` | 주문 취소 | 필요 | + +### 5.2 필드 정의 + +#### Order + +| 필드명 | 타입 | 제약조건 | 검증 규칙 | +|--------|------|----------|-----------| +| id | Long | PK, AUTO_INCREMENT | BaseEntity | +| userId | Long | FK(user.id), NOT NULL | 주문자 | +| status | OrderStatus (Enum) | NOT NULL | 초기값: ORDERED | +| totalPrice | Long | NOT NULL | 주문 항목 합산, 0 이상 | +| orderedAt | ZonedDateTime | NOT NULL | 주문 시점 | +| createdAt | ZonedDateTime | NOT NULL | BaseEntity | +| updatedAt | ZonedDateTime | NOT NULL | BaseEntity | +| deletedAt | ZonedDateTime | nullable | BaseEntity (Soft Delete) | + +#### OrderStatus (Enum) + +| 값 | 설명 | 전이 가능 상태 | +|----|------|----------------| +| ORDERED | 주문 완료 | CANCELLED | +| CANCELLED | 주문 취소 | (종료 상태) | + +#### OrderItem + +| 필드명 | 타입 | 제약조건 | 검증 규칙 | +|--------|------|----------|-----------| +| id | Long | PK, AUTO_INCREMENT | BaseEntity | +| orderId | Long | FK(order.id), NOT NULL | 소속 주문 | +| productId | Long | FK(product.id), NOT NULL | 주문 상품 | +| quantity | Integer | NOT NULL | 1 이상 | +| price | Long | NOT NULL | 주문 시점 상품 가격 | +| createdAt | ZonedDateTime | NOT NULL | BaseEntity | +| updatedAt | ZonedDateTime | NOT NULL | BaseEntity | +| deletedAt | ZonedDateTime | nullable | BaseEntity (Soft Delete) | + +### 5.3 유스케이스 + +#### UC-O01: 주문 생성 + +**Main Flow** +1. 회원이 주문할 상품 목록(productId, quantity)을 전달하여 주문을 요청한다. +2. 시스템은 각 상품이 존재하는지 확인한다. +3. 시스템은 각 상품의 재고가 충분한지 확인한다. +4. 시스템은 주문 시점의 상품 가격으로 OrderItem을 생성한다. +5. 시스템은 totalPrice를 계산한다 (각 항목의 price * quantity 합산). +6. 주문 상태를 ORDERED로 설정하고 저장한다. +7. 각 상품의 재고를 차감한다. +8. 생성된 주문 정보를 응답한다. + +**Exception Flow** +- E1: 주문 항목이 비어 있음 → `400 Bad Request` +- E2: 상품이 존재하지 않음 → `404 Not Found` +- E3: 재고 부족 → `400 Bad Request` +- E4: 인증 실패 → `401 Unauthorized` + +#### UC-O02: 주문 단건 조회 + +**Main Flow** +1. 회원이 orderId로 주문 정보를 조회한다. +2. 시스템은 해당 주문이 본인의 주문인지 확인한다. +3. 주문 정보 + OrderItem 목록을 응답한다. + +**Exception Flow** +- E1: 해당 주문이 존재하지 않음 → `404 Not Found` +- E2: 본인의 주문이 아님 → `404 Not Found` (본인 주문만 조회 가능하도록 쿼리) +- E3: 인증 실패 → `401 Unauthorized` + +#### UC-O03: 내 주문 목록 조회 + +**Main Flow** +1. 회원이 자신의 주문 목록을 조회한다. +2. 시스템은 해당 회원의 주문 목록을 반환한다. + +**Alternate Flow** +- A1: 주문이 없으면 빈 리스트를 반환한다. + +**Exception Flow** +- E1: 인증 실패 → `401 Unauthorized` + +#### UC-O04: 주문 취소 + +**Main Flow** +1. 회원이 특정 주문의 취소를 요청한다. +2. 시스템은 해당 주문이 본인의 주문인지 확인한다. +3. 시스템은 주문 상태가 ORDERED인지 확인한다. +4. 주문 상태를 CANCELLED로 변경한다. +5. 차감되었던 재고를 복원한다. +6. 결과를 응답한다. + +**Exception Flow** +- E1: 주문이 존재하지 않음 → `404 Not Found` +- E2: 본인의 주문이 아님 → `404 Not Found` (본인 주문만 조회 가능하도록 쿼리) +- E3: 이미 취소된 주문 → `400 Bad Request` +- E4: 인증 실패 → `401 Unauthorized` + +### 5.4 에러 케이스 + +| 상황 | HTTP Status | errorCode | message | +|------|-------------|-----------|---------| +| 주문 항목 비어 있음 | 400 | Bad Request | 주문 항목은 최소 1개 이상이어야 합니다 | +| 상품 미존재 | 404 | Not Found | 해당 상품을 찾을 수 없습니다 | +| 재고 부족 | 400 | Bad Request | 재고가 부족합니다 | +| 주문 미존재 | 404 | Not Found | 해당 주문을 찾을 수 없습니다 | +| 본인 주문 아님 (또는 미존재) | 404 | Not Found | 해당 주문을 찾을 수 없습니다 | +| 이미 취소된 주문 | 400 | Bad Request | 이미 취소된 주문입니다 | +| 인증 실패 | 401 | Unauthorized | 인증 정보가 유효하지 않습니다 | + +--- + +## 6. 공통 정책 + +### 6.1 인증 + +- 인증이 필요한 API는 `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더로 인증한다. +- 인증 실패 시 `401 Unauthorized`를 반환한다. +- 1주차 User 도메인의 인증 방식을 그대로 사용한다. + +### 6.2 응답 형식 + +- 모든 API는 `ApiResponse` 형식으로 응답한다. +- 성공: `meta.result = SUCCESS`, `data = 응답 데이터` +- 실패: `meta.result = FAIL`, `meta.errorCode`, `meta.message` 포함 + +### 6.3 Soft Delete + +- 모든 엔티티는 BaseEntity를 상속하며, `deletedAt` 필드로 Soft Delete를 지원한다. +- 조회 시 deletedAt이 null인 데이터만 반환한다. + +### 6.4 검증 순서 + +1. 인증 검증 (헤더) +2. 요청 형식 검증 (Jakarta Validation) +3. 비즈니스 규칙 검증 (도메인 서비스) + +--- + +## 7. 설계 결정 사항 + +> 애매한 요구사항을 선택지 + 영향도 형태로 정리하고, 확정된 결정을 기록한다. + +### 정책 결정 + +#### Q-L01: 좋아요 취소 방식 — Hard Delete 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **B: Hard Delete (물리 삭제)** | +| 선택 이유 | (userId, productId) UNIQUE 제약이 단순해지고, 재좋아요 시 restore 로직이 불필요하다 | +| 트레이드오프 | 좋아요 이력 추적 불가. 향후 이력 분석 필요 시 별도 이벤트 로그 테이블 필요 | +| 고려되었으나 채택하지 않음 | A: Soft Delete — 재좋아요 시 restore 로직 필요, UNIQUE 제약에 deletedAt 조건 결합으로 복잡도 증가 | + +#### Q-O01: 타인 주문 접근 시 응답 코드 — 404 Not Found 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **B: 404 Not Found** | +| 선택 이유 | orderId 존재 여부가 노출되지 않아 보안상 안전. `findByIdAndUserId` 단일 쿼리로 구현 단순화 | +| 트레이드오프 | 디버깅 시 "권한 문제 vs 미존재" 구분 어려움. 서버 로그로 보완 가능 | +| 고려되었으나 채택하지 않음 | A: 403 Forbidden — 의미론적으로 정확하지만 orderId 존재 여부 노출 | + +#### Q-O02: 주문 상태 범위 — ORDERED, CANCELLED만 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **A: ORDERED, CANCELLED만** | +| 선택 이유 | 배송/완료 흐름이 현재 요구사항에 없음. 상태 2개면 전이 규칙이 단순 (ORDERED → CANCELLED) | +| 트레이드오프 | 향후 배송 추적 시 Enum 확장 + 전이 로직 추가 필요. 다만 Enum 값 추가는 하위 호환 가능 | +| 고려되었으나 채택하지 않음 | B: ORDERED → SHIPPING → DELIVERED / CANCELLED — 구현 복잡도 증가, 상태 전이 검증 로직 필요 | + +### 경계 결정 + +#### Q-P01: 상품 가격 범위 — 0 이상 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **A: 0 이상 (무료 상품 허용)** | +| 선택 이유 | 무료 상품을 막을 비즈니스 근거 없음. 검증 로직 `price >= 0`으로 단순 | +| 트레이드오프 | 0원 주문 발생 가능. 필요 시 주문 도메인 정책에서 별도 검증으로 분리 가능 | +| 고려되었으나 채택하지 않음 | B: 1 이상 — 무료 상품 지원 불가, 근거 없는 제약 | + +#### Q-P02: 재고 0인 상품 노출 — 조회에 노출 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **A: 조회에 노출 (재고 0 포함)** | +| 선택 이유 | 목록/상세 조회는 상품 정보 확인 목적. 재고 필터링은 주문 시점에 검증하면 됨 | +| 트레이드오프 | 재고 없는 상품을 보고 주문 시도 가능. 주문 생성 시 재고 부족 에러로 명확히 반환 | +| 고려되었으나 채택하지 않음 | B: 조회에서 제외 — 쿼리 조건 추가로 복잡도 증가 | + +#### Q-O03: 주문 취소 시 재고 복원 — 복원 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **A: 복원한다** | +| 선택 이유 | 데이터 정합성은 복잡도를 이유로 타협할 수 없음 | +| 트레이드오프 | 주문 취소 트랜잭션이 Order + Product를 함께 수정 (트랜잭션 비대화). Facade에서 두 Service를 조합하여 책임 분리 가능 | +| 고려되었으나 채택하지 않음 | B: 복원하지 않음 — 재고 정합성 깨짐, 데이터 신뢰도 하락 | + +### 확장 결정 + +#### Q-E01: 페이징 — 페이징 없음 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **A: 전체 조회 (페이징 없음)** | +| 선택 이유 | 최소 구현 범위에서 데이터 양 제한적. 페이징 적용 시 요청 파라미터 + 응답 구조 + Pageable 처리가 모든 목록 API에 추가 | +| 트레이드오프 | 데이터 수천 건 이상 시 성능 이슈. `List` → `Page` 변경은 비교적 단순하여 이후 추가 가능 | +| 고려되었으나 채택하지 않음 | B: 페이징 적용 — 구현량 대비 현 시점 효용 낮음 | + +#### Q-E02: 상품 수정/삭제 API — 제외 채택 + +| 구분 | 내용 | +|------|------| +| **채택** | **B: 제외** | +| 선택 이유 | 과제 명세에 미명시. 등록 + 조회만으로 핵심 흐름(등록 → 좋아요 → 주문) 완성 가능 | +| 트레이드오프 | 등록한 상품의 정보 수정/삭제 불가. 필요 시 별도 추가하며 기존 설계에 영향 없음 | +| 고려되었으나 채택하지 않음 | A: 포함 — 불필요한 API 증가, 시퀀스/클래스/ERD 문서량 증가 | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..15d64da48 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,302 @@ +# 02. 시퀀스 다이어그램 + +> 핵심 API의 호출 흐름을 레이어 단위로 시각화한다. +> 누가 무엇을 책임지는지, 트랜잭션 경계는 어디인지를 표현하는 것이 목적이다. +> base branch: main + +--- + +## 목차 + +1. [주문 생성 (UC-O01)](#1-주문-생성-uc-o01) — 필수 +2. [주문 취소 (UC-O04)](#2-주문-취소-uc-o04) — 필수 +3. [상품 좋아요 / 취소 (UC-L01, UC-L02)](#3-상품-좋아요--취소-uc-l01-uc-l02) — 선택 +4. [상품 등록 (UC-P01)](#4-상품-등록-uc-p01) — 선택 + +--- + +## 1. 주문 생성 (UC-O01) + +### 이유 + +주문 생성은 이 시스템에서 가장 복잡한 흐름이다. +여러 도메인(Order, Product)을 하나의 트랜잭션에서 조합해야 하고, +재고 검증 → OrderItem 생성 → 재고 차감이 순서대로 이루어져야 한다. + +이 다이어그램으로 확인하려는 것: +- Facade가 OrderService와 ProductService를 어떻게 조합하는가 +- 트랜잭션 경계는 어디인가 +- 재고 부족 시 흐름이 어디서 끊기는가 + +### 다이어그램 + +```mermaid +sequenceDiagram + participant Client + participant OrderController + participant UserService + participant OrderFacade + participant ProductService + participant OrderService + + Client->>OrderController: POST /api/v1/orders + OrderController->>UserService: authenticate(loginId, loginPw) + + alt 인증 실패 + OrderController-->>Client: 401 Unauthorized + end + + OrderController->>OrderFacade: createOrder(userId, orderItems) + + loop 각 주문 항목(productId, quantity) + OrderFacade->>ProductService: getProductById(productId) + OrderFacade->>ProductService: validateStock(product, quantity) + end + + OrderFacade->>OrderService: createOrder(userId, products, quantities) + + loop 각 주문 항목 + OrderFacade->>ProductService: decreaseStock(productId, quantity) + end + + OrderFacade-->>OrderController: Order + OrderController-->>Client: 200 OK +``` + +### 해석 + +**레이어별 책임** +- **Controller**: 인증 처리, 요청 바디 검증(@Valid), 응답 래핑 +- **Facade**: 여러 도메인 서비스(ProductService, OrderService)를 조합하는 유스케이스 오케스트레이터. 트랜잭션의 주체 +- **ProductService**: 상품 조회, 재고 검증, 재고 차감. 자기 도메인의 비즈니스 규칙만 책임 +- **OrderService**: Order + OrderItem 생성, 가격 스냅샷, totalPrice 계산. 주문 도메인 내 로직만 책임 + +**트랜잭션 경계** +- `@Transactional`은 Facade 레벨에 설정한다. +- 상품 조회 → 재고 검증 → 주문 저장 → 재고 차감이 하나의 트랜잭션으로 묶인다. +- 중간에 예외가 발생하면 전체 롤백된다 (재고가 차감되지 않은 상태로 복원). + +**설계 의도** +- OrderService는 ProductService를 직접 참조하지 않는다. Facade가 둘을 조합한다. +- 재고 차감을 주문 저장 이후에 수행하는 이유: 주문 저장이 실패하면 재고 차감도 불필요하기 때문이다. +- 가격은 주문 시점에 스냅샷한다 (OrderItem.price). 이후 상품 가격이 변경되어도 주문 금액은 불변이다. + +**잠재 리스크** +- 트랜잭션 비대화: 상품이 많아질수록 loop 내 DB 호출이 증가한다. 현 최소 구현에서는 허용 가능하나, 대량 주문 시 batch 조회로 개선 가능하다. + +--- + +## 2. 주문 취소 (UC-O04) + +### 이유 + +주문 취소는 상태 전이(ORDERED → CANCELLED)와 재고 복원을 동시에 처리해야 한다. +주문 생성의 역방향 흐름이며, Facade에서 두 도메인을 다시 조합하는 구조를 확인한다. + +이 다이어그램으로 확인하려는 것: +- 상태 전이 검증 로직이 어디에 위치하는가 +- 타인 주문 접근 시 404를 반환하는 쿼리 전략 +- 재고 복원이 트랜잭션 내에서 이루어지는가 + +### 다이어그램 + +```mermaid +sequenceDiagram + participant Client + participant OrderController + participant UserService + participant OrderFacade + participant OrderService + participant ProductService + + Client->>OrderController: PATCH /api/v1/orders/{orderId}/cancel + OrderController->>UserService: authenticate(loginId, loginPw) + + alt 인증 실패 + OrderController-->>Client: 401 Unauthorized + end + + OrderController->>OrderFacade: cancelOrder(userId, orderId) + OrderFacade->>OrderService: getOrderByIdAndUserId(orderId, userId) + + alt 주문 미존재 또는 타인 주문 + OrderService-->>Client: 404 Not Found + end + + OrderFacade->>OrderService: cancel(order) + + alt 이미 취소된 주문 + OrderService-->>Client: 400 Bad Request + end + + loop 각 OrderItem + OrderFacade->>ProductService: increaseStock(productId, quantity) + end + + OrderFacade-->>OrderController: Order + OrderController-->>Client: 200 OK +``` + +### 해석 + +**레이어별 책임** +- **Controller**: 인증, orderId 경로 변수 추출, 응답 래핑 +- **Facade**: 주문 조회 → 상태 전이 → 재고 복원을 하나의 트랜잭션으로 조합 +- **OrderService**: 본인 주문 조회(`findByIdAndUserId`), 상태 전이 검증 및 변경 +- **ProductService**: 재고 복원 (increaseStock) + +**타인 주문 접근 정책 (Q-O01 반영)** +- `findByIdAndUserId(orderId, userId)` 단일 쿼리를 사용한다. +- 주문이 존재하지 않거나 타인의 주문이면 동일하게 `404 Not Found`를 반환한다. +- orderId의 존재 여부가 응답으로 노출되지 않는다. + +**상태 전이 (Q-O02 반영)** +- ORDERED → CANCELLED만 허용한다. +- 이미 CANCELLED인 주문에 대한 취소 요청은 `400 Bad Request`를 반환한다. +- 상태 전이 검증은 OrderService(도메인 레이어)의 책임이다. + +**재고 복원 (Q-O03 반영)** +- 주문 취소 시 각 OrderItem의 quantity만큼 재고를 복원한다. +- 복원은 트랜잭션 내에서 수행되므로, 상태 변경과 재고 복원의 원자성이 보장된다. + +**잠재 리스크** +- 주문 생성과 동일하게 OrderItem이 많을수록 loop 내 DB 호출이 증가한다. +- 상태 변경과 재고 복원이 하나의 트랜잭션으로 묶여 트랜잭션이 비대해질 수 있으나, Facade에서 OrderService/ProductService 책임을 분리하여 각 도메인의 독립성은 유지한다. + +--- + +## 3. 상품 좋아요 / 취소 (UC-L01, UC-L02) + +### 이유 + +좋아요/취소는 단일 도메인 흐름이지만, Hard Delete라는 설계 결정(Q-L01)이 반영된 흐름을 명시적으로 확인할 필요가 있다. +두 흐름이 대칭적이므로 하나의 다이어그램에 통합한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + participant Client + participant LikeController + participant UserService + participant LikeService + participant ProductRepository + participant LikeRepository + + %% ── 좋아요 등록 (UC-L01) ── + Client->>LikeController: POST /api/v1/products/{productId}/likes + LikeController->>UserService: authenticate(loginId, loginPw) + LikeController->>LikeService: like(userId, productId) + LikeService->>ProductRepository: findById(productId) + LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + + alt 이미 좋아요함 + LikeService-->>Client: 409 Conflict + else 좋아요 가능 + LikeService->>LikeRepository: save(ProductLike) + end + + LikeController-->>Client: 200 OK + + %% ── 좋아요 취소 (UC-L02, Hard Delete) ── + Client->>LikeController: DELETE /api/v1/products/{productId}/likes + LikeController->>UserService: authenticate(loginId, loginPw) + LikeController->>LikeService: unlike(userId, productId) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요 기록 없음 + LikeService-->>Client: 404 Not Found + else 좋아요 기록 존재 + LikeService->>LikeRepository: delete(productLike) + end + + LikeController-->>Client: 200 OK +``` + +### 해석 + +**좋아요 등록 흐름** +- 상품 존재 확인 → 중복 좋아요 확인 → 저장 순서로 진행한다. +- 중복 체크는 `existsByUserIdAndProductId`로 수행하며, UNIQUE 제약과 이중 방어된다. + +**좋아요 취소 흐름 (Q-L01 반영)** +- Soft Delete(deletedAt)가 아닌 **Hard Delete(물리 삭제)**를 사용한다. +- `delete()` 호출로 DB에서 레코드를 완전히 제거한다. +- 이로 인해 (userId, productId) UNIQUE 제약이 단순하게 유지되고, 재좋아요 시 새로운 INSERT만 하면 된다. + +**Facade를 사용하지 않는 이유** +- 좋아요 흐름은 단일 도메인(ProductLike) 범위에서 완결된다. +- 상품 존재 확인은 ProductLikeService가 ProductRepository를 조회하는 것으로 충분하다. +- 여러 도메인 서비스를 조합할 필요가 없으므로 Controller → Service 직접 호출이 적절하다. + +--- + +## 4. 상품 등록 (UC-P01) + +### 이유 + +상품 등록은 Brand 도메인과의 관계(FK 참조)를 포함하며, 향후 주문/좋아요의 전제가 되는 흐름이다. +단순한 구조이지만 Brand 존재 검증이라는 교차 도메인 확인 지점을 명시한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant UserService + participant ProductService + participant BrandRepository + participant ProductRepository + + Client->>ProductController: POST /api/v1/products + ProductController->>UserService: authenticate(loginId, loginPw) + + alt 인증 실패 + ProductController-->>Client: 401 Unauthorized + end + + ProductController->>ProductService: register(brandId, name, price, description, stockQuantity) + ProductService->>BrandRepository: findById(brandId) + + alt 브랜드 미존재 + ProductService-->>Client: 404 Not Found + end + + ProductService->>ProductRepository: save(Product) + ProductController-->>Client: 200 OK +``` + +### 해석 + +**Brand 존재 검증** +- 상품은 반드시 존재하는 Brand에 소속되어야 한다. +- ProductService가 BrandRepository를 직접 조회하여 검증한다. +- 이 관계는 단방향이다 (Product → Brand 참조, Brand는 Product를 모른다). + +**Facade를 사용하지 않는 이유** +- Brand 조회는 FK 참조 검증일 뿐, Brand 도메인의 비즈니스 로직을 호출하는 것이 아니다. +- Repository 조회만으로 충분하므로 Service 레벨에서 처리한다. + +**필드 검증 (Q-P01 반영)** +- 가격은 0 이상 허용 (무료 상품 가능). +- 재고 수량도 0 이상 허용 (재고 없는 상품 등록 가능, 조회에 노출됨). + +--- + +## 다이어그램 요약 + +| 다이어그램 | 핵심 포인트 | 관련 설계 결정 | +|------------|-------------|----------------| +| 주문 생성 | Facade에서 Product+Order 조합, 트랜잭션 내 재고 차감 | Q-P02 (재고 0 노출, 주문 시 검증) | +| 주문 취소 | 상태 전이 ORDERED→CANCELLED, 재고 복원 | Q-O01 (404), Q-O02 (2개 상태), Q-O03 (복원) | +| 좋아요/취소 | Hard Delete, UNIQUE 제약 단순화 | Q-L01 (Hard Delete) | +| 상품 등록 | Brand FK 검증, 가격 0 이상 허용 | Q-P01 (0 이상) | + +### 다이어그램에 포함하지 않은 API + +| API | 미포함 사유 | +|-----|-------------| +| GET /api/v1/brands, GET /api/v1/brands/{brandId} | 단순 CRUD 조회. Controller → Service → Repository → DB 직선 흐름으로 별도 다이어그램 불필요 | +| GET /api/v1/products, GET /api/v1/products/{productId} | 동일. 재고 0 상품도 그대로 반환 (필터링 없음) | +| GET /api/v1/orders/{orderId}, GET /api/v1/orders/me | 주문 조회는 주문 취소 다이어그램 내 findByIdAndUserId 패턴으로 설명 완료 | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..7060cb887 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,427 @@ +# 03. 클래스 다이어그램 + +> 도메인 모델의 책임과 관계, 레이어별 클래스 구조를 정의한다. +> base branch: main + +--- + +## 목차 + +1. [도메인 모델 다이어그램](#1-도메인-모델-다이어그램) +2. [레이어 구조 다이어그램](#2-레이어-구조-다이어그램) +3. [레이어별 클래스 목록](#3-레이어별-클래스-목록) + +--- + +## 1. 도메인 모델 다이어그램 + +### 이유 + +도메인 모델 다이어그램은 각 엔티티가 어떤 데이터를 가지고, 엔티티 간 관계가 어떻게 구성되는지를 한눈에 파악하기 위해 필요하다. +특히 Value Object 식별, 연관관계 방향, 도메인 객체의 책임 배치를 확인하는 것이 목적이다. + +### 다이어그램 + +```mermaid +classDiagram + class BaseEntity { + Long id + ZonedDateTime createdAt + ZonedDateTime updatedAt + ZonedDateTime deletedAt + } + + class User { + String loginId + String password + String name + String email + String birthDate + } + + class Brand { + BrandName name + } + + class Product { + Long brandId + ProductName name + Price price + String description + StockQuantity stockQuantity + } + + class ProductLike { + Long userId + Long productId + } + + class Order { + Long userId + OrderStatus status + Long totalPrice + ZonedDateTime orderedAt + List~OrderItem~ orderItems + } + + class OrderItem { + Long orderId + Long productId + Quantity quantity + Price price + } + + class OrderStatus { + <> + ORDERED + CANCELLED + } + + class BrandName { + <> + String value + } + + class ProductName { + <> + String value + } + + class Price { + <> + Long value + } + + class StockQuantity { + <> + Integer value + } + + class Quantity { + <> + Integer value + } + + User --|> BaseEntity + Brand --|> BaseEntity + Product --|> BaseEntity + ProductLike --|> BaseEntity + Order --|> BaseEntity + OrderItem --|> BaseEntity + + Product --> Brand + ProductLike --> User + ProductLike --> Product + Order --> User + Order *-- OrderItem + OrderItem --> Product + Order --> OrderStatus + + Brand --> BrandName + Product --> ProductName + Product --> Price + Product --> StockQuantity + OrderItem --> Price + OrderItem --> Quantity +``` + +### 해석 + +**엔티티 6개, Enum 1개, Value Object 5개** + +| 클래스 | 분류 | 핵심 책임 | +|--------|------|-----------| +| BaseEntity | 추상 클래스 | id, 타임스탬프 자동 관리, Soft Delete(delete/restore), guard 훅 | +| User | Entity (1주차 참조) | 회원 인증 정보 보유, 비밀번호 변경 | +| Brand | Entity | 브랜드 이름 보유, 이름 유효성 검증(guard) | +| Product | Entity | 상품 정보 보유, **재고 차감/복원 책임** (decreaseStock/increaseStock) | +| ProductLike | Entity (조인) | User-Product 간 좋아요 관계 표현 | +| Order | Entity | **상태 전이 책임(cancel)**, totalPrice 계산, OrderItem 관리 | +| OrderItem | Entity | 주문 시점 가격 스냅샷, 수량 보유 | +| OrderStatus | Enum | ORDERED, CANCELLED 2개 상태 | +| BrandName | Value Object | 브랜드 이름 (1~50자, 공백 불가) | +| ProductName | Value Object | 상품명 (1~100자) | +| Price | Value Object | 가격 (0 이상), Product와 OrderItem에서 공유 | +| StockQuantity | Value Object | 재고 수량 (0 이상) | +| Quantity | Value Object | 주문 수량 (1 이상) | + +### 설계 의도 + +**Value Object — 1주차 패턴을 이어간다** + +- 1주차 User 도메인이 `MemberId`, `Email`, `BirthDate` 등을 VO로 정의했듯이, 2주차 도메인도 검증 규칙이 있는 필드를 VO로 추출한다. +- 모든 VO는 `record` 타입으로 정의하고, Compact Constructor에서 검증한다. +- `Price`는 Product와 OrderItem에서 공유한다. 동일한 "0 이상의 금액"이라는 도메인 규칙을 가진다. +- `Quantity`(1 이상)와 `StockQuantity`(0 이상)는 검증 규칙이 다르므로 분리한다. + +**VO 검증 규칙** + +| Value Object | 타입 | 검증 규칙 | +|-------------|------|-----------| +| BrandName | record(String value) | null/blank 불가, 1~50자 | +| ProductName | record(String value) | null/blank 불가, 1~100자 | +| Price | record(Long value) | null 불가, 0 이상 | +| StockQuantity | record(Integer value) | null 불가, 0 이상 | +| Quantity | record(Integer value) | null 불가, 1 이상 | + +**책임 배치 원칙 — 도메인 객체가 자기 규칙을 갖는다** + +- **Product.decreaseStock() / increaseStock()**: 재고 변경은 Product 자신의 책임이다. Service가 직접 `stockQuantity` 필드를 조작하지 않고, Product에게 메시지를 보낸다. `decreaseStock()`은 내부에서 재고 부족 여부를 검증한다. +- **Order.cancel()**: 상태 전이 규칙(ORDERED -> CANCELLED)은 Order 내부에 캡슐화한다. 현재 상태가 ORDERED가 아니면 예외를 던진다. Service는 `order.cancel()`을 호출할 뿐, 상태 값을 직접 변경하지 않는다. +- **Order.calculateTotalPrice()**: OrderItem의 price * quantity 합산 로직은 Order가 소유한다. 외부에서 totalPrice를 임의로 설정하지 않는다. + +**연관관계 — 단방향만 사용한다** + +- 모든 연관관계는 N 쪽에서 1 쪽으로의 단방향이다. +- `Product -> Brand`: Product가 brandId로 Brand를 참조한다. Brand는 Product 목록을 모른다. +- `ProductLike -> User, Product`: ProductLike가 양쪽을 FK로 참조한다. User와 Product는 ProductLike를 모른다. +- `Order -> User`: Order가 userId로 User를 참조한다. +- `OrderItem -> Product`: OrderItem이 productId로 Product를 참조한다. +- **예외: Order *-- OrderItem (composition)**: Order가 OrderItem 리스트를 가진다. 주문 취소 시 OrderItem 목록을 순회하여 재고를 복원해야 하므로, 이 방향의 참조가 필요하다. + +**ProductLike — N:M을 조인 엔티티로 풀었다** + +- User와 Product 사이의 좋아요 관계는 개념적으로 N:M이다. +- 이를 ProductLike 조인 엔티티로 풀어 (userId, productId) UNIQUE 제약으로 중복을 방지한다. +- Hard Delete 정책(Q-L01)이므로 Soft Delete 관련 필드(deletedAt)는 이 엔티티에서 사용하지 않는다. 다만 BaseEntity를 상속하므로 필드 자체는 존재한다. + +**guard() 활용** + +- BaseEntity의 `guard()`를 Brand, Product, Order에서 오버라이드하여 `@PrePersist`, `@PreUpdate` 시점에 도메인 규칙을 검증한다. +- 예: Brand.guard()는 name이 비어 있으면 예외, Product.guard()는 price < 0이면 예외, Order.guard()는 status가 null이면 예외. + +--- + +## 2. 레이어 구조 다이어그램 + +### 이유 + +레이어 구조 다이어그램은 Interfaces → Application → Domain ← Infrastructure의 의존 방향이 올바르게 지켜지는지, 각 레이어에 어떤 클래스가 위치하는지, Facade가 필요한 도메인과 불필요한 도메인을 구분하기 위해 필요하다. +특히 Service 간 직접 참조 금지 원칙이 지켜지는지, 인증 흐름이 어디서 처리되는지를 확인하는 것이 목적이다. + +### 다이어그램 + +```mermaid +classDiagram + namespace Interfaces { + class BrandV1Controller + class ProductV1Controller + class ProductLikeV1Controller + class OrderV1Controller + } + + namespace Application { + class OrderFacade + class OrderInfo + } + + namespace Domain { + class UserService + class BrandService + class ProductService + class ProductLikeService + class OrderService + class BrandRepository { + <> + } + class ProductRepository { + <> + } + class ProductLikeRepository { + <> + } + class OrderRepository { + <> + } + } + + namespace Infrastructure { + class BrandRepositoryImpl + class ProductRepositoryImpl + class ProductLikeRepositoryImpl + class OrderRepositoryImpl + } + + BrandV1Controller --> UserService + BrandV1Controller --> BrandService + ProductV1Controller --> UserService + ProductV1Controller --> ProductService + ProductLikeV1Controller --> UserService + ProductLikeV1Controller --> ProductLikeService + OrderV1Controller --> UserService + OrderV1Controller --> OrderFacade + + OrderFacade --> OrderService + OrderFacade --> ProductService + + BrandService --> BrandRepository + ProductService --> ProductRepository + ProductService --> BrandRepository + ProductLikeService --> ProductLikeRepository + ProductLikeService --> ProductRepository + OrderService --> OrderRepository + + BrandRepositoryImpl ..|> BrandRepository + ProductRepositoryImpl ..|> ProductRepository + ProductLikeRepositoryImpl ..|> ProductLikeRepository + OrderRepositoryImpl ..|> OrderRepository +``` + +### 해석 + +**의존 방향은 항상 안쪽을 향한다** + +``` +Interfaces --> Application --> Domain <-- Infrastructure +``` + +- Controller는 Facade 또는 Service를 호출한다. +- Facade는 여러 Service를 조합한다. +- Service는 Repository 인터페이스에 의존한다. +- Infrastructure가 Repository 인터페이스를 구현한다 (의존성 역전). + +**인증 흐름** + +- 인증이 필요한 API에서 Controller가 UserService.authenticate()를 직접 호출한다. +- UserService는 1주차에 구현된 도메인 서비스로, 2주차 도메인에서 참조한다. +- 시퀀스 다이어그램에서 표현된 인증 흐름과 일치시켰다. + +**Facade가 필요한 도메인과 불필요한 도메인** + +| 도메인 | Facade | 이유 | +|--------|--------|------| +| Order | **OrderFacade 사용** | 주문 생성/취소 시 ProductService + OrderService 조합 필요 | +| Brand | 불필요 | 단일 도메인 완결 | +| Product | 불필요 | Brand FK 검증은 Repository 조회로 충분 | +| ProductLike | 불필요 | 상품 존재 확인은 Repository 조회로 충분 | + +**Service 간 직접 참조 금지** + +- OrderService는 ProductService를 직접 참조하지 않는다. +- 둘의 조합은 반드시 OrderFacade를 통해서만 이루어진다. +- ProductService가 BrandRepository를 참조하는 것은 FK 검증(존재 확인)이므로 허용한다. Brand의 비즈니스 로직을 호출하는 것이 아니다. +- ProductLikeService가 ProductRepository를 참조하는 것도 동일한 이유로 허용한다. + +### 설계 의도 + +**트랜잭션 경계** + +| 클래스 | 메서드 | 트랜잭션 | 이유 | +|--------|--------|----------|------| +| OrderFacade | createOrder() | `@Transactional` | Product 재고 차감 + Order 저장이 원자적이어야 함 | +| OrderFacade | cancelOrder() | `@Transactional` | Order 상태 변경 + Product 재고 복원이 원자적이어야 함 | +| BrandService | register() | `@Transactional` | 단일 도메인 쓰기 | +| BrandService | getBrandById(), getAllBrands() | `@Transactional(readOnly = true)` | 읽기 전용 | +| ProductService | register() | `@Transactional` | 단일 도메인 쓰기 (Brand FK 검증 포함) | +| ProductService | getProductById(), getAllProducts() | `@Transactional(readOnly = true)` | 읽기 전용 | +| ProductService | decreaseStock(), increaseStock() | `@Transactional` | 재고 수정 | +| ProductLikeService | like() | `@Transactional` | 단일 도메인 쓰기 | +| ProductLikeService | unlike() | `@Transactional` | 단일 도메인 삭제 (Hard Delete) | +| OrderService | createOrder() | `@Transactional` | 주문 + OrderItem 저장 | +| OrderService | cancel() | `@Transactional` | 상태 변경 | +| OrderService | getOrderByIdAndUserId(), getOrdersByUserId() | `@Transactional(readOnly = true)` | 읽기 전용 | + +**OrderInfo — 애플리케이션 레벨 DTO** + +- OrderFacade가 Controller에게 반환하는 데이터를 OrderInfo로 정의한다. +- Order 엔티티의 세부사항을 노출하지 않고, Facade가 필요한 정보만 조합하여 전달한다. + +**ProductLikeRepository.delete() — Hard Delete 반영 (Q-L01)** + +- Q-L01 결정에 따라 `delete()` 메서드가 Repository 인터페이스에 포함된다. +- Soft Delete(`deletedAt`)가 아닌 물리 삭제이므로, JpaRepository의 `delete()`를 그대로 위임한다. + +**OrderRepository.findByIdAndUserId() — 404 정책 반영 (Q-O01)** + +- 타인 주문 접근 시 403이 아닌 404를 반환하기 위해, `findByIdAndUserId(orderId, userId)` 단일 쿼리로 존재 여부 + 소유권을 동시 검증한다. +- Optional.empty()가 반환되면 미존재/타인 주문을 구분하지 않고 NOT_FOUND 예외를 던진다. + +**다이어그램에 포함하지 않은 클래스** + +- `{Domain}V1ApiSpec`: OpenAPI 명세 인터페이스. Controller가 구현하며, Swagger 어노테이션만 포함한다. 의존 관계에 영향을 주지 않으므로 다이어그램에서는 생략하고, 레이어별 클래스 목록에서 명시한다. +- `{Domain}V1Dto`: API 요청/응답 DTO. Controller의 파라미터/반환 타입으로 사용되며, 독립적인 record 클래스이므로 다이어그램에서는 생략한다. +- **JPA Converter**: Value Object ↔ DB 컬럼 변환. Infrastructure 레이어에 위치하며, 레이어별 클래스 목록에서 명시한다. + +--- + +## 3. 레이어별 클래스 목록 + +### Domain Layer + +| 도메인 | 클래스 | 타입 | 책임 | +|--------|--------|------|------| +| Brand | Brand | Entity | 브랜드 이름 보유, guard()로 이름 검증 | +| Brand | BrandName | Value Object | 브랜드 이름 (1~50자, 공백 불가) | +| Brand | BrandService | Service | 등록(중복 검증), 단건/목록 조회 | +| Brand | BrandRepository | Interface | save, findById, findAll, existsByName | +| Product | Product | Entity | 상품 정보 보유, **decreaseStock/increaseStock** | +| Product | ProductName | Value Object | 상품명 (1~100자) | +| Product | Price | Value Object | 가격 (0 이상), Product와 OrderItem에서 공유 | +| Product | StockQuantity | Value Object | 재고 수량 (0 이상) | +| Product | ProductService | Service | 등록(Brand FK 검증), 단건/목록 조회, 재고 차감/복원 위임 | +| Product | ProductRepository | Interface | save, findById, findAll | +| ProductLike | ProductLike | Entity | User-Product 좋아요 관계 (조인 엔티티) | +| ProductLike | ProductLikeService | Service | 좋아요(중복 검증), 취소(**Hard Delete**) | +| ProductLike | ProductLikeRepository | Interface | save, **delete**, find/existsByUserIdAndProductId | +| Order | Order | Entity | 상태 전이(**cancel**), totalPrice 계산, OrderItem 관리 | +| Order | OrderItem | Entity | 주문 시점 가격 스냅샷, 수량 보유 | +| Order | Quantity | Value Object | 주문 수량 (1 이상) | +| Order | OrderStatus | Enum | ORDERED, CANCELLED | +| Order | OrderService | Service | 주문 생성, 본인 주문 조회(findByIdAndUserId), 취소 위임 | +| Order | OrderRepository | Interface | save, findByIdAndUserId, findByUserId | + +### Application Layer + +| 도메인 | 클래스 | 타입 | 책임 | +|--------|--------|------|------| +| Order | OrderFacade | Facade | 주문 생성(Product+Order 조합), 주문 취소(상태 전이+재고 복원) | +| Order | OrderInfo | Info (record) | Facade → Controller 간 애플리케이션 레벨 DTO | + +### Infrastructure Layer + +| 도메인 | 클래스 | 타입 | 책임 | +|--------|--------|------|------| +| Brand | BrandRepositoryImpl | RepositoryImpl | BrandRepository 구현, BrandJpaRepository 위임 | +| Brand | BrandJpaRepository | JpaRepository | Spring Data JPA 인터페이스 | +| Brand | BrandNameConverter | JPA Converter | BrandName ↔ VARCHAR 변환 | +| Product | ProductRepositoryImpl | RepositoryImpl | ProductRepository 구현, ProductJpaRepository 위임 | +| Product | ProductJpaRepository | JpaRepository | Spring Data JPA 인터페이스 | +| Product | ProductNameConverter | JPA Converter | ProductName ↔ VARCHAR 변환 | +| Product | PriceConverter | JPA Converter | Price ↔ BIGINT 변환 | +| Product | StockQuantityConverter | JPA Converter | StockQuantity ↔ INT 변환 | +| ProductLike | ProductLikeRepositoryImpl | RepositoryImpl | ProductLikeRepository 구현 (**delete 포함**), ProductLikeJpaRepository 위임 | +| ProductLike | ProductLikeJpaRepository | JpaRepository | Spring Data JPA 인터페이스 | +| Order | OrderRepositoryImpl | RepositoryImpl | OrderRepository 구현, OrderJpaRepository 위임 | +| Order | OrderJpaRepository | JpaRepository | Spring Data JPA 인터페이스 | +| Order | QuantityConverter | JPA Converter | Quantity ↔ INT 변환 | + +### Interfaces Layer + +| 도메인 | 클래스 | 타입 | 책임 | +|--------|--------|------|------| +| Brand | BrandV1Controller | Controller | REST API 3개 (등록, 단건 조회, 목록 조회) | +| Brand | BrandV1ApiSpec | ApiSpec (interface) | OpenAPI 명세 (Swagger 어노테이션) | +| Brand | BrandV1Dto | DTO (record) | RegisterRequest, BrandResponse | +| Product | ProductV1Controller | Controller | REST API 3개 (등록, 단건 조회, 목록 조회) | +| Product | ProductV1ApiSpec | ApiSpec (interface) | OpenAPI 명세 (Swagger 어노테이션) | +| Product | ProductV1Dto | DTO (record) | RegisterRequest, ProductResponse | +| ProductLike | ProductLikeV1Controller | Controller | REST API 2개 (좋아요, 취소) | +| ProductLike | ProductLikeV1ApiSpec | ApiSpec (interface) | OpenAPI 명세 (Swagger 어노테이션) | +| ProductLike | ProductLikeV1Dto | DTO (record) | LikeResponse | +| Order | OrderV1Controller | Controller | REST API 4개 (생성, 단건 조회, 내 목록, 취소) | +| Order | OrderV1ApiSpec | ApiSpec (interface) | OpenAPI 명세 (Swagger 어노테이션) | +| Order | OrderV1Dto | DTO (record) | CreateOrderRequest, OrderItemRequest, OrderResponse, OrderItemResponse | + +### 클래스 총 수 + +| 레이어 | 수 | +|--------|-----| +| Domain (Entity + Enum) | 7 | +| Domain (Value Object) | 5 | +| Domain (Service) | 4 | +| Domain (Repository Interface) | 4 | +| Application (Facade + Info) | 2 | +| Infrastructure (Impl + Jpa) | 8 | +| Infrastructure (Converter) | 5 | +| Interfaces (Controller + ApiSpec + Dto) | 12 | +| **합계** | **47** | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..3032e0901 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,307 @@ +# ERD (Entity-Relationship Diagram) + +> JPA 매핑 기준의 물리 테이블 구조를 정의한다. +> base branch: main + +--- + +## 목차 + +- [ERD 다이어그램](#erd-다이어그램) +- [테이블 상세 명세](#테이블-상세-명세) +- [삭제 정책](#삭제-정책) +- [테이블별 설계 의도](#테이블별-설계-의도) +- [인덱스 전략](#인덱스-전략) +- [트랜잭션 충돌 가능 지점](#트랜잭션-충돌-가능-지점) + +--- + +## ERD 다이어그램 + +### 이유 + +ERD는 도메인 모델의 물리적 데이터베이스 구조를 정의하기 위해 필요하다. +클래스 다이어그램의 논리적 모델이 실제 테이블로 어떻게 매핑되는지, +FK 관계와 제약조건이 데이터 정합성을 어떻게 보장하는지를 확인하는 것이 목적이다. + +### 다이어그램 + +```mermaid +erDiagram + users { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR login_id UK "NOT NULL, 영문+숫자 10자 이내" + VARCHAR password "NOT NULL, BCrypt" + VARCHAR name "NOT NULL, 1~50자" + VARCHAR email "NOT NULL" + VARCHAR birth_date "NOT NULL, yyyy-MM-dd" + TIMESTAMP created_at "NOT NULL" + TIMESTAMP updated_at "NOT NULL" + TIMESTAMP deleted_at "NULL" + } + + brands { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR name UK "NOT NULL, 1~50자" + TIMESTAMP created_at "NOT NULL" + TIMESTAMP updated_at "NOT NULL" + TIMESTAMP deleted_at "NULL" + } + + products { + BIGINT id PK "AUTO_INCREMENT" + BIGINT brand_id FK "NOT NULL" + VARCHAR name "NOT NULL, 1~100자" + BIGINT price "NOT NULL, >= 0" + VARCHAR description "NULL, 최대 500자" + INT stock_quantity "NOT NULL, >= 0" + TIMESTAMP created_at "NOT NULL" + TIMESTAMP updated_at "NOT NULL" + TIMESTAMP deleted_at "NULL" + } + + product_likes { + BIGINT id PK "AUTO_INCREMENT" + BIGINT user_id FK "NOT NULL" + BIGINT product_id FK "NOT NULL" + TIMESTAMP created_at "NOT NULL" + TIMESTAMP updated_at "NOT NULL" + } + + orders { + BIGINT id PK "AUTO_INCREMENT" + BIGINT user_id FK "NOT NULL" + VARCHAR status "NOT NULL, ORDERED or CANCELLED" + BIGINT total_price "NOT NULL, >= 0" + TIMESTAMP ordered_at "NOT NULL" + TIMESTAMP created_at "NOT NULL" + TIMESTAMP updated_at "NOT NULL" + TIMESTAMP deleted_at "NULL" + } + + order_items { + BIGINT id PK "AUTO_INCREMENT" + BIGINT order_id FK "NOT NULL" + BIGINT product_id FK "NOT NULL" + INT quantity "NOT NULL, >= 1" + BIGINT price "NOT NULL, 주문 시점 스냅샷" + TIMESTAMP created_at "NOT NULL" + TIMESTAMP updated_at "NOT NULL" + TIMESTAMP deleted_at "NULL" + } + + users ||--o{ orders : "user_id" + users ||--o{ product_likes : "user_id" + brands ||--o{ products : "brand_id" + products ||--o{ product_likes : "product_id" + products ||--o{ order_items : "product_id" + orders ||--o{ order_items : "order_id" +``` + +### 해석 + +**테이블 6개, 관계 6개** + +- `users` → `orders`, `product_likes`: 회원이 주문을 생성하고 좋아요를 누른다. +- `brands` → `products`: 브랜드가 여러 상품을 가진다. +- `products` → `product_likes`, `order_items`: 상품에 좋아요가 달리고 주문 항목에 포함된다. +- `orders` → `order_items`: 주문이 여러 주문 항목을 가진다. + +**핵심 설계 포인트** + +- `product_likes`는 Hard Delete 정책(Q-L01)으로 `deleted_at` 컬럼이 없다. +- `order_items.price`는 주문 시점 스냅샷으로, `products.price`와 독립적이다. +- `orders.status`는 Enum을 VARCHAR로 저장한다 (`@Enumerated(EnumType.STRING)`). +- 모든 FK 관계는 단방향이며, N 쪽 테이블에 FK 컬럼이 위치한다. + +--- + +## 테이블 상세 명세 + +### users (1주차 구현 완료, 참조용) + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| login_id | VARCHAR(10) | NOT NULL, UNIQUE | 영문+숫자만 | +| password | VARCHAR(255) | NOT NULL | BCrypt 해시 | +| name | VARCHAR(50) | NOT NULL | | +| email | VARCHAR(255) | NOT NULL | | +| birth_date | VARCHAR(10) | NOT NULL | yyyy-MM-dd | +| created_at | TIMESTAMP | NOT NULL | BaseEntity | +| updated_at | TIMESTAMP | NOT NULL | BaseEntity | +| deleted_at | TIMESTAMP | NULL | BaseEntity, Soft Delete | + +### brands + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| name | VARCHAR(50) | NOT NULL, UNIQUE | 브랜드 이름 | +| created_at | TIMESTAMP | NOT NULL | BaseEntity | +| updated_at | TIMESTAMP | NOT NULL | BaseEntity | +| deleted_at | TIMESTAMP | NULL | BaseEntity, Soft Delete | + +### products + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| brand_id | BIGINT | NOT NULL, FK(brands.id) | 소속 브랜드 | +| name | VARCHAR(100) | NOT NULL | 상품명 | +| price | BIGINT | NOT NULL, CHECK(price >= 0) | 판매 가격 (0 허용, Q-P01) | +| description | VARCHAR(500) | NULL | 상품 설명 | +| stock_quantity | INT | NOT NULL, CHECK(stock_quantity >= 0) | 재고 수량 (0 허용, Q-P02) | +| created_at | TIMESTAMP | NOT NULL | BaseEntity | +| updated_at | TIMESTAMP | NOT NULL | BaseEntity | +| deleted_at | TIMESTAMP | NULL | BaseEntity, Soft Delete | + +### product_likes + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| user_id | BIGINT | NOT NULL, FK(users.id) | 좋아요 누른 회원 | +| product_id | BIGINT | NOT NULL, FK(products.id) | 좋아요 대상 상품 | +| created_at | TIMESTAMP | NOT NULL | BaseEntity | +| updated_at | TIMESTAMP | NOT NULL | BaseEntity | + +**UNIQUE 제약**: `UK_product_likes_user_product (user_id, product_id)` + +**deleted_at 컬럼 없음**: Hard Delete 정책(Q-L01)이므로 deleted_at을 사용하지 않는다. BaseEntity를 상속하여 필드는 JPA 엔티티에 존재하지만, 비즈니스적으로 사용하지 않으며 DDL에서는 제외할 수 있다. + +### orders + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| user_id | BIGINT | NOT NULL, FK(users.id) | 주문자 | +| status | VARCHAR(20) | NOT NULL, DEFAULT 'ORDERED' | OrderStatus Enum을 VARCHAR로 저장 | +| total_price | BIGINT | NOT NULL, CHECK(total_price >= 0) | OrderItem 합산 금액 | +| ordered_at | TIMESTAMP | NOT NULL | 주문 시점 | +| created_at | TIMESTAMP | NOT NULL | BaseEntity | +| updated_at | TIMESTAMP | NOT NULL | BaseEntity | +| deleted_at | TIMESTAMP | NULL | BaseEntity, Soft Delete | + +**status 컬럼**: OrderStatus Enum은 별도 테이블로 만들지 않고, JPA `@Enumerated(EnumType.STRING)`으로 VARCHAR에 저장한다. 값은 `ORDERED`, `CANCELLED` 2개. + +### order_items + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| order_id | BIGINT | NOT NULL, FK(orders.id) | 소속 주문 | +| product_id | BIGINT | NOT NULL, FK(products.id) | 주문 상품 | +| quantity | INT | NOT NULL, CHECK(quantity >= 1) | 주문 수량 | +| price | BIGINT | NOT NULL | 주문 시점 상품 가격 (스냅샷) | +| created_at | TIMESTAMP | NOT NULL | BaseEntity | +| updated_at | TIMESTAMP | NOT NULL | BaseEntity | +| deleted_at | TIMESTAMP | NULL | BaseEntity, Soft Delete | + +**price 컬럼**: 주문 시점의 상품 가격을 복사해 저장한다. products.price가 이후 변경되어도 주문 금액은 불변이다. + +--- + +## 삭제 정책 + +| 테이블 | 정책 | deleted_at | 근거 | +|--------|------|------------|------| +| users | Soft Delete | 있음 | 1주차 구현, BaseEntity 기본 패턴 | +| brands | Soft Delete | 있음 | 브랜드 삭제 시 소속 상품 조회에 영향, 이력 보존 | +| products | Soft Delete | 있음 | 삭제된 상품도 기존 주문의 OrderItem에서 참조 가능해야 함 | +| product_likes | **Hard Delete** | **없음** | Q-L01 결정. UNIQUE 제약 단순화, 이력 보존 불필요 | +| orders | Soft Delete | 있음 | 주문 이력 보존 필수 | +| order_items | Soft Delete | 있음 | 주문에 종속, 주문과 동일한 정책 적용 | + +--- + +## 테이블별 설계 의도 + +### users + +- 1주차에 구현 완료된 테이블이다. 2주차 도메인들이 FK로 참조한다. +- login_id에 UNIQUE 제약이 있어 중복 가입을 방지한다. + +### brands + +- name에 UNIQUE 제약을 걸어 동일 브랜드 이름의 중복 등록을 방지한다. +- products 테이블에서 brand_id FK로 참조한다. Brand가 삭제(Soft Delete)되어도 기존 상품은 유지된다. + +### products + +- brand_id FK로 brands 테이블을 단방향 참조한다. +- price와 stock_quantity에 CHECK 제약으로 음수를 방지한다. +- stock_quantity는 주문 생성 시 차감, 주문 취소 시 복원된다. 동시성 이슈 가능 지점이다. +- Soft Delete 적용: 삭제된 상품도 기존 order_items.product_id가 참조할 수 있어야 한다. + +### product_likes + +- User와 Product 간 N:M 관계를 조인 테이블로 풀었다. +- (user_id, product_id) 복합 UNIQUE 제약으로 동일 사용자의 중복 좋아요를 DB 레벨에서 방지한다. +- Hard Delete 정책이므로 취소 시 레코드를 물리 삭제한다. 재좋아요 시 새로운 INSERT만 필요하다. +- deleted_at이 없으므로 UNIQUE 제약에 deletedAt 조건을 결합할 필요가 없다. + +### orders + +- user_id FK로 users 테이블을 단방향 참조한다. +- status는 Enum을 VARCHAR로 저장한다. 별도 테이블로 분리하지 않는다. +- `findByIdAndUserId(orderId, userId)` 쿼리 패턴을 지원하기 위해 (id, user_id) 복합 인덱스가 필요하다. 이 쿼리는 주문 단건 조회와 주문 취소에서 사용되며, 타인 주문 접근 시 404를 반환하는 정책(Q-O01)을 구현한다. + +### order_items + +- order_id FK로 orders, product_id FK로 products를 각각 단방향 참조한다. +- price는 주문 시점 상품 가격의 스냅샷이다. products.price와 독립적으로 유지된다. +- quantity는 1 이상이어야 한다. 주문 취소 시 이 값만큼 재고가 복원된다. + +--- + +## 인덱스 전략 + +### PK 인덱스 (자동 생성) + +| 테이블 | 인덱스 | 대상 | +|--------|--------|------| +| users | PK | id | +| brands | PK | id | +| products | PK | id | +| product_likes | PK | id | +| orders | PK | id | +| order_items | PK | id | + +### UNIQUE 인덱스 + +| 테이블 | 인덱스명 | 대상 | 용도 | +|--------|----------|------|------| +| users | UK_users_login_id | login_id | 로그인 ID 중복 방지, 인증 시 조회 | +| brands | UK_brands_name | name | 브랜드 이름 중복 방지 | +| product_likes | UK_product_likes_user_product | (user_id, product_id) | 중복 좋아요 방지, 좋아요 조회/삭제 | + +### FK 인덱스 + +| 테이블 | 인덱스명 | 대상 | 용도 | +|--------|----------|------|------| +| products | IDX_products_brand_id | brand_id | 브랜드별 상품 조회 | +| product_likes | IDX_product_likes_user_id | user_id | 복합 UNIQUE에 포함 | +| product_likes | IDX_product_likes_product_id | product_id | 상품별 좋아요 조회 | +| orders | IDX_orders_user_id | user_id | 내 주문 목록 조회 (findByUserId) | +| order_items | IDX_order_items_order_id | order_id | 주문별 항목 조회 | +| order_items | IDX_order_items_product_id | product_id | 상품별 주문 항목 역추적 | + +### 비즈니스 인덱스 + +| 테이블 | 인덱스명 | 대상 | 용도 | +|--------|----------|------|------| +| orders | IDX_orders_id_user_id | (id, user_id) | findByIdAndUserId 쿼리 최적화 (Q-O01 정책) | +| orders | IDX_orders_status | status | 상태별 주문 필터링 (향후 확장 대비) | + +--- + +## 트랜잭션 충돌 가능 지점 + +| 지점 | 테이블 | 상황 | 원인 | 대응 | +|------|--------|------|------|------| +| 주문 생성 시 재고 차감 | products | 동일 상품에 대한 동시 주문 | stock_quantity UPDATE 경합 | 현 최소 구현에서는 DB 레벨 row lock으로 처리. 대량 트래픽 시 비관적 락 또는 분산 락 검토 | +| 주문 취소 시 재고 복원 | products | 동일 상품의 재고 차감과 복원이 동시 발생 | stock_quantity UPDATE 경합 | 위와 동일 | +| 좋아요 중복 등록 | product_likes | 동일 사용자가 같은 상품에 동시 좋아요 | UNIQUE 제약 위반 | DB UNIQUE 제약으로 방어. 애플리케이션에서 예외 캐치 후 409 반환 | +| 주문 생성 + 주문 취소 동시 | orders, products | 주문 A 생성(재고 차감) + 주문 B 취소(재고 복원)가 동일 상품에서 동시 발생 | stock_quantity 갱신 순서 | 트랜잭션 격리 수준(READ COMMITTED)으로 처리. lost update는 row lock이 방지 | +| 브랜드 중복 등록 | brands | 동일 이름 브랜드 동시 등록 | UNIQUE 제약 위반 | DB UNIQUE 제약으로 방어. 애플리케이션에서 예외 캐치 후 409 반환 | diff --git a/modules/jpa/build.gradle.kts b/modules/jpa/build.gradle.kts index e62a6a7ed..481f8fb2b 100644 --- a/modules/jpa/build.gradle.kts +++ b/modules/jpa/build.gradle.kts @@ -14,8 +14,8 @@ dependencies { // jdbc-mysql runtimeOnly("com.mysql:mysql-connector-j") - testImplementation("org.testcontainers:mysql") + testImplementation("org.testcontainers:mysql:1.21.0") testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa") - testFixturesImplementation("org.testcontainers:mysql") + testFixturesImplementation("org.testcontainers:mysql:1.21.0") } diff --git a/skill.md b/skill.md new file mode 100644 index 000000000..e10d6e408 --- /dev/null +++ b/skill.md @@ -0,0 +1,369 @@ +# Week 2 - 설계 문서 작성 Skill + +> 2주차 목표: **코드 구현이 아닌 설계 문서 PR 제출** +> 다음 주 개발이 가능한 수준의 설계 문서를 작성한다. + +--- + +## 작업 규칙 + +### 브랜치 +- 작업 브랜치: `week-2` +- PR 대상: `main` +- PR 제목 형식: `[2주차] 설계 문서 제출 - 이름` +- PR 본문: 리뷰 포인트 포함 (고민한 지점, 설계 판단 등) + +### 금지 사항 +- **코드 구현 절대 금지** (Java 파일 생성/수정 불가) +- 산출물은 `docs/design/` 하위 마크다운 파일만 허용 +- 기존 프로덕션 코드, 테스트 코드 변경 금지 + +### 산출물 목록 +| 순서 | 파일 | 내용 | +|------|------|------| +| 1 | `docs/design/01-requirements.md` | 요구사항 정의서 | +| 2 | `docs/design/02-sequence-diagrams.md` | 시퀀스 다이어그램 (최소 2개 이상, Mermaid 권장) | +| 3 | `docs/design/03-class-diagram.md` | 클래스 다이어그램 | +| 4 | `docs/design/04-erd.md` | ERD (Entity-Relationship Diagram) | + +### 작성 순서 (필수) +``` +Requirements → Sequence → Class → ERD +``` +- 각 단계는 이전 단계의 산출물을 근거로 작성한다. +- 순서를 건너뛰거나 역순으로 작성하지 않는다. + +### 설계 대상 도메인 +- **Brand** (브랜드) +- **Product** (상품) +- **ProductLike** (상품 좋아요) +- **Order** (주문) +- **OrderItem** (주문 항목) + +### 기존 도메인 참조 +- **User** (1주차 구현 완료) - 설계 시 관계 참조용으로만 사용, 회원 도메인은 설계 범위에서 제외 + +### 유비쿼터스 언어 규칙 +- 문서 / 코드 / ERD에서 동일한 용어를 사용한다 +- 통일 용어: Brand, Product, Like(ProductLike), Order, OrderItem +- 도메인 용어를 임의로 변역하거나 다른 단어로 대체하지 않는다 + +--- + +## 요구사항 분석 원칙 + +> 모든 Phase에 앞서, 요구사항을 분석할 때 반드시 아래 원칙을 따른다. + +### 1. 요구사항을 그대로 믿지 말고 문제 상황으로 재해석한다 +- "무엇을 만들까?"가 아니라 **"어떤 문제를 해결하려는가?"** +- 사용자 관점 / 비즈니스 관점 / 시스템 관점으로 분리한다 + +### 2. 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하지 않는다 +- 반드시 포함할 질문 유형: + - **정책 질문**: 비즈니스 규칙의 경계 (예: 좋아요 취소 가능 여부) + - **경계 질문**: 값의 범위, 상태 전이 조건 + - **확장 질문**: 향후 변경 가능성이 높은 지점 + +### 3. 질문은 선택지 + 영향도 형태로 제시한다 +- 선택지 A: 장점 / 단점 +- 선택지 B: 장점 / 단점 +- 영향 범위를 함께 기술한다 + +### 4. 코드로 바로 가지 말고 개념 모델부터 정의한다 +- **액터**: 누가 이 시스템을 사용하는가 +- **핵심 도메인**: Brand, Product, ProductLike, Order, OrderItem +- **보조/외부 시스템**: 인증, 결제 등 + +### 5. 다이어그램 작성 순서 +모든 다이어그램은 반드시 아래 순서로 작성한다: +``` +이유(왜 이 다이어그램이 필요한가) → Mermaid 다이어그램 → 해석(읽는 포인트, 설계 의도) +``` +- 다이어그램을 던지고 끝내지 않는다 +- 읽는 포인트(설계 의도)를 반드시 설명한다 + +### 6. 설계의 잠재 리스크를 반드시 언급한다 +- [ ] 트랜잭션 비대화 리스크 +- [ ] 도메인 간 결합도 증가 리스크 +- [ ] 정책 변경 시 영향 범위 + +### 7. 톤 규칙 +- 설계 리뷰 톤 유지 +- 정답처럼 말하지 않는다 +- 코드보다 **의도 / 책임 / 경계**를 강조한다 + +--- + +## 단계별 실행 가이드 + +### Phase 1: 요구사항 정의 (`01-requirements.md`) + +**목적**: 과제 명세를 구조화된 요구사항으로 정리하되, 유저 중심으로 재해석한다 + +**작성 절차**: +1. 유저 시나리오 도출 +2. 행위 중심 기능 목록 정리 +3. 유스케이스 흐름 작성 (Main Flow / Alternate Flow / Exception Flow) +4. 예외 / 조건 / 후속 동작 포함 + +**작성 항목**: + +#### 1-1. 액터 및 개념 모델 +- [ ] 액터 식별 (누가 이 시스템을 사용하는가) +- [ ] 핵심 도메인 나열 (Brand, Product, ProductLike, Order, OrderItem) +- [ ] 보조/외부 시스템 식별 (인증, 결제 등) +- [ ] 도메인 간 관계 개요 (1줄 요약 수준) + +#### 1-2. 도메인별 API 엔드포인트 목록 +- [ ] Brand API: HTTP Method, Path, 설명, 인증 여부 +- [ ] Product API: HTTP Method, Path, 설명, 인증 여부 +- [ ] ProductLike API: HTTP Method, Path, 설명, 인증 여부 +- [ ] Order API: HTTP Method, Path, 설명, 인증 여부 + +#### 1-3. 도메인별 필드 정의 +- [ ] Brand: 필드명, 타입, 제약조건, 검증 규칙 +- [ ] Product: 필드명, 타입, 제약조건, 검증 규칙 +- [ ] ProductLike: 필드명, 타입, 제약조건, 검증 규칙 +- [ ] Order: 필드명, 타입, 제약조건, 검증 규칙 +- [ ] OrderItem: 필드명, 타입, 제약조건, 검증 규칙 + +#### 1-4. 유스케이스 흐름 +- [ ] 각 API별 Main Flow (정상 흐름) +- [ ] 각 API별 Alternate Flow (대안 흐름) +- [ ] 각 API별 Exception Flow (예외 흐름) + +#### 1-5. 비즈니스 규칙 +- [ ] 도메인 간 관계 및 제약 +- [ ] 상태 전이 규칙 (주문 상태 등) +- [ ] 로그인 여부 등 접근 조건 + +#### 1-6. 에러 케이스 +- [ ] 예외 상황별 HTTP Status 정의 +- [ ] 에러 메시지 정의 + +#### 1-7. 애매한 요구사항 및 질문 목록 +- [ ] 정책 질문 (선택지 + 영향도 형태) +- [ ] 경계 질문 (선택지 + 영향도 형태) +- [ ] 확장 질문 (선택지 + 영향도 형태) + +**Phase 1 실수 방지 체크리스트**: +- [ ] 예외 흐름이 누락되지 않았는가 +- [ ] 너무 추상적으로 작성하지 않았는가 (구체적 조건/값 명시) +- [ ] 로그인 여부 등 접근 조건이 누락되지 않았는가 +- [ ] 요구사항을 유저 관점에서 작성했는가 (시스템 관점만으로 작성하지 않았는가) +- [ ] 애매한 요구사항을 추측으로 처리하지 않고 질문으로 드러냈는가 + +--- + +### Phase 2: 시퀀스 다이어그램 (`02-sequence-diagrams.md`) + +**목적**: 누가 무엇을 책임지는지 표현하고, 호출 순서와 트랜잭션 경계를 확인한다 + +**최소 요건**: 시퀀스 다이어그램 **2개 이상** 작성 + +**작성 항목**: + +#### 2-1. 다이어그램 작성 (각 다이어그램마다) +- [ ] **이유**: 왜 이 다이어그램이 필요한가 +- [ ] **Mermaid 시퀀스 다이어그램** (`sequenceDiagram` 문법) +- [ ] **해석**: 읽는 포인트, 설계 의도 설명 + +#### 2-2. 참여자(Participant) 정의 +- [ ] Controller (Interfaces Layer) +- [ ] Facade (Application Layer) — 여러 도메인 조합 시 +- [ ] Service (Domain Layer) +- [ ] Repository (Domain Layer 인터페이스) +- [ ] DB (Infrastructure Layer) + +#### 2-3. 흐름 표현 +- [ ] 정상 흐름 (Main Flow) +- [ ] 주요 예외 흐름 (alt/opt 블록 활용) +- [ ] 트랜잭션 경계 표시 + +#### 2-4. 대상 API (최소 2개, 복잡도 높은 것 우선) +- [ ] 복잡한 비즈니스 로직이 있는 API → 개별 다이어그램 +- [ ] 단순 CRUD API → 통합 다이어그램 가능 + +**Phase 2 완료 기준**: +- [ ] 최소 2개 이상의 시퀀스 다이어그램이 작성되었는가 +- [ ] 모든 다이어그램이 "이유 → 다이어그램 → 해석" 구조인가 +- [ ] 레이어 간 호출 순서가 프로젝트 아키텍처와 일치하는가 +- [ ] 책임 객체가 명확히 드러나는가 +- [ ] 트랜잭션 경계가 표시되었는가 +- [ ] 주요 예외 흐름이 포함되었는가 + +--- + +### Phase 3: 클래스 다이어그램 (`03-class-diagram.md`) + +**목적**: 엔티티/VO 구분, 연관관계 표현, 도메인 객체에 책임 부여 + +**작성 항목**: + +#### 3-1. 도메인 모델 클래스 다이어그램 +- [ ] **이유**: 왜 이 다이어그램이 필요한가 +- [ ] **Mermaid 클래스 다이어그램** (`classDiagram` 문법) +- [ ] **해석**: 읽는 포인트, 설계 의도 설명 + +#### 3-2. 클래스 구성요소 +- [ ] Entity 정의 (Brand, Product, ProductLike, Order, OrderItem) +- [ ] Value Object 식별 및 정의 +- [ ] Enum 식별 및 정의 (주문 상태 등) +- [ ] 각 클래스의 필드 +- [ ] 각 클래스의 주요 메서드 (도메인 책임 표현) + +#### 3-3. 클래스 간 관계 +- [ ] 연관관계는 **단방향 기본** 원칙 +- [ ] 상속 관계 (BaseEntity) +- [ ] 의존 관계 +- [ ] 연관 관계 (1:N, N:M 등 cardinality 표기) + +#### 3-4. 레이어별 클래스 목록 +- [ ] **Domain**: {Domain}Model, {Domain}Service, {Domain}Repository(인터페이스), Value Objects, Enums +- [ ] **Application**: {Domain}Facade, {Domain}Info +- [ ] **Infrastructure**: {Domain}RepositoryImpl, {Domain}JpaRepository, Converter +- [ ] **Interfaces**: {Domain}V1Controller, {Domain}V1ApiSpec, {Domain}V1Dto + +**Phase 3 완료 기준**: +- [ ] 다이어그램이 "이유 → 다이어그램 → 해석" 구조인가 +- [ ] 모든 도메인의 Entity가 정의되었는가 +- [ ] Value Object가 식별되고 정의되었는가 +- [ ] 도메인 객체에 책임(메서드)이 포함되었는가 +- [ ] 연관관계가 단방향 기본 원칙을 따르는가 +- [ ] 클래스 간 관계(연관, 의존, 상속)가 표현되었는가 +- [ ] 레이어별 클래스 목록이 누락 없이 작성되었는가 +- [ ] CLAUDE.md 네이밍 규칙을 따르는가 +- [ ] 1주차 User 도메인 구조 패턴과 일관성이 있는가 +- [ ] 클래스 구조가 도메인 책임을 잘 표현하는가 + +--- + +### Phase 4: ERD (`04-erd.md`) + +**목적**: 물리적 데이터베이스 설계, 데이터 정합성 확보 + +**작성 항목**: + +#### 4-1. ERD 다이어그램 +- [ ] **이유**: 왜 이 다이어그램이 필요한가 +- [ ] **Mermaid ERD** (`erDiagram` 문법) +- [ ] **해석**: 읽는 포인트, 설계 의도 설명 + +#### 4-2. 테이블별 상세 명세 +- [ ] Brand 테이블: 컬럼명, 타입, 제약조건(PK, FK, UNIQUE, NOT NULL) +- [ ] Product 테이블: 컬럼명, 타입, 제약조건 +- [ ] ProductLike 테이블: 컬럼명, 타입, 제약조건 +- [ ] Order 테이블: 컬럼명, 타입, 제약조건 +- [ ] OrderItem 테이블: 컬럼명, 타입, 제약조건 +- [ ] 각 테이블 인덱스 설계 + +#### 4-3. 테이블 간 관계 +- [ ] 1:N 관계 → FK로 표현 +- [ ] N:M 관계 → 조인 테이블로 표현 +- [ ] 기존 User 테이블과의 FK 관계 명시 + +#### 4-4. 공통 설계 기준 +- [ ] BaseEntity 공통 컬럼 포함 (id, created_at, updated_at, deleted_at) +- [ ] Soft Delete 전략 적용 (deleted_at) +- [ ] 상태 컬럼으로 명확한 상태 전이 표현 (주문 상태 등) +- [ ] 데이터 정합성 고려 + +**Phase 4 완료 기준**: +- [ ] 다이어그램이 "이유 → 다이어그램 → 해석" 구조인가 +- [ ] 모든 테이블이 정의되었는가 +- [ ] PK, FK, 인덱스가 명시되었는가 +- [ ] 테이블 간 관계가 정확한가 +- [ ] BaseEntity 컬럼이 포함되었는가 +- [ ] Soft Delete 전략이 적용되었는가 +- [ ] 상태 전이가 상태 컬럼으로 표현되었는가 +- [ ] ERD가 데이터 정합성을 고려하는가 + +--- + +## 산출물 최종 체크리스트 + +### 문서 완성도 +- [ ] `01-requirements.md` 작성 완료 +- [ ] `02-sequence-diagrams.md` 작성 완료 (최소 2개 이상 다이어그램) +- [ ] `03-class-diagram.md` 작성 완료 +- [ ] `04-erd.md` 작성 완료 + +### 도메인 커버리지 +- [ ] Brand 도메인이 모든 문서에 포함되었는가 +- [ ] Product 도메인이 모든 문서에 포함되었는가 +- [ ] ProductLike 도메인이 모든 문서에 포함되었는가 +- [ ] Order 도메인이 모든 문서에 포함되었는가 +- [ ] OrderItem 도메인이 모든 문서에 포함되었는가 + +### 문서 간 일관성 검증 +- [ ] 요구사항의 모든 API가 시퀀스 다이어그램에 반영되었는가 +- [ ] 시퀀스 다이어그램의 참여자가 클래스 다이어그램에 존재하는가 +- [ ] 클래스 다이어그램의 Entity가 ERD 테이블과 1:1 매핑되는가 +- [ ] ERD의 FK 관계가 클래스 다이어그램의 연관 관계와 일치하는가 +- [ ] 유비쿼터스 언어가 모든 문서에서 통일되어 사용되는가 + +### 다이어그램 구조 검증 +- [ ] 모든 다이어그램이 "이유 → Mermaid 다이어그램 → 해석" 순서로 작성되었는가 +- [ ] 모든 다이어그램에 읽는 포인트(설계 의도)가 설명되었는가 + +### 요구사항 품질 검증 +- [ ] 요구사항이 유저 중심인가 +- [ ] 예외 흐름이 누락되지 않았는가 +- [ ] 로그인 여부 등 접근 조건이 명시되었는가 +- [ ] 애매한 요구사항이 질문으로 드러나 있는가 + +### 설계 품질 검증 +- [ ] 시퀀스에서 책임 객체가 드러나는가 +- [ ] 클래스 구조가 도메인 책임을 잘 표현하는가 +- [ ] ERD가 데이터 정합성을 고려하는가 +- [ ] 잠재 리스크(트랜잭션 비대화, 결합도 증가, 정책 변경 영향)가 언급되었는가 + +### 프로젝트 규칙 준수 +- [ ] CLAUDE.md 네이밍 규칙을 따르는가 +- [ ] 레이어 구조(Domain → Application → Infrastructure → Interfaces)를 따르는가 +- [ ] 1주차 User 도메인 패턴과 일관성이 있는가 + +### PR 제출 준비 +- [ ] PR 제목이 `[2주차] 설계 문서 제출 - 이름` 형식인가 +- [ ] PR 본문에 리뷰 포인트(고민한 지점, 설계 판단)가 포함되었는가 + +--- + +## 설계 판단 기록 + +> 설계 과정에서 내린 판단과 그 근거를 여기에 기록한다. +> 각 판단은 "선택지 + 영향도" 형태로 작성한다. +> +> **상세 결정 사항**: [`docs/design/01-requirements.md` § 7. 설계 결정 사항](docs/design/01-requirements.md#7-설계-결정-사항) 참조 + +### 도메인 관계 판단 + +| 판단 항목 | 선택지 | 선택 | 근거 | +|-----------|--------|------|------| +| Brand - Product 관계 | A: 1:N / B: N:M | **A: 1:N** | 상품은 하나의 브랜드에 소속, 개념 모델에서 정의 | +| Product - ProductLike 관계 | A: 1:N | **A: 1:N** | 상품에 여러 좋아요, 좋아요는 하나의 상품 참조 | +| Order - OrderItem 관계 | A: 1:N | **A: 1:N** | 주문은 여러 항목 포함, 항목은 하나의 주문 소속 | +| User - Order 관계 | A: 1:N | **A: 1:N** | 회원이 여러 주문 생성 | +| User - ProductLike 관계 | A: 1:N | **A: 1:N** | 회원이 여러 상품에 좋아요 | + +### 비즈니스 규칙 판단 (확정) + +| 판단 항목 | 선택지 | 선택 | 근거 | +|-----------|--------|------|------| +| Q-O02: 주문 상태 전이 | A: ORDERED+CANCELLED / B: 확장 상태 | **A** | 배송/완료는 현재 요구사항에 없음. Enum 확장은 하위 호환 가능 | +| Q-L01: 좋아요 취소 방식 | A: Soft Delete / B: Hard Delete | **B** | UNIQUE 제약 단순화, restore 로직 불필요 | +| Q-O03: 주문 취소 시 재고 복원 | A: 복원 / B: 미복원 | **A** | 데이터 정합성은 타협 불가. Facade에서 책임 분리 | +| Q-P01: 상품 가격 범위 | A: 0 이상 / B: 1 이상 | **A** | 무료 상품 제한 근거 없음. 0원 주문은 주문 정책으로 분리 가능 | +| Q-P02: 재고 0 상품 노출 | A: 노출 / B: 제외 | **A** | 재고 검증은 주문 시점 책임. 조회 쿼리 단순화 | +| Q-E01: 페이징 | A: 없음 / B: 적용 | **A** | 최소 구현 범위에서 데이터 양 제한적. 이후 추가 용이 | +| Q-E02: 상품 수정/삭제 API | A: 포함 / B: 제외 | **B** | 명세 미명시. 핵심 흐름에 불필요 | +| Q-O01: 타인 주문 접근 응답 | A: 403 / B: 404 | **B** | orderId 존재 여부 비노출. findByIdAndUserId 단일 쿼리 | + +### 잠재 리스크 기록 + +| 리스크 항목 | 영향 범위 | 대응 방안 | +|-------------|-----------|-----------| +| 트랜잭션 비대화 | 주문 취소 시 Order 상태 변경 + Product 재고 복원이 하나의 트랜잭션 | Facade에서 OrderService + ProductService 조합, 각 도메인 책임 분리 | +| 도메인 간 결합도 증가 | 주문 생성/취소 시 Product 도메인 의존 | Facade 레이어에서만 조합, Service 간 직접 참조 금지 | +| 정책 변경 시 영향 범위 | 주문 상태 확장 시 Enum + 전이 검증 + 시퀀스 전체 수정 | 현재 2개 상태로 최소화, Enum 확장은 하위 호환 가능 설계 |