diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index f08144908..73a96f30d 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -18,7 +18,7 @@ allowed-tools: Read, Grep ↓ ┌─────────────────────────────────────────┐ │ Application Layer │ ← 유스케이스 조합 -│ (Facade, Info) │ +│ (App, Facade, Info) │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ @@ -233,38 +233,75 @@ public interface MemberRepository { ## Application Layer (응용 계층) ### 책임 -- 여러 도메인 서비스 조합 -- 유스케이스 구현 -- 트랜잭션 경계 설정 (선택적) +- **App**: 단일 도메인의 유스케이스 처리, Service 호출 및 Model → Info 변환 +- **Facade**: **2개 이상의 App을 조합**할 때만 사용, 크로스 도메인 오케스트레이션 -### Facade 예시 +### App 예시 (단일 도메인 — 기본 패턴) ```java @Component @RequiredArgsConstructor -public class MemberFacade { +public class MemberApp { private final MemberService memberService; - private final PointService pointService; - private final NotificationService notificationService; - @Transactional - public MemberInfo registerMemberWithWelcomePoint(MemberInfo.RegisterRequest request) { - // 1. 회원 가입 - MemberModel member = memberService.register( - request.memberId(), request.password(), request.email(), - request.birthDate(), request.name(), request.gender() - ); + public MemberInfo register(String memberId, String password, String email, + String birthDate, String name, Gender gender) { + MemberModel member = memberService.register(memberId, password, email, birthDate, name, gender); + return MemberInfo.from(member); + } - // 2. 웰컴 포인트 지급 - pointService.grantWelcomePoint(member.getMemberId()); + @Transactional(readOnly = true) + public MemberInfo getMe(String loginId, String loginPw) { + MemberModel member = memberService.authenticate(loginId, loginPw); + return MemberInfo.from(member); + } - // 3. 가입 환영 알림 발송 - notificationService.sendWelcomeNotification(member.getEmail()); + public void changePassword(String loginId, String loginPw, + String currentPassword, String newPassword) { + memberService.changePassword(loginId, loginPw, currentPassword, newPassword); + } +} +``` - return MemberInfo.from(member); +**App 의존성 규칙**: +``` +✅ App → Service (허용) +❌ App → Repository 직접 의존 (금지) +❌ App → App 의존 (금지 — 크로스 도메인은 Facade 책임) +❌ App → Facade 의존 (금지) +``` + +### Facade 예시 (크로스 도메인 — 2개 이상 App 조합 시에만) +```java +@Component +@RequiredArgsConstructor +public class OrderFacade { + private final MemberApp memberApp; + private final ProductApp productApp; + private final OrderApp orderApp; + + @Transactional + public OrderInfo placeOrder(String memberId, String productId, int quantity) { + // 1. 회원 확인 + MemberInfo member = memberApp.getMe(memberId, /* ... */); + + // 2. 상품 재고 확인 및 차감 + productApp.decreaseStock(productId, quantity); + + // 3. 주문 생성 + return orderApp.createOrder(member.id(), productId, quantity); } } ``` +**Facade 의존성 규칙**: +``` +✅ Facade → App (허용, 반드시 2개 이상) +❌ Facade → Service 직접 호출 (금지 — 반드시 App 경유) +❌ Facade → Repository 직접 의존 (금지) +❌ Facade → Facade 의존 (금지) +❌ 단일 도메인만 처리하는 Facade 생성 (금지 — App을 사용할 것) +``` + --- ## Interfaces Layer (인터페이스 계층) @@ -282,18 +319,18 @@ public class MemberFacade { @RequestMapping("/api/v1/members") public class MemberV1Controller implements MemberV1ApiSpec { - private final MemberService memberService; + private final MemberApp memberApp; @PostMapping("/register") @Override public ApiResponse register( @Valid @RequestBody MemberV1Dto.RegisterRequest request) { - MemberModel member = memberService.register( + MemberInfo info = memberApp.register( request.memberId(), request.password(), request.email(), request.birthDate(), request.name(), request.gender() ); - MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.from(member); + MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.from(info); return ApiResponse.success(response); } } @@ -374,8 +411,12 @@ com.loopers │ │ └── PasswordHasher.java # Interface ├── application # 응용 계층 │ ├── member -│ │ ├── MemberFacade.java # Facade +│ │ ├── MemberApp.java # App (단일 도메인 유스케이스) │ │ └── MemberInfo.java # Info +│ ├── order # 크로스 도메인 예시 +│ │ ├── OrderApp.java # App (order 도메인) +│ │ ├── OrderFacade.java # Facade (MemberApp + ProductApp + OrderApp 조합) +│ │ └── OrderInfo.java # Info ├── infrastructure # 인프라 계층 │ ├── member │ │ ├── MemberRepositoryImpl.java @@ -423,3 +464,85 @@ com.loopers - ❌ 순환 참조 - ❌ 도메인 로직 누수 (Controller에 비즈니스 로직) - ❌ God Service (하나의 Service에 모든 로직) + +--- + +## Application Layer 규칙 (확정 결정) + +### 어노테이션 +- App: **`@Component`** 사용 (절대 `@Service` 사용 금지) +- Facade: **`@Component`** 사용 (절대 `@Service` 사용 금지) +- Service: **`@Service`** 사용 + +### App 의존성 규칙 +``` +✅ App → Service (허용) +❌ App → Repository 직접 의존 (금지) +❌ App → App 의존 (금지) +❌ App → Facade 의존 (금지) +``` + +### Facade 사용 조건 및 의존성 규칙 +``` +✅ Facade → App (허용, 반드시 2개 이상의 App 사용 시에만 Facade 생성) +❌ Facade → Service 직접 호출 (금지 — 반드시 App 경유) +❌ Facade → Repository 직접 의존 (금지) +❌ Facade → Facade 의존 (금지) +❌ 단일 App만 사용하는 Facade 생성 (금지 — App을 직접 사용할 것) +``` + +### 크로스 도메인 오케스트레이션은 Facade 책임 (App 경유) +```java +// ✅ 올바른 예: BrandFacade에서 cascade delete 처리 (App 경유) +@Transactional +public void deleteBrand(String brandId) { + brandApp.deleteBrand(brandId); // BrandService 호출 + productApp.deleteProductsByBrandRefId(brandId); // ProductService 호출 +} + +// ❌ 잘못된 예: Facade에서 Service 직접 호출 +@Transactional +public void deleteBrand(String brandId) { + brandService.deleteBrand(brandId); // 금지: Facade → Service 직접 호출 + productService.deleteProductsByBrandRefId(brand.getId()); // 금지 +} +``` + +도메인 간 연쇄 처리(cascade)가 필요하면 Service에 두지 말고 Facade에서 App을 통해 조율할 것. + +--- + +## 도메인 간 의존성 규칙 (확정 결정) + +### Model과 Repository 인터페이스: 자기 도메인 VO만 사용 +```java +// ✅ OrderModel은 order.vo만 import +import com.loopers.domain.order.vo.OrderId; +import com.loopers.domain.order.vo.RefMemberId; // order 도메인 소유 + +// ❌ OrderModel이 like.vo를 import하는 것은 금지 +import com.loopers.domain.like.vo.RefMemberId; +``` + +### Service: 타 도메인 Repository 호출 시 해당 도메인 VO import 허용 +```java +// ✅ LikeService가 ProductRepository를 호출하기 위해 ProductId VO import +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; // 관계가 있으므로 허용 + +// ✅ ProductService가 BrandRepository를 호출하기 위해 BrandId VO import +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; // 관계가 있으므로 허용 +``` + +Service 계층에서 타 도메인 Repository를 직접 사용하는 것은 **트랜잭션 원자성 보장** 목적으로 허용됨. +단, 이는 의도적 설계 결정이며, 단순 조회 위임이라면 타 도메인 Service를 통하는 것을 검토할 것. + +### 참조 VO (RefOOOId) 소유권 +- `RefMemberId`, `RefProductId` 등 타 도메인의 PK를 참조하는 VO는 **사용하는 도메인이 자기 vo 패키지에 별도 정의** +- 예: `order.vo.RefMemberId`, `like.vo.RefMemberId` — 같은 이름이어도 별개의 독립 VO +- 한 도메인의 VO를 다른 도메인 Model/Repository가 import하는 것은 도메인 경계 위반 + +### Converter 소유권 +- 각 도메인의 VO에 대응하는 Converter는 해당 도메인의 VO를 사용하는 Entity 맥락에 맞게 별도 정의 +- 예: `RefMemberIdConverter` (like.vo용), `OrderRefMemberIdConverter` (order.vo용) diff --git a/.claude/skills/chat/SKILL.md b/.claude/skills/chat/SKILL.md new file mode 100644 index 000000000..a79508ead --- /dev/null +++ b/.claude/skills/chat/SKILL.md @@ -0,0 +1,13 @@ +--- +name: chat +description: 파일을 수정하지 않고 질문에만 답변하는 채팅 모드. 코드 분석, 개념 설명, 아키텍처 논의 등 순수 대화가 필요할 때 사용. +disable-model-invocation: true +allowed-tools: Read, Grep, Glob +--- + +채팅 모드가 활성화되었습니다. + +이 모드에서는 파일 수정, 생성, 삭제, 명령어 실행을 하지 않습니다. +코드 읽기와 검색만 허용되며, 질문에 대한 답변과 분석에 집중합니다. + +$ARGUMENTS \ No newline at end of file diff --git a/.claude/skills/coding-standards/SKILL.md b/.claude/skills/coding-standards/SKILL.md index 872b946a6..80bae82ae 100644 --- a/.claude/skills/coding-standards/SKILL.md +++ b/.claude/skills/coding-standards/SKILL.md @@ -23,11 +23,17 @@ allowed-tools: Read, Grep | Controller | `{Domain}V{version}Controller` | `MemberV1Controller` | `interfaces.api.{domain}` | | API Spec | `{Domain}V{version}ApiSpec` | `MemberV1ApiSpec` | `interfaces.api.{domain}` | | DTO | `{Domain}V{version}Dto` | `MemberV1Dto` | `interfaces.api.{domain}` | -| Facade | `{Domain}Facade` | `MemberFacade` | `application.{domain}` | +| **App** | `{Domain}App` | `MemberApp`, `OrderApp` | `application.{domain}` | +| **Facade** | `{Domain}Facade` | `OrderFacade` | `application.{domain}` | | Info | `{Domain}Info` | `MemberInfo` | `application.{domain}` | | Exception | `{Concept}Exception` | `CoreException` | `support.error` | | Converter | `{ValueObject}Converter` | `MemberIdConverter` | `infrastructure.jpa.converter` | +> **App vs Facade 선택 기준**: +> - 단일 도메인 유스케이스 → **`{Domain}App`** 사용 +> - 2개 이상의 App을 조합하는 크로스 도메인 → **`{Domain}Facade`** 사용 +> - Facade는 반드시 2개 이상의 App을 호출할 때만 생성 (단일 App만 쓰는 Facade 금지) + ### 메서드 네이밍 #### Repository @@ -39,6 +45,15 @@ allowed-tools: Read, Grep #### Service - 도메인 용어 사용: `register`, `getMemberByMemberId`, `updateProfile`, `withdraw` +- 타 도메인 PK(DB id)를 파라미터로 받는 메서드: **`RefId` 접미사** 사용 (`DbId` 사용 금지) + ```java + // ✅ 올바름 + ProductModel getProductByRefId(Long id) + void deleteProductsByBrandRefId(Long brandDbId) + BrandModel getBrandByRefId(Long id) + // ❌ 금지 + ProductModel getProductByDbId(Long id) + ``` #### Controller - RESTful 원칙: GET (조회), POST (생성), PUT (전체 수정), PATCH (부분 수정), DELETE (삭제) @@ -401,6 +416,34 @@ public record Email(String address) { 5. **Unused Import**: 사용하지 않는 import 제거 6. **Raw Type**: 제네릭 타입 명시 7. **Exception Swallowing**: 예외를 무시하지 말 것 +8. **`var` 키워드 사용 금지**: 반드시 명시적 타입 사용 + ```java + // ❌ 금지 + var product = productRepository.findById(id); + // ✅ 허용 + Optional product = productRepository.findById(id); + ``` +9. **중첩 클래스/레코드 정의 금지**: 클래스나 record 내부에 다른 record/class 정의 금지 → 별도 파일로 분리 + ```java + // ❌ 금지 + public class OrderApp { + public record OrderCommand(String productId, int qty) {} + } + // ✅ 허용: OrderCommand.java 별도 파일로 생성 + ``` +10. **단일 도메인에 Facade 생성 금지**: 단일 도메인은 App으로 처리 + ```java + // ❌ 금지: MemberFacade가 MemberApp 하나만 사용하는 경우 + public class MemberFacade { + private final MemberApp memberApp; // 단일 App만 사용 → Facade 불필요 + } + // ✅ 허용: App 직접 사용 + public class MemberV1Controller { + private final MemberApp memberApp; + } + ``` +11. **App/Facade에서 Repository 직접 의존 금지**: 반드시 Service 경유 +12. **Facade에서 Service 직접 호출 금지**: 반드시 App 경유 ### ✅ Best Practices 1. **불변 객체 선호**: `record`, `final` 활용 diff --git a/.claude/skills/jpa-database/SKILL.md b/.claude/skills/jpa-database/SKILL.md index 78474d828..583e90ef2 100644 --- a/.claude/skills/jpa-database/SKILL.md +++ b/.claude/skills/jpa-database/SKILL.md @@ -452,6 +452,66 @@ class MemberServiceIntegrationTest { --- +## @Query 패턴 (확정 결정) + +### EntityManager 직접 사용 금지 +프로덕션 코드에서 `EntityManager`를 직접 사용하는 것은 금지. 반드시 JpaRepository의 `@Query`로 대체. + +```java +// ❌ 금지: EntityManager 직접 사용 +@Autowired +private EntityManager entityManager; + +public Page findProducts(...) { + Query query = entityManager.createNativeQuery("SELECT ...", ProductModel.class); + // ... +} + +// ✅ 올바름: JpaRepository에 @Query 정의 +public interface ProductJpaRepository extends JpaRepository { + @Query(value = "SELECT * FROM products WHERE ...", nativeQuery = true) + Page findActiveProducts(...); +} +``` + +### 페이징 네이티브 쿼리: countQuery 필수 +```java +// ✅ 페이징 native query는 반드시 countQuery 명시 +@Query( + value = "SELECT p.* FROM products p LEFT JOIN likes l ON p.id = l.ref_product_id " + + "WHERE p.deleted_at IS NULL GROUP BY p.id ORDER BY COUNT(l.id) DESC", + countQuery = "SELECT COUNT(*) FROM products p WHERE p.deleted_at IS NULL", + nativeQuery = true +) +Page findActiveSortByLikesDesc(Pageable pageable); +``` + +### 조건부 UPDATE: @Modifying + @Query +```java +// ✅ 재고 차감처럼 조건부 UPDATE는 @Modifying 사용 +@Modifying +@Query(value = "UPDATE products SET stock_quantity = stock_quantity - :quantity " + + "WHERE id = :productId AND stock_quantity >= :quantity", nativeQuery = true) +int decreaseStockIfAvailable(@Param("productId") Long productId, @Param("quantity") int quantity); +``` + +### nullable 파라미터 조건 필터링: JPQL 활용 +```java +// ✅ null 가능한 파라미터는 JPQL의 조건부 표현식으로 처리 +@Query("SELECT o FROM OrderModel o WHERE o.refMemberId = :refMemberId " + + "AND (:startDateTime IS NULL OR o.createdAt >= :startDateTime) " + + "AND (:endDateTime IS NULL OR o.createdAt <= :endDateTime) " + + "ORDER BY o.createdAt DESC") +Page findByRefMemberIdWithDateFilter( + @Param("refMemberId") RefMemberId refMemberId, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable +); +``` + +--- + ## 주의사항 ### Entity 설계 @@ -462,6 +522,7 @@ class MemberServiceIntegrationTest { ### Repository 설계 - ❌ **Service에서 JpaRepository 직접 사용 금지**: RepositoryImpl 경유 +- ❌ **EntityManager 직접 사용 금지**: `@Query` 어노테이션으로 대체 - ✅ **Domain Repository 인터페이스**: 도메인 용어 사용 - ✅ **쿼리 메서드 활용**: 간단한 조회는 메서드명으로 diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 0d3eddb97..37ae2c1ea 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -45,6 +45,131 @@ void testExample() { } ``` +**주석 규칙:** +- 단위/통합 테스트: `// given`, `// when`, `// then` +- E2E 테스트: `// arrange`, `// act`, `// assert` + +### @Nested 테스트 구조 패턴 + +**목적:** 관련된 테스트를 논리적으로 그룹화하여 가독성 향상 + +**구조:** +```java +@DisplayName("{Entity} 엔티티") +class EntityTest { + + @DisplayName("{행위}를 할 때,") + @Nested + class ContextGroup { + @Test + @DisplayName("{조건}이면 {결과}") + void test_method_name() { + // given: 테스트 데이터 준비 + + // when: 테스트 대상 실행 + + // then: 결과 검증 + } + } +} +``` + +**네이밍 컨벤션:** +- **테스트 클래스 DisplayName**: `"{Entity} 엔티티"` 또는 `"{Domain} {레이어}"` +- **@Nested 클래스명**: 영어 명사/동사 (Create, Delete, DecreaseStock 등) +- **@Nested DisplayName**: 한글 동사구 + 쉼표 (예: `"브랜드를 생성할 때,"`, `"재고를 차감할 때,"`) +- **테스트 메서드명**: 영어 snake_case (예: `create_brand_model`, `decreaseStock_success`) +- **테스트 메서드 DisplayName**: 한글 명사구 (예: `"create() 정적 팩토리로 BrandModel 생성 성공"`) + +**실제 예시 - Entity 테스트:** +```java +@DisplayName("ProductModel Entity") +class ProductModelTest { + + @DisplayName("상품을 생성할 때,") + @Nested + class Create { + @Test + @DisplayName("create() 정적 팩토리로 ProductModel 생성 성공") + void create_product_model() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + // when + ProductModel product = ProductModel.create(productId, brandId, productName, price, stockQuantity); + + // then + assertThat(product.getProductId()).isEqualTo(new ProductId(productId)); + assertThat(product.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(product.getStockQuantity().value()).isEqualTo(stockQuantity); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + @Test + @DisplayName("재고가 충분하면 차감 성공") + void decreaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + + // when + product.decreaseStock(10); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(40); + } + + @Test + @DisplayName("재고가 부족하면 예외 발생") + void decreaseStock_insufficient_stock_throws_exception() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 5); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다"); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class Delete { + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + product.markAsDeleted(); + + // then + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } + } +} +``` + +**@Nested 사용 시기:** +- ✅ Entity 테스트: 도메인 행위별 그룹화 (Create, Update, Delete 등) +- ✅ Service/Facade 통합 테스트: 유스케이스별 그룹화 (Register, Login 등) +- ✅ Controller E2E 테스트: 엔드포인트별 그룹화 (POST /api/v1/members 등) +- ❌ Value Object 단위 테스트: 단순한 경우 @Nested 불필요 + +**장점:** +- 테스트 리포트의 계층 구조로 가독성 향상 +- 관련 테스트끼리 논리적으로 묶어 관리 용이 +- 각 @Nested 클래스에서 공통 setup 가능 (@BeforeEach) + --- ## 단위 테스트 (Unit Test) diff --git a/.http/brand.http b/.http/brand.http new file mode 100644 index 000000000..0add6a61e --- /dev/null +++ b/.http/brand.http @@ -0,0 +1,32 @@ +### 브랜드 생성 +POST http://localhost:8080/api/v1/brands +Content-Type: application/json + +{ + "brandId": "nike", + "brandName": "Nike" +} + +### 브랜드 생성 - Adidas +POST http://localhost:8080/api/v1/brands +Content-Type: application/json + +{ + "brandId": "adidas", + "brandName": "Adidas" +} + +### 브랜드 생성 - 중복 ID (실패 케이스) +POST http://localhost:8080/api/v1/brands +Content-Type: application/json + +{ + "brandId": "nike", + "brandName": "Nike Duplicate" +} + +### 브랜드 삭제 +DELETE http://localhost:8080/api/v1/brands/nike + +### 브랜드 삭제 - 존재하지 않는 브랜드 (실패 케이스) +DELETE http://localhost:8080/api/v1/brands/nonexistent diff --git a/.http/like.http b/.http/like.http new file mode 100644 index 000000000..3cb16ca8b --- /dev/null +++ b/.http/like.http @@ -0,0 +1,26 @@ +### 좋아요 추가 +POST http://localhost:8080/api/v1/likes +Content-Type: application/json + +{ + "memberId": 1, + "productId": "prod1" +} + +### 좋아요 취소 +DELETE http://localhost:8080/api/v1/likes +Content-Type: application/json + +{ + "memberId": 1, + "productId": "prod1" +} + +### 존재하지 않는 상품에 좋아요 추가 (404 에러) +POST http://localhost:8080/api/v1/likes +Content-Type: application/json + +{ + "memberId": 1, + "productId": "invalid" +} diff --git a/.http/product.http b/.http/product.http new file mode 100644 index 000000000..33e219cfa --- /dev/null +++ b/.http/product.http @@ -0,0 +1,97 @@ +### Product API 테스트 + +### 1. 상품 생성 (먼저 브랜드를 생성해야 함) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod001", + "brandId": "nike", + "productName": "Nike Air Max 2024", + "price": 150000, + "stockQuantity": 100 +} + +### 2. 상품 목록 조회 (전체) +GET http://localhost:8080/api/v1/products?page=0&size=10&sort=latest + +### 3. 상품 목록 조회 (브랜드 필터링) +GET http://localhost:8080/api/v1/products?brandId=nike&page=0&size=10&sort=latest + +### 4. 상품 목록 조회 (가격 낮은순 정렬) +GET http://localhost:8080/api/v1/products?page=0&size=10&sort=price_asc + +### 5. 상품 삭제 +DELETE http://localhost:8080/api/v1/products/prod001 + +### 6. 여러 상품 생성 (테스트용) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod002", + "brandId": "nike", + "productName": "Nike Air Force 1", + "price": 120000, + "stockQuantity": 50 +} + +### +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod003", + "brandId": "nike", + "productName": "Nike Dunk Low", + "price": 130000, + "stockQuantity": 30 +} + +### 7. 중복 상품 ID 테스트 (409 Conflict 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod001", + "brandId": "nike", + "productName": "Duplicate Product", + "price": 100000, + "stockQuantity": 10 +} + +### 8. 존재하지 않는 브랜드 테스트 (404 Not Found 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod999", + "brandId": "nobrand", + "productName": "No Brand Product", + "price": 100000, + "stockQuantity": 10 +} + +### 9. 유효성 검증 테스트 - 빈 상품명 (400 Bad Request 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod004", + "brandId": "nike", + "productName": "", + "price": 100000, + "stockQuantity": 10 +} + +### 10. 유효성 검증 테스트 - 음수 가격 (400 Bad Request 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod005", + "brandId": "nike", + "productName": "Invalid Price Product", + "price": -1000, + "stockQuantity": 10 +} diff --git a/CLAUDE.md b/CLAUDE.md index 36b69ba47..68f396a58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,9 +44,9 @@ Root ``` Interfaces Layer (Controller, ApiSpec, Dto) ↓ -Application Layer (Facade, Info) +Application Layer (App, Facade, Info) ↓ -Domain Layer (Model, Reader, Service, Repository, VO) +Domain Layer (Model, Service, Repository, VO) ↓ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ``` @@ -55,12 +55,13 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) **Domain Layer** - 핵심 비즈니스 로직 - **Model**: JPA Entity, `BaseEntity` 상속, 정적 팩토리 `create()`, 도메인 행위 메서드 -- **Reader**: 읽기 전용 조회, VO 변환, 조회+예외 통합 (`getOrThrow`) -- **Service**: 교차 엔티티 규칙 (중복 체크 등), 트랜잭션 관리 +- **Service**: 비즈니스 규칙 검증, 상태 변경, 크로스 도메인 조율 등 비즈니스 로직 담당. 단순 조회만 하는 경우 App에서 Repository 직접 호출 허용 +- **Repository**: 데이터 조회 및 저장, `findByXXX().orElseThrow()` 패턴 사용 - **Value Object**: `record` 타입, Compact Constructor 검증, 불변 **Application Layer** - 유스케이스 조합 -- **Facade**: 여러 도메인 서비스 조합 +- **App**: 단일 도메인 유스케이스 처리. Service 또는 Repository 호출 및 Model → Info 변환 담당 +- **Facade**: **2개 이상의 App을 조합**할 때만 사용. 크로스 도메인 오케스트레이션 **Interfaces Layer** - 외부 통신 - **Controller**: REST API, `ApiResponse` 반환 @@ -77,13 +78,15 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ### 네이밍 규칙 - Entity: `{Domain}Model` (예: `MemberModel`) -- Reader: `{Domain}Reader` - Service: `{Domain}Service` - Repository: `{Domain}Repository` / `{Domain}RepositoryImpl` / `{Domain}JpaRepository` - Controller: `{Domain}V{version}Controller` - DTO: `{Domain}V{version}Dto` - Value Object: `{Name}` (예: `MemberId`, `Email`) - Converter: `{ValueObject}Converter` +- App: `{Domain}App` (예: `MemberApp`) — 단일 도메인 유스케이스 +- Facade: `{Domain}Facade` (예: `OrderFacade`) — 2개 이상 App 조합 시에만 사용 +- Info: `{Domain}Info` (예: `MemberInfo`) — Application 레이어 결과 VO ### 타입 사용 - **Entity**: `class` (가변 상태) @@ -121,6 +124,15 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - ❌ null-safety 위반 금지 (Optional 활용) - ❌ println 코드 남기지 말 것 (`@Slf4j` 사용) - ❌ 테스트 임의 삭제/수정 금지 (`@Disabled`, assertion 약화 금지) +- ❌ `var` 키워드 사용 금지 — 반드시 명시적 타입으로 선언 +- ❌ `EntityManager` 직접 사용 금지 — JpaRepository `@Query`로 대체 +- ❌ 비즈니스 로직(검증·상태변경·크로스도메인)이 있는 경우 App에서 Repository 직접 의존 금지 — 반드시 Service 경유 +- ✅ 단순 조회(비즈니스 규칙·상태 변경 없음)는 App에서 Repository 직접 의존 허용 +- ❌ App → App 의존 금지 (크로스 도메인은 Facade 책임) +- ❌ Facade → Facade 의존 금지 +- ❌ Facade → Service 직접 호출 금지 — 반드시 App 경유 +- ❌ Facade를 단일 도메인에서만 사용 금지 — App을 사용할 것 +- ❌ 클래스/record 내부에 nested 클래스/record 정의 금지 — 별도 파일로 분리 ### Recommendation (권장사항) - ✅ 실제 API를 호출해 확인하는 E2E 테스트 작성 @@ -142,37 +154,6 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - **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: http://localhost:8080/swagger-ui.html -- Grafana: http://localhost:3000 (admin/admin) - ---- - -## 빌드 및 실행 - -```bash -# 전체 빌드 -./gradlew clean build - -# 테스트 -./gradlew test - -# 커버리지 -./gradlew test jacocoTestReport - -# commerce-api 실행 -./gradlew :apps:commerce-api:bootRun -``` - --- ## 도메인 예시 (Member) @@ -203,4 +184,42 @@ docker-compose -f ./docker/monitoring-compose.yml up --- +## 도메인 & 객체 설계 전략 + +- 도메인 모델링은 데이터 설계가 아니라 **업무 규칙을 객체 책임으로 고정**하는 작업입니다. +- **Entity**: ID로 동일성 판단, 상태 변화와 연속성이 핵심(행위를 내부에 둠). +- **VO**: 값 자체가 핵심, 불변 + 생성 시 유효성 강제(원시타입 규칙 중복 제거). +- **Domain Service**: 특정 엔티티에 두기 부자연스러운 "도메인 규칙"만, 무상태로 둠. +- **Application Service(Usecase)**: 트랜잭션/권한/저장/외부연동 등 "흐름 조립" 담당, 규칙은 도메인에 위임. +- 규칙이 여러 서비스에 반복되면 → **도메인(엔티티/VO/도메인서비스)로 내려갈 신호**입니다. +- 관계 자체가 의미를 가지면(누가/언제/중복/취소/이력) → `Like`처럼 **독립 도메인으로 분리**합니다. +- 동시성 규칙은 if문만 믿지 말고 **DB 제약(유니크)로 최종 방어선**을 둡니다. +- 의존 방향은 **Interfaces → Application → Domain ← Infrastructure**(Repo 인터페이스는 Domain, 구현은 Infra). +- 리뷰 기준: 도메인이 기술(Spring/JPA/HTTP)을 모르고, 컬렉션/상태 변경은 루트가 통제하며, 테스트는 Fake로 가능해야 합니다. + +--- + +## 아키텍처, 패키지 구성 전략 + +- **레이어 의존성 방향 (단일 도메인, 비즈니스 로직 있음)**: `Controller → App → Service → Repository` +- **레이어 의존성 방향 (단일 도메인, 단순 조회)**: `Controller → App → Repository` +- **레이어 의존성 방향 (크로스 도메인)**: `Controller → Facade → App(복수) → Service/Repository` +- Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). +- **App 원칙**: App은 단일 도메인의 유스케이스를 처리. Model → Info 변환 담당. 비즈니스 로직은 Service에 위임. 단순 조회는 Repository 직접 호출 허용. +- **Facade 사용 조건**: **2개 이상의 App을 조합할 때만** Facade를 사용. 단일 도메인은 App으로 처리. +- **App 어노테이션**: App은 `@Component`, Service는 `@Service`, Facade는 `@Component` — 절대 혼용 금지. +- **App 의존성**: App → Service (비즈니스 로직), App → Repository (단순 조회) 허용. App → App 의존은 금지. +- **Facade 의존성**: Facade → App만 허용 (2개 이상). Facade → Service 직접 호출, Facade → Repository 직접 의존, Facade → Facade 의존은 모두 금지. +- **크로스 도메인 오케스트레이션**: 여러 도메인을 걸치는 연쇄 처리(cascade 등)는 Service가 아닌 Facade에서 App을 통해 조율. +- **DTO vs Info vs Model 분리**: DTO(HTTP 계층) → Info(Application 결과 VO) → Model(Domain Entity), 각 레이어 독립성 유지. +- **Service 책임**: 비즈니스 규칙 검증, 상태 변경, 크로스 도메인 Repository 조율. 단순 조회만 하는 경우 Service 생략 가능. +- **Service 크로스 도메인**: Service는 트랜잭션 원자성을 위해 타 도메인 Repository를 직접 사용 가능. 이때 해당 도메인 VO import도 허용. +- **Repository Pattern**: Domain에 Repository 인터페이스(Port), Infrastructure에 구현체(Adapter), Domain이 Infrastructure를 모름. +- **도메인 VO 소유권**: Model과 Repository 인터페이스는 자기 도메인 vo만 사용. `RefMemberId` 같은 참조 VO는 사용하는 도메인이 자기 vo 패키지에 별도 정의. +- **Info 변환**: App에서 Model → Info 변환, Controller는 Model 노출 금지(Info만 사용), 레이어 격리 유지. +- **컴포넌트 책임**: Controller(HTTP), App(단일 도메인 유스케이스 + Info 변환), Facade(복수 App 조합), Service(비즈니스 로직 + 조회), Repository(영속화). +- **메서드 네이밍**: 타 도메인 PK를 파라미터로 받는 메서드는 `RefId` 접미사 사용 (`DbId` 금지). 예: `getProductByRefId(Long id)`. + +--- + 이 문서는 프로젝트의 핵심 원칙과 구조를 요약합니다. 상세 내용은 skills를 참조하세요. diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 1a98c156e..4bb64d4da 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:security")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -10,7 +11,6 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") - implementation("org.springframework.security:spring-security-crypto") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java new file mode 100644 index 000000000..fde80e6c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java @@ -0,0 +1,45 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandApp { + + private final BrandService brandService; + private final BrandRepository brandRepository; + + @Transactional + public BrandInfo createBrand(String brandId, String brandName) { + BrandModel brand = brandService.createBrand(brandId, brandName); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getBrand(String brandId) { + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + return BrandInfo.from(brand); + } + + @Transactional + public BrandInfo deleteBrand(String brandId) { + BrandModel brand = brandService.deleteBrand(brandId); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getBrandByRefId(Long id) { + BrandModel brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + return BrandInfo.from(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..914c0f4a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,20 @@ +package com.loopers.application.brand; + +import com.loopers.application.product.ProductApp; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandApp brandApp; + private final ProductApp productApp; + + @Transactional + public void deleteBrand(String brandId) { + BrandInfo brand = brandApp.deleteBrand(brandId); + productApp.deleteProductsByBrandRefId(brand.id()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..3d27a3215 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,18 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +public record BrandInfo( + Long id, + String brandId, + String brandName +) { + + public static BrandInfo from(BrandModel brand) { + return new BrandInfo( + brand.getId(), + brand.getBrandId().value(), + brand.getBrandName().value() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java new file mode 100644 index 000000000..97859e86b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java @@ -0,0 +1,35 @@ +package com.loopers.application.like; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeApp { + + private final LikeService likeService; + private final LikeRepository likeRepository; + + @Transactional + public LikeInfo addLike(Long memberId, String productId) { + LikeModel like = likeService.addLike(memberId, productId); + return LikeInfo.from(like); + } + + @Transactional + public void removeLike(Long memberId, String productId) { + likeService.removeLike(memberId, productId); + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long memberId, Pageable pageable) { + return likeRepository.findByRefMemberId(new RefMemberId(memberId), pageable).map(LikeInfo::from); + } +} 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..74a0e9fe2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,34 @@ +package com.loopers.application.like; + +import com.loopers.application.brand.BrandApp; +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.product.ProductApp; +import com.loopers.application.product.ProductInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LikeFacade { + + private final LikeApp likeApp; + private final ProductApp productApp; + private final BrandApp brandApp; + + public Page getMyLikedProducts(Long memberId, Pageable pageable) { + return likeApp.getMyLikes(memberId, pageable) + .map(like -> { + ProductInfo product = productApp.getProductByRefId(like.refProductId()); + BrandInfo brand = brandApp.getBrandByRefId(product.refBrandId()); + return new LikedProductInfo( + product.productId(), + product.productName(), + brand.brandName(), + product.price(), + like.likedAt() + ); + }); + } +} 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..69de15646 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; + +import java.time.ZonedDateTime; + +public record LikeInfo( + Long id, + Long refMemberId, + Long refProductId, + ZonedDateTime likedAt +) { + public static LikeInfo from(LikeModel like) { + return new LikeInfo( + like.getId(), + like.getRefMemberId().value(), + like.getRefProductId().value(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java new file mode 100644 index 000000000..bdad547de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.like; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public record LikedProductInfo( + String productId, + String productName, + String brandName, + BigDecimal price, + ZonedDateTime likedAt +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java new file mode 100644 index 000000000..c4d7b11e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java @@ -0,0 +1,36 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Gender; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + + +@RequiredArgsConstructor +@Component +public class MemberApp { + private final MemberService memberService; + + @Transactional + public MemberInfo register(String memberId, String password, String email, + String birthDate, String name, Gender gender) { + MemberModel member = memberService.register( + memberId, password, email, birthDate, name, gender + ); + return MemberInfo.from(member); + } + + @Transactional(readOnly = true) + public MemberInfo authenticate(String loginId, String loginPw) { + MemberModel member = memberService.authenticate(loginId, loginPw); + return MemberInfo.from(member); + } + + @Transactional + public void changePassword(String loginId, String loginPw, + String currentPassword, String newPassword) { + memberService.changePassword(loginId, loginPw, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 000000000..5781fddc3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + + +public record MemberInfo( + Long id, + String memberId, + String email, + String birthDate, + String name, + String gender +) { + + public static MemberInfo from(MemberModel member) { + return new MemberInfo( + member.getId(), + member.getMemberId().value(), + member.getEmail().address(), + member.getBirthDate().asString(), + member.getName().value(), + member.getGender().name() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java new file mode 100644 index 000000000..7902dfd02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java @@ -0,0 +1,49 @@ +package com.loopers.application.order; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.order.OrderItemRequest; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class OrderApp { + + private final OrderService orderService; + private final OrderRepository orderRepository; + + @Transactional + public OrderInfo createOrder(Long memberId, List items) { + List orderItems = items.stream() + .map(OrderItemCommand::toOrderItemRequest) + .toList(); + OrderModel order = orderService.createOrder(memberId, orderItems); + return OrderInfo.from(order); + } + + @Transactional + public OrderInfo cancelOrder(Long memberId, String orderId) { + OrderModel order = orderService.cancelOrder(memberId, orderId); + return OrderInfo.from(order); + } + + @Transactional(readOnly = true) + public OrderInfo getMyOrder(Long memberId, String orderId) { + return OrderInfo.from(orderService.getMyOrder(memberId, orderId)); + } + + @Transactional(readOnly = true) + public Page getMyOrders(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { + return orderRepository.findByRefMemberId(new RefMemberId(memberId), startDateTime, endDateTime, pageable) + .map(OrderInfo::from); + } +} 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..20da15b3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.math.BigDecimal; +import java.util.List; + +public record OrderInfo( + Long id, + String orderId, + Long refMemberId, + String status, + BigDecimal totalAmount, + List items +) { + public static OrderInfo from(OrderModel order) { + return new OrderInfo( + order.getId(), + order.getOrderId().value(), + order.getRefMemberId().value(), + order.getStatus().name(), + order.getTotalAmount(), + order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..bb38e0937 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,10 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemRequest; + +public record OrderItemCommand(String productId, int quantity) { + + public OrderItemRequest toOrderItemRequest() { + return new OrderItemRequest(productId, quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..3d0d3f4bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; + +import java.math.BigDecimal; + +public record OrderItemInfo( + Long id, + String orderItemId, + String productId, + String productName, + BigDecimal price, + int quantity, + BigDecimal totalPrice + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.getId(), + item.getOrderItemId().value(), + item.getProductId(), + item.getProductName(), + item.getPrice(), + item.getQuantity(), + item.getTotalPrice() + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java new file mode 100644 index 000000000..a89b1159e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java @@ -0,0 +1,69 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductApp { + + private final ProductService productService; + private final ProductRepository productRepository; + + @Transactional + public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity); + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public ProductInfo getProduct(String productId) { + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + return ProductInfo.from(product); + } + + @Transactional + public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity); + return ProductInfo.from(product); + } + + @Transactional + public void deleteProduct(String productId) { + productService.deleteProduct(productId); + } + + @Transactional(readOnly = true) + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + return productService.getProducts(brandId, sortBy, pageable).map(ProductInfo::from); + } + + @Transactional(readOnly = true) + public ProductInfo getProductByRefId(Long id) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 존재하지 않습니다.")); + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public long countLikes(Long productId) { + return productRepository.countLikes(productId); + } + + @Transactional + public void deleteProductsByBrandRefId(Long brandId) { + productService.deleteProductsByBrandRefId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..d75b8eded --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,53 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandApp; +import com.loopers.application.brand.BrandInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductApp productApp; + private final BrandApp brandApp; + + public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + ProductInfo product = productApp.createProduct(productId, brandId, productName, price, stockQuantity); + return enrichProductInfo(product); + } + + public ProductInfo getProduct(String productId) { + ProductInfo product = productApp.getProduct(productId); + return enrichProductInfo(product); + } + + public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductInfo product = productApp.updateProduct(productId, productName, price, stockQuantity); + return enrichProductInfo(product); + } + + public void deleteProduct(String productId) { + productApp.deleteProduct(productId); + } + + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + Page products = productApp.getProducts(brandId, sortBy, pageable); + return products.map(this::enrichProductInfo); + } + + public ProductInfo getProductByRefId(Long id) { + ProductInfo product = productApp.getProductByRefId(id); + return enrichProductInfo(product); + } + + private ProductInfo enrichProductInfo(ProductInfo product) { + BrandInfo brand = brandApp.getBrandByRefId(product.refBrandId()); + long likesCount = productApp.countLikes(product.id()); + return product.enrich(brand, likesCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..61c1d8986 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,48 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; + +import java.math.BigDecimal; + +public record ProductInfo( + Long id, + String productId, + Long refBrandId, + String productName, + BigDecimal price, + int stockQuantity, + BrandInfo brand, + long likesCount +) { + public static ProductInfo from(ProductModel product, BrandModel brand, long likesCount) { + return new ProductInfo( + product.getId(), + product.getProductId().value(), + product.getRefBrandId().value(), + product.getProductName().value(), + product.getPrice().value(), + product.getStockQuantity().value(), + BrandInfo.from(brand), + likesCount + ); + } + + public static ProductInfo from(ProductModel product) { + return new ProductInfo( + product.getId(), + product.getProductId().value(), + product.getRefBrandId().value(), + product.getProductName().value(), + product.getPrice().value(), + product.getStockQuantity().value(), + null, + 0L + ); + } + + public ProductInfo enrich(BrandInfo brand, long likesCount) { + return new ProductInfo(id, productId, refBrandId, productName, price, stockQuantity, brand, likesCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..37bb69b75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,42 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.infrastructure.jpa.converter.BrandIdConverter; +import com.loopers.infrastructure.jpa.converter.BrandNameConverter; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Table(name = "brands") +@Getter +public class BrandModel extends BaseEntity { + + @Convert(converter = BrandIdConverter.class) + @Column(name = "brand_id", nullable = false, unique = true, length = 10) + private BrandId brandId; + + @Convert(converter = BrandNameConverter.class) + @Column(name = "brand_name", nullable = false, length = 50) + private BrandName brandName; + + protected BrandModel() {} + + private BrandModel(String brandId, String brandName) { + this.brandId = new BrandId(brandId); + this.brandName = new BrandName(brandName); + } + + public static BrandModel create(String brandId, String brandName) { + return new BrandModel(brandId, brandName); + } + + public void markAsDeleted() { + delete(); + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } +} 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..9729d7c0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; + +import java.util.Optional; + +public interface BrandRepository { + BrandModel save(BrandModel brand); + Optional findByBrandId(BrandId brandId); + Optional findById(Long id); + boolean existsByBrandId(BrandId brandId); +} 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..988fcee2f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + public BrandModel createBrand(String brandId, String brandName) { + if (brandRepository.existsByBrandId(new BrandId(brandId))) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 ID입니다."); + } + + BrandModel brand = BrandModel.create(brandId, brandName); + return brandRepository.save(brand); + } + + public BrandModel deleteBrand(String brandId) { + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + + brand.markAsDeleted(); + return brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java new file mode 100644 index 000000000..82d172b24 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java @@ -0,0 +1,23 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record BrandId(String value) { + + // 영문+숫자, 1~10자 + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + public BrandId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "brandId가 비어 있습니다"); + } + value = value.trim(); + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "brandId는 영문+숫자, 1~10자로 이루어져야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java new file mode 100644 index 000000000..fe3ed4352 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java @@ -0,0 +1,18 @@ +package com.loopers.domain.brand.vo; + +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.isEmpty() || value.length() > 50) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명 길이는 1자 이상 50자 이하여야 합니다: " + value.length()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefBrandId.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefBrandId.java new file mode 100644 index 000000000..657814701 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefBrandId.java @@ -0,0 +1,16 @@ +package com.loopers.domain.common.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record RefBrandId(Long value) { + + public RefBrandId { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "refBrandId가 비어 있습니다"); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "refBrandId는 양수여야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefMemberId.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefMemberId.java new file mode 100644 index 000000000..fde0c3562 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefMemberId.java @@ -0,0 +1,16 @@ +package com.loopers.domain.common.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record RefMemberId(Long value) { + + public RefMemberId { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "refMemberId가 비어 있습니다"); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "refMemberId는 양수여야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefProductId.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefProductId.java new file mode 100644 index 000000000..fa2a7e9a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefProductId.java @@ -0,0 +1,16 @@ +package com.loopers.domain.common.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record RefProductId(Long value) { + + public RefProductId { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "refProductId가 비어 있습니다"); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "refProductId는 양수여야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..234c22d2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,40 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +import com.loopers.infrastructure.jpa.converter.RefMemberIdConverter; +import com.loopers.infrastructure.jpa.converter.RefProductIdConverter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_member_product", columnNames = {"ref_member_id", "ref_product_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LikeModel extends BaseEntity { + + @Convert(converter = RefMemberIdConverter.class) + @Column(name = "ref_member_id", nullable = false) + private RefMemberId refMemberId; + + @Convert(converter = RefProductIdConverter.class) + @Column(name = "ref_product_id", nullable = false) + private RefProductId refProductId; + + private LikeModel(Long refMemberId, Long refProductId) { + this.refMemberId = new RefMemberId(refMemberId); + this.refProductId = new RefProductId(refProductId); + } + + public static LikeModel create(Long refMemberId, Long refProductId) { + return new LikeModel(refMemberId, refProductId); + } +} 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..7c72c7377 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.like; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface LikeRepository { + LikeModel save(LikeModel like); + Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId); + void delete(LikeModel like); + Page findByRefMemberId(RefMemberId refMemberId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..465035e28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,55 @@ +package com.loopers.domain.like; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + public LikeModel addLike(Long memberId, String productId) { + // 상품 존재 확인 + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + RefMemberId refMemberId = new RefMemberId(memberId); + RefProductId refProductId = new RefProductId(product.getId()); + + // 이미 좋아요가 있는지 확인 (멱등성) + return likeRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId) + .orElseGet(() -> { + try { + LikeModel like = LikeModel.create(memberId, product.getId()); + return likeRepository.save(like); + } catch (DataIntegrityViolationException e) { + // 동시성 이슈로 UNIQUE 제약 위반 시 다시 조회 + return likeRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId) + .orElseThrow(() -> new CoreException(ErrorType.CONFLICT, "좋아요 추가 중 오류가 발생했습니다.")); + } + }); + } + + public void removeLike(Long memberId, String productId) { + // 상품 존재 확인 + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + RefMemberId refMemberId = new RefMemberId(memberId); + RefProductId refProductId = new RefProductId(product.getId()); + + // 좋아요가 있으면 삭제 (멱등성 - 없어도 예외 발생 안함) + likeRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId) + .ifPresent(likeRepository::delete); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index 5541fa979..5d40dee7e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -1,11 +1,15 @@ package com.loopers.domain.member; - import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.domain.member.vo.Name; import com.loopers.infrastructure.jpa.converter.BirthDateConverter; import com.loopers.infrastructure.jpa.converter.EmailConverter; import com.loopers.infrastructure.jpa.converter.MemberIdConverter; import com.loopers.infrastructure.jpa.converter.NameConverter; +import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java deleted file mode 100644 index ea559ba3d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.domain.member; - -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; - -@Component -@RequiredArgsConstructor -public class MemberReader { - - private final MemberRepository memberRepository; - - @Transactional(readOnly = true) - public MemberModel getMemberByMemberId(String memberId) { - return memberRepository.findByMemberId(new MemberId(memberId)) - .orElse(null); - } - - @Transactional(readOnly = true) - public MemberModel getOrThrow(String memberId) { - return memberRepository.findByMemberId(new MemberId(memberId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); - } - - @Transactional(readOnly = true) - public boolean existsByMemberId(String memberId) { - return memberRepository.existsByMemberId(new MemberId(memberId)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index fce19e93c..9ae2f2a48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -1,5 +1,7 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; + import java.util.Optional; public interface MemberRepository { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index 7ec98d1ff..c62246246 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -1,22 +1,21 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; - private final MemberReader memberReader; private final PasswordHasher passwordHasher; - @Transactional public MemberModel register(String memberId, String rawPassword, String email, String birthDate, String name, Gender gender) { - if (memberReader.existsByMemberId(memberId)) { + if (memberRepository.existsByMemberId(new MemberId(memberId))) { throw new CoreException(ErrorType.CONFLICT, "이미 가입된 ID 입니다."); } @@ -24,19 +23,19 @@ public MemberModel register(String memberId, String rawPassword, String email, S return memberRepository.save(member); } - @Transactional(readOnly = true) public MemberModel authenticate(String loginId, String loginPw) { - MemberModel member = memberReader.getOrThrow(loginId); + MemberModel member = memberRepository.findByMemberId(new MemberId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); if (!member.verifyPassword(passwordHasher, loginPw)) { throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); } return member; } - @Transactional public void changePassword(String loginId, String loginPw, String currentPassword, String newPassword) { - MemberModel member = memberReader.getOrThrow(loginId); + MemberModel member = memberRepository.findByMemberId(new MemberId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); if (!member.verifyPassword(passwordHasher, loginPw)) { throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/BirthDate.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java index 7fefe5c60..747eed6af 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java index 9c9711993..558836e5d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/MemberId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java index d9c99ef97..516b9f799 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/Name.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java index 8a956383c..aa8bc23a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..4e3226147 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.vo.OrderItemId; +import com.loopers.infrastructure.jpa.converter.OrderItemIdConverter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "order_items") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItemModel extends BaseEntity { + + @Convert(converter = OrderItemIdConverter.class) + @Column(name = "order_item_id", nullable = false, unique = true, length = 36) + private OrderItemId orderItemId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderModel order; + + @Column(name = "product_id", nullable = false, length = 20) + private String productId; // 스냅샷: 주문 시점의 상품 ID + + @Column(name = "product_name", nullable = false, length = 100) + private String productName; // 스냅샷: 주문 시점의 상품명 + + @Column(name = "price", nullable = false, precision = 10, scale = 2) + private BigDecimal price; // 스냅샷: 주문 시점의 가격 + + @Column(name = "quantity", nullable = false) + private int quantity; + + private OrderItemModel(String productId, String productName, BigDecimal price, int quantity) { + this.orderItemId = OrderItemId.generate(); + this.productId = productId; + this.productName = productName; + this.price = price; + this.quantity = quantity; + } + + public static OrderItemModel create(String productId, String productName, BigDecimal price, int quantity) { + return new OrderItemModel(productId, productName, price, quantity); + } + + public BigDecimal getTotalPrice() { + return price.multiply(BigDecimal.valueOf(quantity)); + } + + void setOrder(OrderModel order) { + this.order = order; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java new file mode 100644 index 000000000..ec1b7fa08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record OrderItemRequest(String productId, int quantity) { + public OrderItemRequest { + if (productId == null || productId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1개 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..2297cd85b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,77 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.order.vo.OrderId; +import com.loopers.infrastructure.jpa.converter.OrderIdConverter; +import com.loopers.infrastructure.jpa.converter.RefMemberIdConverter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderModel extends BaseEntity { + + @Convert(converter = OrderIdConverter.class) + @Column(name = "order_id", nullable = false, unique = true, length = 36) + private OrderId orderId; + + @Convert(converter = RefMemberIdConverter.class) + @Column(name = "ref_member_id", nullable = false) + private RefMemberId refMemberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderItems = new ArrayList<>(); + + private OrderModel(Long memberId, List items) { + this.orderId = OrderId.generate(); + this.refMemberId = new RefMemberId(memberId); + this.status = OrderStatus.PENDING; + items.forEach(this::addOrderItem); + } + + public static OrderModel create(Long memberId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품이 비어 있습니다."); + } + return new OrderModel(memberId, items); + } + + public void cancel() { + if (this.status == OrderStatus.CANCELED) { + // 멱등성: 이미 취소된 주문은 그대로 반환 + return; + } + this.status.validateTransition(OrderStatus.CANCELED); + this.status = OrderStatus.CANCELED; + } + + public boolean isOwner(Long memberId) { + return this.refMemberId.value().equals(memberId); + } + + public BigDecimal getTotalAmount() { + return orderItems.stream() + .map(OrderItemModel::getTotalPrice) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private void addOrderItem(OrderItemModel item) { + this.orderItems.add(item); + item.setOrder(this); + } +} 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..372622e52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.order.vo.OrderId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface OrderRepository { + OrderModel save(OrderModel order); + Optional findByOrderId(OrderId orderId); + Page findByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..ed540c663 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,101 @@ +package com.loopers.domain.order; + +import com.loopers.domain.order.vo.OrderId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + + public OrderModel createOrder(Long memberId, List itemRequests) { + // 1. 중복 상품 수량 합산 + Map aggregatedItems = aggregateQuantities(itemRequests); + + // 2. 상품 ID 정렬 (데드락 방지) + List sortedProductIds = aggregatedItems.keySet().stream() + .sorted() + .toList(); + + // 3. 상품 조회 및 재고 차감 + List orderItems = new ArrayList<>(); + for (String productIdValue : sortedProductIds) { + int quantity = aggregatedItems.get(productIdValue); + + ProductModel product = productRepository.findByProductId(new ProductId(productIdValue)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "해당 ID의 상품이 존재하지 않습니다: " + productIdValue)); + + boolean decreased = productRepository.decreaseStockIfAvailable(product.getId(), quantity); + if (!decreased) { + throw new CoreException(ErrorType.CONFLICT, + "재고가 부족합니다. 상품 ID: " + productIdValue); + } + + OrderItemModel orderItem = OrderItemModel.create( + product.getProductId().value(), + product.getProductName().value(), + product.getPrice().value(), + quantity + ); + orderItems.add(orderItem); + } + + // 4. OrderModel 생성 및 저장 + OrderModel order = OrderModel.create(memberId, orderItems); + return orderRepository.save(order); + } + + public OrderModel cancelOrder(Long memberId, String orderId) { + OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 주문이 존재하지 않습니다.")); + + if (!order.isOwner(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 취소할 수 있습니다."); + } + + OrderStatus previousStatus = order.getStatus(); + order.cancel(); + + if (previousStatus == OrderStatus.PENDING) { + for (OrderItemModel item : order.getOrderItems()) { + productRepository.findByProductId(new ProductId(item.getProductId())) + .ifPresent(product -> + productRepository.increaseStock(product.getId(), item.getQuantity()) + ); + } + } + + return orderRepository.save(order); + } + + public OrderModel getMyOrder(Long memberId, String orderId) { + OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + if (!order.isOwner(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 조회할 수 있습니다."); + } + return order; + } + + private Map aggregateQuantities(List itemRequests) { + Map aggregated = new HashMap<>(); + for (OrderItemRequest request : itemRequests) { + aggregated.merge(request.productId(), request.quantity(), Integer::sum); + } + return aggregated; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..a058bcea6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public enum OrderStatus { + PENDING, // 주문 대기 + CANCELED; // 주문 취소 + + public boolean canTransitionTo(OrderStatus newStatus) { + return switch (this) { + case PENDING -> newStatus == CANCELED; + case CANCELED -> newStatus == CANCELED; // 멱등성: 이미 취소된 상태에서 취소 허용 + }; + } + + public void validateTransition(OrderStatus newStatus) { + if (!canTransitionTo(newStatus)) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("주문 상태를 %s에서 %s로 변경할 수 없습니다.", this, newStatus) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java new file mode 100644 index 000000000..4e4f6ce49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.UUID; + +public record OrderId(String value) { + + public OrderId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId가 비어 있습니다"); + } + // UUID 형식 검증 + try { + UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 UUID 형식이어야 합니다: " + value); + } + } + + public static OrderId generate() { + return new OrderId(UUID.randomUUID().toString()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java new file mode 100644 index 000000000..ef6824046 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.UUID; + +public record OrderItemId(String value) { + + public OrderItemId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderItemId가 비어 있습니다"); + } + // UUID 형식 검증 + try { + UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderItemId는 UUID 형식이어야 합니다: " + value); + } + } + + public static OrderItemId generate() { + return new OrderItemId(UUID.randomUUID().toString()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..c42d6d401 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,84 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.domain.product.vo.ProductName; +import com.loopers.domain.common.vo.RefBrandId; +import com.loopers.domain.product.vo.StockQuantity; +import com.loopers.infrastructure.jpa.converter.PriceConverter; +import com.loopers.infrastructure.jpa.converter.ProductIdConverter; +import com.loopers.infrastructure.jpa.converter.ProductNameConverter; +import com.loopers.infrastructure.jpa.converter.RefBrandIdConverter; +import com.loopers.infrastructure.jpa.converter.StockQuantityConverter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "products") +@Getter +public class ProductModel extends BaseEntity { + + @Convert(converter = ProductIdConverter.class) + @Column(name = "product_id", nullable = false, unique = true, length = 20) + private ProductId productId; + + @Convert(converter = RefBrandIdConverter.class) + @Column(name = "ref_brand_id", nullable = false) + private RefBrandId refBrandId; + + @Convert(converter = ProductNameConverter.class) + @Column(name = "product_name", nullable = false, length = 100) + private ProductName productName; + + @Convert(converter = PriceConverter.class) + @Column(name = "price", nullable = false, precision = 10, scale = 2) + private Price price; + + @Convert(converter = StockQuantityConverter.class) + @Column(name = "stock_quantity", nullable = false) + private StockQuantity stockQuantity; + + protected ProductModel() {} + + private ProductModel(String productId, Long refBrandId, String productName, BigDecimal price, int stockQuantity) { + this.productId = new ProductId(productId); + this.refBrandId = new RefBrandId(refBrandId); + this.productName = new ProductName(productName); + this.price = new Price(price); + this.stockQuantity = new StockQuantity(stockQuantity); + } + + public static ProductModel create(String productId, Long refBrandId, String productName, BigDecimal price, int stockQuantity) { + return new ProductModel(productId, refBrandId, productName, price, stockQuantity); + } + + public void decreaseStock(int quantity) { + if (this.stockQuantity.value() < quantity) { + throw new CoreException(ErrorType.CONFLICT, "재고가 부족합니다. 현재 재고: " + this.stockQuantity.value() + ", 요청 수량: " + quantity); + } + this.stockQuantity = new StockQuantity(this.stockQuantity.value() - quantity); + } + + public void increaseStock(int quantity) { + this.stockQuantity = new StockQuantity(this.stockQuantity.value() + quantity); + } + + public void update(String productName, BigDecimal price, int stockQuantity) { + this.productName = new ProductName(productName); + this.price = new Price(price); + this.stockQuantity = new StockQuantity(stockQuantity); + } + + public void markAsDeleted() { + delete(); + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } +} 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..d070baa2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,44 @@ +package com.loopers.domain.product; + +import com.loopers.domain.product.vo.ProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + ProductModel save(ProductModel product); + + Optional findByProductId(ProductId productId); + + boolean existsByProductId(ProductId productId); + + Page findProducts(Long refBrandId, String sortBy, Pageable pageable); + + /** + * 재고를 차감합니다. 재고가 부족하면 false를 반환합니다. + * 동시성 제어를 위해 조건부 UPDATE를 사용합니다. + */ + boolean decreaseStockIfAvailable(Long productId, int quantity); + + /** + * 재고를 증가시킵니다. + */ + void increaseStock(Long productId, int quantity); + + /** + * 상품의 좋아요 수를 조회합니다. + */ + long countLikes(Long productId); + + /** + * 브랜드 DB PK로 삭제되지 않은 상품 목록을 조회합니다. + */ + List findByRefBrandId(Long brandId); + + /** + * DB PK로 상품을 조회합니다. + */ + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..22aa5aee2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,77 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public ProductModel createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + // 중복 체크 + if (productRepository.existsByProductId(new ProductId(productId))) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 상품 ID입니다."); + } + + // 브랜드 존재 확인 및 PK 획득 + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + Long refBrandId = brand.getId(); + + // 상품 생성 + ProductModel product = ProductModel.create(productId, refBrandId, productName, price, stockQuantity); + + // 저장 + return productRepository.save(product); + } + + public ProductModel updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + product.update(productName, price, stockQuantity); + return productRepository.save(product); + } + + public void deleteProduct(String productId) { + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + // Soft delete + product.markAsDeleted(); + productRepository.save(product); + } + + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + // brandId가 제공되면 Brand PK로 변환 + Long refBrandId = null; + if (brandId != null && !brandId.isBlank()) { + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + refBrandId = brand.getId(); + } + return productRepository.findProducts(refBrandId, sortBy, pageable); + } + + public void deleteProductsByBrandRefId(Long brandDbId) { + List products = productRepository.findByRefBrandId(brandDbId); + for (ProductModel product : products) { + product.markAsDeleted(); + productRepository.save(product); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java new file mode 100644 index 000000000..a52cee998 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java @@ -0,0 +1,23 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public record Price(BigDecimal value) { + + public Price { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격이 비어 있습니다"); + } + + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다: " + value); + } + + // scale을 2로 설정 (소수점 2자리, 반올림) + value = value.setScale(2, RoundingMode.HALF_UP); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java new file mode 100644 index 000000000..9447d89b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java @@ -0,0 +1,23 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record ProductId(String value) { + + // 영문+숫자, 1~20자 + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,20}$"); + + public ProductId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId가 비어 있습니다"); + } + value = value.trim(); + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 영문+숫자, 1~20자로 이루어져야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java new file mode 100644 index 000000000..e46f663e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record ProductName(String value) { + + public ProductName { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명이 비어 있습니다"); + } + value = value.trim(); + + if (value.isEmpty() || value.length() > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명 길이는 1자 이상 100자 이하여야 합니다: " + value.length()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java new file mode 100644 index 000000000..666bb2f7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record StockQuantity(int value) { + + public StockQuantity { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다: " + value); + } + } +} 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..6fc4c24a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.vo.BrandId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + boolean existsByBrandId(BrandId brandId); + Optional findByBrandId(BrandId brandId); +} 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..ff8eb2d24 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findByBrandId(BrandId brandId) { + return brandJpaRepository.findByBrandId(brandId); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public boolean existsByBrandId(BrandId brandId) { + return brandJpaRepository.existsByBrandId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java index 0c51a2939..c4f59b05c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.BirthDate; +import com.loopers.domain.member.vo.BirthDate; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java new file mode 100644 index 000000000..0c138c112 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.brand.vo.BrandId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class BrandIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(BrandId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public BrandId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new BrandId(dbData); + } +} 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..6daef7c15 --- /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.vo.BrandName; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +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/EmailConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java index 9162511fe..a1cb198ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.Email; +import com.loopers.domain.member.vo.Email; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java index 264d13a0b..cee28ac78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.MemberId; +import com.loopers.domain.member.vo.MemberId; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java index d3769254a..d254055a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.Name; +import com.loopers.domain.member.vo.Name; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java new file mode 100644 index 000000000..f99e79a71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.order.vo.OrderId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class OrderIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OrderId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public OrderId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new OrderId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java new file mode 100644 index 000000000..7a06e68e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.order.vo.OrderItemId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class OrderItemIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OrderItemId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public OrderItemId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new OrderItemId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java new file mode 100644 index 000000000..21169d7cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.Price; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.math.BigDecimal; + +@Converter(autoApply = false) +public class PriceConverter implements AttributeConverter { + + @Override + public BigDecimal convertToDatabaseColumn(Price attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public Price convertToEntityAttribute(BigDecimal dbData) { + return dbData == null ? null : new Price(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java new file mode 100644 index 000000000..4d61bf345 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.ProductId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class ProductIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(ProductId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public ProductId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new ProductId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java new file mode 100644 index 000000000..bc24c5f4a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.ProductName; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class ProductNameConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(ProductName attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public ProductName convertToEntityAttribute(String dbData) { + return dbData == null ? null : new ProductName(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java new file mode 100644 index 000000000..3c63bfeda --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.common.vo.RefBrandId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class RefBrandIdConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(RefBrandId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public RefBrandId convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new RefBrandId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java new file mode 100644 index 000000000..592346346 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.common.vo.RefMemberId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class RefMemberIdConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(RefMemberId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public RefMemberId convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new RefMemberId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java new file mode 100644 index 000000000..f7fbf79a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.common.vo.RefProductId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class RefProductIdConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(RefProductId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public RefProductId convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new RefProductId(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..947e29446 --- /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.vo.StockQuantity; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +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..5a1f45cdb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId); + + @Query( + value = "SELECT l.* FROM likes l JOIN products p ON l.ref_product_id = p.id " + + "WHERE l.ref_member_id = :memberId AND p.deleted_at IS NULL " + + "ORDER BY l.created_at DESC", + countQuery = "SELECT COUNT(*) FROM likes l JOIN products p ON l.ref_product_id = p.id " + + "WHERE l.ref_member_id = :memberId AND p.deleted_at IS NULL", + nativeQuery = true + ) + Page findActiveByRefMemberId(@Param("memberId") Long memberId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..caf6c8702 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId) { + return likeJpaRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId); + } + + @Override + public void delete(LikeModel like) { + likeJpaRepository.delete(like); + } + + @Override + public Page findByRefMemberId(RefMemberId refMemberId, Pageable pageable) { + return likeJpaRepository.findActiveByRefMemberId(refMemberId.value(), pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java index 6fa82bd28..810324a84 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.member; -import com.loopers.domain.member.MemberId; +import com.loopers.domain.member.vo.MemberId; import com.loopers.domain.member.MemberModel; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java index c9a1594a9..4a87e4d78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.member; -import com.loopers.domain.member.MemberId; +import com.loopers.domain.member.vo.MemberId; import com.loopers.domain.member.MemberModel; import com.loopers.domain.member.MemberRepository; import lombok.RequiredArgsConstructor; 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..dea29e452 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.vo.OrderId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + Optional findByOrderId(OrderId orderId); + + @Query("SELECT o FROM OrderModel o WHERE o.refMemberId = :refMemberId " + + "AND (:startDateTime IS NULL OR o.createdAt >= :startDateTime) " + + "AND (:endDateTime IS NULL OR o.createdAt <= :endDateTime) " + + "ORDER BY o.createdAt DESC") + Page findByRefMemberIdWithDateFilter( + @Param("refMemberId") RefMemberId refMemberId, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..97ee790da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findByOrderId(OrderId orderId) { + return orderJpaRepository.findByOrderId(orderId); + } + + @Override + public Page findByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { + return orderJpaRepository.findByRefMemberIdWithDateFilter(refMemberId, startDateTime, endDateTime, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..78825d8ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.vo.ProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + Optional findByProductId(ProductId productId); + + boolean existsByProductId(ProductId productId); + + @Query( + value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY updated_at DESC", + countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", + nativeQuery = true + ) + Page findActiveSortByLatest(@Param("refBrandId") Long refBrandId, Pageable pageable); + + @Query( + value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY price ASC", + countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", + nativeQuery = true + ) + Page findActiveSortByPriceAsc(@Param("refBrandId") Long refBrandId, Pageable pageable); + + @Query( + value = "SELECT p.* FROM products p LEFT JOIN likes l ON p.id = l.ref_product_id WHERE p.deleted_at IS NULL AND (:refBrandId IS NULL OR p.ref_brand_id = :refBrandId) GROUP BY p.id ORDER BY COUNT(l.id) DESC, p.updated_at DESC", + countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", + nativeQuery = true + ) + Page findActiveSortByLikesDesc(@Param("refBrandId") Long refBrandId, Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query( + value = "UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :productId AND stock_quantity >= :quantity", + nativeQuery = true + ) + int decreaseStockIfAvailable(@Param("productId") Long productId, @Param("quantity") int quantity); + + @Modifying(clearAutomatically = true) + @Query( + value = "UPDATE products SET stock_quantity = stock_quantity + :quantity WHERE id = :productId", + nativeQuery = true + ) + void increaseStock(@Param("productId") Long productId, @Param("quantity") int quantity); + + @Query( + value = "SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId", + nativeQuery = true + ) + long countLikesByProductId(@Param("productId") Long productId); + + @Query( + value = "SELECT * FROM products WHERE ref_brand_id = :brandId AND deleted_at IS NULL", + nativeQuery = true + ) + List findActiveByRefBrandId(@Param("brandId") Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..47be6645d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findByProductId(ProductId productId) { + return productJpaRepository.findByProductId(productId); + } + + @Override + public boolean existsByProductId(ProductId productId) { + return productJpaRepository.existsByProductId(productId); + } + + @Override + public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { + if ("likes_desc".equals(sortBy)) { + return productJpaRepository.findActiveSortByLikesDesc(refBrandId, pageable); + } else if ("price_asc".equals(sortBy)) { + return productJpaRepository.findActiveSortByPriceAsc(refBrandId, pageable); + } + return productJpaRepository.findActiveSortByLatest(refBrandId, pageable); + } + + @Override + public boolean decreaseStockIfAvailable(Long productId, int quantity) { + return productJpaRepository.decreaseStockIfAvailable(productId, quantity) > 0; + } + + @Override + public void increaseStock(Long productId, int quantity) { + productJpaRepository.increaseStock(productId, quantity); + } + + @Override + public long countLikes(Long productId) { + return productJpaRepository.countLikesByProductId(productId); + } + + @Override + public List findByRefBrandId(Long brandId) { + return productJpaRepository.findActiveByRefBrandId(brandId); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..d7a394b37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "브랜드 관리 API", description = "브랜드 관련 API") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 단건 조회", + description = "brandId로 브랜드 정보를 조회합니다." + ) + ApiResponse getBrand( + @Parameter(description = "브랜드 ID") @PathVariable String brandId + ); + + @Operation( + summary = "브랜드 생성", + description = "새로운 브랜드를 생성합니다." + ) + ApiResponse createBrand( + @Schema(name = "브랜드 생성 요청 DTO", description = "브랜드 생성에 필요한 정보를 담고 있는 DTO") + @Valid @RequestBody BrandV1Dto.CreateBrandRequest request + ); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다 (soft delete). 상품이 참조하고 있는 경우 삭제할 수 없습니다." + ) + ApiResponse deleteBrand( + @Parameter(description = "브랜드 ID") @PathVariable String brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..000a3706d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandApp; +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandApp brandApp; + private final BrandFacade brandFacade; + + @Override + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable String brandId) { + BrandInfo info = brandApp.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.fromInfo(info)); + } + + @PostMapping + @Override + public ApiResponse createBrand( + @Valid @RequestBody BrandV1Dto.CreateBrandRequest request + ) { + BrandInfo info = brandApp.createBrand(request.brandId(), request.brandName()); + return ApiResponse.success(BrandV1Dto.BrandResponse.fromInfo(info)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse deleteBrand(@PathVariable String brandId) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..5c81aba9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; + +public class BrandV1Dto { + + public record CreateBrandRequest( + @NotBlank String brandId, + @NotBlank String brandName + ) {} + + public record BrandResponse( + Long id, + String brandId, + String brandName + ) { + public static BrandResponse fromInfo(BrandInfo info) { + return new BrandResponse( + info.id(), + info.brandId(), + info.brandName() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..363e9bcbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeApp; +import com.loopers.application.like.LikeInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.loopers.interfaces.api.like.LikeV1Dto.*; + +@RestController +@RequestMapping("/api/v1/products/{productId}/likes") +@RequiredArgsConstructor +public class LikeV1Controller { + + private final LikeApp likeApp; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse addLike( + @PathVariable String productId, + @Valid @RequestBody AddLikeRequest request + ) { + LikeInfo info = likeApp.addLike(request.memberId(), productId); + return ApiResponse.success(LikeResponse.from(info)); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponse removeLike( + @PathVariable String productId, + @Valid @RequestBody RemoveLikeRequest request + ) { + likeApp.removeLike(request.memberId(), productId); + return ApiResponse.success(null); + } +} 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..ebe5c7310 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; +import com.loopers.application.like.LikedProductInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record AddLikeRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId + ) {} + + public record RemoveLikeRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId + ) {} + + public record LikeResponse( + Long id, + Long refMemberId, + Long refProductId + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.id(), + info.refMemberId(), + info.refProductId() + ); + } + } + + public record LikedProductResponse( + String productId, + String productName, + String brandName, + BigDecimal price, + ZonedDateTime likedAt + ) { + public static LikedProductResponse from(LikedProductInfo info) { + return new LikedProductResponse( + info.productId(), + info.productName(), + info.brandName(), + info.price(), + info.likedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java new file mode 100644 index 000000000..173180bfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikedProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/users/me/likes") +@RequiredArgsConstructor +public class MyLikeV1Controller { + + private final LikeFacade likeFacade; + + @GetMapping + public ResponseEntity>> getMyLikedProducts( + @RequestParam Long memberId, + Pageable pageable + ) { + Page likes = likeFacade.getMyLikedProducts(memberId, pageable); + List response = likes.getContent().stream() + .map(LikeV1Dto.LikedProductResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index e6bbd03b3..ea1286c4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.member; -import com.loopers.domain.member.MemberModel; -import com.loopers.domain.member.MemberService; +import com.loopers.application.member.MemberApp; +import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -18,12 +18,12 @@ @RequestMapping("/api/v1/members") public class MemberV1Controller implements MemberV1ApiSpec { - private final MemberService memberService; + private final MemberApp memberApp; @PostMapping("/register") @Override public ApiResponse register(@Valid @RequestBody MemberV1Dto.RegisterRequest request) { - MemberModel member = memberService.register( + MemberInfo info = memberApp.register( request.memberId(), request.password(), request.email(), @@ -32,7 +32,7 @@ public ApiResponse register(@Valid @RequestBody Memb request.gender() ); - MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.from(member); + MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.fromInfo(info); return ApiResponse.success(response); } @@ -42,9 +42,9 @@ public ApiResponse getMe( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - MemberModel member = memberService.authenticate(loginId, loginPw); + MemberInfo info = memberApp.authenticate(loginId, loginPw); - MemberV1Dto.MeResponse response = MemberV1Dto.MeResponse.from(member); + MemberV1Dto.MeResponse response = MemberV1Dto.MeResponse.fromInfo(info); return ApiResponse.success(response); } @@ -55,7 +55,7 @@ public ApiResponse changePassword( @RequestHeader("X-Loopers-LoginPw") String loginPw, @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request ) { - memberService.changePassword(loginId, loginPw, + memberApp.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index 4a1bc0618..086b70d64 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.member; +import com.loopers.application.member.MemberInfo; import com.loopers.domain.member.Gender; import com.loopers.domain.member.MemberModel; import jakarta.validation.constraints.NotBlank; @@ -34,6 +35,17 @@ public static MemberResponse from(MemberModel member) { member.getGender() ); } + + public static MemberResponse fromInfo(MemberInfo info) { + return new MemberResponse( + info.id(), + info.memberId(), + info.email(), + info.birthDate(), + info.name(), + Gender.valueOf(info.gender()) + ); + } } public record ChangePasswordRequest( @@ -56,6 +68,15 @@ public static MeResponse from(MemberModel member) { ); } + public static MeResponse fromInfo(MemberInfo info) { + return new MeResponse( + info.memberId(), + maskName(info.name()), + info.birthDate(), + info.email() + ); + } + private static String maskName(String name) { if (name == null || name.isEmpty()) { return name; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..1bf940853 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; + +@Tag(name = "주문 API", description = "주문 생성 및 취소 API") +public interface OrderV1ApiSpec { + + @Operation(summary = "내 주문 목록 조회", description = "회원의 주문 목록을 기간 필터와 페이징으로 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "주문 목록 조회 성공") + }) + ResponseEntity> getOrders( + @Parameter(description = "회원 DB PK") @RequestParam Long memberId, + @Parameter(description = "조회 시작일 (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @Parameter(description = "조회 종료일 (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Pageable pageable + ); + + @Operation(summary = "주문 상세 조회", description = "주문 ID로 특정 주문을 조회합니다. 본인의 주문만 조회할 수 있습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "주문 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인의 주문이 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "주문을 찾을 수 없음") + }) + ResponseEntity> getOrder( + @Parameter(description = "주문 ID (UUID)") @PathVariable String orderId, + @Parameter(description = "회원 DB PK") @RequestParam Long memberId + ); + + @Operation(summary = "주문 생성", description = "여러 상품을 포함한 주문을 생성합니다. 재고 차감과 스냅샷 저장이 단일 트랜잭션으로 처리됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "주문 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (빈 주문, 수량 < 1)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "재고 부족") + }) + ResponseEntity> createOrder( + @RequestBody OrderV1Dto.CreateOrderRequest request + ); + + @Operation(summary = "주문 취소", description = "PENDING 상태의 주문을 취소합니다. 이미 취소된 주문은 멱등 성공(200)으로 처리됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "주문 취소 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인의 주문이 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "주문을 찾을 수 없음") + }) + ResponseEntity> cancelOrder( + @Parameter(description = "주문 ID (UUID)") @PathVariable String orderId, + @RequestBody OrderV1Dto.CancelOrderRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..ecd5371ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderApp; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/orders") +@RequiredArgsConstructor +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderApp orderApp; + + @PostMapping + @Override + public ResponseEntity> createOrder( + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + List items = request.items().stream() + .map(OrderV1Dto.OrderItemRequest::toCommand) + .toList(); + + OrderInfo info = orderApp.createOrder(request.memberId(), items); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); + } + + @GetMapping + @Override + public ResponseEntity> getOrders( + @RequestParam Long memberId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Pageable pageable + ) { + Page orders = orderApp.getMyOrders( + memberId, + startDate != null ? startDate.atStartOfDay() : null, + endDate != null ? endDate.plusDays(1).atStartOfDay() : null, + pageable + ); + return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderListResponse.from(orders))); + } + + @GetMapping("/{orderId}") + @Override + public ResponseEntity> getOrder( + @PathVariable String orderId, + @RequestParam Long memberId + ) { + OrderInfo info = orderApp.getMyOrder(memberId, orderId); + return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); + } + + @PatchMapping("/{orderId}/cancel") + @Override + public ResponseEntity> cancelOrder( + @PathVariable String orderId, + @Valid @RequestBody OrderV1Dto.CancelOrderRequest request + ) { + OrderInfo info = orderApp.cancelOrder(request.memberId(), orderId); + return ResponseEntity.ok(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..01c7e020d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,106 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderItemInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.math.BigDecimal; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId, + + @NotNull(message = "주문 항목은 필수입니다.") + @NotEmpty(message = "주문 항목은 1개 이상이어야 합니다.") + @Valid + List items + ) {} + + public record OrderItemRequest( + @NotBlank(message = "상품 ID는 필수입니다.") + String productId, + + @Min(value = 1, message = "수량은 1개 이상이어야 합니다.") + int quantity + ) { + public OrderItemCommand toCommand() { + return new OrderItemCommand(productId, quantity); + } + } + + public record CancelOrderRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId + ) {} + + public record OrderResponse( + Long id, + String orderId, + Long refMemberId, + String status, + BigDecimal totalAmount, + List items + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.orderId(), + info.refMemberId(), + info.status(), + info.totalAmount(), + info.items().stream() + .map(OrderItemResponse::from) + .toList() + ); + } + } + + public record OrderItemResponse( + Long id, + String orderItemId, + String productId, + String productName, + BigDecimal price, + int quantity, + BigDecimal totalPrice + ) { + public static OrderItemResponse from(OrderItemInfo item) { + return new OrderItemResponse( + item.id(), + item.orderItemId(), + item.productId(), + item.productName(), + item.price(), + item.quantity(), + item.totalPrice() + ); + } + } + + public record OrderListResponse( + List content, + long totalElements, + int page, + int size + ) { + public static OrderListResponse from(Page page) { + return new OrderListResponse( + page.getContent().stream() + .map(OrderResponse::from) + .toList(), + page.getTotalElements(), + page.getNumber(), + page.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..07a17b9ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Product Admin API", description = "어드민 상품 관리 API") +public interface ProductAdminV1ApiSpec { + + @Operation(summary = "상품 수정", description = "상품 정보(상품명, 가격, 재고)를 수정합니다. brandId 변경은 허용되지 않습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 또는 brandId 변경 시도"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "어드민 인증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> updateProduct( + @Parameter(description = "LDAP 어드민 토큰") @RequestHeader("X-Loopers-Ldap") String ldapHeader, + @Parameter(description = "상품 ID") @PathVariable String productId, + @RequestBody ProductAdminV1Dto.UpdateProductAdminRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..19b37e035 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +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; + +@RestController +@RequestMapping("/api-admin/v1/products") +@RequiredArgsConstructor +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final ProductFacade productFacade; + + @PutMapping("/{productId}") + @Override + public ResponseEntity> updateProduct( + @RequestHeader(value = "X-Loopers-Ldap", required = false) String ldapHeader, + @PathVariable String productId, + @Valid @RequestBody ProductAdminV1Dto.UpdateProductAdminRequest request + ) { + if (!ADMIN_LDAP_VALUE.equals(ldapHeader)) { + throw new CoreException(ErrorType.FORBIDDEN, "어드민 권한이 필요합니다."); + } + + if (request.brandId() != null && !request.brandId().isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "brandId는 변경할 수 없습니다."); + } + + ProductInfo info = productFacade.updateProduct( + productId, + request.productName(), + request.price(), + request.stockQuantity() + ); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductResponse.from(info))); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java new file mode 100644 index 000000000..159bbc8b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.product; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public class ProductAdminV1Dto { + + public record UpdateProductAdminRequest( + @NotBlank(message = "상품명은 필수입니다") + @Size(min = 1, max = 100, message = "상품명은 1~100자여야 합니다") + String productName, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0.0", inclusive = true, message = "가격은 0 이상이어야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + int stockQuantity, + + String brandId + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..2f83b4d6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,83 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Product API", description = "상품 관리 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 단건 조회", description = "productId로 상품 상세 정보를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> getProduct( + @Parameter(description = "상품 ID", example = "prod1") + @PathVariable String productId + ); + + @Operation(summary = "상품 수정", description = "상품 정보(상품명, 가격, 재고)를 수정합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> updateProduct( + @Parameter(description = "상품 ID", example = "prod1") + @PathVariable String productId, + @RequestBody ProductV1Dto.UpdateProductRequest request + ); + + @Operation(summary = "상품 생성", description = "새로운 상품을 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "상품 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "브랜드를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "중복된 상품 ID") + }) + ResponseEntity> createProduct( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "상품 생성 요청", + required = true, + content = @Content(schema = @Schema(implementation = ProductV1Dto.CreateProductRequest.class)) + ) + @RequestBody ProductV1Dto.CreateProductRequest request + ); + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 조회합니다. 브랜드 필터링, 정렬, 페이징을 지원합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공") + }) + ResponseEntity> getProducts( + @Parameter(description = "브랜드 ID (선택)", example = "nike") + @RequestParam(required = false) String brandId, + + @Parameter(description = "정렬 기준 (latest: 최신순, price_asc: 가격 낮은순)", example = "latest") + @RequestParam(required = false, defaultValue = "latest") String sort, + + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(defaultValue = "10") int size + ); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다 (Soft Delete).") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> deleteProduct( + @Parameter(description = "상품 ID", example = "prod1") + @PathVariable String productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..b7652124c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,83 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/products") +@RequiredArgsConstructor +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + @Override + public ResponseEntity> getProduct(@PathVariable String productId) { + ProductInfo info = productFacade.getProduct(productId); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductResponse.from(info))); + } + + @PutMapping("/{productId}") + @Override + public ResponseEntity> updateProduct( + @PathVariable String productId, + @Valid @RequestBody ProductV1Dto.UpdateProductRequest request + ) { + ProductInfo info = productFacade.updateProduct( + productId, + request.productName(), + request.price(), + request.stockQuantity() + ); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductResponse.from(info))); + } + + @PostMapping + @Override + public ResponseEntity> createProduct( + @Valid @RequestBody ProductV1Dto.CreateProductRequest request + ) { + ProductInfo info = productFacade.createProduct( + request.productId(), + request.brandId(), + request.productName(), + request.price(), + request.stockQuantity() + ); + + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response)); + } + + @GetMapping + @Override + public ResponseEntity> getProducts( + @RequestParam(required = false) String brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page productPage = productFacade.getProducts(brandId, sort, pageable); + + ProductV1Dto.ProductListResponse response = ProductV1Dto.ProductListResponse.from(productPage); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @DeleteMapping("/{productId}") + @Override + public ResponseEntity> deleteProduct(@PathVariable String productId) { + productFacade.deleteProduct(productId); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..edf3042be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,89 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.product.ProductInfo; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; + +public class ProductV1Dto { + + public record CreateProductRequest( + @NotBlank(message = "상품 ID는 필수입니다") + @Pattern(regexp = "^[A-Za-z0-9]{1,20}$", message = "상품 ID는 영문+숫자, 1~20자여야 합니다") + String productId, + + @NotBlank(message = "브랜드 ID는 필수입니다") + @Pattern(regexp = "^[A-Za-z0-9]{1,10}$", message = "브랜드 ID는 영문+숫자, 1~10자여야 합니다") + String brandId, + + @NotBlank(message = "상품명은 필수입니다") + @Size(min = 1, max = 100, message = "상품명은 1~100자여야 합니다") + String productName, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0.0", inclusive = true, message = "가격은 0 이상이어야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + int stockQuantity + ) { + } + + public record ProductResponse( + Long id, + String productId, + Long refBrandId, + String productName, + BigDecimal price, + int stockQuantity, + BrandInfo brand, + long likesCount + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.productId(), + info.refBrandId(), + info.productName(), + info.price(), + info.stockQuantity(), + info.brand(), + info.likesCount() + ); + } + } + + public record UpdateProductRequest( + @NotBlank(message = "상품명은 필수입니다") + @Size(min = 1, max = 100, message = "상품명은 1~100자여야 합니다") + String productName, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0.0", inclusive = true, message = "가격은 0 이상이어야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + int stockQuantity + ) {} + + public record ProductListResponse( + java.util.List products, + int currentPage, + int pageSize, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(org.springframework.data.domain.Page page) { + return new ProductListResponse( + page.getContent().stream() + .map(ProductResponse::from) + .toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 8d493491a..b770c36e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -12,6 +12,7 @@ public enum ErrorType { BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); private final HttpStatus status; diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java new file mode 100644 index 000000000..ea7184b3a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java @@ -0,0 +1,44 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Application Layer 규칙") +class ApplicationLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("App은 application 패키지에 위치해야 함") + void apps_must_reside_in_application() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("App") + .should().resideInAPackage("..application..") + .because("App은 단일 도메인 유스케이스를 처리하는 Application Layer에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Facade는 application 패키지에 위치해야 함") + void facades_must_reside_in_application() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Facade") + .should().resideInAPackage("..application..") + .because("Facade는 2개 이상의 App을 조합하는 Application Layer에 위치해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java new file mode 100644 index 000000000..e56276d88 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java @@ -0,0 +1,59 @@ +package com.loopers.architecture; + +import com.loopers.domain.BaseEntity; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Domain Layer 규칙") +class DomainLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("JPA Entity는 BaseEntity를 상속해야 함") + void entities_must_extend_base_entity() { + ArchRule rule = classes() + .that().areAnnotatedWith(Entity.class) + .should().beAssignableTo(BaseEntity.class) + .because("모든 Entity는 공통 필드(id, createdAt, updatedAt)를 위해 BaseEntity를 상속해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Value Object는 record 타입이어야 함") + void value_objects_must_be_records() { + ArchRule rule = classes() + .that().resideInAPackage("..vo..") + .and().areNotNestedClasses() + .and().haveSimpleNameNotEndingWith("Test") + .should().beRecords() + .because("Value Object는 불변성을 보장하기 위해 record 타입을 사용해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Service는 domain 패키지에 위치해야 함") + void service_must_reside_in_application() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Service") + .should().resideInAPackage("..domain..") + .because("Service 클래스는 도메인 로직을 포함하므로 domain 패키지에 위치해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java new file mode 100644 index 000000000..b96b280c6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java @@ -0,0 +1,57 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.AttributeConverter; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.JpaRepository; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Infrastructure Layer 규칙") +class InfrastructureLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("Repository 구현체는 infrastructure 패키지에 위치해야 함") + void repository_implementations_must_reside_in_infrastructure() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("RepositoryImpl") + .should().resideInAPackage("..infrastructure..") + .because("Repository 구현체는 기술적 세부사항으로 infrastructure 패키지에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("JpaRepository는 infrastructure.jpa 패키지에 위치해야 함") + void jpa_repositories_must_reside_in_infrastructure_jpa() { + ArchRule rule = classes() + .that().areAssignableTo(JpaRepository.class) + .should().resideInAPackage("..infrastructure..") + .because("Spring Data JPA Repository는 infrastructure.jpa 패키지에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Converter는 'Converter'로 끝나야 함") + void converters_must_end_with_converter() { + ArchRule rule = classes() + .that().implement(AttributeConverter.class) + .should().haveSimpleNameEndingWith("Converter") + .because("JPA Converter는 'Converter' 접미사를 사용해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java new file mode 100644 index 000000000..af294575a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java @@ -0,0 +1,69 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Interfaces Layer 규칙") +class InterfacesLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("Controller는 interfaces.api 패키지에 위치해야 함") + void controllers_must_reside_in_interfaces_api() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideInAPackage("..interfaces.api..") + .because("Controller는 외부 통신을 담당하는 Interfaces Layer에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Controller는 @RestController 어노테이션을 가져야 함") + void controllers_must_be_annotated_with_rest_controller() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Controller") + .and().resideInAPackage("..interfaces.api..") + .should().beAnnotatedWith(RestController.class) + .because("REST API Controller는 @RestController 어노테이션을 사용해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Dto는 interfaces.api 패키지에 위치해야 함") + void dtos_must_reside_in_interfaces_api() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Dto") + .should().resideInAPackage("..interfaces.api..") + .because("Dto는 API 계층의 데이터 전달 객체로 interfaces.api 패키지에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Interfaces Layer는 다른 레이어에서 접근하지 않아야 함") + void interfaces_should_not_be_accessed_by_any_layer() { + ArchRule rule = classes() + .that().resideInAPackage("..interfaces..") + .should().onlyBeAccessed().byClassesThat().resideInAnyPackage("..interfaces..", ""); + + // 참고: 빈 패키지("")는 테스트 클래스 등을 허용하기 위함 + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java new file mode 100644 index 000000000..ada298757 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -0,0 +1,223 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import com.tngtech.archunit.library.Architectures; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +/** + * 레이어드 아키텍처 의존성 규칙 테스트 + * + *

아키텍처 구조: + *

+ * Interfaces Layer (Controller, Dto)
+ *     ↓
+ * Application Layer (App, Facade, Info)
+ *     ↓
+ * Domain Layer (Model, Reader, Service, Repository)
+ *     ↑
+ * Infrastructure Layer (RepositoryImpl, JpaRepository, Converter)
+ * 
+ * App: 단일 도메인 유스케이스. Facade: 2개 이상 App 조합 시에만 사용. + */ +@DisplayName("레이어드 아키텍처 의존성 규칙") +class LayeredArchitectureTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("com.loopers"); + } + + @Test + @DisplayName("레이어 간 의존성 방향이 올바른지 검증") + void layer_dependencies_are_respected() { + Architectures.LayeredArchitecture architecture = layeredArchitecture() + .consideringAllDependencies() + + // 레이어 정의 + .layer("Interfaces").definedBy("com.loopers.interfaces..") + .layer("Application").definedBy("com.loopers.application..") + .layer("Domain").definedBy("com.loopers.domain..") + .layer("Infrastructure").definedBy("com.loopers.infrastructure..") + + // 예외 1: Domain → Infrastructure.Converter 의존 허용 (JPA @Convert 어노테이션 때문) + .ignoreDependency( + DescribedPredicate.describe( + "Domain classes using JPA Converters", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.domain") + ), + DescribedPredicate.describe( + "JPA Converter classes", + javaClass -> javaClass.getPackageName().contains("infrastructure.jpa.converter") + ) + ) + + // 예외 2: Infrastructure → Domain.Repository 구현 허용 (DIP 패턴) + .ignoreDependency( + DescribedPredicate.describe( + "Infrastructure repository implementations", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure") + && javaClass.getSimpleName().endsWith("RepositoryImpl") + ), + DescribedPredicate.describe( + "Domain repository interfaces", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.domain") + && javaClass.isInterface() + && javaClass.getSimpleName().endsWith("Repository") + ) + ) + + // 예외 3: 데이터 타입(VO, Enum, Entity)은 모든 레이어에서 사용 가능 + // 컴포넌트(Service, Repository, Facade 등) 간 의존성만 검증 + .ignoreDependency( + DescribedPredicate.alwaysTrue(), + DescribedPredicate.describe( + "Data types (VO, Enum, Entity)", + javaClass -> javaClass.getPackageName().contains(".vo") + || javaClass.isEnum() + || javaClass.isAnnotatedWith("jakarta.persistence.Entity") + ) + ) + + // 의존성 규칙 (다이어그램과 동일한 방향) + // Interfaces → Application → Domain ↔ Infrastructure + .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application") + .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Domain"); + + ArchRule rule = architecture + .because("컴포넌트(Service, Repository, App, Facade) 간 단방향 의존성을 검증합니다. " + + "(데이터 타입(VO/Enum/Entity)은 검증 대상에서 제외)"); + + rule.check(classes); + } + + @Test + @DisplayName("Interfaces 컴포넌트는 Domain, Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void interfaces_components_should_not_depend_on_domain_or_infrastructure_components() { + // Controller가 Service나 Repository를 직접 호출하는 것 금지 + // (VO, Enum, Entity 같은 데이터 타입 의존은 허용) + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.interfaces..") + .and().haveSimpleNameEndingWith("Controller") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.domain..", "com.loopers.infrastructure..") + .andShould().haveSimpleNameEndingWith("Service") + .orShould().haveSimpleNameEndingWith("Repository") + .orShould().haveSimpleNameEndingWith("RepositoryImpl") + .because("Controller는 App 또는 Facade를 통해서만 하위 레이어에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Application 컴포넌트(App)는 Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void app_components_should_not_depend_on_infrastructure_components() { + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.application..") + .and().haveSimpleNameEndingWith("App") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.infrastructure..") + .andShould().haveSimpleNameEndingWith("RepositoryImpl") + .because("App은 Domain Service를 통해서만 데이터에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Application 컴포넌트(Facade)는 Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void facade_components_should_not_depend_on_infrastructure_components() { + // Facade가 Repository 구현체를 직접 호출하는 것 금지 + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.application..") + .and().haveSimpleNameEndingWith("Facade") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.infrastructure..") + .andShould().haveSimpleNameEndingWith("RepositoryImpl") + .because("Facade는 App을 통해서만 데이터에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Domain Service는 Repository 인터페이스만 의존해야 함") + void domain_services_should_only_depend_on_repository_interfaces() { + // Service가 RepositoryImpl을 직접 의존하지 않고 Repository 인터페이스만 사용 + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.domain..") + .and().haveSimpleNameEndingWith("Service") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.infrastructure..") + .andShould().haveSimpleNameNotEndingWith("Converter") // Converter는 예외 + .because("Service는 Repository 인터페이스를 통해 데이터에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("domain.common.vo 패키지에는 Ref*Id 형식의 참조 VO만 허용") + void common_vo_should_only_contain_reference_value_objects() { + ArchCondition haveRefIdName = new ArchCondition<>("have simple name matching Ref*Id pattern") { + @Override + public void check(JavaClass item, ConditionEvents events) { + boolean matches = item.getSimpleName().matches("Ref[A-Z][a-zA-Z]+Id"); + if (!matches) { + events.add(SimpleConditionEvent.violated(item, + item.getSimpleName() + " does not match Ref*Id pattern in domain.common.vo")); + } + } + }; + + ArchRule rule = classes() + .that().resideInAPackage("com.loopers.domain.common.vo") + .should(haveRefIdName) + .because("domain.common.vo 패키지는 FK 참조용 Ref*Id VO만 허용합니다. " + + "비즈니스 로직이 있는 VO는 각 도메인 vo 패키지에 정의하세요."); + + rule.check(classes); + } + + @Test + @DisplayName("레이어는 역방향으로 접근할 수 없음 (상위 레이어 접근 금지)") + void layers_should_not_access_upper_layers() { + // Application이 Interfaces 접근 금지 + ArchRule applicationToInterfaces = noClasses() + .that().resideInAPackage("com.loopers.application..") + .should().dependOnClassesThat().resideInAnyPackage("com.loopers.interfaces..") + .because("하위 레이어가 상위 레이어를 의존하면 순환 의존성이 발생합니다"); + + // Domain이 Interfaces, Application 접근 금지 + ArchRule domainToUpper = noClasses() + .that().resideInAPackage("com.loopers.domain..") + .should().dependOnClassesThat().resideInAnyPackage("com.loopers.interfaces..", "com.loopers.application..") + .because("하위 레이어가 상위 레이어를 의존하면 순환 의존성이 발생합니다"); + + // Infrastructure가 Interfaces, Application 접근 금지 + ArchRule infraToUpper = noClasses() + .that().resideInAPackage("com.loopers.infrastructure..") + .should().dependOnClassesThat().resideInAnyPackage("com.loopers.interfaces..", "com.loopers.application..") + .because("하위 레이어가 상위 레이어를 의존하면 순환 의존성이 발생합니다"); + + applicationToInterfaces.check(classes); + domainToUpper.check(classes); + infraToUpper.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java new file mode 100644 index 000000000..fb5d1dc59 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java @@ -0,0 +1,34 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("네이밍 규칙") +class NamingConventionTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("JPA Entity는 'Model'로 끝나야 함") + void entities_must_end_with_model() { + ArchRule rule = classes() + .that().areAnnotatedWith(Entity.class) + .should().haveSimpleNameEndingWith("Model") + .because("JPA Entity는 'Model' 접미사를 사용하여 도메인 모델임을 명확히 해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..bcbd55cc7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,87 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.vo.BrandName; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BrandModel Entity") +class BrandModelTest { + + @DisplayName("브랜드를 생성할 때,") + @Nested + class Create { + @Test + @DisplayName("create() 정적 팩토리로 BrandModel 생성 성공") + void create_brand_model() { + // given + String brandId = "nike"; + String brandName = "Nike"; + + // when + BrandModel brand = BrandModel.create(brandId, brandName); + + // then + assertThat(brand.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(brand.getBrandName()).isEqualTo(new BrandName(brandName)); + assertThat(brand.getDeletedAt()).isNull(); + assertThat(brand.isDeleted()).isFalse(); + } + } + + @DisplayName("브랜드를 삭제할 때,") + @Nested + class Delete { + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + BrandModel brand = BrandModel.create("adidas", "Adidas"); + assertThat(brand.getDeletedAt()).isNull(); + + // when + ZonedDateTime beforeDelete = ZonedDateTime.now(); + brand.markAsDeleted(); + ZonedDateTime afterDelete = ZonedDateTime.now(); + + // then + assertThat(brand.getDeletedAt()).isNotNull(); + assertThat(brand.getDeletedAt()) + .isAfterOrEqualTo(beforeDelete) + .isBeforeOrEqualTo(afterDelete); + } + + @Test + @DisplayName("isDeleted()는 deletedAt이 null이 아니면 true 반환") + void isDeleted_returns_true_when_deletedAt_is_not_null() { + // given + BrandModel brand = BrandModel.create("puma", "Puma"); + + // when & then + assertThat(brand.isDeleted()).isFalse(); + + brand.markAsDeleted(); + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + @DisplayName("markAsDeleted() 중복 호출 시 deletedAt 변경되지 않음 (멱등성)") + void markAsDeleted_idempotent() { + // given + BrandModel brand = BrandModel.create("reebok", "Reebok"); + brand.markAsDeleted(); + ZonedDateTime firstDeletedAt = brand.getDeletedAt(); + + // when + brand.markAsDeleted(); + + // then + assertThat(brand.getDeletedAt()).isEqualTo(firstDeletedAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..fa503e20b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,219 @@ +package com.loopers.domain.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@DisplayName("BrandService 통합 테스트") +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private BrandFacade brandFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandRepository spyBrandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + Mockito.reset(spyBrandRepository); + } + + @Test + @DisplayName("브랜드 생성 성공") + void createBrand_success() { + // given + String brandId = "nike"; + String brandName = "Nike"; + + // when + BrandModel savedBrand = brandService.createBrand(brandId, brandName); + + // then + verify(spyBrandRepository, times(1)).save(any(BrandModel.class)); + + assertAll( + () -> assertThat(savedBrand).isNotNull(), + () -> assertThat(savedBrand.getId()).isNotNull(), + () -> assertThat(savedBrand.getBrandId()).isEqualTo(new BrandId(brandId)), + () -> assertThat(savedBrand.getBrandName().value()).isEqualTo(brandName), + () -> assertThat(savedBrand.isDeleted()).isFalse() + ); + + // DB에서 직접 조회하여 검증 + BrandModel foundBrand = brandJpaRepository.findById(savedBrand.getId()).orElseThrow(); + assertAll( + () -> assertThat(foundBrand.getBrandId()).isEqualTo(new BrandId(brandId)), + () -> assertThat(foundBrand.getBrandName().value()).isEqualTo(brandName), + () -> assertThat(foundBrand.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("중복된 브랜드 ID로 생성 시 예외 발생") + void createBrand_duplicateId_throwsException() { + // given + String brandId = "adidas"; + brandService.createBrand(brandId, "Adidas"); + + // when & then + assertThatThrownBy(() -> brandService.createBrand(brandId, "Adidas2")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 브랜드 ID입니다.") + .extracting("errorType") + .isEqualTo(ErrorType.CONFLICT); + } + + @Test + @DisplayName("브랜드 삭제 성공 (soft delete)") + void deleteBrand_success() { + // given + String brandId = "puma"; + BrandModel brand = brandService.createBrand(brandId, "Puma"); + assertThat(brand.isDeleted()).isFalse(); + + // when + brandService.deleteBrand(brandId); + + // then + BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); + assertThat(deletedBrand.isDeleted()).isTrue(); + assertThat(deletedBrand.getDeletedAt()).isNotNull(); + + // save가 2번 호출됨 (생성 1회 + 삭제 1회) + verify(spyBrandRepository, times(2)).save(any(BrandModel.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 삭제 시 예외 발생") + void deleteBrand_notFound_throwsException() { + // given + String nonExistentBrandId = "nonexist"; + + // when & then + assertThatThrownBy(() -> brandService.deleteBrand(nonExistentBrandId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다.") + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("브랜드 삭제 시 해당 브랜드의 상품도 연쇄 soft delete (BrandFacade 경유)") + void deleteBrand_cascadeDeletesProducts() { + // given + String brandId = "samsung"; + brandService.createBrand(brandId, "Samsung"); + + ProductModel product1 = productService.createProduct("prod1", brandId, "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brandId, "Product 2", new BigDecimal("20000"), 20); + + assertThat(product1.isDeleted()).isFalse(); + assertThat(product2.isDeleted()).isFalse(); + + // when - cascade는 Facade 책임 + brandFacade.deleteBrand(brandId); + + // then - 브랜드 삭제됨 + BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); + assertThat(deletedBrand.isDeleted()).isTrue(); + + // then - 상품도 연쇄 삭제됨 + ProductModel deletedProduct1 = productJpaRepository.findById(product1.getId()).orElseThrow(); + ProductModel deletedProduct2 = productJpaRepository.findById(product2.getId()).orElseThrow(); + assertThat(deletedProduct1.isDeleted()).isTrue(); + assertThat(deletedProduct2.isDeleted()).isTrue(); + } + + @Test + @DisplayName("이미 삭제된 상품은 브랜드 삭제 시 영향받지 않음") + void deleteBrand_alreadyDeletedProduct_notAffected() { + // given + String brandId = "lg"; + BrandModel brand = brandService.createBrand(brandId, "LG"); + + ProductModel product = productService.createProduct("prodlg", brandId, "LG Product", new BigDecimal("50000"), 5); + productService.deleteProduct("prodlg"); // 미리 삭제 + + // when + brandService.deleteBrand(brandId); + + // then - 브랜드는 삭제됨 + BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); + assertThat(deletedBrand.isDeleted()).isTrue(); + + // then - 상품 삭제 상태는 그대로 + ProductModel deletedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(deletedProduct.isDeleted()).isTrue(); + } + + @TestConfiguration + static class SpyConfig { + @Bean + @Primary + public BrandRepository spyBrandRepository(BrandJpaRepository brandJpaRepository) { + return Mockito.spy(new BrandRepository() { + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findByBrandId(BrandId brandId) { + return brandJpaRepository.findByBrandId(brandId); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public boolean existsByBrandId(BrandId brandId) { + return brandJpaRepository.existsByBrandId(brandId); + } + }); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..c0ded67eb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BrandService 단위 테스트") +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private BrandService brandService; + + @Test + @DisplayName("브랜드 생성 - 성공") + void createBrand_success() { + // given + String brandId = "nike"; + String brandName = "Nike"; + BrandModel mockBrand = BrandModel.create(brandId, brandName); + + when(brandRepository.existsByBrandId(any(BrandId.class))).thenReturn(false); + when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); + + // when + BrandModel result = brandService.createBrand(brandId, brandName); + + // then + assertThat(result).isNotNull(); + assertThat(result.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(result.getBrandName()).isEqualTo(new BrandName(brandName)); + + verify(brandRepository, times(1)).existsByBrandId(any(BrandId.class)); + verify(brandRepository, times(1)).save(any(BrandModel.class)); + } + + @Test + @DisplayName("브랜드 생성 - 중복 ID로 실패") + void createBrand_duplicateId_throwsException() { + // given + String brandId = "adidas"; + when(brandRepository.existsByBrandId(any(BrandId.class))).thenReturn(true); + + // when & then + assertThatThrownBy(() -> brandService.createBrand(brandId, "Adidas")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 브랜드 ID입니다.") + .extracting("errorType") + .isEqualTo(ErrorType.CONFLICT); + + verify(brandRepository, times(1)).existsByBrandId(any(BrandId.class)); + verify(brandRepository, never()).save(any(BrandModel.class)); + } + + @Test + @DisplayName("브랜드 삭제 - 성공") + void deleteBrand_success() { + // given + String brandId = "puma"; + BrandModel mockBrand = BrandModel.create(brandId, "Puma"); + + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.of(mockBrand)); + when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); + + // when + brandService.deleteBrand(brandId); + + // then + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); + verify(brandRepository, times(1)).save(mockBrand); + assertThat(mockBrand.isDeleted()).isTrue(); + } + + @Test + @DisplayName("브랜드 삭제 - 존재하지 않는 브랜드") + void deleteBrand_notFound_throwsException() { + // given + String brandId = "invalid123"; // 10자 이하로 변경 + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandService.deleteBrand(brandId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다.") + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + }); + + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); + verify(brandRepository, never()).save(any(BrandModel.class)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java new file mode 100644 index 000000000..175e3102b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java @@ -0,0 +1,84 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("BrandId VO") +class BrandIdTest { + + @Test + @DisplayName("유효한 BrandId 생성 성공 - 영문+숫자 1-10자") + void create_valid_brandId() { + // given & when + BrandId brandId1 = new BrandId("brand1"); + BrandId brandId2 = new BrandId("BRAND123"); + BrandId brandId3 = new BrandId("b"); + BrandId brandId4 = new BrandId("1234567890"); + + // then + assertThat(brandId1.value()).isEqualTo("brand1"); + assertThat(brandId2.value()).isEqualTo("BRAND123"); + assertThat(brandId3.value()).isEqualTo("b"); + assertThat(brandId4.value()).isEqualTo("1234567890"); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_brandId_throws_exception() { + assertThatThrownBy(() -> new BrandId(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("brandId가 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_brandId_throws_exception() { + assertThatThrownBy(() -> new BrandId("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("brandId가 비어 있습니다"); + } + + @Test + @DisplayName("공백 문자열이면 예외 발생") + void blank_brandId_throws_exception() { + assertThatThrownBy(() -> new BrandId(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("brandId가 비어 있습니다"); + } + + @Test + @DisplayName("특수문자 포함 시 예외 발생") + void brandId_with_special_characters_throws_exception() { + assertThatThrownBy(() -> new BrandId("brand-1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + + assertThatThrownBy(() -> new BrandId("brand_1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + + assertThatThrownBy(() -> new BrandId("brand@1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + } + + @Test + @DisplayName("11자 이상이면 예외 발생") + void brandId_longer_than_10_throws_exception() { + assertThatThrownBy(() -> new BrandId("12345678901")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + } + + @Test + @DisplayName("한글 포함 시 예외 발생") + void brandId_with_korean_throws_exception() { + assertThatThrownBy(() -> new BrandId("브랜드1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java new file mode 100644 index 000000000..14d053b6b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("BrandName VO") +class BrandNameTest { + + @Test + @DisplayName("유효한 BrandName 생성 성공 - 1-50자") + void create_valid_brandName() { + // given & when + BrandName brandName1 = new BrandName("Nike"); + BrandName brandName2 = new BrandName("삼성전자"); + BrandName brandName3 = new BrandName("A"); + BrandName brandName4 = new BrandName("A".repeat(50)); + + // then + assertThat(brandName1.value()).isEqualTo("Nike"); + assertThat(brandName2.value()).isEqualTo("삼성전자"); + assertThat(brandName3.value()).isEqualTo("A"); + assertThat(brandName4.value()).hasSize(50); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_brandName_throws_exception() { + assertThatThrownBy(() -> new BrandName(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명이 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_brandName_throws_exception() { + assertThatThrownBy(() -> new BrandName("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명이 비어 있습니다"); + } + + @Test + @DisplayName("공백 문자열이면 예외 발생") + void blank_brandName_throws_exception() { + assertThatThrownBy(() -> new BrandName(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명이 비어 있습니다"); + } + + @Test + @DisplayName("51자 이상이면 예외 발생") + void brandName_longer_than_50_throws_exception() { + String longName = "A".repeat(51); + assertThatThrownBy(() -> new BrandName(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명 길이는 1자 이상 50자 이하여야 합니다"); + } + + @Test + @DisplayName("앞뒤 공백은 trim 처리됨") + void brandName_with_leading_trailing_spaces_is_trimmed() { + // given & when + BrandName brandName = new BrandName(" Nike "); + + // then + assertThat(brandName.value()).isEqualTo("Nike"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..53e471c35 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +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; + +@DisplayName("LikeModel Entity") +class LikeModelTest { + + @DisplayName("좋아요를 생성할 때,") + @Nested + class Create { + + @Test + @DisplayName("create() 정적 팩토리로 LikeModel 생성 성공") + void create_like_model() { + // given + Long refMemberId = 1L; + Long refProductId = 100L; + + // when + LikeModel like = LikeModel.create(refMemberId, refProductId); + + // then + assertThat(like).isNotNull(); + assertThat(like.getRefMemberId()).isEqualTo(new RefMemberId(refMemberId)); + assertThat(like.getRefProductId()).isEqualTo(new RefProductId(refProductId)); + } + + @Test + @DisplayName("Member PK와 Product PK로 좋아요 생성") + void create_withMemberAndProductIds() { + // given + Long refMemberId = 5L; + Long refProductId = 200L; + + // when + LikeModel like = LikeModel.create(refMemberId, refProductId); + + // then + assertThat(like.getRefMemberId().value()).isEqualTo(5L); + assertThat(like.getRefProductId().value()).isEqualTo(200L); + } + } +} 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..42af83c79 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,227 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("LikeService 통합 테스트") +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요를 추가할 때,") + @Nested + class AddLike { + + @Test + @DisplayName("좋아요 추가 성공") + void addLike_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + + // when + LikeModel like = likeService.addLike(memberId, "prod1"); + + // then + assertThat(like).isNotNull(); + assertThat(like.getRefMemberId().value()).isEqualTo(memberId); + assertThat(like.getRefProductId().value()).isEqualTo(product.getId()); + } + + @Test + @DisplayName("중복 좋아요 추가 시 기존 좋아요 반환 (멱등성)") + void addLike_duplicate_returnsExisting() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + + // when + LikeModel firstLike = likeService.addLike(memberId, "prod1"); + LikeModel secondLike = likeService.addLike(memberId, "prod1"); + + // then + assertThat(firstLike.getId()).isEqualTo(secondLike.getId()); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 추가 시 예외 발생") + void addLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + + // when & then + assertThatThrownBy(() -> likeService.addLike(memberId, "invalid")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class RemoveLike { + + @Test + @DisplayName("좋아요 취소 성공") + void removeLike_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + likeService.addLike(memberId, "prod1"); + + // when + likeService.removeLike(memberId, "prod1"); + + // then - 중복 취소해도 예외 발생하지 않음 + likeService.removeLike(memberId, "prod1"); + } + + @Test + @DisplayName("좋아요가 없어도 예외 발생하지 않음 (멱등성)") + void removeLike_notExists_noException() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + + // when & then - 예외 발생하지 않음 + likeService.removeLike(memberId, "prod1"); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 취소 시 예외 발생") + void removeLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + + // when & then + assertThatThrownBy(() -> likeService.removeLike(memberId, "invalid")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + } + } + + @DisplayName("내 좋아요 목록을 조회할 때,") + @Nested + class GetMyLikes { + + @Test + @DisplayName("좋아요한 상품 목록 페이징 조회 성공") + void getMyLikes_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + ProductModel product2 = productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + // when + Page likes = likeRepository.findByRefMemberId(new RefMemberId(memberId), PageRequest.of(0, 10)); + + // then + assertAll( + () -> assertThat(likes.getTotalElements()).isEqualTo(2), + () -> assertThat(likes.getContent()).hasSize(2) + ); + } + + @Test + @DisplayName("삭제된 상품은 좋아요 목록에 포함되지 않음") + void getMyLikes_excludesDeletedProducts() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + // 상품 삭제 + productService.deleteProduct("prod2"); + + // when + Page likes = likeRepository.findByRefMemberId(new RefMemberId(memberId), PageRequest.of(0, 10)); + + // then + assertThat(likes.getTotalElements()).isEqualTo(1); + assertThat(likes.getContent().get(0).getRefProductId().value()) + .isEqualTo(productRepository.findByProductId(new ProductId("prod1")).orElseThrow().getId()); + } + + @Test + @DisplayName("좋아요가 없으면 빈 목록 반환") + void getMyLikes_noLikes_returnsEmpty() { + // when + Page likes = likeRepository.findByRefMemberId(new RefMemberId(99L), PageRequest.of(0, 10)); + + // then + assertThat(likes.getContent()).isEmpty(); + assertThat(likes.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("다른 회원의 좋아요는 포함되지 않음") + void getMyLikes_onlyReturnsOwnLikes() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + + likeService.addLike(1L, "prod1"); + likeService.addLike(2L, "prod1"); + + // when + Page likes = likeRepository.findByRefMemberId(new RefMemberId(1L), PageRequest.of(0, 10)); + + // then + assertThat(likes.getTotalElements()).isEqualTo(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..ae07b8429 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,172 @@ +package com.loopers.domain.like; + +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("LikeService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @Mock + private LikeRepository likeRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private LikeService likeService; + + @DisplayName("좋아요를 추가할 때,") + @Nested + class AddLike { + + @Test + @DisplayName("유효한 상품에 좋아요 추가 성공") + void addLike_success() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + + LikeModel mockLike = LikeModel.create(memberId, 100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.empty()); + when(likeRepository.save(any(LikeModel.class))).thenReturn(mockLike); + + // when + LikeModel result = likeService.addLike(memberId, productId); + + // then + assertThat(result).isNotNull(); + verify(productRepository, times(1)).findByProductId(any(ProductId.class)); + verify(likeRepository, times(1)).findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class)); + verify(likeRepository, times(1)).save(any(LikeModel.class)); + } + + @Test + @DisplayName("이미 좋아요가 있으면 기존 좋아요 반환 (멱등성)") + void addLike_alreadyExists_returnsExisting() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + LikeModel existingLike = LikeModel.create(memberId, 100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.of(existingLike)); + + // when + LikeModel result = likeService.addLike(memberId, productId); + + // then + assertThat(result).isEqualTo(existingLike); + verify(likeRepository, never()).save(any(LikeModel.class)); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 추가 시 예외 발생") + void addLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + String productId = "invalid"; + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> likeService.addLike(memberId, productId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + + verify(likeRepository, never()).save(any(LikeModel.class)); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class RemoveLike { + + @Test + @DisplayName("좋아요 취소 성공") + void removeLike_success() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + LikeModel existingLike = LikeModel.create(memberId, 100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.of(existingLike)); + + // when + likeService.removeLike(memberId, productId); + + // then + verify(likeRepository, times(1)).delete(existingLike); + } + + @Test + @DisplayName("좋아요가 없어도 예외 발생하지 않음 (멱등성)") + void removeLike_notExists_noException() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.empty()); + + // when + likeService.removeLike(memberId, productId); + + // then + verify(likeRepository, never()).delete(any(LikeModel.class)); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 취소 시 예외 발생") + void removeLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + String productId = "invalid"; + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> likeService.removeLike(memberId, productId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + + verify(likeRepository, never()).delete(any(LikeModel.class)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index 5c9ca525d..a1876bbe7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -1,6 +1,8 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -28,9 +30,6 @@ class MemberServiceIntegrationTest { @Autowired private MemberService memberService; - @Autowired - private MemberReader memberReader; - @Autowired private MemberJpaRepository memberJpaRepository; @@ -241,7 +240,7 @@ void changesPassword_whenValidCurrentAndNewPassword() { memberService.changePassword(VALID_MEMBER_ID, VALID_PASSWORD, VALID_PASSWORD, newPassword); // assert - MemberModel member = memberReader.getOrThrow(VALID_MEMBER_ID); + MemberModel member = memberJpaRepository.findByMemberId(new MemberId(VALID_MEMBER_ID)).orElseThrow(); assertThat(member.verifyPassword(passwordHasher, newPassword)).isTrue(); } @@ -338,7 +337,7 @@ void returnsMemberInfo_whenMemberExists() { ); // act - MemberModel foundMember = memberReader.getMemberByMemberId(VALID_MEMBER_ID); + MemberModel foundMember = spyMemberRepository.findByMemberId(new MemberId(VALID_MEMBER_ID)).orElse(null); // assert assertAll( @@ -362,7 +361,7 @@ void returnsNull_whenMemberDoesNotExist() { String nonExistentMemberId = "nonexist1"; // act - MemberModel foundMember = memberReader.getMemberByMemberId(nonExistentMemberId); + MemberModel foundMember = spyMemberRepository.findByMemberId(new MemberId(nonExistentMemberId)).orElse(null); // assert assertThat(foundMember).isNull(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..457557f76 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OrderItemModel Entity") +class OrderItemModelTest { + + @Test + @DisplayName("create() 정적 팩토리로 OrderItemModel 생성 성공") + void create_orderItem_success() { + // given + String productId = "prod1"; + String productName = "Test Product"; + BigDecimal price = new BigDecimal("10000"); + int quantity = 3; + + // when + OrderItemModel item = OrderItemModel.create(productId, productName, price, quantity); + + // then + assertThat(item).isNotNull(); + assertThat(item.getOrderItemId()).isNotNull(); + assertThat(item.getProductId()).isEqualTo(productId); + assertThat(item.getProductName()).isEqualTo(productName); + assertThat(item.getPrice()).isEqualByComparingTo(price); + assertThat(item.getQuantity()).isEqualTo(quantity); + } + + @Test + @DisplayName("getTotalPrice() = price * quantity") + void getTotalPrice() { + // given + OrderItemModel item = OrderItemModel.create( + "prod1", + "Test Product", + new BigDecimal("10000"), + 3 + ); + + // when + BigDecimal totalPrice = item.getTotalPrice(); + + // then + assertThat(totalPrice).isEqualByComparingTo(new BigDecimal("30000")); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..e6d6e52d3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("OrderModel Entity") +class OrderModelTest { + + @DisplayName("주문을 생성할 때,") + @Nested + class Create { + + @Test + @DisplayName("create() 정적 팩토리로 주문 생성 성공") + void create_order_success() { + // given + Long memberId = 1L; + OrderItemModel item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); + OrderItemModel item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); + List items = List.of(item1, item2); + + // when + OrderModel order = OrderModel.create(memberId, items); + + // then + assertThat(order).isNotNull(); + assertThat(order.getOrderId()).isNotNull(); + assertThat(order.getRefMemberId().value()).isEqualTo(memberId); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getOrderItems()).hasSize(2); + } + + @Test + @DisplayName("주문 상품이 비어있으면 예외 발생") + void create_emptyItems_throwsException() { + // given + Long memberId = 1L; + List items = List.of(); + + // when & then + assertThatThrownBy(() -> OrderModel.create(memberId, items)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("주문 상품이 비어 있습니다"); + } + + @Test + @DisplayName("총 주문 금액 계산") + void getTotalAmount() { + // given + Long memberId = 1L; + OrderItemModel item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); // 20000 + OrderItemModel item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); // 20000 + OrderModel order = OrderModel.create(memberId, List.of(item1, item2)); + + // when + BigDecimal totalAmount = order.getTotalAmount(); + + // then + assertThat(totalAmount).isEqualByComparingTo(new BigDecimal("40000")); + } + } + + @DisplayName("주문을 취소할 때,") + @Nested + class Cancel { + + @Test + @DisplayName("PENDING 상태에서 취소 성공") + void cancel_fromPending_success() { + // given + Long memberId = 1L; + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + + // when + order.cancel(); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELED); + } + + @Test + @DisplayName("이미 취소된 주문 재취소 시 멱등성 보장") + void cancel_alreadyCanceled_idempotent() { + // given + Long memberId = 1L; + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + order.cancel(); + + // when + order.cancel(); // 두 번째 취소 + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELED); + } + } + + @DisplayName("주문 소유자 확인") + @Nested + class IsOwner { + + @Test + @DisplayName("소유자가 맞으면 true 반환") + void isOwner_correctMember_returnsTrue() { + // given + Long memberId = 1L; + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + + // when + boolean isOwner = order.isOwner(1L); + + // then + assertThat(isOwner).isTrue(); + } + + @Test + @DisplayName("소유자가 아니면 false 반환") + void isOwner_wrongMember_returnsFalse() { + // given + Long memberId = 1L; + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + + // when + boolean isOwner = order.isOwner(2L); + + // then + assertThat(isOwner).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java new file mode 100644 index 000000000..29dfbc272 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java @@ -0,0 +1,160 @@ +package com.loopers.domain.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +import com.loopers.domain.order.OrderItemRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +@DisplayName("OrderService 주문 생성 통합 테스트") +class OrderServiceCreateIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + // Brand와 Product 생성 + brandService.createBrand("nike", "Nike"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("주문 생성 성공 (재고 감소 확인)") + void createOrder_success_decreasesStock() { + // given + ProductModel product = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("prod1", 3)); + + // when + OrderModel order = orderService.createOrder(memberId, requests); + + // then + assertThat(order).isNotNull(); + assertThat(order.getOrderId()).isNotNull(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("300000.00")); // 100000 * 3 + + // 재고 감소 확인 + ProductModel updatedProduct = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + assertThat(updatedProduct.getStockQuantity().value()).isEqualTo(7); // 10 - 3 = 7 + } + + @Test + @DisplayName("재고 부족 시 409 Conflict (롤백 확인)") + void createOrder_insufficientStock_rollback() { + // given + ProductModel product = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 5); + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("prod1", 10)); // 재고보다 많이 요청 + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.CONFLICT); + + // 재고가 롤백되어 원래대로 유지됨 + ProductModel unchangedProduct = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + assertThat(unchangedProduct.getStockQuantity().value()).isEqualTo(5); + } + + @Test + @DisplayName("중복 상품 수량 합산 동작") + void createOrder_aggregateDuplicates() { + // given + ProductModel product = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod1", 3) // 동일 상품 + ); + + // when + OrderModel order = orderService.createOrder(memberId, requests); + + // then + assertThat(order.getOrderItems()).hasSize(1); // 하나로 합쳐짐 + assertThat(order.getOrderItems().get(0).getQuantity()).isEqualTo(5); // 2+3=5 + + // 재고 감소 확인 + ProductModel updatedProduct = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + assertThat(updatedProduct.getStockQuantity().value()).isEqualTo(5); // 10 - 5 = 5 + } + + @Test + @DisplayName("존재하지 않는 상품 404") + void createOrder_productNotFound() { + // given + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("invalid", 1)); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("여러 상품 주문 시 재고 감소 확인") + void createOrder_multipleProducts() { + // given + ProductModel product1 = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 10); + ProductModel product2 = createProduct("prod2", "Nike Jordan", new BigDecimal("200000"), 5); + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod2", 3) + ); + + // when + OrderModel order = orderService.createOrder(memberId, requests); + + // then + assertThat(order.getOrderItems()).hasSize(2); + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("800000.00")); // (100000*2) + (200000*3) + + // 재고 감소 확인 + ProductModel updatedProduct1 = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + ProductModel updatedProduct2 = productRepository.findByProductId(new ProductId("prod2")).orElseThrow(); + assertThat(updatedProduct1.getStockQuantity().value()).isEqualTo(8); // 10 - 2 + assertThat(updatedProduct2.getStockQuantity().value()).isEqualTo(2); // 5 - 3 + } + + private ProductModel createProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = ProductModel.create(productId, 1L, productName, price, stockQuantity); + return productRepository.save(product); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java new file mode 100644 index 000000000..be52c337b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.order.OrderItemRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@Transactional +@DisplayName("OrderService 주문 조회 통합 테스트") +class OrderServiceQueryIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private BrandService brandService; + + @Autowired + private com.loopers.domain.product.ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + com.loopers.domain.product.ProductModel product = com.loopers.domain.product.ProductModel.create( + "prod1", 1L, "Nike Air", new BigDecimal("100000"), 100); + productRepository.save(product); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주문 상세 조회 - getMyOrder") + @Nested + class GetMyOrder { + + @Test + @DisplayName("본인 주문 조회 성공") + void getMyOrder_success() { + // given + Long memberId = 1L; + OrderModel order = orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + + // when + OrderModel found = orderService.getMyOrder(memberId, order.getOrderId().value()); + + // then + assertAll( + () -> assertThat(found).isNotNull(), + () -> assertThat(found.getOrderId()).isEqualTo(order.getOrderId()), + () -> assertThat(found.isOwner(memberId)).isTrue() + ); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 시 404 예외") + void getMyOrder_notFound_throwsException() { + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(1L, "00000000-0000-0000-0000-000000000001")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("타인의 주문 조회 시 403 예외") + void getMyOrder_notOwner_throwsForbidden() { + // given + Long ownerId = 1L; + Long otherMemberId = 2L; + OrderModel order = orderService.createOrder(ownerId, List.of(new OrderItemRequest("prod1", 1))); + + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(otherMemberId, order.getOrderId().value())) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.FORBIDDEN); + } + } + + @DisplayName("주문 목록 조회 - getMyOrders") + @Nested + class GetMyOrders { + + @Test + @DisplayName("회원 주문 목록 페이징 조회") + void getMyOrders_success() { + // given + Long memberId = 1L; + orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + + // when + Page orders = orderRepository.findByRefMemberId(new RefMemberId(memberId), null, null, PageRequest.of(0, 10)); + + // then + assertAll( + () -> assertThat(orders.getTotalElements()).isEqualTo(2), + () -> assertThat(orders.getContent()).hasSize(2) + ); + } + + @Test + @DisplayName("다른 회원의 주문은 조회되지 않음") + void getMyOrders_onlyReturnsOwnOrders() { + // given + Long memberId1 = 1L; + Long memberId2 = 2L; + orderService.createOrder(memberId1, List.of(new OrderItemRequest("prod1", 1))); + orderService.createOrder(memberId2, List.of(new OrderItemRequest("prod1", 1))); + + // when + Page orders = orderRepository.findByRefMemberId(new RefMemberId(memberId1), null, null, PageRequest.of(0, 10)); + + // then + assertThat(orders.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("주문이 없는 회원은 빈 목록 반환") + void getMyOrders_noOrders_returnsEmpty() { + // when + Page orders = orderRepository.findByRefMemberId(new RefMemberId(99L), null, null, PageRequest.of(0, 10)); + + // then + assertThat(orders.getContent()).isEmpty(); + assertThat(orders.getTotalElements()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..b49cf899b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,201 @@ +package com.loopers.domain.order; + +import com.loopers.domain.order.vo.OrderId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import com.loopers.domain.order.OrderItemRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OrderService 단위 테스트") +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private OrderService orderService; + + @Test + @DisplayName("createOrder: 주문 생성 성공") + void createOrder_success() { + // given + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod2", 3) + ); + + ProductModel product1 = mockProduct("prod1", "Product 1", new BigDecimal("10000"), 100L); + ProductModel product2 = mockProduct("prod2", "Product 2", new BigDecimal("20000"), 101L); + + when(productRepository.findByProductId(new ProductId("prod1"))).thenReturn(Optional.of(product1)); + when(productRepository.findByProductId(new ProductId("prod2"))).thenReturn(Optional.of(product2)); + when(productRepository.decreaseStockIfAvailable(100L, 2)).thenReturn(true); + when(productRepository.decreaseStockIfAvailable(101L, 3)).thenReturn(true); + + OrderModel savedOrder = mock(OrderModel.class); + when(orderRepository.save(any(OrderModel.class))).thenReturn(savedOrder); + + // when + OrderModel result = orderService.createOrder(memberId, requests); + + // then + assertThat(result).isEqualTo(savedOrder); + verify(productRepository).decreaseStockIfAvailable(100L, 2); + verify(productRepository).decreaseStockIfAvailable(101L, 3); + verify(orderRepository).save(any(OrderModel.class)); + } + + @Test + @DisplayName("createOrder: 중복 상품 수량 합산") + void createOrder_aggregateQuantities() { + // given + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod1", 3) // 동일 상품 + ); + + ProductModel product = mockProduct("prod1", "Product 1", new BigDecimal("10000"), 100L); + when(productRepository.findByProductId(new ProductId("prod1"))).thenReturn(Optional.of(product)); + when(productRepository.decreaseStockIfAvailable(100L, 5)).thenReturn(true); // 2+3=5 + + OrderModel savedOrder = mock(OrderModel.class); + when(orderRepository.save(any(OrderModel.class))).thenReturn(savedOrder); + + // when + orderService.createOrder(memberId, requests); + + // then + verify(productRepository).decreaseStockIfAvailable(100L, 5); + } + + @Test + @DisplayName("createOrder: 재고 부족 시 예외 발생") + void createOrder_insufficientStock_throwsException() { + // given + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("prod1", 100)); + + // 재고 부족 시에는 OrderItemModel 생성 전에 예외 발생하므로 getId()만 필요 + ProductModel product = mock(ProductModel.class); + when(product.getId()).thenReturn(100L); + when(productRepository.findByProductId(new ProductId("prod1"))).thenReturn(Optional.of(product)); + when(productRepository.decreaseStockIfAvailable(100L, 100)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.CONFLICT); + } + + @Test + @DisplayName("createOrder: 존재하지 않는 상품 시 예외 발생") + void createOrder_productNotFound_throwsException() { + // given + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("invalid", 1)); + + when(productRepository.findByProductId(new ProductId("invalid"))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("OrderItemRequest: 상품 ID null 시 예외 발생") + void orderItemRequest_nullProductId_throwsException() { + // when & then + assertThatThrownBy(() -> new OrderItemRequest(null, 1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("OrderItemRequest: 수량 0 이하 시 예외 발생") + void orderItemRequest_invalidQuantity_throwsException() { + // when & then + assertThatThrownBy(() -> new OrderItemRequest("prod1", 0)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("getMyOrder: 본인 주문 조회 성공") + void getMyOrder_success() { + // given + Long memberId = 1L; + String orderId = "550e8400-e29b-41d4-a716-446655440000"; + OrderModel order = mock(OrderModel.class); + when(order.isOwner(memberId)).thenReturn(true); + when(orderRepository.findByOrderId(new OrderId(orderId))).thenReturn(Optional.of(order)); + + // when + OrderModel result = orderService.getMyOrder(memberId, orderId); + + // then + assertThat(result).isEqualTo(order); + } + + @Test + @DisplayName("getMyOrder: 존재하지 않는 주문 → 404") + void getMyOrder_notFound_throwsException() { + // given + String validButNonExistentUuid = "00000000-0000-0000-0000-000000000001"; + when(orderRepository.findByOrderId(any(OrderId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(1L, validButNonExistentUuid)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("getMyOrder: 타인의 주문 조회 시 → 403") + void getMyOrder_notOwner_throwsForbidden() { + // given + Long memberId = 1L; + String orderId = "550e8400-e29b-41d4-a716-446655440000"; + OrderModel order = mock(OrderModel.class); + when(order.isOwner(memberId)).thenReturn(false); + when(orderRepository.findByOrderId(new OrderId(orderId))).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(memberId, orderId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.FORBIDDEN); + } + + private ProductModel mockProduct(String productId, String productName, BigDecimal price, Long id) { + ProductModel product = mock(ProductModel.class); + when(product.getId()).thenReturn(id); + when(product.getProductId()).thenReturn(new ProductId(productId)); + when(product.getProductName()).thenReturn(new com.loopers.domain.product.vo.ProductName(productName)); + when(product.getPrice()).thenReturn(new com.loopers.domain.product.vo.Price(price)); + return product; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java new file mode 100644 index 000000000..bba6a7c00 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("OrderStatus Enum 테스트") +class OrderStatusTest { + + @Test + @DisplayName("PENDING에서 CANCELED로 전이 가능") + void canTransition_pendingToCanceled() { + // given + OrderStatus status = OrderStatus.PENDING; + + // when + boolean canTransition = status.canTransitionTo(OrderStatus.CANCELED); + + // then + assertThat(canTransition).isTrue(); + } + + @Test + @DisplayName("CANCELED에서 CANCELED로 전이 가능 (멱등성)") + void canTransition_canceledToCanceled() { + // given + OrderStatus status = OrderStatus.CANCELED; + + // when + boolean canTransition = status.canTransitionTo(OrderStatus.CANCELED); + + // then + assertThat(canTransition).isTrue(); + } + + @Test + @DisplayName("CANCELED에서 PENDING으로 전이 불가") + void cannotTransition_canceledToPending() { + // given + OrderStatus status = OrderStatus.CANCELED; + + // when + boolean canTransition = status.canTransitionTo(OrderStatus.PENDING); + + // then + assertThat(canTransition).isFalse(); + } + + @Test + @DisplayName("불가능한 상태 전이 시 예외 발생") + void validateTransition_throwsException() { + // given + OrderStatus status = OrderStatus.CANCELED; + + // when & then + assertThatThrownBy(() -> status.validateTransition(OrderStatus.PENDING)) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..2cc80092a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,123 @@ +package com.loopers.domain.product; + + +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.domain.product.vo.ProductName; +import com.loopers.domain.product.vo.StockQuantity; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductModel Entity") +class ProductModelTest { + + @DisplayName("상품을 생성할 때,") + @Nested + class Create { + @Test + @DisplayName("create() 정적 팩토리로 ProductModel 생성 성공") + void create_product_model() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + // when + ProductModel product = ProductModel.create(productId, 1L, productName, price, stockQuantity); + + // then + assertThat(product.getProductId()).isEqualTo(new ProductId(productId)); + assertThat(product.getProductName()).isEqualTo(new ProductName(productName)); + assertThat(product.getPrice().value()).isEqualByComparingTo(price.setScale(2, java.math.RoundingMode.HALF_UP)); + assertThat(product.getStockQuantity().value()).isEqualTo(stockQuantity); + assertThat(product.isDeleted()).isFalse(); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + @Test + @DisplayName("재고가 충분하면 차감 성공") + void decreaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); + + // when + product.decreaseStock(10); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(40); + } + + @Test + @DisplayName("재고가 부족하면 예외 발생") + void decreaseStock_insufficient_stock_throws_exception() { + // given + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 5); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("0개 차감 시 재고 변화 없음") + void decreaseStock_zero_does_not_change_stock() { + // given + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); + + // when + product.decreaseStock(0); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(50); + } + } + + @DisplayName("재고를 증가할 때,") + @Nested + class IncreaseStock { + @Test + @DisplayName("재고 증가 성공") + void increaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); + + // when + product.increaseStock(20); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(70); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class Delete { + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + product.markAsDeleted(); + + // then + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..f33644ae1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,299 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("ProductService 통합 테스트") +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private LikeService likeService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품을 생성할 때,") + @Nested + class CreateProduct { + + @Test + @DisplayName("유효한 브랜드로 상품 생성 성공") + void createProduct_withValidBrand_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + String productId = "prod1"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + // when + ProductModel savedProduct = productService.createProduct(productId, brand.getBrandId().value(), productName, price, stockQuantity); + + // then + assertAll( + () -> assertThat(savedProduct).isNotNull(), + () -> assertThat(savedProduct.getId()).isNotNull(), + () -> assertThat(savedProduct.getProductId().value()).isEqualTo(productId), + () -> assertThat(savedProduct.getRefBrandId().value()).isEqualTo(brand.getId()), + () -> assertThat(savedProduct.getProductName().value()).isEqualTo(productName), + () -> assertThat(savedProduct.getPrice().value()).isEqualByComparingTo(price.setScale(2)), + () -> assertThat(savedProduct.getStockQuantity().value()).isEqualTo(stockQuantity), + () -> assertThat(savedProduct.isDeleted()).isFalse() + ); + + // DB에서 직접 조회하여 검증 + ProductModel foundProduct = productJpaRepository.findById(savedProduct.getId()).orElseThrow(); + assertThat(foundProduct.getProductId().value()).isEqualTo(productId); + } + + @Test + @DisplayName("중복된 상품 ID로 생성 시 예외 발생") + void createProduct_withDuplicateId_throwsException() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + String productId = "prod1"; + productService.createProduct(productId, brand.getBrandId().value(), "Nike Air Max", new BigDecimal("150000"), 100); + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, brand.getBrandId().value(), "Nike Air Max 2", new BigDecimal("200000"), 50)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 상품 ID입니다"); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성 시 예외 발생") + void createProduct_withNonExistentBrand_throwsException() { + // given + String productId = "prod1"; + String invalidBrandId = "nobrand"; // 유효한 형식이지만 존재하지 않는 brandId (10자 이내) + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, invalidBrandId, "Product", new BigDecimal("10000"), 10)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드가 존재하지 않습니다"); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class DeleteProduct { + + @Test + @DisplayName("존재하는 상품 삭제 성공 (soft delete)") + void deleteProduct_existingProduct_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", brand.getBrandId().value(), "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + productService.deleteProduct(product.getProductId().value()); + + // then + ProductModel deletedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(deletedProduct.isDeleted()).isTrue(); + assertThat(deletedProduct.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 예외 발생") + void deleteProduct_nonExistentProduct_throwsException() { + // given + String invalidProductId = "invalidProduct"; + + // when & then + assertThatThrownBy(() -> productService.deleteProduct(invalidProductId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품이 존재하지 않습니다"); + } + } + + @DisplayName("상품 목록을 조회할 때,") + @Nested + class GetProducts { + + @Test + @DisplayName("삭제되지 않은 상품만 조회됨") + void getProducts_excludesDeletedProducts() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("20000"), 20); + ProductModel product3 = productService.createProduct("prod3", brand.getBrandId().value(), "Product 3", new BigDecimal("30000"), 30); + + // product2 삭제 + productService.deleteProduct(product2.getProductId().value()); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "latest", pageable); + + // then + assertThat(products.getContent()).hasSize(2); + assertThat(products.getContent()) + .extracting(p -> p.getProductId().value()) + .containsExactlyInAnyOrder("prod1", "prod3") + .doesNotContain("prod2"); + } + + @Test + @DisplayName("브랜드 필터링 동작") + void getProducts_filtersByBrand() { + // given + BrandModel nike = brandService.createBrand("nike", "Nike"); + BrandModel adidas = brandService.createBrand("adidas", "Adidas"); + + productService.createProduct("prod1", nike.getBrandId().value(), "Nike Product", new BigDecimal("10000"), 10); + productService.createProduct("prod2", adidas.getBrandId().value(), "Adidas Product", new BigDecimal("20000"), 20); + productService.createProduct("prod3", nike.getBrandId().value(), "Nike Product 2", new BigDecimal("30000"), 30); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page nikeProducts = productService.getProducts(nike.getBrandId().value(), "latest", pageable); + + // then + assertThat(nikeProducts.getContent()).hasSize(2); + assertThat(nikeProducts.getContent()) + .allMatch(p -> p.getRefBrandId().value().equals(nike.getId())); + } + + @Test + @DisplayName("latest 정렬 (updatedAt DESC)") + void getProducts_sortByLatest() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("20000"), 20); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "latest", pageable); + + // then + assertThat(products.getContent()).hasSize(2); + // 최신 생성된 상품이 먼저 (updatedAt DESC) + assertThat(products.getContent().get(0).getUpdatedAt()) + .isAfterOrEqualTo(products.getContent().get(1).getUpdatedAt()); + } + + @Test + @DisplayName("price_asc 정렬 (가격 오름차순)") + void getProducts_sortByPriceAsc() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", brand.getBrandId().value(), "Expensive", new BigDecimal("100000"), 10); + productService.createProduct("prod2", brand.getBrandId().value(), "Cheap", new BigDecimal("10000"), 20); + productService.createProduct("prod3", brand.getBrandId().value(), "Medium", new BigDecimal("50000"), 30); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "price_asc", pageable); + + // then + assertThat(products.getContent()).hasSize(3); + assertThat(products.getContent()) + .extracting(p -> p.getPrice().value()) + .containsExactly( + new BigDecimal("10000.00"), + new BigDecimal("50000.00"), + new BigDecimal("100000.00") + ); + } + + @Test + @DisplayName("페이징 동작") + void getProducts_pagination() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page firstPage = productService.getProducts(null, "latest", pageable); + Page secondPage = productService.getProducts(null, "latest", PageRequest.of(1, 10)); + + // then + assertThat(firstPage.getContent()).hasSize(10); + assertThat(secondPage.getContent()).hasSize(5); + assertThat(firstPage.getTotalElements()).isEqualTo(15); + assertThat(firstPage.getTotalPages()).isEqualTo(2); + } + + @Test + @DisplayName("likes_desc 정렬 (좋아요 많은 순)") + void getProducts_sortByLikesDesc() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("20000"), 20); + ProductModel product3 = productService.createProduct("prod3", brand.getBrandId().value(), "Product 3", new BigDecimal("30000"), 30); + + // product2: 좋아요 3개 + likeService.addLike(1L, "prod2"); + likeService.addLike(2L, "prod2"); + likeService.addLike(3L, "prod2"); + + // product1: 좋아요 1개 + likeService.addLike(1L, "prod1"); + + // product3: 좋아요 0개 + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "likes_desc", pageable); + + // then + assertThat(products.getContent()).hasSize(3); + // 좋아요 많은 순: prod2(3) > prod1(1) > prod3(0) + assertThat(products.getContent()) + .extracting(p -> p.getProductId().value()) + .containsExactly("prod2", "prod1", "prod3"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..4c98b82d3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,157 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("ProductService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private ProductService productService; + + @DisplayName("상품을 생성할 때,") + @Nested + class CreateProduct { + + @Test + @DisplayName("유효한 브랜드로 상품 생성 성공") + void createProduct_withValidBrand_success() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + BrandModel mockBrand = mock(BrandModel.class); + when(mockBrand.getId()).thenReturn(1L); + + when(productRepository.existsByProductId(any(ProductId.class))).thenReturn(false); + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.of(mockBrand)); + when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ProductModel result = productService.createProduct(productId, brandId, productName, price, stockQuantity); + + // then + assertThat(result).isNotNull(); + assertThat(result.getProductId().value()).isEqualTo(productId); + assertThat(result.getRefBrandId().value()).isEqualTo(1L); + verify(productRepository, times(1)).existsByProductId(any(ProductId.class)); + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); + verify(productRepository, times(1)).save(any(ProductModel.class)); + } + + @Test + @DisplayName("중복된 상품 ID로 생성 시 예외 발생") + void createProduct_withDuplicateId_throwsException() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + when(productRepository.existsByProductId(any(ProductId.class))).thenReturn(true); + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, brandId, productName, price, stockQuantity)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 상품 ID입니다"); + + verify(productRepository, times(1)).existsByProductId(any(ProductId.class)); + verify(brandRepository, never()).findByBrandId(any(BrandId.class)); + verify(productRepository, never()).save(any(ProductModel.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성 시 예외 발생") + void createProduct_withNonExistentBrand_throwsException() { + // given + String productId = "prod1"; + String brandId = "invalid12"; // 10자 이하로 변경 + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + when(productRepository.existsByProductId(any(ProductId.class))).thenReturn(false); + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, brandId, productName, price, stockQuantity)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다"); + + verify(productRepository, times(1)).existsByProductId(any(ProductId.class)); + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); + verify(productRepository, never()).save(any(ProductModel.class)); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class DeleteProduct { + + @Test + @DisplayName("존재하는 상품 삭제 성공 (soft delete)") + void deleteProduct_existingProduct_success() { + // given + String productId = "prod1"; + ProductModel product = ProductModel.create(productId, 1L, "Nike Air", new BigDecimal("100000"), 50); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(product)); + when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + productService.deleteProduct(productId); + + // then + assertThat(product.isDeleted()).isTrue(); + verify(productRepository, times(1)).findByProductId(any(ProductId.class)); + verify(productRepository, times(1)).save(product); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 예외 발생") + void deleteProduct_nonExistentProduct_throwsException() { + // given + String productId = "invalidProduct"; + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.deleteProduct(productId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품이 존재하지 않습니다"); + + verify(productRepository, times(1)).findByProductId(any(ProductId.class)); + verify(productRepository, never()).save(any(ProductModel.class)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java new file mode 100644 index 000000000..19ab2655e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Price VO") +class PriceTest { + + @Test + @DisplayName("유효한 Price 생성 성공 - 음수 불가, scale 2") + void create_valid_price() { + // given & when + Price price1 = new Price(new BigDecimal("1000")); + Price price2 = new Price(new BigDecimal("0")); + Price price3 = new Price(new BigDecimal("99999.99")); + + // then + assertThat(price1.value()).isEqualByComparingTo(new BigDecimal("1000.00")); + assertThat(price2.value()).isEqualByComparingTo(new BigDecimal("0.00")); + assertThat(price3.value()).isEqualByComparingTo(new BigDecimal("99999.99")); + assertThat(price1.value().scale()).isEqualTo(2); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_price_throws_exception() { + assertThatThrownBy(() -> new Price(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격이 비어 있습니다"); + } + + @Test + @DisplayName("음수이면 예외 발생") + void negative_price_throws_exception() { + assertThatThrownBy(() -> new Price(new BigDecimal("-1"))) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격은 0 이상이어야 합니다"); + } + + @Test + @DisplayName("소수점 3자리 이상은 반올림되어 2자리로 저장됨") + void price_with_more_than_2_decimals_is_rounded() { + // given & when + Price price = new Price(new BigDecimal("1234.567")); + + // then + assertThat(price.value()).isEqualByComparingTo(new BigDecimal("1234.57")); + assertThat(price.value().scale()).isEqualTo(2); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java new file mode 100644 index 000000000..51707a2f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java @@ -0,0 +1,60 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductId VO") +class ProductIdTest { + + @Test + @DisplayName("유효한 ProductId 생성 성공 - 영문+숫자 1-20자") + void create_valid_productId() { + // given & when + ProductId productId1 = new ProductId("prod1"); + ProductId productId2 = new ProductId("PRODUCT123"); + ProductId productId3 = new ProductId("p"); + ProductId productId4 = new ProductId("12345678901234567890"); + + // then + assertThat(productId1.value()).isEqualTo("prod1"); + assertThat(productId2.value()).isEqualTo("PRODUCT123"); + assertThat(productId3.value()).isEqualTo("p"); + assertThat(productId4.value()).isEqualTo("12345678901234567890"); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_productId_throws_exception() { + assertThatThrownBy(() -> new ProductId(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("productId가 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_productId_throws_exception() { + assertThatThrownBy(() -> new ProductId("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("productId가 비어 있습니다"); + } + + @Test + @DisplayName("21자 이상이면 예외 발생") + void productId_longer_than_20_throws_exception() { + assertThatThrownBy(() -> new ProductId("123456789012345678901")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~20자"); + } + + @Test + @DisplayName("특수문자 포함 시 예외 발생") + void productId_with_special_characters_throws_exception() { + assertThatThrownBy(() -> new ProductId("prod-1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~20자"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java new file mode 100644 index 000000000..b4abf6d49 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java @@ -0,0 +1,63 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductName VO") +class ProductNameTest { + + @Test + @DisplayName("유효한 ProductName 생성 성공 - 1-100자") + void create_valid_productName() { + // given & when + ProductName productName1 = new ProductName("Nike Air Max"); + ProductName productName2 = new ProductName("갤럭시 S24"); + ProductName productName3 = new ProductName("A"); + ProductName productName4 = new ProductName("A".repeat(100)); + + // then + assertThat(productName1.value()).isEqualTo("Nike Air Max"); + assertThat(productName2.value()).isEqualTo("갤럭시 S24"); + assertThat(productName3.value()).isEqualTo("A"); + assertThat(productName4.value()).hasSize(100); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_productName_throws_exception() { + assertThatThrownBy(() -> new ProductName(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명이 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_productName_throws_exception() { + assertThatThrownBy(() -> new ProductName("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명이 비어 있습니다"); + } + + @Test + @DisplayName("101자 이상이면 예외 발생") + void productName_longer_than_100_throws_exception() { + String longName = "A".repeat(101); + assertThatThrownBy(() -> new ProductName(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명 길이는 1자 이상 100자 이하여야 합니다"); + } + + @Test + @DisplayName("앞뒤 공백은 trim 처리됨") + void productName_with_leading_trailing_spaces_is_trimmed() { + // given & when + ProductName productName = new ProductName(" Nike Air "); + + // then + assertThat(productName.value()).isEqualTo("Nike Air"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java new file mode 100644 index 000000000..964ca1607 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java @@ -0,0 +1,34 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("StockQuantity VO") +class StockQuantityTest { + + @Test + @DisplayName("유효한 StockQuantity 생성 성공 - 음수 불가") + void create_valid_stockQuantity() { + // given & when + StockQuantity quantity1 = new StockQuantity(0); + StockQuantity quantity2 = new StockQuantity(100); + StockQuantity quantity3 = new StockQuantity(999999); + + // then + assertThat(quantity1.value()).isEqualTo(0); + assertThat(quantity2.value()).isEqualTo(100); + assertThat(quantity3.value()).isEqualTo(999999); + } + + @Test + @DisplayName("음수이면 예외 발생") + void negative_stockQuantity_throws_exception() { + assertThatThrownBy(() -> new StockQuantity(-1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고 수량은 0 이상이어야 합니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java new file mode 100644 index 000000000..4478da6a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java @@ -0,0 +1,128 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ApiResponse.Metadata.Result; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("BrandV1Controller E2E 테스트") +class BrandV1ControllerE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("브랜드 생성 → 삭제 전체 플로우") + void brandLifecycle() { + // given + BrandV1Dto.CreateBrandRequest createRequest = new BrandV1Dto.CreateBrandRequest("nike", "Nike"); + + // when - 브랜드 생성 + ResponseEntity createResponse = restTemplate.postForEntity( + "/api/v1/brands", + createRequest, + ApiResponse.class + ); + + // then - 생성 성공 + assertAll( + () -> assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(createResponse.getBody()).isNotNull(), + () -> assertThat(createResponse.getBody().meta().result()).isEqualTo(Result.SUCCESS) + ); + + // when - 브랜드 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + "/api/v1/brands/nike", + HttpMethod.DELETE, + null, + ApiResponse.class + ); + + // then - 삭제 성공 + assertAll( + () -> assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(deleteResponse.getBody()).isNotNull(), + () -> assertThat(deleteResponse.getBody().meta().result()).isEqualTo(Result.SUCCESS) + ); + } + + @Test + @DisplayName("중복된 브랜드 ID로 생성 시 409 Conflict") + void createBrand_duplicate_returns409() { + // given + BrandV1Dto.CreateBrandRequest request = new BrandV1Dto.CreateBrandRequest("adidas", "Adidas"); + restTemplate.postForEntity("/api/v1/brands", request, ApiResponse.class); + + // when - 동일한 ID로 재생성 + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/brands", + request, + ApiResponse.class + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @Test + @DisplayName("존재하지 않는 브랜드 삭제 시 404 Not Found") + void deleteBrand_notFound_returns404() { + // when + ResponseEntity response = restTemplate.exchange( + "/api/v1/brands/notexist", + HttpMethod.DELETE, + null, + ApiResponse.class + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @Test + @DisplayName("유효하지 않은 요청 데이터로 생성 시 400 Bad Request") + void createBrand_invalidRequest_returns400() { + // given - brandId가 빈 문자열 + BrandV1Dto.CreateBrandRequest invalidRequest = new BrandV1Dto.CreateBrandRequest("", "Nike"); + + // when + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/brands", + invalidRequest, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java new file mode 100644 index 000000000..11eb29aaf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java @@ -0,0 +1,183 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ApiResponse.Metadata.Result; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static com.loopers.interfaces.api.like.LikeV1Dto.*; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Like API E2E 테스트") +class LikeV1ControllerE2ETest { + + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private String baseUrl(String productId) { + return "http://localhost:" + port + "/api/v1/products/" + productId + "/likes"; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class AddLike { + + @Test + @DisplayName("좋아요 추가 성공 시 201 Created와 생성된 좋아요 정보 반환") + void addLike_success_returns201() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + AddLikeRequest request = new AddLikeRequest(1L); + + // when + ResponseEntity response = restTemplate.postForEntity( + baseUrl("prod1"), + request, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().result()).isEqualTo(Result.SUCCESS); + } + + @Test + @DisplayName("중복 좋아요 추가 시 201 Created 반환 (멱등성)") + void addLike_duplicate_returns201() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + AddLikeRequest request = new AddLikeRequest(1L); + + // when + ResponseEntity firstResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); + ResponseEntity secondResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); + + // then + assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 추가 시 404 Not Found 반환") + void addLike_productNotFound_returns404() { + // given + AddLikeRequest request = new AddLikeRequest(1L); + + // when + ResponseEntity response = restTemplate.postForEntity( + baseUrl("invalid"), + request, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class RemoveLike { + + @Test + @DisplayName("좋아요 취소 성공 시 204 No Content 반환") + void removeLike_success_returns204() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + AddLikeRequest addRequest = new AddLikeRequest(1L); + restTemplate.postForEntity(baseUrl("prod1"), addRequest, ApiResponse.class); + + RemoveLikeRequest removeRequest = new RemoveLikeRequest(1L); + + // when + ResponseEntity response = restTemplate.exchange( + baseUrl("prod1"), + HttpMethod.DELETE, + new HttpEntity<>(removeRequest), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("좋아요가 없어도 204 No Content 반환 (멱등성)") + void removeLike_notExists_returns204() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + RemoveLikeRequest removeRequest = new RemoveLikeRequest(1L); + + // when + ResponseEntity response = restTemplate.exchange( + baseUrl("prod1"), + HttpMethod.DELETE, + new HttpEntity<>(removeRequest), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 취소 시 404 Not Found 반환") + void removeLike_productNotFound_returns404() { + // given + RemoveLikeRequest removeRequest = new RemoveLikeRequest(1L); + + // when + ResponseEntity response = restTemplate.exchange( + baseUrl("invalid"), + HttpMethod.DELETE, + new HttpEntity<>(removeRequest), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java new file mode 100644 index 000000000..192819962 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java @@ -0,0 +1,172 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("MyLikeV1Controller E2E 테스트") +class MyLikeV1ControllerE2ETest { + + private static final String MY_LIKES_URL = "/api/v1/users/me/likes"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private LikeService likeService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/users/me/likes - 내 좋아요 목록 조회") + @Nested + class GetMyLikes { + + @Test + @DisplayName("좋아요 목록 조회 성공 - 200 OK") + void getMyLikedProducts_success_returns200() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(2) + ); + } + + @Test + @DisplayName("삭제된 상품은 좋아요 목록에 포함되지 않음") + void getMyLikedProducts_excludesDeletedProducts() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + // 상품 삭제 + productService.deleteProduct("prod2"); + + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).productId()).isEqualTo("prod1") + ); + } + + @Test + @DisplayName("좋아요 목록에 상품명, 브랜드명, 가격 정보 포함") + void getMyLikedProducts_containsProductInfo() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air Max", new BigDecimal("150000"), 10); + Long memberId = 1L; + likeService.addLike(memberId, "prod1"); + + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + LikeV1Dto.LikedProductResponse item = response.getBody().data().get(0); + assertAll( + () -> assertThat(item.productId()).isEqualTo("prod1"), + () -> assertThat(item.productName()).isEqualTo("Nike Air Max"), + () -> assertThat(item.brandName()).isEqualTo("Nike"), + () -> assertThat(item.price()).isEqualByComparingTo(new BigDecimal("150000")), + () -> assertThat(item.likedAt()).isNotNull() + ); + } + + @Test + @DisplayName("좋아요가 없으면 빈 목록 반환 - 200 OK") + void getMyLikedProducts_noLikes_returnsEmpty() { + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=99", + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java new file mode 100644 index 000000000..504951ac6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java @@ -0,0 +1,208 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderApp; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("OrderV1Controller E2E 테스트") +class OrderV1ControllerE2ETest { + + private static final String ORDERS_URL = "/api/v1/orders"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private OrderApp orderApp; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + com.loopers.domain.product.ProductModel product = com.loopers.domain.product.ProductModel.create( + "prod1", 1L, "Nike Air", new BigDecimal("100000"), 100); + productRepository.save(product); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/orders/{orderId} - 주문 상세 조회") + @Nested + class GetOrder { + + @Test + @DisplayName("본인 주문 조회 성공 - 200 OK") + void getOrder_success_returns200() { + // given + Long memberId = 1L; + OrderInfo order = orderApp.createOrder(memberId, List.of(new OrderItemCommand("prod1", 2))); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "/" + order.orderId() + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(order.orderId()), + () -> assertThat(response.getBody().data().refMemberId()).isEqualTo(memberId), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 - 404 Not Found") + void getOrder_notFound_returns404() { + // when + ResponseEntity response = restTemplate.exchange( + ORDERS_URL + "/00000000-0000-0000-0000-000000000001?memberId=1", + HttpMethod.GET, + null, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("타인의 주문 조회 - 403 Forbidden") + void getOrder_notOwner_returns403() { + // given + Long ownerId = 1L; + Long otherMemberId = 2L; + OrderInfo order = orderApp.createOrder(ownerId, List.of(new OrderItemCommand("prod1", 1))); + + // when + ResponseEntity response = restTemplate.exchange( + ORDERS_URL + "/" + order.orderId() + "?memberId=" + otherMemberId, + HttpMethod.GET, + null, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @DisplayName("GET /api/v1/orders - 주문 목록 조회") + @Nested + class GetOrders { + + @Test + @DisplayName("주문 목록 페이징 조회 성공 - 200 OK") + void getOrders_success_returns200() { + // given + Long memberId = 1L; + orderApp.createOrder(memberId, List.of(new OrderItemCommand("prod1", 1))); + orderApp.createOrder(memberId, List.of(new OrderItemCommand("prod1", 1))); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "?memberId=" + memberId + "&page=0&size=10", + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2), + () -> assertThat(response.getBody().data().content()).hasSize(2) + ); + } + + @Test + @DisplayName("주문이 없는 회원은 빈 목록 반환 - 200 OK") + void getOrders_noOrders_returnsEmpty() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "?memberId=99", + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + @DisplayName("다른 회원의 주문은 포함되지 않음") + void getOrders_onlyReturnsOwnOrders() { + // given + Long memberId1 = 1L; + Long memberId2 = 2L; + orderApp.createOrder(memberId1, List.of(new OrderItemCommand("prod1", 1))); + orderApp.createOrder(memberId2, List.of(new OrderItemCommand("prod1", 1))); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "?memberId=" + memberId1, + HttpMethod.GET, + null, + responseType + ); + + // then + assertThat(response.getBody().data().totalElements()).isEqualTo(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java new file mode 100644 index 000000000..217ca5cce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java @@ -0,0 +1,223 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("ProductAdminV1Controller E2E 테스트") +class ProductAdminV1ControllerE2ETest { + + private static final String ADMIN_PRODUCTS_URL = "/api-admin/v1/products"; + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE); + return headers; + } + + @DisplayName("PUT /api-admin/v1/products/{productId} - 상품 수정") + @Nested + class UpdateProduct { + + @Test + @DisplayName("상품 수정 성공 - 200 OK") + void updateProduct_success_returns200() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Nike Air Max Updated", + new BigDecimal("120000"), + 30, + null + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().productName()).isEqualTo("Nike Air Max Updated"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("120000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(30) + ); + } + + @Test + @DisplayName("어드민 인증 헤더 없으면 403 Forbidden") + void updateProduct_noAdminHeader_returns403() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + null + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("잘못된 어드민 LDAP 값이면 403 Forbidden") + void updateProduct_invalidLdap_returns403() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + null + ); + + HttpHeaders invalidHeaders = new HttpHeaders(); + invalidHeaders.set(ADMIN_LDAP_HEADER, "invalid.user"); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, invalidHeaders), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("brandId 변경 시도 시 400 Bad Request") + void updateProduct_brandIdChangeAttempt_returns400() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + "adidas" + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 상품 수정 시 404 Not Found") + void updateProduct_notFound_returns404() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + null + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/nonexistent", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("가격이 음수이면 400 Bad Request") + void updateProduct_negativePrice_returns400() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("-1000"), + 50, + null + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java new file mode 100644 index 000000000..7dba05ba2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java @@ -0,0 +1,368 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Product API E2E 테스트") +class ProductV1ControllerE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/products") + @Nested + class CreateProduct { + + @Test + @DisplayName("상품 생성 성공 시 201 Created와 생성된 상품 정보 반환") + void createProduct_success_returns201() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", + "nike", + "Nike Air Max", + new BigDecimal("150000"), + 100 + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().productId()).isEqualTo("prod1"), + () -> assertThat(response.getBody().data().refBrandId()).isNotNull(), + () -> assertThat(response.getBody().data().productName()).isEqualTo("Nike Air Max"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("150000.00")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().brand()).isNotNull(), + () -> assertThat(response.getBody().data().brand().brandId()).isEqualTo("nike"), + () -> assertThat(response.getBody().data().brand().brandName()).isEqualTo("Nike"), + () -> assertThat(response.getBody().data().likesCount()).isEqualTo(0) + ); + } + + @Test + @DisplayName("중복된 상품 ID로 생성 시 409 Conflict 반환") + void createProduct_duplicateId_returns409() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", + "nike", + "Nike Air Max", + new BigDecimal("150000"), + 100 + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + // 첫 번째 생성 + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // act - 중복 생성 시도 + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성 시 404 Not Found 반환") + void createProduct_nonExistentBrand_returns404() { + // arrange + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", + "nobrand", + "Product", + new BigDecimal("10000"), + 10 + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetProducts { + + @Test + @DisplayName("상품 목록 조회 성공") + void getProducts_success_returns200() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request1 = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Product 1", new BigDecimal("10000"), 10 + ); + ProductV1Dto.CreateProductRequest request2 = new ProductV1Dto.CreateProductRequest( + "prod2", "nike", "Product 2", new BigDecimal("20000"), 20 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request1), createResponseType); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request2), createResponseType); + + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?page=0&size=10&sort=latest", + HttpMethod.GET, + null, + listResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().products()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) + ); + } + + @Test + @DisplayName("브랜드 필터링 동작") + void getProducts_filterByBrand_success() { + // arrange + brandService.createBrand("nike", "Nike"); + brandService.createBrand("adidas", "Adidas"); + + ProductV1Dto.CreateProductRequest nikeProduct = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Nike Product", new BigDecimal("10000"), 10 + ); + ProductV1Dto.CreateProductRequest adidasProduct = new ProductV1Dto.CreateProductRequest( + "prod2", "adidas", "Adidas Product", new BigDecimal("20000"), 20 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(nikeProduct), createResponseType); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(adidasProduct), createResponseType); + + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?brandId=nike", + HttpMethod.GET, + null, + listResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().products()).hasSize(1), + () -> assertThat(response.getBody().data().products().get(0).refBrandId()).isNotNull() + ); + } + + @Test + @DisplayName("price_asc 정렬 동작") + void getProducts_sortByPriceAsc_success() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest expensive = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Expensive", new BigDecimal("100000"), 10 + ); + ProductV1Dto.CreateProductRequest cheap = new ProductV1Dto.CreateProductRequest( + "prod2", "nike", "Cheap", new BigDecimal("10000"), 20 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(expensive), createResponseType); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(cheap), createResponseType); + + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=price_asc", + HttpMethod.GET, + null, + listResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().products()).hasSize(2), + () -> assertThat(response.getBody().data().products().get(0).price()) + .isLessThan(response.getBody().data().products().get(1).price()) + ); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}") + @Nested + class DeleteProduct { + + @Test + @DisplayName("상품 삭제 성공 시 200 OK 반환") + void deleteProduct_success_returns200() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Nike Air", new BigDecimal("100000"), 50 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), createResponseType); + + ParameterizedTypeReference> deleteResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/prod1", + HttpMethod.DELETE, + null, + deleteResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + + // 삭제 후 목록 조회 시 제외됨 확인 + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + ResponseEntity> listResponse = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.GET, + null, + listResponseType + ); + + assertThat(listResponse.getBody().data().products()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 404 Not Found 반환") + void deleteProduct_nonExistent_returns404() { + // arrange + ParameterizedTypeReference> deleteResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/nonexistent", + HttpMethod.DELETE, + null, + deleteResponseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java index cb5c8bebd..668672619 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -9,6 +9,7 @@ import java.time.Duration; import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZoneId; @Slf4j @@ -24,23 +25,23 @@ void beforeJob(JobExecution jobExecution) { @AfterJob void afterJob(JobExecution jobExecution) { - var startTime = jobExecution.getExecutionContext().getLong("startTime"); - var endTime = System.currentTimeMillis(); + long startTime = jobExecution.getExecutionContext().getLong("startTime"); + long endTime = System.currentTimeMillis(); - var startDateTime = Instant.ofEpochMilli(startTime) + LocalDateTime startDateTime = Instant.ofEpochMilli(startTime) .atZone(ZoneId.systemDefault()) .toLocalDateTime(); - var endDateTime = Instant.ofEpochMilli(endTime) + LocalDateTime endDateTime = Instant.ofEpochMilli(endTime) .atZone(ZoneId.systemDefault()) .toLocalDateTime(); - var totalTime = endTime - startTime; - var duration = Duration.ofMillis(totalTime); - var hours = duration.toHours(); - var minutes = duration.toMinutes() % 60; - var seconds = duration.getSeconds() % 60; + long totalTime = endTime - startTime; + Duration duration = Duration.ofMillis(totalTime); + long hours = duration.toHours(); + long minutes = duration.toMinutes() % 60; + long seconds = duration.getSeconds() % 60; - var message = String.format( + String message = String.format( """ *Start Time:* %s *End Time:* %s diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java index 4f22f40b0..603c531ce 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -23,8 +23,8 @@ public void beforeStep(@Nonnull StepExecution stepExecution) { @Override public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { if (!stepExecution.getFailureExceptions().isEmpty()) { - var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); - var exceptions = stepExecution.getFailureExceptions().stream() + String jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + String exceptions = stepExecution.getFailureExceptions().stream() .map(Throwable::getMessage) .filter(Objects::nonNull) .collect(Collectors.joining("\n")); diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java index dafe59a18..a4bbaaa6c 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.test.JobLauncherTestUtils; import org.springframework.batch.test.context.SpringBatchTest; @@ -46,7 +48,7 @@ void shouldNotSaveCategories_whenApiError() throws Exception { jobLauncherTestUtils.setJob(job); // act - var jobExecution = jobLauncherTestUtils.launchJob(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(); // assert assertAll( @@ -62,10 +64,10 @@ void success() throws Exception { jobLauncherTestUtils.setJob(job); // act - var jobParameters = new JobParametersBuilder() + JobParameters jobParameters = new JobParametersBuilder() .addLocalDate("requestDate", LocalDate.now()) .toJobParameters(); - var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); // assert assertAll( diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..9168790fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ subprojects { testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers") testImplementation("org.testcontainers:junit-jupiter") + testImplementation("com.tngtech.archunit:archunit-junit5:${project.properties["archunitVersion"]}") } tasks.withType(Jar::class) { enabled = true } diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 48c0e88d8..6b2254727 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -26,6 +26,7 @@ | 주문 항목 | Order Item | 주문 내 개별 상품 라인 | Order Line, Line Item | | 스냅샷 | Snapshot | 주문 시점의 상품 정보 복사본 | Copy, Archive | | 삭제 일시 | Deleted At | Soft Delete 타임스탬프 | Removed At | +| 비관적 락 | Pessimistic Lock | 재고 차감 전 행(row) 잠금 (`SELECT ... FOR UPDATE`) | Exclusive Lock | ### 상태(Enum) @@ -123,37 +124,32 @@ - items 배열이 비어있지 않음 - 각 quantity >= 1 - 동일 productId가 여러 개 있으면 quantity 합산 -4. **상품 존재 확인**: 각 productId에 대해 - - product 테이블에서 조회 - - `deleted_at IS NULL` 확인 - - 존재하지 않으면 → 404 Not Found -5. **재고 차감** (동시성 제어): +4. **재고 차감** (동시성 제어): - productId를 오름차순으로 정렬 (데드락 방지) - - 각 상품에 대해 조건부 UPDATE 실행: + - 각 상품에 대해 **비관적 락** 획득 후 재고 검증 및 차감: ```sql - UPDATE product - SET stock_qty = stock_qty - :quantity, updated_at = NOW() - WHERE id = :productId - AND deleted_at IS NULL - AND stock_qty >= :quantity; + -- 1단계: 비관적 락 획득 + SELECT * FROM products WHERE id = :productId FOR UPDATE; + -- 2단계: 재고 검증 (애플리케이션 레이어) + -- stock_quantity < :quantity → 409 Conflict + -- 3단계: 재고 차감 + UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :productId; ``` - - affected rows = 0이면 재고 부족 → 전체 롤백 후 409 Conflict + - 재고 부족 → 전체 롤백 후 409 Conflict +5. **상품 존재 확인**: 재고 차감 전 productId로 조회 + - 존재하지 않으면 → 404 Not Found 6. **주문 저장**: - - `orders` 테이블에 INSERT: `user_id`, `total_amount`, `status=PENDING`, `ordered_at=NOW()` - - `total_amount` = Σ(unit_price × quantity) + - `orders` 테이블에 INSERT: `ref_member_id`, `order_id(UUID)`, `status=PENDING` 7. **주문 항목 스냅샷 저장**: - - `order_item` 테이블에 각 항목 INSERT: - - `order_id`, `product_id`, `product_name`, `brand_id`, `brand_name` - - `unit_price`, `quantity`, `line_amount` (= unit_price × quantity) - - 선택: `image_url` + - `order_items` 테이블에 각 항목 INSERT: + - `order_id`, `product_id(스냅샷)`, `product_name(스냅샷)`, `price(스냅샷)`, `quantity` 8. **응답**: 201 Created - - `orderId`, `status`, `orderedAt`, `totalAmount` - - `items` 배열 (스냅샷 포함) + - `orderId`, `status`, `items` 배열 (스냅샷 포함) #### Alternate Flow - **A1**: 동일 productId 중복 - quantity를 합산하여 단일 항목으로 처리 - - 예: `[{productId:1, qty:2}, {productId:1, qty:3}]` → `{productId:1, qty:5}` + - 예: `[{productId:"P001", qty:2}, {productId:"P001", qty:3}]` → `{productId:"P001", qty:5}` #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized @@ -172,27 +168,26 @@ 3. **주문 조회**: - `orderId`로 주문 조회 - 존재하지 않으면 → 404 Not Found -4. **소유권 확인**: `order.user_id == user_id` 검증 +4. **소유권 확인**: `order.ref_member_id == user_id` 검증 - 다르면 → 403 Forbidden 5. **상태 확인**: `status == PENDING` 확인 - - CANCELED 또는 다른 상태면 → 409 Conflict (Alternate Flow A1 참조) + - CANCELED 상태면 → Alternate Flow A1 (멱등 성공) 6. **상태 전이**: 같은 트랜잭션 내에서 - - `orders.status = CANCELED`, `canceled_at = NOW()` UPDATE + - `orders.status = CANCELED` UPDATE 7. **재고 복구**: - - `order_item`의 각 항목에 대해: + - `order_items`의 각 항목에 대해: ```sql - UPDATE product - SET stock_qty = stock_qty + :quantity, updated_at = NOW() + UPDATE products + SET stock_quantity = stock_quantity + :quantity WHERE id = :productId; ``` - 상품이 삭제되었어도 재고 복구 시도 (soft delete이므로 가능) 8. **응답**: 200 OK - - `orderId`, `status=CANCELED`, `canceledAt` + - `orderId`, `status=CANCELED` #### Alternate Flow - **A1**: 이미 CANCELED 상태 - - 멱등 처리: 200 OK 응답 (재고 복구는 중복 실행하지 않음) - - 또는 409 Conflict (정책에 따라 선택, 권장은 200 OK) + - 멱등 처리: 200 OK 응답 (재고 복구 중복 실행하지 않음) #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized @@ -206,22 +201,22 @@ ### UC-C04: 좋아요 추가 (POST /api/v1/products/{productId}/likes) #### Main Flow -1. **요청**: 사용자가 `productId`로 좋아요 추가 요청 +1. **요청**: 사용자가 `memberId`, `productId`로 좋아요 추가 요청 2. **인증**: 헤더 검증 → `user_id` 추출 3. **상품 존재 확인**: - `productId`로 상품 조회 - `deleted_at IS NULL` 확인 - 존재하지 않으면 → 404 Not Found -4. **중복 확인**: `(user_id, product_id)` UNIQUE 제약 +4. **중복 확인**: `(ref_member_id, ref_product_id)` UNIQUE 제약 - 이미 존재하면 → Alternate Flow A1 5. **좋아요 저장**: - - `like` 테이블에 INSERT: `user_id`, `product_id`, `created_at=NOW()` -6. **응답**: 201 Created (또는 204 No Content) + - `likes` 테이블에 INSERT: `ref_member_id`, `ref_product_id`, `created_at=NOW()` +6. **응답**: 200 OK (기존 또는 신규 좋아요 정보 반환) #### Alternate Flow - **A1**: 이미 좋아요 존재 - - 멱등 처리: 200 OK 또는 204 No Content (INSERT 스킵) - - UNIQUE 제약 위반 catch 후 성공 처리 + - 멱등 처리: 200 OK (INSERT 스킵, 기존 좋아요 반환) + - UNIQUE 제약 위반 catch 후 재조회하여 성공 처리 #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized @@ -233,19 +228,23 @@ ### UC-C05: 좋아요 취소 (DELETE /api/v1/products/{productId}/likes) #### Main Flow -1. **요청**: 사용자가 `productId`로 좋아요 취소 요청 +1. **요청**: 사용자가 `memberId`, `productId`로 좋아요 취소 요청 2. **인증**: 헤더 검증 → `user_id` 추출 -3. **좋아요 삭제**: - - `DELETE FROM like WHERE user_id = :userId AND product_id = :productId` -4. **응답**: 204 No Content +3. **상품 존재 확인**: + - `productId`로 상품 조회 + - 존재하지 않으면 → 404 Not Found +4. **좋아요 삭제**: + - `(ref_member_id, ref_product_id)` 조건으로 조회 후 삭제 +5. **응답**: 204 No Content #### Alternate Flow - **A1**: 좋아요 존재하지 않음 - - 멱등 처리: 204 No Content (affected rows = 0이어도 성공) + - 멱등 처리: 204 No Content (없어도 성공) #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized -- **E2**: DB 에러 → 500 Internal Server Error +- **E2**: 상품 존재하지 않음 → 404 Not Found +- **E3**: DB 에러 → 500 Internal Server Error --- @@ -255,12 +254,12 @@ 1. **요청**: 사용자가 자신의 좋아요 목록 조회 2. **인증**: 헤더 검증 → `user_id` 추출 3. **조회**: - - `like` 테이블에서 `user_id`로 필터링 - - JOIN `product` ON `like.product_id = product.id` - - `product.deleted_at IS NULL` 필터링 (삭제된 상품 제외) + - `likes` 테이블에서 `ref_member_id`로 필터링 + - JOIN `products` ON `likes.ref_product_id = products.id` + - `products.deleted_at IS NULL` 필터링 (삭제된 상품 제외) 4. **페이징**: page, size 파라미터 (선택) 5. **응답**: 200 OK - - products 배열: `productId`, `productName`, `brandName`, `price`, `imageUrl`, `likedAt` + - products 배열: `productId`, `productName`, `brandName`, `price`, `likedAt` #### Alternate Flow - **A1**: 좋아요한 상품이 없음 @@ -275,27 +274,24 @@ #### Main Flow 1. **요청**: 사용자가 상품 목록 조회 (쿼리 파라미터: `brandId`, `sort`, `page`, `size`) -2. **인증**: 헤더 검증 (선택: 비로그인도 허용 가능, 정책에 따라) -3. **필터링**: +2. **필터링**: - `deleted_at IS NULL` (삭제된 상품 제외) - - `brandId` 제공 시: `product.brand_id = :brandId` -4. **정렬**: - - `latest` (필수): `updated_at DESC` - - `price_asc` (선택): `price ASC` - - `likes_desc` (선택): - - Phase 1: `SELECT ..., (SELECT COUNT(*) FROM like WHERE product_id = product.id) AS like_count ORDER BY like_count DESC` - - Phase 2 (병목 시): `product.like_count DESC` -5. **페이징**: page, size 적용 (기본: page=0, size=20) -6. **응답**: 200 OK - - products 배열: `productId`, `productName`, `brandName`, `price`, `stockQty`, `imageUrl`, `likeCount`(선택) + - `brandId` 제공 시: `products.ref_brand_id = :brandId` +3. **정렬**: + - `latest` (기본): `updated_at DESC` + - `price_asc`: `price ASC` + - `likes_desc`: + - LEFT JOIN likes, GROUP BY p.id, COUNT(l.id) DESC +4. **페이징**: page, size 적용 (기본: page=0, size=20) +5. **응답**: 200 OK + - products 배열: `productId`, `productName`, `brandId`, `brandName`, `price`, `stockQuantity`, `likesCount` #### Alternate Flow - **A1**: 조건에 맞는 상품 없음 - 빈 배열 반환 #### Exception Flow -- **E1**: 인증 실패 (인증 필수 정책인 경우) → 401 Unauthorized -- **E2**: 유효하지 않은 sort 값 → 400 Bad Request +- **E1**: 유효하지 않은 sort 값 → 400 Bad Request --- @@ -303,16 +299,15 @@ #### Main Flow 1. **요청**: 사용자가 `productId`로 상품 상세 조회 -2. **인증**: 헤더 검증 (선택: 비로그인도 허용 가능) -3. **조회**: +2. **조회**: - `productId`로 상품 조회 - `deleted_at IS NULL` 확인 - - JOIN `brand` ON `product.brand_id = brand.id` + - Brand 정보 조회 (ref_brand_id → brands 테이블) - 존재하지 않으면 → 404 Not Found -4. **좋아요 수 집계** (선택): - - `SELECT COUNT(*) FROM like WHERE product_id = :productId` -5. **응답**: 200 OK - - `productId`, `productName`, `brandId`, `brandName`, `price`, `stockQty`, `description`, `imageUrl`, `likeCount` +3. **좋아요 수 집계**: + - `SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId` +4. **응답**: 200 OK + - `productId`, `productName`, `brandId`, `brandName`, `price`, `stockQuantity`, `likesCount` #### Exception Flow - **E1**: 상품 존재하지 않음 또는 deleted → 404 Not Found @@ -322,21 +317,18 @@ ### UC-A08: 상품 등록 (POST /api-admin/v1/products) #### Main Flow -1. **요청**: 어드민이 상품 등록 (필드: `productName`, `brandId`, `price`, `stockQty`, `description?`, `imageUrl?`, `status?`) +1. **요청**: 어드민이 상품 등록 (필드: `productId`, `brandId`, `productName`, `price`, `stockQuantity`) 2. **인증**: `X-Loopers-Ldap=loopers.admin` 검증 - 실패 시 → 403 Forbidden 3. **입력 검증**: - - `price >= 0`, `stockQty >= 0` + - `price >= 0`, `stockQuantity >= 0` - `productName` 비어있지 않음 4. **브랜드 존재 확인**: - `brandId`로 브랜드 조회 - `deleted_at IS NULL` 확인 - 존재하지 않으면 → 404 Not Found -5. **상품 저장**: - - `product` 테이블에 INSERT - - `status` 기본값: `ACTIVE` (정책에 따라) +5. **상품 저장**: `products` 테이블에 INSERT 6. **응답**: 201 Created - - `productId`, `productName`, `brandId`, `price`, `stockQty`, ... #### Exception Flow - **E1**: 인증 실패 → 403 Forbidden @@ -348,19 +340,16 @@ ### UC-A09: 상품 수정 (PUT /api-admin/v1/products/{productId}) #### Main Flow -1. **요청**: 어드민이 상품 수정 (수정 가능: `productName`, `price`, `stockQty`, `description`, `imageUrl`, `status`) +1. **요청**: 어드민이 상품 수정 (수정 가능: `productName`, `price`, `stockQuantity`) 2. **인증**: `X-Loopers-Ldap=loopers.admin` 검증 3. **상품 조회**: - `productId`로 조회 - 존재하지 않으면 → 404 Not Found 4. **입력 검증**: - - `price >= 0`, `stockQty >= 0` + - `price >= 0`, `stockQuantity >= 0` - **brandId 변경 시도 확인**: 요청에 brandId가 포함되어 있으면 → 400 Bad Request -5. **상품 수정**: - - UPDATE `product` SET ... `updated_at = NOW()` - - `stockQty`는 절대값 SET 방식 (운영 목적) +5. **상품 수정**: Dirty Checking으로 UPDATE 6. **응답**: 200 OK - - 수정된 상품 정보 #### Exception Flow - **E1**: 인증 실패 → 403 Forbidden @@ -380,8 +369,8 @@ - 존재하지 않으면 → 404 Not Found 4. **연쇄 삭제** (Soft Delete): - 같은 트랜잭션 내에서: - - `UPDATE brand SET deleted_at = NOW() WHERE id = :brandId` - - `UPDATE product SET deleted_at = NOW() WHERE brand_id = :brandId AND deleted_at IS NULL` + - `UPDATE brands SET deleted_at = NOW() WHERE id = :brandId` + - `UPDATE products SET deleted_at = NOW() WHERE ref_brand_id = :brandId AND deleted_at IS NULL` 5. **응답**: 204 No Content #### Exception Flow @@ -406,7 +395,7 @@ - 주문 상태 불일치 (주문 취소 시 status != PENDING) - **멱등 성공**: - 좋아요 추가/취소: 이미 존재/없어도 성공 - - 주문 취소: 이미 CANCELED면 성공 (권장) + - 주문 취소: 이미 CANCELED면 성공 처리 (권장) --- @@ -417,10 +406,21 @@ - **주문 취소**: 상태 전이 + 재고 복구 → 단일 트랜잭션 - **브랜드 삭제**: 브랜드 soft delete + 상품 연쇄 soft delete → 단일 트랜잭션 -### 동시성 제어 -- **재고 차감**: 조건부 원자 UPDATE (`WHERE stock_qty >= :qty`) -- **데드락 방지**: productId 오름차순 정렬로 락 순서 고정 -- **좋아요 중복**: UNIQUE 제약 (`user_id`, `product_id`)으로 DB 레벨 보장 +### 동시성 제어 전략 + +| 도메인 | 경합 수준 | 중요도 | 전략 | +|--------|-----------|--------|------| +| 재고 (stock) | **높음** | **비즈니스 핵심** | **비관적 락** (`SELECT ... FOR UPDATE`) | +| 좋아요 (like) | 낮음 | 참고 데이터 | DB UNIQUE 제약 (`uk_likes_member_product`) | + +**재고 차감 (비관적 락)**: +- `SELECT ... FOR UPDATE`로 행 잠금 후 재고 검증 및 차감 +- 데드락 방지: productId 오름차순 정렬로 락 획득 순서 고정 +- 재고 부족 시: 예외 발생 → 트랜잭션 전체 롤백 → 409 Conflict + +**좋아요 중복 방지 (DB 제약)**: +- UNIQUE 제약 (`ref_member_id`, `ref_product_id`)으로 DB 레벨 최종 방어 +- 경합 발생 시: `DataIntegrityViolationException` catch → 기존 좋아요 재조회 → 멱등 성공 ### 일관성 수준 - **강한 일관성**: 재고 수량 (과판매 절대 불가) @@ -432,58 +432,47 @@ ### Risk-01: Soft Delete 필터 누락 - **리스크**: 모든 조회 쿼리에 `deleted_at IS NULL` 필터 필요, 누락 시 삭제된 항목 노출 -- **증상**: 고객에게 삭제된 상품/브랜드가 보임, 주문 생성 시 삭제된 상품 선택 가능 - **완화책**: - - Repository 기본 조건/전역 스코프 적용 (JPA `@Where`, QueryDSL BooleanExpression) - - 코드 리뷰 체크리스트에 추가 + - Repository Native Query에 `deleted_at IS NULL` 조건 포함 - E2E 테스트에 삭제 시나리오 포함 ### Risk-02: 좋아요 수 집계 성능 -- **리스크**: `likes_desc` 정렬 시 COUNT 집계가 느려질 수 있음 (상품 수 × 좋아요 수 증가 시) -- **증상**: 상품 목록 조회 API 응답 시간 증가 (1초 이상) +- **리스크**: `likes_desc` 정렬 시 COUNT 집계가 느려질 수 있음 - **완화책**: - - Phase 1: COUNT 집계 (정확성 우선) - - Phase 2: `product.like_count` 컬럼 도입 (약한 일관성 허용) + - Phase 1: LEFT JOIN + GROUP BY + COUNT (정확성 우선, 현재 구현) + - Phase 2: `products.like_count` 컬럼 도입 (약한 일관성 허용) - 전환 시점: APM 모니터링으로 병목 관측 후 결정 ### Risk-03: 재고 차감 데드락 -- **리스크**: 다품목 주문 시 productId 순서가 다르면 데드락 발생 가능 -- **증상**: 주문 생성 실패, DB 로그에 deadlock 감지 +- **리스크**: 다품목 주문 시 productId 순서가 다르면 비관적 락 데드락 가능 - **완화책**: - - productId 오름차순 정렬로 락 순서 고정 - - 재시도 로직 (exponential backoff) - - 모니터링: 데드락 발생 횟수 추적 + - productId 오름차순 정렬로 락 순서 고정 (모든 트랜잭션이 동일한 순서로 락 획득) + - DB 데드락 타임아웃 설정 (innodb_lock_wait_timeout) + - 데드락 발생 시 자동 롤백 → 클라이언트 재시도 ### Risk-04: 주문 스냅샷 불완전 - **리스크**: 스냅샷에 필수 정보 누락 시, 상품/브랜드 삭제 후 주문 조회 불가 -- **증상**: 주문 내역에 "알 수 없는 상품" 표시, 고객 불만 - **완화책**: - - 최소 스냅샷 필드 명시: `product_name`, `brand_name`, `unit_price`, `quantity` + - 최소 스냅샷 필드: `product_id`, `product_name`, `price`, `quantity` - E2E 테스트: 상품 삭제 후 주문 조회 시나리오 ### Risk-05: latest 정렬 조작 가능 - **리스크**: `updated_at` 기준 정렬 시, 운영자가 단순 수정으로 상단 노출 조작 가능 -- **증상**: 특정 상품이 부당하게 상단 노출, 공정성 문제 - **완화책**: - Phase 1: `updated_at` 사용 (단순함 우선) - - Phase 2: `published_at` 또는 `last_restocked_at` 컬럼 도입 (의미 있는 갱신만 반영) - - 정책: 어드민 수정 시 경고 메시지 또는 별도 "노출 순서" 필드 + - Phase 2: `published_at` 또는 `last_restocked_at` 컬럼 도입 ### Risk-06: 권한 검증 누락 - **리스크**: owner check 누락 시, 타 유저의 주문/좋아요 접근 가능 -- **증상**: 보안 취약점, 개인정보 노출 - **완화책**: - 모든 "내 리소스" API에 owner check 필수화 - - AOP 또는 Spring Security 필터로 공통화 - E2E 테스트: 타 유저 접근 시도 시나리오 (403 확인) ### Risk-07: 주문 취소 멱등성 불명확 - **리스크**: 이미 CANCELED 상태일 때 200 vs 409 정책 불명확 -- **증상**: 클라이언트 재시도 로직 혼란 - **완화책**: - - 명확한 정책 수립: 멱등 성공 (200 OK) 권장 - - API 문서에 멱등성 명시 - - 클라이언트 가이드 제공 + - 명확한 정책: 멱등 성공 (200 OK) 채택 + - OrderModel.cancel()에서 이미 CANCELED면 그대로 반환 (예외 없음) --- @@ -496,24 +485,25 @@ - **연쇄 삭제**: 브랜드 삭제 시 해당 브랜드의 모든 상품도 soft delete 처리 ### 2. 정렬 기준 -- **latest**: `updated_at DESC` (상품 갱신 반영, 추후 `published_at` 확장 가능) +- **latest**: `updated_at DESC` (상품 갱신 반영) - **price_asc**: `price ASC` -- **likes_desc**: 기본은 COUNT 집계, 병목 시 `like_count` 컬럼 도입 +- **likes_desc**: LEFT JOIN likes, COUNT(l.id) DESC (Phase 1, 병목 시 like_count 컬럼 도입) ### 3. 좋아요 집계 정합성 -- **기본안(Phase 1)**: 조회 시 COUNT 집계 (정확성 우선) -- **확장안(Phase 2)**: `product.like_count` 컬럼 유지 (성능 우선, 약한 일관성 허용) +- **기본안(Phase 1)**: 조회 시 COUNT 집계 (정확성 우선, 현재 구현) +- **확장안(Phase 2)**: `products.like_count` 컬럼 유지 (성능 우선, 약한 일관성 허용) - **전환 시점**: 병목 관측 후 결정 ### 4. 재고 차감 동시성 제어 -- **방식**: 조건부 원자 UPDATE (`UPDATE ... WHERE stock_qty >= :qty`) -- **데드락 완화**: productId 오름차순 정렬로 락 순서 고정 +- **방식**: 비관적 락 (`SELECT ... FOR UPDATE`) +- **근거**: 재고는 경합이 심하고 비즈니스적으로 중요한 자원. 과판매는 절대 허용 불가. 낙관적 접근(조건부 UPDATE)보다 명시적 락으로 직렬화 보장 +- **데드락 방지**: productId 오름차순 정렬로 락 순서 고정 - **실패 처리**: 재고 부족 시 전체 롤백, 409 Conflict 응답 +- **좋아요와의 차이**: 좋아요는 경합이 낮고 중복 1건은 치명적이지 않아 UNIQUE 제약만으로 충분 ### 5. 주문 스냅샷 범위 -- **최소 스냅샷**: `product_name`, `brand_name`, `unit_price`, `quantity`, `line_amount` -- **선택 필드**: `image_url`, `product_id`, `brand_id` (참조용) -- **제외**: 상품 상세 메타데이터 (description 등) +- **최소 스냅샷**: `product_id(비즈니스 ID)`, `product_name`, `price`, `quantity` +- **제외**: brand_name, line_amount (총 금액은 getTotalPrice()로 계산), image_url ### 6. 주문 상태 - **PENDING**: 주문 생성 직후 (결제 전 상태) @@ -521,13 +511,13 @@ - **허용 전이**: `PENDING → CANCELED` (이번 범위에서는 결제 상태 제외) ### 7. 멱등성 정책 -- **좋아요 추가(POST)**: 이미 존재해도 200/204 성공 +- **좋아요 추가(POST)**: 이미 존재해도 200 성공 (기존 좋아요 반환) - **좋아요 취소(DELETE)**: 없어도 204 성공 -- **주문 취소(PATCH)**: 이미 CANCELED면 성공 처리 (권장) +- **주문 취소(PATCH)**: 이미 CANCELED면 성공 처리 ### 8. 권한/접근 제어 -- **내 좋아요 목록**: 본인만 조회 가능 (URI: `/api/v1/users/me/likes`) -- **내 주문 목록/상세**: 본인만 조회 가능 (owner check 필수) +- **내 좋아요 목록**: 본인만 조회 가능 +- **내 주문 목록/상세**: 본인만 조회 가능 (isOwner() check 필수) - **어드민 API**: `X-Loopers-Ldap=loopers.admin` 검증 --- diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index d5dcdc063..e029fb9f4 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -6,8 +6,15 @@ **레이어 구조**: - **Interfaces Layer**: Controller +- **Application Layer**: Facade - **Domain Layer**: Service, Reader, Model -- **Infrastructure Layer**: Repository, JpaRepository +- **Infrastructure Layer**: Repository(Impl), JpaRepository + +**동시성 전략 요약**: +| 도메인 | 전략 | 근거 | +|--------|------|------| +| 재고 (stock) | 비관적 락 (`SELECT ... FOR UPDATE`) | 높은 경합, 과판매 절대 불가 | +| 좋아요 (like) | DB UNIQUE 제약 + 예외 catch | 낮은 경합, 중복 1건은 치명적이지 않음 | --- @@ -16,7 +23,7 @@ ### 검증 목적 주문 생성의 핵심은 **재고 차감의 동시성 제어**와 **스냅샷 저장의 트랜잭션 일관성**이다. 이 다이어그램은: 1. productId 정렬로 데드락을 방지하는 흐름 -2. 조건부 원자 UPDATE로 재고 부족을 안전하게 감지하는 방법 +2. 비관적 락(`SELECT ... FOR UPDATE`)으로 재고를 안전하게 차감하는 방법 3. 주문/주문항목 저장과 재고 차감이 단일 트랜잭션으로 묶이는 경계 를 검증한다. @@ -27,46 +34,53 @@ sequenceDiagram actor Customer participant Controller as OrderV1Controller participant OrderService as OrderService - participant ProductReader as ProductReader + participant ProductRepo as ProductRepository Customer->>Controller: POST /api/v1/orders
{items: [{productId, quantity}]} - Controller->>OrderService: createOrder(userId, items) + Controller->>OrderService: createOrder(memberId, items) activate OrderService Note over OrderService: @Transactional 시작 -%% 입력 검증 - OrderService->>OrderService: 중복 상품 합산 + %% 1. 중복 상품 합산 + OrderService->>OrderService: aggregateQuantities(items)
동일 productId 수량 합산 -%% 상품 존재 확인 - loop 각 상품 - OrderService->>ProductReader: getOrThrow(productId) - ProductReader-->>OrderService: ProductInfo - end + %% 2. productId 오름차순 정렬 (데드락 방지) + OrderService->>OrderService: sort productIds ASC
(락 획득 순서 고정) + + %% 3. 상품 조회 + 비관적 락 + 재고 차감 + loop 각 상품 (정렬된 순서) + OrderService->>ProductRepo: findByProductId(productId) + ProductRepo-->>OrderService: ProductModel (or 404) -%% 재고 차감 (정렬된 순서) - loop 각 항목 (정렬된 순서) - alt affected rows = 0 - OrderService-->>Controller: 409 Conflict (Stock 부족) - Controller-->>Customer: 409 Conflict
{재고 부족} + Note over OrderService,ProductRepo: SELECT FOR UPDATE
(비관적 락 획득) + OrderService->>ProductRepo: decreaseStock(productId, quantity)
재고 검증 후 차감 + + alt 재고 부족 + OrderService-->>Controller: CoreException(409 CONFLICT) + Controller-->>Customer: 409 Conflict {재고 부족} + Note over OrderService: @Transactional 롤백
(락 해제) end + + OrderService->>OrderService: createOrderItem(snapshot)
product_id, product_name, price, quantity 저장 end -%% 주문 저장 - OrderService->>OrderService: createOrderEntityAndSave(userId, orderItems) - OrderService-->>Controller: OrderInfo + %% 4. 주문 저장 + OrderService->>ProductRepo: save(OrderModel + OrderItemModels) + ProductRepo-->>OrderService: OrderModel - Note over OrderService: @Transactional 커밋 + Note over OrderService: @Transactional 커밋
(모든 비관적 락 해제) deactivate OrderService Controller-->>Customer: 201 Created
{orderId, status, items} ``` ### 해석 -- **트랜잭션 경계**: Service의 `@Transactional`이 재고 차감부터 주문 저장까지 묶는다. 재고 부족 시 전체 롤백된다. -- **동시성 제어**: `UPDATE ... WHERE stock_qty >= ?`로 조건부 원자 업데이트를 수행하며, productId 정렬로 데드락을 완화한다. -- **책임 분리**: ProductReader는 조회만, ProductRepository는 재고 차감, OrderService는 주문 로직과 스냅샷 저장을 담당한다. -- **실패 지점**: 재고 부족 시 affected rows=0 감지 후 즉시 예외를 던지고 트랜잭션이 롤백된다. +- **트랜잭션 경계**: `OrderService@Transactional`이 재고 차감부터 주문 저장까지 묶는다. 재고 부족 시 전체 롤백. +- **비관적 락 전략**: `SELECT ... FOR UPDATE`로 행 잠금 → 재고 검증 → 차감. 조건부 UPDATE 방식보다 명시적이고 안전. +- **데드락 방지**: productId 오름차순 정렬로 모든 트랜잭션이 동일한 순서로 락을 획득하여 순환 대기 제거. +- **스냅샷 패턴**: OrderItemModel에 주문 시점의 product_id, product_name, price를 복사 저장. Product 삭제/수정 후에도 주문 이력 유지. +- **Facade 없음**: 주문 도메인은 OrderService가 직접 ProductRepository를 의존하여 재고 차감 + 주문 저장 오케스트레이션. --- @@ -87,60 +101,56 @@ sequenceDiagram participant Controller as OrderV1Controller participant OrderService as OrderService participant OrderReader as OrderReader + participant ProductRepo as ProductRepository Customer->>Controller: PATCH /api/v1/orders/{orderId}/cancel - Controller->>OrderService: cancelOrder(userId, orderId) + Controller->>OrderService: cancelOrder(memberId, orderId) activate OrderService Note over OrderService: @Transactional 시작 %% 주문 조회 OrderService->>OrderReader: getOrThrow(orderId) - OrderReader-->>OrderService: OrderInfo + OrderReader-->>OrderService: OrderModel (or 404) %% 소유권 확인 - OrderService->>OrderService: validateOwner(order.userId == userId) + OrderService->>OrderService: order.isOwner(memberId) alt owner mismatch - OrderService-->>Controller: CoreException (403 Forbidden) + OrderService-->>Controller: CoreException(403 Forbidden) Controller-->>Customer: 403 Forbidden else owner ok %% 상태 확인 (멱등 포함) - OrderService->>OrderService: validateStatus(order.status) alt status == CANCELED - Note over OrderService: idempotent success - OrderService-->>Controller: OrderInfo (existing) - Controller-->>Customer: 200 OK (already canceled) - else status != PENDING - OrderService-->>Controller: ConflictException (409 invalid status) - Controller-->>Customer: 409 Conflict + Note over OrderService: 멱등 성공 (재고 복구 생략) + OrderService-->>Controller: OrderModel (already canceled) + Controller-->>Customer: 200 OK else status == PENDING - %% 상태 전이 + 아이템 조회 - OrderService->>OrderService: updateStatusToCanceled(orderId) - OrderService->>OrderService: findItems(orderId) + %% 상태 전이 + OrderService->>OrderService: order.cancel()
status = CANCELED %% 재고 복구 - loop each item - OrderService->>OrderService: increaseStock(productId, quantity) + loop each orderItem + OrderService->>ProductRepo: increaseStock(productId, quantity)
UPDATE products SET stock_quantity += :qty end - OrderService-->>Controller: OrderInfo (CANCELED) - Controller-->>Customer: 200 OK (canceled) + OrderService-->>Controller: OrderModel (CANCELED) + Controller-->>Customer: 200 OK end end Note over OrderService: @Transactional 커밋 deactivate OrderService - ``` ### 해석 - **트랜잭션 경계**: 상태 전이와 재고 복구가 단일 트랜잭션으로 묶여, 부분 성공을 방지한다. -- **멱등성**: 이미 CANCELED 상태면 200 OK로 성공 처리 (재고 복구 중복 실행 방지). -- **책임 분리**: OrderReader는 조회+검증, OrderService는 상태 전이+재고 복구 오케스트레이션. -- **소유권 확인**: Service에서 userId 일치 여부를 확인하여 타 유저 접근을 차단한다. +- **멱등성**: 이미 CANCELED 상태면 재고 복구 없이 200 OK 성공 처리 (중복 복구 방지). +- **책임 분리**: OrderReader는 orderId 기반 조회 + 404 처리, OrderService는 상태 전이 + 재고 복구 오케스트레이션. +- **소유권 확인**: `order.isOwner(memberId)`로 타 유저 접근 차단 (도메인 행위 메서드). +- **재고 복구**: 단순 증가 UPDATE (비관적 락 불필요 - 복구는 충돌 없음). --- @@ -148,9 +158,9 @@ sequenceDiagram ### 검증 목적 좋아요는 **멱등성**과 **UNIQUE 제약 처리**가 핵심이다. 이 다이어그램은: -1. 추가 시 중복 처리 (UNIQUE 제약 catch) +1. 추가 시 중복 처리 (UNIQUE 제약 catch → 기존 좋아요 반환) 2. 취소 시 없어도 성공 처리 -3. 상품 존재 확인 흐름 +3. 상품 존재 확인 흐름 (LikeService → ProductRepository) 을 검증한다. ### 시퀀스 다이어그램(좋아요 추가) @@ -159,71 +169,94 @@ sequenceDiagram sequenceDiagram actor Customer participant Controller as LikeV1Controller + participant LikeFacade as LikeFacade participant LikeService as LikeService - participant ProductReader as ProductReader + participant ProductRepo as ProductRepository participant LikeRepo as LikeRepository - Customer->>Controller: POST /likes
{productId} - Controller->>LikeService: addLike(userId, productId) + Customer->>Controller: POST /api/v1/likes
{memberId, productId} + Controller->>LikeFacade: addLike(memberId, productId) + LikeFacade->>LikeService: addLike(memberId, productId) activate LikeService Note over LikeService: @Transactional 시작 -%% 상품 존재 확인 - LikeService->>ProductReader: getOrThrow(productId) - ProductReader-->>LikeService: ProductInfo - -%% 좋아요 저장 - LikeService->>LikeRepo: save(LikeModel) - - alt unique constraint violation - LikeRepo-->>LikeService: DataIntegrityViolationException - Note over LikeService: idempotent success (already liked)
기존 Like 조회 or 바로 성공 처리 - LikeService->>LikeRepo: findByUserIdAndProductId(userId, productId) - LikeRepo-->>LikeService: LikeInfo (existing) - LikeService-->>Controller: LikeInfo (existing) - Controller-->>Customer: 200 OK
{already liked} - else inserted - LikeRepo-->>LikeService: LikeInfo (new) - LikeService-->>Controller: LikeInfo (new) - Controller-->>Customer: 201 Created
{likeId, productId} + %% 상품 존재 확인 (ProductRepository 직접 사용) + LikeService->>ProductRepo: findByProductId(productId) + ProductRepo-->>LikeService: ProductModel (or 404) + + %% 중복 좋아요 확인 (멱등성 - 선조회) + LikeService->>LikeRepo: findByRefMemberIdAndRefProductId(refMemberId, refProductId) + + alt already liked (existing) + LikeRepo-->>LikeService: LikeModel (existing) + Note over LikeService: 멱등 성공 (INSERT 생략) + LikeService-->>LikeFacade: LikeModel (existing) + else not found → try insert + LikeRepo-->>LikeService: empty + LikeService->>LikeRepo: save(LikeModel.create(memberId, productId)) + + alt DataIntegrityViolationException (UNIQUE 위반 - 동시 요청) + Note over LikeService: 동시성 fallback:
UNIQUE 위반 catch → 재조회 + LikeService->>LikeRepo: findByRefMemberIdAndRefProductId(...) + LikeRepo-->>LikeService: LikeModel (existing) + LikeService-->>LikeFacade: LikeModel (existing) + else insert success + LikeRepo-->>LikeService: LikeModel (new) + LikeService-->>LikeFacade: LikeModel (new) + end end Note over LikeService: @Transactional 커밋 deactivate LikeService + LikeFacade-->>Controller: LikeInfo + Controller-->>Customer: 200 OK {likeId, memberId, productId} ``` ### 시퀀스 다이어그램(좋아요 취소) + ```mermaid sequenceDiagram actor Customer participant Controller as LikeV1Controller + participant LikeFacade as LikeFacade participant LikeService as LikeService + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository - Customer->>Controller: DELETE /likes
{productId} - Controller->>LikeService: removeLike(userId, productId) + Customer->>Controller: DELETE /api/v1/likes
{memberId, productId} + Controller->>LikeFacade: removeLike(memberId, productId) + LikeFacade->>LikeService: removeLike(memberId, productId) activate LikeService Note over LikeService: @Transactional 시작 - - alt affectedRows == 0 - Note over LikeService: idempotent success (already unliked) + %% 상품 존재 확인 + LikeService->>ProductRepo: findByProductId(productId) + ProductRepo-->>LikeService: ProductModel (or 404) + + %% 좋아요 조회 후 삭제 (멱등성 - 없어도 성공) + LikeService->>LikeRepo: findByRefMemberIdAndRefProductId(refMemberId, refProductId) + alt found + LikeRepo-->>LikeService: LikeModel + LikeService->>LikeRepo: delete(likeModel) + else not found + Note over LikeService: 멱등 성공 (없어도 정상) end - LikeService-->>Controller: void - Controller-->>Customer: 204 No Content - Note over LikeService: @Transactional 커밋 deactivate LikeService + + LikeFacade-->>Controller: void + Controller-->>Customer: 204 No Content ``` ### 해석 -- **멱등성**: 추가 시 UNIQUE 제약 위반을 catch하여 성공 처리, 취소 시 affected rows=0이어도 성공. -- **책임 분리**: ProductReader는 상품 존재 확인만, LikeService는 좋아요 추가/삭제 로직 담당. -- **간결한 트랜잭션**: 좋아요는 단순 CUD이므로 트랜잭션이 짧고 명확하다. -- **예외 처리**: 상품이 삭제되었거나 존재하지 않으면 404 Not Found (ProductReader.getOrThrow). +- **락 불필요**: 좋아요는 경합이 낮고, 중복 1건은 비즈니스적으로 치명적이지 않다. DB UNIQUE 제약이 최종 방어선. +- **멱등성**: 추가 시 선조회로 중복 확인, UNIQUE 위반 시 catch 후 재조회로 성공 처리. 취소 시 없어도 성공. +- **Reader 미사용**: LikeService가 ProductRepository를 직접 사용하여 상품 존재 확인 (Reader 패턴 제거됨). +- **Facade 역할**: LikeFacade는 LikeService를 위임 호출하고 LikeInfo로 변환하는 thin facade. --- @@ -232,8 +265,8 @@ sequenceDiagram ### 검증 목적 상품 목록 조회는 **soft delete 필터링**, **정렬 옵션**, **좋아요 수 집계**의 성능 트레이드오프를 보여준다. 이 다이어그램은: 1. deleted_at 필터가 항상 적용되는지 -2. likes_desc 정렬 시 COUNT 집계 또는 like_count 컬럼 사용 -3. 페이징 적용 흐름 +2. likes_desc 정렬 시 LEFT JOIN + COUNT 집계 +3. Brand 정보와 좋아요 수 enrichment 흐름 을 검증한다. ### 시퀀스 다이어그램 @@ -242,45 +275,60 @@ sequenceDiagram sequenceDiagram actor Customer participant Controller as ProductV1Controller + participant ProductFacade as ProductFacade participant ProductService as ProductService - participant ProductReader as ProductReader + participant ProductRepo as ProductRepository + participant BrandRepo as BrandRepository Customer->>Controller: GET /api/v1/products
?brandId=&sort=likes_desc&page=0&size=20 - Controller->>ProductService: getProducts(criteria) + Controller->>ProductFacade: getProducts(brandId, sortBy, pageable) + + activate ProductFacade + Note over ProductFacade: @Transactional(readOnly=true) - activate ProductService - Note over ProductService: read-only usecase + ProductFacade->>ProductService: getProducts(brandId, sortBy, pageable) + ProductService->>ProductRepo: findProducts(refBrandId, sortBy, pageable) - ProductService->>ProductReader: findProducts(criteria) + Note over ProductRepo: Native SQL:
SELECT * FROM products
WHERE deleted_at IS NULL
[AND ref_brand_id = :brandId]
[LEFT JOIN likes GROUP BY id
ORDER BY COUNT(l.id) DESC] - ProductService-->>Controller: List - deactivate ProductService + ProductRepo-->>ProductService: Page + ProductService-->>ProductFacade: Page + + %% enrichment (Brand 정보 + 좋아요 수) + loop each ProductModel + ProductFacade->>BrandRepo: findById(refBrandId) + BrandRepo-->>ProductFacade: BrandModel + ProductFacade->>ProductRepo: countLikes(productId) + ProductRepo-->>ProductFacade: long likesCount + ProductFacade->>ProductFacade: ProductInfo.from(product, brand, likesCount) + end - Controller-->>Customer: 200 OK
{products: [...], page, size} + deactivate ProductFacade + ProductFacade-->>Controller: Page + Controller-->>Customer: 200 OK
{products: [...], page, size, totalElements} ``` ### 해석 -- **Soft Delete 필터**: 모든 쿼리에 `deleted_at IS NULL`이 포함되어 삭제된 상품을 제외한다. +- **Soft Delete 필터**: 모든 쿼리에 `deleted_at IS NULL`이 포함되어 삭제된 상품을 제외. - **정렬 옵션**: - - **Phase 1 (likes_desc)**: COUNT 서브쿼리로 실시간 집계 (정확, 느릴 수 있음) - - **Phase 2 (likes_desc)**: `like_count` 컬럼 사용 (빠름, 약한 일관성) - - **latest**: `updated_at DESC` - - **price_asc**: `price ASC` -- **페이징**: `LIMIT`, `OFFSET` 적용으로 성능 보장. -- **책임 분리**: ProductReader는 조회 조건 조합, ProductRepository는 쿼리 실행, Controller는 DTO 변환. -- **성능 리스크**: likes_desc + COUNT는 상품/좋아요 수 증가 시 병목 가능 → 모니터링 후 Phase 2로 전환. + - **latest** (기본): `ORDER BY updated_at DESC` + - **price_asc**: `ORDER BY price ASC` + - **likes_desc**: `LEFT JOIN likes ON p.id = l.ref_product_id GROUP BY p.id ORDER BY COUNT(l.id) DESC` +- **Native Query 사용 이유**: VO 타입 (`ProductId`, `RefBrandId` 등)이 JPQL에서 처리 어려워 Native Query 사용. +- **Enrichment**: Facade에서 각 상품별 Brand 정보 + 좋아요 수를 추가로 조회하여 ProductInfo 구성 (N+1 주의 - 병목 시 단일 쿼리로 통합 고려). +- **성능 리스크**: likes_desc + COUNT는 상품/좋아요 수 증가 시 병목 가능 → 모니터링 후 Phase 2 (like_count 컬럼) 전환. --- ## 다이어그램 요약 -| 유스케이스 | 핵심 검증 포인트 | 트랜잭션 범위 | -|-----------|-----------------|--------------------------| -| 주문 생성 | 재고 차감 동시성, 스냅샷 저장 | Service (@Transactional) | -| 주문 취소 | 상태 전이, 재고 복구, 멱등성 | Service (@Transactional) | -| 좋아요 추가/취소 | UNIQUE 제약, 멱등성 | Service (@Transactional) | -| 상품 목록 조회 | Soft Delete 필터, 정렬/집계 성능 | 없음 (읽기 전용) | +| 유스케이스 | 핵심 검증 포인트 | 트랜잭션 범위 | 동시성 전략 | +|-----------|-----------------|--------------|------------| +| 주문 생성 | 재고 차감 동시성, 스냅샷 저장 | Service (@Transactional) | 비관적 락 + productId 정렬 | +| 주문 취소 | 상태 전이, 재고 복구, 멱등성 | Service (@Transactional) | 없음 (복구는 충돌 없음) | +| 좋아요 추가/취소 | UNIQUE 제약, 멱등성 | Service (@Transactional) | DB UNIQUE 제약 | +| 상품 목록 조회 | Soft Delete 필터, 정렬/집계 성능 | 없음 (읽기 전용) | 없음 | --- diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index ea1a2d0b6..16070d808 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -2,7 +2,7 @@ ## 개요 -이 문서는 레이어드 아키텍처에 따른 도메인 모델과 각 레이어의 책임을 정의한다. 클래스 다이어그램은 **의존성 방향**, **책임 경계**, **불변 규칙**을 중심으로 작성되며, 과도한 필드 객체화를 지양하고 실제 비즈니스 규칙이 있는 Value Object만 도입한다. +이 문서는 레이어드 아키텍처에 따른 도메인 모델과 각 레이어의 책임을 정의한다. 클래스 다이어그램은 **의존성 방향**, **책임 경계**, **불변 규칙**을 중심으로 작성되며, **실제 코드 구현**을 기반으로 한다. **레이어 의존성 규칙**: ```mermaid @@ -19,17 +19,17 @@ flowchart LR | 레이어 | 구성 요소 | 책임 | |--------|----------|------| -| **Interfaces** | Controller, Dto | HTTP 요청/응답 처리, DTO 변환 | -| **Application** | Facade, Info | 유스케이스 조합, 도메인 서비스 오케스트레이션 | -| **Domain** | Model, Service, Reader, VO, Repository(interface) | 핵심 비즈니스 규칙, 상태 변화, 조회 로직 | -| **Infrastructure** | RepositoryImpl, JpaRepository, Converter | 기술 구현, 영속화, VO 변환 | +| **Interfaces** | Controller, Dto, ApiSpec | HTTP 요청/응답 처리, DTO 변환 | +| **Application** | Facade, Info | 유스케이스 조합, 도메인 서비스 오케스트레이션, Info 변환 | +| **Domain** | Model, Service, Reader(Order만), VO, Repository(interface) | 핵심 비즈니스 규칙, 상태 변화, 트랜잭션 관리 | +| **Infrastructure** | RepositoryImpl, JpaRepository, Converter | 기술 구현, 영속화, VO ↔ DB 변환 | --- ## 도메인 모델 전체 구조 ### 검증 목적 -전체 도메인 모델의 **관계**와 **의존성 방향**을 파악한다. Brand-Product, User-Like-Product, User-Order-OrderItem 관계가 명확히 드러나야 하며, 각 도메인이 다른 도메인의 **구현 세부사항에 의존하지 않는지** 확인한다. +전체 도메인 모델의 **관계**와 **의존성 방향**을 파악한다. Brand-Product, Member-Like-Product, Member-Order-OrderItem 관계가 명확히 드러나야 하며, 각 도메인이 다른 도메인의 **구현 세부사항에 의존하지 않는지** 확인한다. ### 다이어그램 @@ -40,82 +40,81 @@ classDiagram class BrandModel { <> +Long id - +String brandName + +BrandId brandId + +BrandName brandName + +create(brandId, brandName) BrandModel$ + +markAsDeleted() + +isDeleted() boolean } class ProductModel { <> +Long id - +Long brandId - +String productName - +BigDecimal price - +int stockQty - +ProductStatus status + +ProductId productId + +RefBrandId refBrandId + +ProductName productName + +Price price + +StockQuantity stockQuantity + +create(productId, refBrandId, productName, price, stockQuantity) ProductModel$ +decreaseStock(int qty) +increaseStock(int qty) + +markAsDeleted() + +isDeleted() boolean } class LikeModel { <> +Long id - +Long userId - +Long productId - +create(Long userId, Long productId) LikeModel + +RefMemberId refMemberId + +RefProductId refProductId + +create(refMemberId, refProductId) LikeModel$ } class OrderModel { <> +Long id - +Long userId - +BigDecimal totalAmount + +OrderId orderId + +RefMemberId refMemberId +OrderStatus status + +List~OrderItemModel~ orderItems + +create(memberId, items) OrderModel$ +cancel() - +isCancelable() boolean + +isOwner(memberId) boolean + +getTotalAmount() BigDecimal } class OrderItemModel { <> +Long id - +Long orderId - +Long productId + +OrderItemId orderItemId + +String productId +String productName - +String brandName - +BigDecimal unitPrice + +BigDecimal price +int quantity - +BigDecimal lineAmount - } - - class ProductStatus { - <> - ACTIVE - INACTIVE - OUT_OF_STOCK + +create(productId, productName, price, quantity) OrderItemModel$ + +getTotalPrice() BigDecimal } class OrderStatus { <> PENDING CANCELED + +validateTransition(target) } - BrandModel "1" --> "0..*" ProductModel : brandId - ProductModel "1" --> "0..*" LikeModel : productId - OrderModel "1" --> "1..*" OrderItemModel : orderId - -%% 주문항목은 productId로만 참조하고, 나머지는 스냅샷으로 고정 - OrderItemModel ..> ProductModel : reference(productId) - - ProductModel --> ProductStatus + BrandModel "1" --> "0..*" ProductModel : refBrandId + ProductModel "1" --> "0..*" LikeModel : refProductId + OrderModel "1" --> "1..*" OrderItemModel : oneToMany(cascade) + OrderItemModel ..> ProductModel : productId(스냅샷 참조) OrderModel --> OrderStatus - ``` ### 해석 -- **Brand-Product**: 1:N 관계이지만, ProductModel은 brandId(Long)만 보유하고 BrandModel 객체를 직접 참조하지 않는다 (느슨한 결합). -- **User-Like-Product**: LikeModel은 userId, productId만 보유 (User 도메인은 이번 범위 밖). -- **Order-OrderItem**: 1:N 강한 연관. OrderModel이 OrderItemModel을 Aggregate Root로 관리. -- **OrderItem-Product**: OrderItemModel은 productId를 참조하되, 스냅샷(productName, brandName, unitPrice 등)을 저장하여 Product 삭제에 독립적. -- **Soft Delete**: 모든 Model에 deletedAt 필드 존재 (BrandModel, ProductModel). +- **Brand-Product**: 1:N 관계. ProductModel은 `refBrandId(Long)`만 보유하고 BrandModel 객체를 직접 참조하지 않는다 (느슨한 결합). +- **Like**: Member-Product 간 독립 도메인. `refMemberId(Long)`, `refProductId(Long)`로 간접 참조. +- **Order-OrderItem**: 1:N 강한 연관 (cascade). OrderModel이 Aggregate Root로 OrderItemModel을 관리. +- **OrderItem 스냅샷**: productId, productName, price를 저장 시점의 값으로 복사. Product 삭제/수정 후에도 주문 이력 유지. +- **Soft Delete**: BrandModel, ProductModel에 deletedAt 필드 존재 (BaseEntity 상속). LikeModel, OrderModel은 hard delete 또는 삭제 없음. --- @@ -124,7 +123,7 @@ classDiagram ### 1. Brand 도메인 #### 검증 목적 -Brand 도메인은 **soft delete 연쇄**와 **단순 CRUD** 책임을 가진다. Facade에서 Brand 삭제 시 Product도 함께 soft delete 처리하는 오케스트레이션을 확인한다. +Brand 도메인은 **soft delete**와 **단순 CRUD** 책임을 가진다. BrandService가 브랜드 생성/삭제를 담당하며, BrandFacade가 Controller와 Service를 연결한다. #### 다이어그램 @@ -135,34 +134,45 @@ classDiagram %% Interfaces class BrandV1Controller { <> - } - class BrandAdminV1Controller { - <> + +createBrand(request) ApiResponse + +deleteBrand(brandId) ApiResponse } class BrandV1Dto { - <> + <> + +CreateBrandRequest + +BrandResponse } %% Application class BrandFacade { <> + +createBrand(brandId, brandName) BrandInfo + +deleteBrand(brandId) } class BrandInfo { - <> + <> + +Long id + +String brandId + +String brandName } %% Domain class BrandService { <> - } - class BrandReader { - <> + +createBrand(brandId, brandName) BrandModel + +deleteBrand(brandId) } class BrandRepository { <> + +save(brand) BrandModel + +findByBrandId(brandId) Optional + +findById(id) Optional + +existsByBrandId(brandId) boolean } class BrandModel { <> + +BrandId brandId + +BrandName brandName } %% Infrastructure @@ -172,39 +182,21 @@ classDiagram class BrandJpaRepository { <> } - class ProductService { - <> - } -%% Relationships (dependency direction) +%% Relationships BrandV1Controller --> BrandFacade - BrandAdminV1Controller --> BrandFacade BrandV1Controller ..> BrandV1Dto - BrandAdminV1Controller ..> BrandV1Dto - -%% Facade -> Service only (no direct Reader call) BrandFacade --> BrandService BrandFacade ..> BrandInfo - -%% Service owns query + command orchestration - BrandService --> BrandReader BrandService --> BrandRepository - BrandReader --> BrandRepository - -%% Port/Adapter BrandRepository <|.. BrandRepositoryImpl BrandRepositoryImpl --> BrandJpaRepository - -%% Domain persistence relation - BrandRepository ..> BrandModel : persists/loads - -%% Business rule - BrandService --> ProductService : cascade delete + BrandRepository ..> BrandModel ``` #### 해석 -- **Facade 책임**: 브랜드 삭제 시 ProductService를 호출하여 연쇄 soft delete 처리 (도메인 간 조합). -- **Reader vs Service**: BrandReader는 조회 전용, BrandService는 CUD 담당. +- **Reader 미사용**: BrandService가 BrandRepository를 직접 사용. Reader 패턴은 Order 도메인에서만 유지. +- **Thin Facade**: BrandFacade는 BrandService를 위임 호출하고 BrandInfo로 변환. - **의존성 역전**: Domain의 BrandRepository(interface)를 Infrastructure의 BrandRepositoryImpl이 구현. --- @@ -212,7 +204,7 @@ classDiagram ### 2. Product 도메인 #### 검증 목적 -Product 도메인은 **재고 차감/복구**, **soft delete**, **Brand 참조** 책임을 가진다. ProductModel의 `decreaseStock`, `increaseStock` 메서드가 도메인 행위를 표현하는지 확인한다. +Product 도메인은 **재고 차감/복구**, **soft delete**, **Brand 참조**, **좋아요 수 집계** 책임을 가진다. ProductFacade가 Brand 정보와 좋아요 수를 enrichment하는 흐름을 확인한다. #### 다이어그램 @@ -223,91 +215,97 @@ classDiagram %% Interfaces class ProductV1Controller { <> - } - class ProductAdminV1Controller { - <> + +createProduct(request) ApiResponse + +getProducts(brandId, sort, page, size) ApiResponse + +deleteProduct(productId) ApiResponse } class ProductV1Dto { - <> + <> + +CreateProductRequest + +ProductResponse + +ProductListResponse } %% Application class ProductFacade { <> + +createProduct(...) ProductInfo + +deleteProduct(productId) + +getProducts(brandId, sortBy, pageable) Page~ProductInfo~ + -enrichProductInfo(product) ProductInfo } class ProductInfo { - <> + <> + +Long id + +String productId + +Long refBrandId + +String productName + +BigDecimal price + +int stockQuantity + +BrandInfo brand + +long likesCount } %% Domain class ProductService { <> - } - class ProductReader { - <> - } - class BrandReader { - <> + +createProduct(productId, brandId, productName, price, stockQuantity) ProductModel + +deleteProduct(productId) + +getProducts(brandId, sortBy, pageable) Page~ProductModel~ } class ProductRepository { <> + +save(product) ProductModel + +findByProductId(productId) Optional + +existsByProductId(productId) boolean + +findProducts(refBrandId, sortBy, pageable) Page + +decreaseStockIfAvailable(productId, quantity) boolean + +increaseStock(productId, quantity) + +countLikes(productId) long } class ProductModel { <> } - class ProductStatus { - <> - ACTIVE - INACTIVE - OUT_OF_STOCK - } %% Infrastructure class ProductRepositoryImpl { <> + -EntityManager entityManager + +findProducts() Native SQL + +decreaseStockIfAvailable() Native SQL UPDATE + +increaseStock() Native SQL UPDATE + +countLikes() Native SQL COUNT } class ProductJpaRepository { <> } -%% Relationships (dependency direction) +%% Relationships ProductV1Controller --> ProductFacade - ProductAdminV1Controller --> ProductFacade ProductV1Controller ..> ProductV1Dto - ProductAdminV1Controller ..> ProductV1Dto - -%% Facade -> Service only (no direct Reader call) ProductFacade --> ProductService + ProductFacade --> ProductRepository : countLikes + ProductFacade --> BrandRepository : enrichment ProductFacade ..> ProductInfo - -%% Service owns query + command orchestration + validation - ProductService --> ProductReader ProductService --> ProductRepository - ProductReader --> ProductRepository - - ProductService --> BrandReader : validate brand - -%% Port/Adapter + ProductService --> BrandRepository : validate brand exists ProductRepository <|.. ProductRepositoryImpl ProductRepositoryImpl --> ProductJpaRepository - -%% Domain relations - ProductModel --> ProductStatus - ProductRepository ..> ProductModel : persists/loads + ProductRepository ..> ProductModel ``` #### 해석 -- **재고 도메인 행위**: `decreaseStock`, `increaseStock`은 ProductModel의 도메인 메서드이지만, 동시성 제어를 위해 Repository에서 조건부 UPDATE 실행. -- **Brand 참조**: ProductService가 BrandReader를 의존하여 브랜드 존재 확인 (brandId 검증). -- **연쇄 삭제**: ProductFacade의 `softDeleteByBrandId`는 BrandFacade에서 호출됨. -- **정렬/집계**: ProductRepository의 findAll에서 likes_desc 정렬 시 COUNT 서브쿼리 또는 like_count 컬럼 사용. +- **Facade Enrichment**: ProductFacade가 ProductModel → ProductInfo 변환 시, BrandRepository와 ProductRepository(countLikes)를 추가 조회하여 Brand 정보와 좋아요 수를 enrichment. +- **Reader 미사용**: ProductService가 ProductRepository를 직접 사용. +- **Native Query**: VO 타입(ProductId, RefBrandId 등)이 JPQL과 호환 어려워 EntityManager + Native SQL 사용. +- **재고 동시성**: `decreaseStockIfAvailable`는 비관적 락 기반으로 구현 예정 (`SELECT FOR UPDATE`). --- ### 3. Like 도메인 #### 검증 목적 -Like 도메인은 **멱등성**과 **UNIQUE 제약** 처리를 확인한다. LikeService에서 중복 처리 로직이 명확한지 검증한다. +Like 도메인은 **멱등성**과 **UNIQUE 제약** 처리를 확인한다. LikeService가 ProductRepository를 직접 사용하여 상품 존재 확인을 하는지 검증한다. #### 다이어그램 @@ -318,31 +316,45 @@ classDiagram %% Interfaces class LikeV1Controller { <> + +addLike(request) ApiResponse + +removeLike(request) ApiResponse } class LikeV1Dto { - <> + <> + +AddLikeRequest + +RemoveLikeRequest + +LikeResponse } %% Application class LikeFacade { <> + +addLike(memberId, productId) LikeInfo + +removeLike(memberId, productId) } class LikeInfo { - <> + <> + +Long id + +Long refMemberId + +Long refProductId } %% Domain class LikeService { <> - } - class ProductReader { - <> + +addLike(memberId, productId) LikeModel + +removeLike(memberId, productId) } class LikeRepository { <> + +save(like) LikeModel + +findByRefMemberIdAndRefProductId(refMemberId, refProductId) Optional + +delete(like) } class LikeModel { <> + +RefMemberId refMemberId + +RefProductId refProductId } %% Infrastructure @@ -351,41 +363,32 @@ classDiagram } class LikeJpaRepository { <> + +UNIQUE(ref_member_id, ref_product_id) } -%% Relationships (dependency direction) +%% Relationships LikeV1Controller --> LikeFacade LikeV1Controller ..> LikeV1Dto - -%% Facade -> Service only LikeFacade --> LikeService LikeFacade ..> LikeInfo - -%% Service owns validation + orchestration - LikeService --> ProductReader : validate product exists LikeService --> LikeRepository - -%% Port/Adapter + LikeService --> ProductRepository : validate product exists LikeRepository <|.. LikeRepositoryImpl LikeRepositoryImpl --> LikeJpaRepository - -%% Persistence relation + invariant - LikeRepository ..> LikeModel : persists/loads - LikeJpaRepository ..> LikeModel : UNIQUE(userId, productId) - + LikeRepository ..> LikeModel ``` #### 해석 -- **멱등 처리**: LikeService의 `addLike`에서 UNIQUE 제약 위반 시 catch하여 성공 처리, `removeLike`는 affected rows=0이어도 성공. -- **Facade 책임**: ProductReader를 호출하여 상품 존재 확인 (삭제된 상품에 좋아요 방지). -- **간결한 도메인**: LikeModel은 단순 CUD만 수행, 복잡한 비즈니스 규칙 없음. +- **ProductRepository 직접 사용**: LikeService가 Reader 없이 ProductRepository를 직접 의존하여 상품 존재 확인. +- **UNIQUE 제약**: `uk_likes_member_product(ref_member_id, ref_product_id)` - DB 레벨 중복 방지. +- **멱등 처리**: 선조회로 중복 확인 → INSERT 시도 → DataIntegrityViolationException catch → 재조회 반환. --- ### 4. Order 도메인 #### 검증 목적 -Order 도메인은 **재고 차감**, **스냅샷 저장**, **주문 취소** 책임을 가진다. OrderFacade가 ProductService, OrderService를 조합하여 트랜잭션을 관리하는지 확인한다. +Order 도메인은 **재고 차감(비관적 락)**, **스냅샷 저장**, **주문 취소** 책임을 가진다. OrderService가 ProductRepository를 직접 사용하여 재고 차감 + 주문 저장을 오케스트레이션하는지 확인한다. #### 다이어그램 @@ -393,52 +396,52 @@ Order 도메인은 **재고 차감**, **스냅샷 저장**, **주문 취소** classDiagram direction LR -%% Interfaces +%% Interfaces (TODO: 미구현) class OrderV1Controller { - <> - } - class OrderV1Dto { - <> - } - -%% Application - class OrderFacade { - <> - } - class OrderInfo { - <> - } - class OrderItemInfo { - <> + <> } %% Domain class OrderService { <> + +createOrder(memberId, items) OrderModel + -aggregateQuantities(items) Map } class OrderReader { <> + +getOrThrow(orderId) OrderModel } - class ProductReader { - <> - } - class ProductService { - <> - } - class OrderRepository { <> + +save(order) OrderModel + +findByOrderId(orderId) Optional } class OrderModel { <> + +OrderId orderId + +RefMemberId refMemberId + +OrderStatus status + +List~OrderItemModel~ orderItems + +create(memberId, items) OrderModel$ + +cancel() + +isOwner(memberId) boolean + +getTotalAmount() BigDecimal } class OrderItemModel { <> + +OrderItemId orderItemId + +String productId + +String productName + +BigDecimal price + +int quantity + +create(...) OrderItemModel$ + +getTotalPrice() BigDecimal } class OrderStatus { <> PENDING CANCELED + +validateTransition(target) } %% Infrastructure @@ -448,81 +451,106 @@ classDiagram class OrderJpaRepository { <> } - class OrderItemJpaRepository { - <> - } - -%% Relationships (dependency direction) - OrderV1Controller --> OrderFacade - OrderV1Controller ..> OrderV1Dto -%% Facade -> Service only (no direct Reader call) - OrderFacade --> OrderService - OrderFacade ..> OrderInfo - OrderFacade ..> OrderItemInfo - -%% Service owns query + command orchestration - OrderService --> OrderReader +%% Relationships OrderService --> OrderRepository + OrderService --> ProductRepository : 재고 차감/복구 + 상품 조회 OrderReader --> OrderRepository - -%% Order creation needs product lookup + stock change (or compensation on cancel) - OrderService --> ProductReader : load product for snapshot/price - OrderService --> ProductService : decrease/increase stock - -%% Port/Adapter OrderRepository <|.. OrderRepositoryImpl OrderRepositoryImpl --> OrderJpaRepository - OrderRepositoryImpl --> OrderItemJpaRepository - -%% Aggregate - OrderModel "1" --> "1..*" OrderItemModel : contains + OrderModel "1" --> "1..*" OrderItemModel : cascade OrderModel --> OrderStatus - OrderRepository ..> OrderModel : persists/loads - OrderRepository ..> OrderItemModel : persists/loads - + OrderRepository ..> OrderModel ``` #### 해석 -- **Facade 트랜잭션 조합**: - - 주문 생성: ProductReader(상품 조회) → ProductService(재고 차감) → OrderService(주문+스냅샷 저장) - - 주문 취소: OrderReader(주문 조회) → OrderService(상태 전이) → ProductService(재고 복구) -- **스냅샷**: OrderItemModel이 productName, brandName 등을 저장하여 Product 삭제에 독립적. -- **상태 전이**: OrderModel의 `cancel()` 메서드가 상태 검증 후 CANCELED로 전이. -- **Aggregate Root**: OrderModel이 OrderItemModel을 소유 (1:N 강한 연관). +- **Facade 미구현**: Order 도메인은 아직 Facade 없이 OrderService가 직접 ProductRepository와 OrderRepository를 사용. +- **OrderReader 유지**: orderId 기반 조회 + 404 처리를 담당하는 Reader는 Order 도메인에만 존재. +- **재고 차감 책임**: OrderService가 ProductRepository.decreaseStockIfAvailable()를 호출하여 재고 차감. +- **OrderItem은 별도 JpaRepository 없음**: OrderModel에 cascade ALL 설정으로 OrderJpaRepository가 order_items도 함께 관리. --- ## Value Object 설계 ### 검증 목적 -이 프로젝트에서는 **간단한 원시 타입은 VO로 만들지 않는다**. 대신 **검증 규칙이나 연산이 필요한 경우에만** VO를 도입한다. 현재 설계에서는 ProductStatus, OrderStatus만 enum으로 정의하고, 나머지(price, stockQty 등)는 원시 타입 사용. +이 프로젝트에서 VO는 **검증 규칙이 있는 원시값을 캡슐화**한다. `record` 타입의 Compact Constructor에서 검증을 수행하여, 잘못된 상태가 생성 시점에 차단된다. #### 다이어그램 ```mermaid classDiagram - class ProductStatus { - <> - ACTIVE - INACTIVE - OUT_OF_STOCK - +isActive() boolean + class ProductId { + <> + +String value + %% ^[A-Za-z0-9]{1,20}$ + } + class ProductName { + <> + +String value + %% 1-100자 + } + class Price { + <> + +BigDecimal value + %% >= 0, scale=2 + } + class StockQuantity { + <> + +int value + %% >= 0 + } + class RefBrandId { + <> + +Long value + %% > 0 + } + class BrandId { + <> + +String value + %% ^[A-Za-z0-9]{1,10}$ + } + class BrandName { + <> + +String value + %% 1-50자 + } + class OrderId { + <> + +String value + +generate() OrderId$ + %% UUID 형식 + } + class OrderItemId { + <> + +String value + +generate() OrderItemId$ + %% UUID 형식 + } + class RefMemberId { + <> + +Long value + %% > 0 + } + class RefProductId { + <> + +Long value + %% > 0 } - class OrderStatus { <> PENDING CANCELED - +isCancelable() boolean - +canTransitionTo(target) boolean + +validateTransition(target) } ``` #### 해석 -- **ProductStatus**: 상품 상태 관리 (활성/비활성/품절). 확장 가능 (추후 SOLD_OUT 등 추가). -- **OrderStatus**: 주문 상태 전이 검증 메서드 제공 (`isCancelable`, `canTransitionTo`). -- **VO 미도입 대상**: price (BigDecimal), stockQty (Integer), userId (Long) 등은 별도 검증 규칙 없이 원시 타입 사용. +- **record 타입**: 불변 + Compact Constructor 검증으로 잘못된 값 생성 차단. +- **Converter 패턴**: 각 VO에 대응하는 JPA Converter가 DB 저장/조회 시 원시타입 ↔ VO 변환. +- **FK 참조 VO**: `RefBrandId(Long)`, `RefMemberId(Long)`, `RefProductId(Long)` - 외래키를 VO로 래핑. +- **UUID VO**: `OrderId`, `OrderItemId` - UUID 기반으로 정적 `generate()` 메서드 제공. +- **OrderStatus**: 상태 전이 검증 메서드(`validateTransition`) 포함. --- @@ -551,9 +579,9 @@ flowchart LR subgraph D["Domain"] direction TB S["Service"] - R["Reader"] + R["Reader (Order only)"] M["Model (Entity)"] - VO["Value Object"] + VO["Value Object (record)"] REPO["Repository (interface)"] end @@ -561,7 +589,7 @@ flowchart LR direction TB REPOIMPL["RepositoryImpl"] JPA["JpaRepository"] - CONV["Converter"] + CONV["Converter (VO ↔ DB)"] end %% ===== Main dependency flow ===== @@ -571,8 +599,7 @@ flowchart LR F --> S F -. "returns" .-> INFO -%% Service owns orchestration (including reads) - S --> R +%% Service uses Repository directly (Reader only in Order domain) S --> REPO R --> REPO @@ -583,12 +610,12 @@ flowchart LR REPOIMPL --> JPA REPOIMPL --> CONV -%% Domain composition (expressed in flowchart terms) +%% Domain composition S -. "uses" .-> M R -. "uses" .-> M M -->|composes| VO -%% ===== Styling (light) ===== +%% ===== Styling ===== classDef layer fill:#f7f7f7,stroke:#999,stroke-width:1px,color:#111; classDef domain fill:#eef6ff,stroke:#3b82f6,stroke-width:1px,color:#111; classDef infra fill:#fff3e6,stroke:#f59e0b,stroke-width:1px,color:#111; @@ -596,13 +623,13 @@ flowchart LR class C,DTO,F,INFO layer; class S,R,M,VO,REPO domain; class REPOIMPL,JPA,CONV infra; - ``` ### 해석 -- **의존성 역전**: Domain의 Repository(interface)를 Infrastructure의 RepositoryImpl이 구현 (점선). -- **DTO 변환**: Controller에서 Dto → Info 변환, Facade에서 Info → Dto 변환 (레이어 간 격리). -- **도메인 독립성**: Domain Layer는 Infrastructure를 직접 의존하지 않음 (Spring, JPA 무관). +- **Reader 패턴**: Brand/Product 도메인에서는 제거됨. Order 도메인에만 OrderReader 유지 (orderId 기반 조회 특화). +- **의존성 역전**: Domain의 Repository(interface) ← Infrastructure의 RepositoryImpl이 구현 (점선). +- **Converter**: VO ↔ DB 원시타입 변환 담당. EntityManager Native Query 사용 시 VO 타입 제약 우회. +- **도메인 독립성**: Domain Layer는 Spring, JPA 기술을 직접 알지 않음 (단, JPA Entity 어노테이션은 예외). --- @@ -610,28 +637,20 @@ flowchart LR ### 1. 레이어 책임 분리 - **Controller**: HTTP 프로토콜, DTO 변환, 인증 헤더 추출 -- **Facade**: 유스케이스 조합, 트랜잭션 경계 (@Transactional) -- **Service**: 도메인 규칙 실행, 교차 엔티티 로직 -- **Reader**: 조회 전용, VO 변환, getOrThrow 패턴 -- **Repository**: 영속화, 쿼리 실행 +- **Facade**: 유스케이스 조합, Model → Info 변환, Thin Facade (로직 없음) +- **Service**: 도메인 규칙 실행, @Transactional 경계, Repository 호출 +- **Reader**: orderId 기반 조회 + orElseThrow (Order 도메인 한정) +- **Repository**: 영속화, 쿼리 실행 (Domain interface → Infrastructure 구현) ### 2. 도메인 모델 설계 -- **정적 팩토리**: `create()` 메서드로 생성 (생성자 private) -- **도메인 행위**: `cancel()`, `decreaseStock()` 등 도메인 메서드 제공 -- **검증 로직**: private 메서드로 캡슐화 (`validateStockSufficient`) +- **정적 팩토리**: `create()` 메서드로 생성 (생성자 private/protected) +- **도메인 행위**: `cancel()`, `decreaseStock()`, `isOwner()` 등 도메인 메서드 제공 +- **불변 VO**: record 타입, Compact Constructor 검증, Converter로 DB 연동 -### 3. Value Object 최소화 -- **원칙**: 검증/연산 규칙이 없으면 원시 타입 사용 -- **VO 도입 기준**: 불변 + 검증 규칙 + 의미 있는 연산 -- **현재 VO**: ProductStatus, OrderStatus (enum) +### 3. 동시성 제어 +- **재고**: 비관적 락 (`SELECT ... FOR UPDATE`) - 경합 높음, 과판매 불가 +- **좋아요**: DB UNIQUE 제약 + catch - 경합 낮음, 중복 1건 허용 범위 ### 4. 의존성 역전 - Domain이 Infrastructure를 의존하지 않음 - Repository interface를 Domain에 두고, Infrastructure에서 구현 - ---- - -## 다음 단계 - -이 클래스 다이어그램을 기반으로: -- **04-erd.md**: 각 Model의 테이블 구조, UNIQUE 제약, FK, 인덱스 설계 diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 332fb967d..ba0f5e2c6 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -2,206 +2,207 @@ ## 개요 -이 문서는 데이터베이스 설계를 정의한다. ERD는 **테이블 구조**, **제약 조건**, **인덱스 후보**, **상태/삭제 정책**을 포함하며, 요구사항에서 도출된 **핵심 조회 패턴**을 기반으로 검증한다. +이 문서는 데이터베이스 설계를 정의한다. ERD는 **실제 구현된 테이블 구조**, **제약 조건**, **인덱스 후보**, **상태/삭제 정책**을 포함하며, 요구사항에서 도출된 **핵심 조회 패턴**을 기반으로 검증한다. **핵심 조회 패턴** (인덱스 설계 근거): -1. 상품 목록 조회 (brandId 필터, 정렬, soft delete 필터) -2. 좋아요한 상품 목록 조회 (userId 필터) -3. 주문 목록 조회 (userId 필터, 기간 필터) +1. 상품 목록 조회 (ref_brand_id 필터, 정렬, soft delete 필터) +2. 좋아요 수 집계 (ref_product_id 기반 COUNT) +3. 주문 목록 조회 (ref_member_id 필터) --- ## 전체 ERD ### 검증 목적 -테이블 간 **FK 관계**, **UNIQUE 제약**, **soft delete** 컬럼이 요구사항과 일치하는지 확인한다. 특히 order_item의 **스냅샷 컬럼**이 product/brand 삭제에 독립적인지 검증한다. +테이블 간 **FK 관계**, **UNIQUE 제약**, **soft delete** 컬럼이 요구사항과 일치하는지 확인한다. 특히 order_items의 **스냅샷 컬럼**이 products/brands 삭제에 독립적인지 검증한다. ### 다이어그램 ```mermaid erDiagram - member ||--o{ like : "1:N" - member ||--o{ orders : "1:N" - brand ||--o{ product : "1:N" - product ||--o{ like : "1:N" - product ||--o{ order_item : "1:N (참조)" - orders ||--|{ order_item : "1:N" - - member { + members ||--o{ likes : "1:N" + members ||--o{ orders : "1:N" + brands ||--o{ products : "1:N" + products ||--o{ likes : "1:N" + orders ||--|{ order_items : "1:N" + + members { bigint id PK "AUTO_INCREMENT" varchar(10) member_id UK "NOT NULL, 영문+숫자, 1~10자" varchar(320) email UK "NOT NULL, RFC 5322" date birth_date "NOT NULL" varchar(255) password "NOT NULL" - datetime created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - datetime updated_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" } - brand { + brands { bigint id PK "AUTO_INCREMENT" - varchar(100) brand_name "NOT NULL" - text description "NULL" - varchar(500) logo_url "NULL" + varchar(10) brand_id UK "NOT NULL, 영문+숫자, 1~10자" + varchar(50) brand_name "NOT NULL" datetime created_at "NOT NULL" datetime updated_at "NOT NULL" datetime deleted_at "NULL, soft delete" } - product { + products { bigint id PK "AUTO_INCREMENT" - bigint brand_id FK "NOT NULL, REFERENCES brand(id)" - varchar(200) product_name "NOT NULL" - decimal(152) price "NOT NULL, >= 0" - int stock_qty "NOT NULL, >= 0" - text description "NULL" - varchar(500) image_url "NULL" - varchar(20) status "NOT NULL, DEFAULT 'ACTIVE'" + varchar(20) product_id UK "NOT NULL, 영문+숫자, 1~20자" + bigint ref_brand_id "NOT NULL, REFERENCES brands(id)" + varchar(100) product_name "NOT NULL" + decimal(10_2) price "NOT NULL, >= 0" + int stock_quantity "NOT NULL, >= 0" datetime created_at "NOT NULL" datetime updated_at "NOT NULL" datetime deleted_at "NULL, soft delete" } - like { + likes { bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL, REFERENCES member(id)" - bigint product_id FK "NOT NULL, REFERENCES product(id)" + bigint ref_member_id "NOT NULL, REFERENCES members(id)" + bigint ref_product_id "NOT NULL, REFERENCES products(id)" datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL (미사용)" } orders { bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL, REFERENCES member(id)" - decimal(152) total_amount "NOT NULL, >= 0" - varchar(20) status "NOT NULL, DEFAULT 'PENDING'" - datetime ordered_at "NOT NULL" - datetime canceled_at "NULL" + varchar(36) order_id UK "NOT NULL, UUID" + bigint ref_member_id "NOT NULL, REFERENCES members(id)" + varchar(20) status "NOT NULL, PENDING or CANCELED" datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL (미사용)" } - order_item { + order_items { bigint id PK "AUTO_INCREMENT" + varchar(36) order_item_id UK "NOT NULL, UUID" bigint order_id FK "NOT NULL, REFERENCES orders(id)" - bigint product_id FK "NOT NULL, REFERENCES product(id), 참조용" - varchar(200) product_name "NOT NULL, 스냅샷" - bigint brand_id "NULL, 참조용" - varchar(100) brand_name "NOT NULL, 스냅샷" - decimal(152) unit_price "NOT NULL, 스냅샷" + varchar(20) product_id "NOT NULL, 스냅샷(비즈니스 ID)" + varchar(100) product_name "NOT NULL, 스냅샷" + decimal(10_2) price "NOT NULL, 스냅샷" int quantity "NOT NULL, >= 1" - decimal(152) line_amount "NOT NULL, = unit_price * quantity" - varchar(500) image_url "NULL, 스냅샷" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL (미사용)" } ``` ### 해석 -- **Soft Delete**: brand, product 테이블에 `deleted_at` 컬럼 존재. 삭제 시 `deleted_at = NOW()` UPDATE. -- **스냅샷**: order_item이 product_name, brand_name, unit_price 등을 저장하여 product/brand 삭제에 독립적. -- **FK 관계**: product → brand, like → member/product, orders → member, order_item → orders/product (참조용). -- **UNIQUE 제약**: like 테이블에 (user_id, product_id) UNIQUE (다이어그램에서 명시 필요). +- **Soft Delete**: brands, products 테이블에 `deleted_at` 컬럼 존재. 삭제 시 `deleted_at = NOW()` UPDATE. +- **스냅샷**: order_items가 product_id(비즈니스 ID), product_name, price를 저장하여 products 삭제에 독립적. +- **BaseEntity 상속**: created_at, updated_at, deleted_at 컬럼은 모든 테이블에 포함 (JPA BaseEntity 상속). +- **likes 테이블**: ref_member_id, ref_product_id로 members/products를 간접 참조. Hard delete (물리 삭제). +- **orders/order_items**: 삭제 없음 (영구 보존). cascade로 order_items는 orders와 함께 관리. --- ## 테이블 상세 설계 -### 1. member (이미 구현됨) +### 1. members (이미 구현됨) 이 테이블은 이번 설계 범위 밖이지만, 참조 무결성을 위해 명시한다. | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 회원 ID | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | | member_id | VARCHAR(10) | NOT NULL, UNIQUE | 로그인 ID (영문+숫자, 1~10자) | | email | VARCHAR(320) | NOT NULL, UNIQUE | 이메일 (RFC 5322) | | birth_date | DATE | NOT NULL | 생년월일 | | password | VARCHAR(255) | NOT NULL | 비밀번호 해시 | -| created_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 생성 일시 | -| updated_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 수정 일시 | - -**제약**: -- PK: `id` +| created_at | DATETIME | NOT NULL | 생성 일시 | +| updated_at | DATETIME | NOT NULL | 수정 일시 | --- -### 2. brand +### 2. brands | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 ID | -| brand_name | VARCHAR(100) | NOT NULL | 브랜드명 | -| description | TEXT | NULL | 브랜드 설명 | -| logo_url | VARCHAR(500) | NULL | 로고 이미지 URL | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| brand_id | VARCHAR(10) | NOT NULL, UNIQUE | 브랜드 비즈니스 ID (영문+숫자, 1~10자) | +| brand_name | VARCHAR(50) | NOT NULL | 브랜드명 | | created_at | DATETIME | NOT NULL | 생성 일시 | | updated_at | DATETIME | NOT NULL | 수정 일시 | | deleted_at | DATETIME | NULL | 삭제 일시 (soft delete) | **제약**: - PK: `id` +- UK: `brand_id` **삭제 정책**: - Soft Delete: `deleted_at = NOW()` -- 연쇄 삭제: brand 삭제 시 product도 soft delete 처리 (애플리케이션 레벨) +- 연쇄 삭제: brand 삭제 시 products도 soft delete 처리 (애플리케이션 레벨 트랜잭션) --- -### 3. product +### 3. products | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 상품 ID | -| brand_id | BIGINT | NOT NULL | 브랜드 ID | -| product_name | VARCHAR(200) | NOT NULL | 상품명 | -| price | DECIMAL(15,2) | NOT NULL, CHECK (price >= 0) | 가격 | -| stock_qty | INT | NOT NULL, CHECK (stock_qty >= 0) | 재고 수량 | -| description | TEXT | NULL | 상품 설명 | -| image_url | VARCHAR(500) | NULL | 상품 이미지 URL | -| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | 상품 상태 (ACTIVE/INACTIVE/OUT_OF_STOCK) | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| product_id | VARCHAR(20) | NOT NULL, UNIQUE | 상품 비즈니스 ID (영문+숫자, 1~20자) | +| ref_brand_id | BIGINT | NOT NULL | 브랜드 시스템 ID 참조 | +| product_name | VARCHAR(100) | NOT NULL | 상품명 | +| price | DECIMAL(10,2) | NOT NULL, >= 0 | 가격 | +| stock_quantity | INT | NOT NULL, >= 0 | 재고 수량 | | created_at | DATETIME | NOT NULL | 생성 일시 | | updated_at | DATETIME | NOT NULL | 수정 일시 (latest 정렬 기준) | | deleted_at | DATETIME | NULL | 삭제 일시 (soft delete) | **제약**: - PK: `id` -- FK: `brand_id` REFERENCES `brand(id)` -- CHECK: `price >= 0`, `stock_qty >= 0` - -**재고 차감 동시성 제어**: -- 조건부 원자 UPDATE: - ```sql - UPDATE product - SET stock_qty = stock_qty - :quantity, updated_at = NOW() - WHERE id = :productId - AND deleted_at IS NULL - AND stock_qty >= :quantity; - ``` -- affected rows = 0이면 재고 부족 또는 삭제된 상품 +- UK: `product_id` +- FK: `ref_brand_id` → `brands(id)` + +**재고 차감 동시성 제어 (비관적 락)**: +```sql +-- 1단계: 비관적 락 획득 (트랜잭션 내) +SELECT * FROM products WHERE id = :productId FOR UPDATE; + +-- 2단계: 애플리케이션 레이어에서 재고 검증 +-- product.stockQuantity < quantity → throw CONFLICT + +-- 3단계: 재고 차감 +UPDATE products +SET stock_quantity = stock_quantity - :quantity +WHERE id = :productId; +``` +- `SELECT ... FOR UPDATE`로 행 잠금 → 검증 → 차감이 원자적으로 실행 +- productId 오름차순 정렬로 락 획득 순서 고정 (데드락 방지) **삭제 정책**: - Soft Delete: `deleted_at = NOW()` -- 조회 필터: 모든 조회 쿼리에 `deleted_at IS NULL` 조건 필수 +- 조회 필터: 모든 SELECT 쿼리에 `deleted_at IS NULL` 조건 필수 --- -### 4. like +### 4. likes | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 ID | -| user_id | BIGINT | NOT NULL | 사용자 ID | -| product_id | BIGINT | NOT NULL | 상품 ID | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| ref_member_id | BIGINT | NOT NULL | 회원 시스템 ID 참조 | +| ref_product_id | BIGINT | NOT NULL | 상품 시스템 ID 참조 | | created_at | DATETIME | NOT NULL | 좋아요 일시 | +| updated_at | DATETIME | NOT NULL | 수정 일시 | +| deleted_at | DATETIME | NULL | 미사용 (hard delete) | **제약**: - PK: `id` -- UK: `(user_id, product_id)` (중복 좋아요 방지) -- FK: `user_id` REFERENCES `member(id)` -- FK: `product_id` REFERENCES `product(id)` +- UK: `uk_likes_member_product(ref_member_id, ref_product_id)` — 중복 좋아요 방지 -**멱등성**: -- INSERT 시 UNIQUE 제약 위반 catch → 성공 처리 +**멱등성 (동시성 전략)**: +- 락 없음: 좋아요는 경합이 낮고 1건 중복은 비즈니스적으로 치명적이지 않음 +- 선조회로 중복 확인 후 INSERT 시도 +- 동시 요청으로 UNIQUE 위반 시: `DataIntegrityViolationException` catch → 재조회 → 멱등 성공 - DELETE 시 affected rows = 0 → 성공 처리 **좋아요 수 집계**: -- Phase 1: `SELECT COUNT(*) FROM like WHERE product_id = ?` -- Phase 2 (병목 시): product.like_count 컬럼 도입 (별도 DDL 필요) +- Phase 1 (현재): `SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId` +- Phase 2 (병목 시): products.like_count 컬럼 도입 (별도 DDL 필요) --- @@ -209,88 +210,88 @@ erDiagram | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 주문 ID | -| user_id | BIGINT | NOT NULL | 사용자 ID | -| total_amount | DECIMAL(15,2) | NOT NULL, CHECK (total_amount >= 0) | 총 주문 금액 | -| status | VARCHAR(20) | NOT NULL, DEFAULT 'PENDING' | 주문 상태 (PENDING/CANCELED) | -| ordered_at | DATETIME | NOT NULL | 주문 일시 | -| canceled_at | DATETIME | NULL | 취소 일시 | -| created_at | DATETIME | NOT NULL | 생성 일시 | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| order_id | VARCHAR(36) | NOT NULL, UNIQUE | 주문 UUID (비즈니스 ID) | +| ref_member_id | BIGINT | NOT NULL | 회원 시스템 ID 참조 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 (PENDING/CANCELED) | +| created_at | DATETIME | NOT NULL | 생성 일시 (= 주문 일시) | +| updated_at | DATETIME | NOT NULL | 수정 일시 (= 취소 일시) | +| deleted_at | DATETIME | NULL | 미사용 (영구 보존) | **제약**: - PK: `id` -- FK: `user_id` REFERENCES `member(id)` -- CHECK: `total_amount >= 0` +- UK: `order_id` +- FK: `ref_member_id` → `members(id)` **상태 전이**: -- PENDING → CANCELED (주문 취소) -- 추후 확장: PENDING → PAID → SHIPPED → COMPLETED +- `PENDING → CANCELED` (주문 취소) +- CANCELED 상태에서 cancel() 호출 시 멱등 성공 (예외 없음) **취소 정책**: -- status = PENDING인 경우에만 취소 가능 -- 취소 시 canceled_at = NOW(), status = CANCELED -- 재고 복구: order_item 기반으로 product.stock_qty += quantity +- PENDING 상태인 경우만 취소 가능 (도메인 OrderStatus.validateTransition) +- 취소 시 order_items 기반으로 products.stock_quantity 복구 --- -### 6. order_item +### 6. order_items | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 주문 항목 ID | -| order_id | BIGINT | NOT NULL | 주문 ID | -| product_id | BIGINT | NOT NULL | 상품 ID (참조용) | -| product_name | VARCHAR(200) | NOT NULL | 상품명 (스냅샷) | -| brand_id | BIGINT | NULL | 브랜드 ID (참조용) | -| brand_name | VARCHAR(100) | NOT NULL | 브랜드명 (스냅샷) | -| unit_price | DECIMAL(15,2) | NOT NULL | 단가 (스냅샷) | -| quantity | INT | NOT NULL, CHECK (quantity >= 1) | 수량 | -| line_amount | DECIMAL(15,2) | NOT NULL | 행 금액 (= unit_price × quantity) | -| image_url | VARCHAR(500) | NULL | 상품 이미지 URL (스냅샷) | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| order_item_id | VARCHAR(36) | NOT NULL, UNIQUE | 주문항목 UUID (비즈니스 ID) | +| order_id | BIGINT | NOT NULL | 주문 시스템 ID (FK) | +| product_id | VARCHAR(20) | NOT NULL | 상품 비즈니스 ID (스냅샷) | +| product_name | VARCHAR(100) | NOT NULL | 상품명 (스냅샷) | +| price | DECIMAL(10,2) | NOT NULL | 단가 (스냅샷) | +| quantity | INT | NOT NULL, >= 1 | 수량 | +| created_at | DATETIME | NOT NULL | 생성 일시 | +| updated_at | DATETIME | NOT NULL | 수정 일시 | +| deleted_at | DATETIME | NULL | 미사용 | **제약**: - PK: `id` -- FK: `order_id` REFERENCES `orders(id)` ON DELETE CASCADE -- FK: `product_id` REFERENCES `product(id)` (참조용, ON DELETE RESTRICT 또는 SET NULL 고려) -- CHECK: `quantity >= 1` +- UK: `order_item_id` +- FK: `order_id` → `orders(id)` ON DELETE CASCADE **스냅샷 정책**: -- **저장 필드**: product_name, brand_name, unit_price, quantity, line_amount, image_url -- **참조 필드**: product_id, brand_id (조인 최소화, 삭제 후에도 조회 가능) -- **제외 필드**: description (최소 스냅샷 원칙) - -**삭제 정책**: -- product 삭제 시: order_item.product_id는 유지 (FK ON DELETE RESTRICT 또는 SET NULL) -- 스냅샷 덕분에 product 삭제 후에도 주문 조회 가능 +- **저장 필드**: product_id(비즈니스 ID), product_name, price, quantity +- **총액 계산**: `price × quantity` (getTotalPrice() 메서드 - DB 컬럼 없음) +- **제외 필드**: brand_name, image_url (최소 스냅샷 원칙) +- 스냅샷이므로 product 삭제/수정 후에도 주문 이력 조회 가능 --- ## 제약 조건 요약 ### UNIQUE 제약 -| 테이블 | 컬럼 | 목적 | -|--------|------|------| -| member | member_id | 중복 로그인 ID 방지 | -| member | email | 중복 이메일 방지 | -| like | (user_id, product_id) | 중복 좋아요 방지 | + +| 테이블 | 제약명 | 컬럼 | 목적 | +|--------|--------|------|------| +| members | - | member_id | 중복 로그인 ID 방지 | +| members | - | email | 중복 이메일 방지 | +| brands | - | brand_id | 중복 브랜드 ID 방지 | +| products | - | product_id | 중복 상품 ID 방지 | +| likes | uk_likes_member_product | (ref_member_id, ref_product_id) | 중복 좋아요 방지 | +| orders | - | order_id | 중복 주문 ID 방지 | +| order_items | - | order_item_id | 중복 주문항목 ID 방지 | ### FK 제약 -| 자식 테이블 | 부모 테이블 | 삭제 정책 | -|-----------|-----------|----------| -| product | brand | RESTRICT (애플리케이션 레벨 soft delete) | -| like | member | CASCADE 또는 RESTRICT | -| like | product | CASCADE 또는 RESTRICT | -| orders | member | RESTRICT | -| order_item | orders | CASCADE | -| order_item | product | RESTRICT 또는 SET NULL | + +| 자식 테이블 | 컬럼 | 부모 테이블 | 삭제 정책 | +|-----------|------|-----------|----------| +| products | ref_brand_id | brands(id) | RESTRICT (애플리케이션 레벨 soft delete) | +| likes | ref_member_id | members(id) | RESTRICT | +| likes | ref_product_id | products(id) | RESTRICT | +| orders | ref_member_id | members(id) | RESTRICT | +| order_items | order_id | orders(id) | CASCADE | ### CHECK 제약 + | 테이블 | 제약 | 목적 | |--------|------|------| -| product | price >= 0 | 음수 가격 방지 | -| product | stock_qty >= 0 | 음수 재고 방지 | -| orders | total_amount >= 0 | 음수 금액 방지 | -| order_item | quantity >= 1 | 0 이하 수량 방지 | +| products | price >= 0 | 음수 가격 방지 | +| products | stock_quantity >= 0 | 음수 재고 방지 | +| order_items | quantity >= 1 | 0 이하 수량 방지 | --- @@ -299,136 +300,204 @@ erDiagram ### 인덱스 후보 우선순위 #### 1. 필수 인덱스 (P0) -- **product**: `(deleted_at, updated_at)` - latest 정렬 + soft delete 필터 (가장 빈번한 조회) -- **product**: `(brand_id, deleted_at)` - 브랜드별 상품 조회 -- **like**: `(user_id, product_id)` - UNIQUE 제약 + 좋아요 추가/취소 -- **orders**: `(user_id, ordered_at)` - 사용자별 주문 목록 + 기간 필터 -- **order_item**: `order_id` - 주문별 항목 조회 +- **products**: `(deleted_at, updated_at)` — latest 정렬 + soft delete 필터 +- **products**: `(ref_brand_id, deleted_at)` — 브랜드별 상품 조회 +- **likes**: `(ref_member_id, ref_product_id)` — UNIQUE 제약 (자동 인덱스) +- **likes**: `ref_product_id` — 상품별 좋아요 수 집계 +- **order_items**: `order_id` — 주문별 항목 조회 #### 2. 성능 개선 인덱스 (P1) -- **product**: `(deleted_at, price)` - price_asc 정렬 (사용 빈도 중간) -- **like**: `product_id` - 상품별 좋아요 수 집계 (likes_desc 정렬 시) -- **brand**: `deleted_at` - soft delete 필터링 +- **products**: `(deleted_at, price)` — price_asc 정렬 +- **brands**: `deleted_at` — soft delete 필터링 +- **orders**: `ref_member_id` — 사용자별 주문 목록 #### 3. 확장 인덱스 (P2, 병목 시 고려) -- **product**: `like_count` - likes_desc 정렬 성능 개선 (컬럼 추가 필요) -- **order_item**: `product_id` - 상품별 주문 이력 조회 (어드민 분석용) +- **products**: `like_count` — likes_desc 정렬 성능 개선 (컬럼 추가 필요) ### 복합 인덱스 설계 근거 -- **(deleted_at, updated_at)**: WHERE deleted_at IS NULL AND ORDER BY updated_at DESC -- **(brand_id, deleted_at)**: WHERE brand_id = ? AND deleted_at IS NULL -- **(user_id, ordered_at)**: WHERE user_id = ? AND ordered_at BETWEEN ? AND ? ORDER BY ordered_at DESC +- `(deleted_at, updated_at)`: `WHERE deleted_at IS NULL ORDER BY updated_at DESC` +- `(ref_brand_id, deleted_at)`: `WHERE ref_brand_id = ? AND deleted_at IS NULL` +- `(ref_member_id, ref_product_id)`: UNIQUE 제약이므로 자동 생성 --- ## 상태 및 삭제 정책 ### Soft Delete 정책 -- **대상 테이블**: brand, product -- **구현**: `deleted_at DATETIME NULL` -- **삭제 동작**: `UPDATE {table} SET deleted_at = NOW() WHERE id = ?` +- **대상 테이블**: brands, products +- **구현**: `deleted_at DATETIME NULL` (BaseEntity 상속) +- **삭제 동작**: `markAsDeleted()` → `delete()` → `deletedAt = LocalDateTime.now()` - **조회 필터**: 모든 SELECT 쿼리에 `deleted_at IS NULL` 조건 필수 -- **연쇄 삭제**: brand 삭제 시 해당 brand_id의 모든 product도 soft delete (애플리케이션 레벨 트랜잭션) +- **연쇄 삭제**: brand 삭제 시 해당 ref_brand_id의 모든 products도 soft delete (애플리케이션 트랜잭션) ### Hard Delete 대상 -- **like**: 삭제 시 물리 삭제 (이력 불필요) -- **order/order_item**: 삭제하지 않음 (영구 보존) +- **likes**: 물리 삭제 (이력 불필요, `likeRepository.delete(like)`) + +### 삭제 없음 (영구 보존) +- **orders, order_items**: 주문 이력은 삭제하지 않음 ### 상태 전이 -- **product.status**: ACTIVE ↔ INACTIVE, ACTIVE → OUT_OF_STOCK -- **orders.status**: PENDING → CANCELED (이번 범위, 추후 확장 가능) +- **orders.status**: `PENDING → CANCELED` (취소) --- ## 데이터 정합성 규칙 -### 재고 일관성 -- **강한 일관성**: 조건부 원자 UPDATE로 재고 음수 방지 -- **동시성 제어**: WHERE stock_qty >= :quantity -- **데드락 방지**: productId 오름차순 정렬로 락 순서 고정 +### 재고 일관성 (비관적 락) -### 주문 스냅샷 -- **불변성**: order_item은 생성 후 수정 불가 (INSERT만) -- **독립성**: product/brand 삭제 후에도 order_item 조회 가능 +``` +전략: SELECT ... FOR UPDATE + 애플리케이션 검증 + UPDATE + +트랜잭션 내 처리 순서: +1. 상품 목록을 productId 오름차순 정렬 (데드락 방지) +2. 각 상품에 대해: + a. SELECT * FROM products WHERE id = :productId FOR UPDATE; (비관적 락) + b. 재고 검증: stockQuantity >= requestedQuantity + c. 재고 부족 → 예외 발생 → 트랜잭션 전체 롤백 (모든 락 해제) + d. UPDATE products SET stock_quantity = stock_quantity - :qty WHERE id = :id; +3. OrderModel + OrderItemModel 저장 +4. 트랜잭션 커밋 (모든 비관적 락 해제) + +장점: 명시적 직렬화, 명확한 재고 검증 로직 +단점: 락 보유 시간 증가, 높은 경합 시 처리량 감소 +``` -### 좋아요 중복 방지 -- **DB 레벨**: UNIQUE (user_id, product_id) -- **애플리케이션 레벨**: DuplicateKeyException catch → 멱등 성공 +### 좋아요 중복 방지 (DB 제약) + +``` +전략: UNIQUE 제약 + 예외 catch (락 없음) + +처리 순서: +1. findByRefMemberIdAndRefProductId 선조회 +2. 없으면 INSERT 시도 +3. DataIntegrityViolationException 발생 시 재조회하여 멱등 성공 +4. 있으면 기존 반환 + +장점: 락 없음, 높은 처리량 +단점: 동시 요청 시 예외 발생 후 재조회 오버헤드 (극히 드문 케이스) +``` + +### 주문 스냅샷 불변성 +- order_items는 생성 후 수정 불가 (INSERT only) +- product/brand 삭제 후에도 주문 이력 조회 가능 --- ## 성능 최적화 고려사항 ### 1. 좋아요 수 집계 (likes_desc 정렬) -- **Phase 1**: COUNT 서브쿼리 (정확성 우선) - ```sql - SELECT p.*, (SELECT COUNT(*) FROM like WHERE product_id = p.id) AS like_count - FROM product p - WHERE deleted_at IS NULL - ORDER BY like_count DESC; - ``` -- **Phase 2**: like_count 컬럼 도입 (성능 우선) - - DDL: `ALTER TABLE product ADD COLUMN like_count INT NOT NULL DEFAULT 0;` - - 집계: INSERT/DELETE like 시 +1/-1 (약한 일관성 허용) - - 인덱스: `(deleted_at, like_count)` + +**Phase 1 (현재 구현)**: LEFT JOIN + COUNT +```sql +SELECT p.* +FROM products p +LEFT JOIN likes l ON p.id = l.ref_product_id +WHERE p.deleted_at IS NULL +[AND p.ref_brand_id = :refBrandId] +GROUP BY p.id +ORDER BY COUNT(l.id) DESC, p.updated_at DESC; +``` + +**Phase 2 (병목 시)**: like_count 컬럼 도입 +```sql +ALTER TABLE products ADD COLUMN like_count INT NOT NULL DEFAULT 0; +-- like INSERT/DELETE 시 +1/-1 (약한 일관성 허용) +-- 인덱스: (deleted_at, like_count) +``` ### 2. Soft Delete 필터 성능 -- **문제**: deleted_at IS NULL 조건이 모든 쿼리에 필요 -- **완화**: deleted_at에 인덱스 생성, 또는 복합 인덱스 활용 -- **JPA 전역 필터**: `@Where(clause = "deleted_at IS NULL")` 또는 QueryDSL BooleanExpression +- `deleted_at IS NULL` 조건이 모든 쿼리에 필요 +- 복합 인덱스 `(deleted_at, 정렬컬럼)` 활용 -### 3. 주문 목록 조회 성능 -- **인덱스**: (user_id, ordered_at)로 기간 필터 + 정렬 최적화 -- **페이징**: LIMIT/OFFSET 또는 Cursor 기반 페이징 +### 3. Facade Enrichment N+1 주의 +- 현재: 상품 목록에서 각 상품별 Brand 조회 + 좋아요 수 조회 (N+1 위험) +- 개선 방향: 단일 쿼리로 JOIN하여 통합 조회 --- ## DDL 예시 -### product 테이블 +### brands 테이블 + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id VARCHAR(10) NOT NULL, + brand_name VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_brand_id (brand_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### products 테이블 ```sql -CREATE TABLE product ( +CREATE TABLE products ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - brand_id BIGINT NOT NULL, - product_name VARCHAR(200) NOT NULL, - price DECIMAL(15,2) NOT NULL CHECK (price >= 0), - stock_qty INT NOT NULL CHECK (stock_qty >= 0), - description TEXT, - image_url VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + product_id VARCHAR(20) NOT NULL, + ref_brand_id BIGINT NOT NULL, + product_name VARCHAR(100) NOT NULL, + price DECIMAL(10,2) NOT NULL CHECK (price >= 0), + stock_quantity INT NOT NULL CHECK (stock_quantity >= 0), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at DATETIME + deleted_at DATETIME, + UNIQUE KEY uk_product_id (product_id), + KEY idx_ref_brand_deleted (ref_brand_id, deleted_at), + KEY idx_deleted_updated (deleted_at, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` -### like 테이블 +### likes 테이블 ```sql -CREATE TABLE `like` ( +CREATE TABLE likes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, + ref_member_id BIGINT NOT NULL, + ref_product_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_product (user_id, product_id) + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_likes_member_product (ref_member_id, ref_product_id), + KEY idx_ref_product_id (ref_product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` -### order_item 테이블 +### orders 테이블 ```sql -CREATE TABLE order_item ( +CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id VARCHAR(36) NOT NULL, + ref_member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_order_id (order_id), + KEY idx_ref_member_id (ref_member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### order_items 테이블 + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_item_id VARCHAR(36) NOT NULL, order_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - product_name VARCHAR(200) NOT NULL, - brand_id BIGINT, - brand_name VARCHAR(100) NOT NULL, - unit_price DECIMAL(15,2) NOT NULL, + product_id VARCHAR(20) NOT NULL, + product_name VARCHAR(100) NOT NULL, + price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL CHECK (quantity >= 1), - line_amount DECIMAL(15,2) NOT NULL, - image_url VARCHAR(500) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_order_item_id (order_item_id), + KEY idx_order_id (order_id), + CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` @@ -437,16 +506,16 @@ CREATE TABLE order_item ( ## 확장 고려사항 ### 1. 좋아요 수 캐싱 (Phase 2) -- **컬럼 추가**: `ALTER TABLE product ADD COLUMN like_count INT NOT NULL DEFAULT 0;` -- **동기화**: like INSERT/DELETE 시 product.like_count +1/-1 +- **컬럼 추가**: `ALTER TABLE products ADD COLUMN like_count INT NOT NULL DEFAULT 0;` +- **동기화**: like INSERT/DELETE 시 products.like_count +1/-1 - **정합성**: 약한 일관성 허용 (Eventual Consistency) ### 2. 주문 상태 확장 - **현재**: PENDING, CANCELED - **확장**: PAID, SHIPPED, COMPLETED, REFUNDED -- **전이 규칙**: 상태 다이어그램 정의 필요 +- **전이 규칙**: OrderStatus.validateTransition에서 상태 다이어그램 정의 -### 3. 상품 이력 (History Table) -- **목적**: 가격 변경 이력 추적 -- **테이블**: product_history (product_id, price, changed_at) -- **트리거**: product UPDATE 시 이력 INSERT \ No newline at end of file +### 3. 재고 락 방식 전환 가능성 +- **현재**: 비관적 락 (`SELECT ... FOR UPDATE`) +- **고트래픽 시 대안**: 낙관적 락 (`@Version`) 또는 Redis 분산 락 +- **전환 시점**: 재고 차감 병목이 확인된 후 결정 diff --git a/docs/design/05-component-diagram.md b/docs/design/05-component-diagram.md new file mode 100644 index 000000000..2f58a980d --- /dev/null +++ b/docs/design/05-component-diagram.md @@ -0,0 +1,425 @@ +# 컴포넌트 다이어그램 + +## 개요 + +이 문서는 레이어드 아키텍처의 컴포넌트 구조와 의존성 관계를 PlantUML로 표현한다. 컴포넌트 다이어그램은 **패키지 구조**, **컴포넌트 책임**, **의존성 방향**을 중심으로 작성되며, 클래스 다이어그램(03-class-diagram.md)을 보완한다. + +**핵심 검증 포인트**: +1. 레이어 간 의존성 방향 (Interfaces → Application → Domain ← Infrastructure) +2. 컴포넌트 간 결합도 (느슨한 결합, 인터페이스 기반) +3. 각 컴포넌트의 단일 책임 준수 + +**표기법**: +- `[Component]`: 컴포넌트 (클래스 또는 인터페이스) +- `-->`: 의존성 (사용 관계) +- `..>`: 약한 의존성 (생성, 반환) +- `..|>`: 구현 관계 + +--- + +## 예시: Enrollment 도메인 컴포넌트 다이어그램 + +다음은 Enrollment/Course/Student 도메인의 컴포넌트 다이어그램 예시이다. 이 패턴을 Member 도메인에 적용한다. + +```plantuml +@startuml +skinparam componentStyle rectangle +top to bottom direction + +package "Presentation Layer\n(interfaces)" { + [EnrollmentController] as Controller + [EnrollmentRequest] as Request + [EnrollmentResponse] as Response + note right of Controller + 역할: + - HTTP 요청/응답 + - Request → Facade 파라미터 변환 + - Result VO → Response 변환 + end note +} + +package "Application Layer\n(application)" { + [EnrollmentApp] as App + [EnrollmentResult] as ResultVO + note right of App + 역할 (App): + - @Transactional 관리 + - Domain → Result VO 변환 + - Domain Service 조율만 + (단일 도메인 유스케이스) + end note +} + +package "Domain Layer\n(domain)" { + [EnrollmentService] as DomainService + [Enrollment] as EnrollmentEntity + [Course] as CourseEntity + [Student] as StudentEntity + interface "EnrollmentRepository" as EnrollmentRepo + interface "CourseRepository" as CourseRepo + interface "StudentRepository" as StudentRepo + + note right of DomainService + 비즈니스 로직: + - validateCapacity() + - validateCreditLimit() + - validateScheduleConflict() + - enroll() → Enrollment 반환 + end note + + note right of CourseEntity + 도메인 메서드: + - isFull() + - incrementEnrolledCount() + end note +} + +package "Infrastructure Layer\n(infrastructure)" { + [EnrollmentRepositoryImpl] as EnrollmentRepoImpl + [EnrollmentJpaRepository] as EnrollmentJpa + [CourseRepositoryImpl] as CourseRepoImpl + [StudentRepositoryImpl] as StudentRepoImpl +} + +' --- Requests flow (올바른 의존성 방향) --- +Controller --> App : 호출 +Controller --> Request : 사용 +Controller ..> Response : 생성 (from Result) + +' --- App to Domain Service --- +App --> DomainService : 호출 +App ..> ResultVO : 생성 (from Entity) + +' --- Domain Service to Repository --- +DomainService --> EnrollmentRepo : 의존 +DomainService --> CourseRepo : 의존 +DomainService --> StudentRepo : 의존 +DomainService --> EnrollmentEntity : 생성 +DomainService --> CourseEntity : 사용 + +' --- Domain relationships --- +EnrollmentEntity --> StudentEntity : 연관 +EnrollmentEntity --> CourseEntity : 연관 + +' --- Infrastructure implements Domain --- +EnrollmentRepoImpl ..|> EnrollmentRepo : 구현 +CourseRepoImpl ..|> CourseRepo : 구현 +StudentRepoImpl ..|> StudentRepo : 구현 + +EnrollmentRepoImpl --> EnrollmentJpa : 위임 + +@enduml +``` + +--- + +## Member 도메인 컴포넌트 다이어그램 + +### 검증 목적 +Member 도메인의 레이어드 아키텍처 구조를 확인한다. Controller → App → Service → Repository 의존성 흐름이 명확히 드러나야 하며, App이 Service만 호출하고 Reader를 직접 호출하지 않는지 검증한다. + +### 다이어그램 + +```plantuml +@startuml +skinparam componentStyle rectangle +skinparam linetype ortho +skinparam backgroundColor #FEFEFE + +package "Presentation Layer\n(interfaces.api.member)" #E3F2FD { + [MemberV1Controller] as Controller + note right of Controller + **역할:** + - HTTP request/response 처리 + - 인증 헤더 추출 + - DTO ↔ Info 변환 + + **엔드포인트:** + - POST /api/v1/members/register + - GET /api/v1/members/me + - PATCH /api/v1/members/me/password + end note + + package "DTOs (record)" { + [RegisterRequest] as RegReq + [MemberResponse] as MemResp + [MeResponse] as MeResp + [ChangePasswordRequest] as ChgPwdReq + } +} + +package "Application Layer\n(application.member)" #FFF3E0 { + [MemberApp] as App + note right of App + **역할 (App — 단일 도메인):** + - Service 조합 + - Model → Info 변환 + - 유스케이스 경계 + + **패턴:** + - Service만 호출 (Reader 직접 호출 금지) + - Info 반환 (Model 노출 금지) + - 크로스 도메인은 Facade에 위임 + end note + + [MemberInfo] as Info + note right of Info + **타입:** record (불변) + + **필드:** + - id, memberId, email + - birthDate, name, gender + + **팩토리:** + - from(MemberModel) + end note +} + +package "Domain Layer\n(domain.member)" #E8F5E9 { + [MemberService] as Service + note right of Service + **책임:** + - 비즈니스 로직 + - 교차 엔티티 규칙 + - @Transactional 경계 + + **메서드:** + - register(...) + - authenticate(...) + - changePassword(...) + end note + + [MemberModel] as Model + note right of Model + **타입:** JPA Entity + + **도메인 행위:** + - verifyPassword() + - changePassword() + + **팩토리:** + - create(...) + end note + + [MemberReader] as Reader + note right of Reader + **책임:** + - 읽기 전용 조회 + - getOrThrow 패턴 + - 존재 확인 (exists) + end note + + interface "MemberRepository" as Repo + note right of Repo + **Port (인터페이스)** + 영속화 추상화 + end note +} + +package "Infrastructure Layer\n(infrastructure.member)" #F3E5F5 { + [MemberRepositoryImpl] as RepoImpl + note right of RepoImpl + **Adapter** + Domain Repository 구현 + end note + + [MemberJpaRepository] as JpaRepo + note right of JpaRepo + **Spring Data JPA** + extends JpaRepository + end note +} + +' === 의존성 흐름 (레이어 간) === +Controller --> App : 호출 +Controller ..> RegReq : 사용 +Controller ..> MemResp : 반환 +Controller ..> MeResp : 반환 +Controller ..> ChgPwdReq : 사용 + +App --> Service : 조율 +App ..> Info : 반환 + +Service --> Reader : 조회 +Service --> Repo : 영속화 +Service ..> Model : 조작 + +Reader --> Repo : 조회 +Reader ..> Model : 로드 + +Repo <|.. RepoImpl : 구현 +RepoImpl --> JpaRepo : 위임 +RepoImpl ..> Model : 로드/저장 + +@enduml +``` + +### 해석 + +**레이어 흐름**: +- **Controller**: HTTP 요청 수신 → App 호출 → Info 수신 → DTO 변환하여 응답 +- **App**: Service 호출 → Model 수신 → Info 변환하여 반환 (단일 도메인 유스케이스) +- **Service**: 비즈니스 로직 실행 → Reader/Repository 사용 → Model 반환 +- **Reader**: Repository 사용 → 조회 전용 → Model 반환 +- **Repository**: 영속화 추상화 (Port) +- **RepositoryImpl**: Repository 구현 (Adapter) → JpaRepository 위임 + +**핵심 패턴**: +1. **App은 Service만 호출**: Reader를 직접 호출하지 않음 (Service가 Reader 소유) +2. **Info 변환**: App에서 Model → Info 변환 (레이어 격리) +3. **Port-Adapter**: Domain의 Repository(interface)를 Infrastructure의 RepositoryImpl이 구현 +4. **DTO vs Info**: DTO는 HTTP 계층, Info는 Application 계층 (서로 다른 관심사) +5. **App vs Facade**: 단일 도메인은 App, 2개 이상 App 조합 시에만 Facade + +--- + +## 설계 원칙 + +### 1. App / Facade Pattern (Application Layer) + +**App 원칙 (기본 패턴)**: +- App은 단일 도메인 유스케이스를 담당 +- Service만 호출, Reader 직접 호출 금지 +- Model → Info 변환 담당 +- Controller는 단일 도메인 처리 시 App을 직접 호출 + +**Facade 원칙 (크로스 도메인)**: +- Facade는 **2개 이상의 App**을 조합할 때만 사용 +- App → App 의존은 금지이므로 크로스 도메인은 Facade 책임 +- Facade는 Service를 직접 호출하지 않고 App 경유 + +**Info 변환**: +- App이 Model → Info 변환 담당 (레이어 격리) +- Controller는 Info를 알지만 Model은 모름 +- Domain Model이 Presentation Layer에 노출되지 않음 + +**트랜잭션 경계**: +- App 메서드 또는 Facade 메서드가 @Transactional 경계 +- 유스케이스 단위로 트랜잭션 관리 + +**변환 흐름**: +``` +단일 도메인: Controller → App → Service → Repository +크로스 도메인: Controller → Facade → App(복수) → Service → Repository +``` + +--- + +### 2. DTO vs Info vs Model + +| 타입 | 레이어 | 목적 | 특징 | +|-----|--------|------|------| +| **DTO** | Presentation | HTTP 요청/응답 | Jakarta Validation, record 타입 | +| **Info** | Application | 유스케이스 결과 | record 타입, 불변, from(Model) 팩토리 | +| **Model** | Domain | 도메인 엔티티 | JPA Entity, 도메인 행위 메서드 | + +**분리 이유**: +- **DTO**: HTTP 프로토콜 의존 (헤더, 쿼리 파라미터 등) +- **Info**: 애플리케이션 유스케이스 결과 (도메인 독립적) +- **Model**: 비즈니스 규칙과 영속화 (인프라 독립적) + +**변환 흐름**: +``` +HTTP Request → DTO → Facade(파라미터) → Service +Service → Model → Facade → Info → Controller → DTO → HTTP Response +``` + +--- + +### 3. Repository Pattern (Port-Adapter) + +**Port (인터페이스)**: +- Domain 레이어에 정의 (MemberRepository) +- 영속화 추상화 (기술 독립적) +- Domain이 Infrastructure를 의존하지 않음 + +**Adapter (구현체)**: +- Infrastructure 레이어에 구현 (MemberRepositoryImpl) +- JpaRepository에 위임 +- JPA 기술 세부사항 캡슐화 + +**의존성 역전**: +``` +Domain (Repository interface) <--- Infrastructure (RepositoryImpl) +``` +- Domain이 Infrastructure를 모름 +- Infrastructure가 Domain 인터페이스를 구현 + +--- + +### 4. Reader vs Service 분리 + +**Reader 책임**: +- 읽기 전용 조회 +- getOrThrow 패턴 (조회 + 예외 통합) +- exists 체크 + +**Service 책임**: +- CUD (Create, Update, Delete) +- 비즈니스 규칙 (중복 체크, 검증) +- @Transactional 관리 + +**관계**: +- Service가 Reader를 의존 (Service가 Reader 소유) +- Facade는 Service만 호출 (Reader 직접 호출 금지) + +--- + +## 레이어별 책임 정리 + +| 레이어 | 패키지 | 컴포넌트 | 책임 | +|--------|--------|----------|------| +| **Presentation** | interfaces.api.member | MemberV1Controller | HTTP 엔드포인트, 인증 헤더 추출, DTO ↔ Info 변환 | +| | | DTOs (record) | 요청/응답 데이터, Jakarta Validation | +| **Application** | application.member | MemberApp | 단일 도메인 유스케이스, Service 호출, Model → Info 변환 | +| | | (크로스 도메인 시) XxxFacade | 2개 이상 App 조합, 크로스 도메인 오케스트레이션 | +| | | MemberInfo (record) | 애플리케이션 결과 VO, 불변 | +| **Domain** | domain.member | MemberService | 비즈니스 로직, 트랜잭션 경계, 교차 엔티티 규칙 | +| | | MemberReader | 읽기 전용 조회, getOrThrow 패턴 | +| | | MemberModel | JPA Entity, 도메인 행위 메서드 (verifyPassword, changePassword) | +| | | MemberRepository (interface) | 영속화 추상화 (Port) | +| **Infrastructure** | infrastructure.member | MemberRepositoryImpl | Repository 구현 (Adapter) | +| | | MemberJpaRepository | Spring Data JPA, extends JpaRepository | + +--- + +## 기존 다이어그램과의 관계 + +### 02-sequence-diagrams.md (시퀀스 다이어그램) +- **시퀀스**: 동적 흐름 (시간 순서, 메서드 호출 순서) +- **컴포넌트**: 정적 구조 (의존성 관계, 패키지 구조) +- **보완 관계**: 시퀀스는 "어떻게 동작하는가", 컴포넌트는 "어떻게 구성되는가" + +### 03-class-diagram.md (클래스 다이어그램) +- **클래스**: 클래스 상세 (필드, 메서드, 타입) +- **컴포넌트**: 컴포넌트 책임 (레이어, 의존성, 역할) +- **보완 관계**: 클래스는 "무엇을 가지는가", 컴포넌트는 "왜 분리되는가" + +### 04-erd.md (ERD) +- **ERD**: 데이터베이스 테이블 구조 (컬럼, FK, 인덱스) +- **컴포넌트**: 애플리케이션 레이어 구조 (컴포넌트, 의존성) +- **연결 고리**: Infrastructure 레이어의 JpaRepository가 ERD 테이블과 매핑 + +--- + +## 검증 체크리스트 + +- [x] 레이어 간 의존성 방향: Interfaces → Application → Domain ← Infrastructure +- [x] 단일 도메인 유스케이스는 App 사용 (Facade 금지) +- [x] Facade는 2개 이상의 App을 조합할 때만 사용 +- [x] App은 Service만 호출 (Reader 직접 호출 금지) +- [x] Facade는 App만 호출 (Service 직접 호출 금지) +- [x] Info는 Application 레이어 (Model은 Domain 레이어) +- [x] Repository는 Domain에 정의 (Port), Infrastructure에 구현 (Adapter) +- [x] Controller는 Model을 모름 (Info만 알음) +- [x] 모든 컴포넌트가 단일 책임 원칙 준수 + +--- + +## 다음 단계 + +이 컴포넌트 다이어그램을 기반으로: +- **구현**: MemberApp, MemberInfo 구현 +- **테스트**: App 단위 테스트 (Service 모킹) +- **확장**: 다른 도메인에도 동일한 패턴 적용 (Product, Order 등) +- **크로스 도메인**: 2개 이상 App 조합이 필요한 경우에만 Facade 추가 diff --git a/gradle.properties b/gradle.properties index 142d7120f..8ec36075b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,5 +14,6 @@ springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 instancioJUnitVersion=5.0.2 +archunitVersion=1.3.0 slackAppenderVersion=1.6.1 kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m diff --git a/modules/security/build.gradle.kts b/modules/security/build.gradle.kts new file mode 100644 index 000000000..ac6d65309 --- /dev/null +++ b/modules/security/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + // Spring Security for BCrypt + api("org.springframework.security:spring-security-crypto") +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/config/SecurityConfig.java b/modules/security/src/main/java/com/loopers/config/SecurityConfig.java similarity index 63% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/config/SecurityConfig.java rename to modules/security/src/main/java/com/loopers/config/SecurityConfig.java index 40f0996c7..04df98327 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/config/SecurityConfig.java +++ b/modules/security/src/main/java/com/loopers/config/SecurityConfig.java @@ -1,12 +1,13 @@ -package com.loopers.infrastructure.config; +package com.loopers.config; -import com.loopers.domain.member.PasswordHasher; -import com.loopers.infrastructure.security.BCryptPasswordHasher; +import com.loopers.security.BCryptPasswordHasher; +import com.loopers.security.PasswordHasher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SecurityConfig { + @Bean public PasswordHasher passwordHasher() { return new BCryptPasswordHasher(); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordHasher.java b/modules/security/src/main/java/com/loopers/security/BCryptPasswordHasher.java similarity index 55% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordHasher.java rename to modules/security/src/main/java/com/loopers/security/BCryptPasswordHasher.java index 267037546..c51fbd1a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordHasher.java +++ b/modules/security/src/main/java/com/loopers/security/BCryptPasswordHasher.java @@ -1,6 +1,5 @@ -package com.loopers.infrastructure.security; +package com.loopers.security; -import com.loopers.domain.member.PasswordHasher; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -9,12 +8,12 @@ public class BCryptPasswordHasher implements PasswordHasher { private static final PasswordEncoder encoder = new BCryptPasswordEncoder(); @Override - public String hash(String raw) { - return encoder.encode(raw); + public String hash(String rawPassword) { + return encoder.encode(rawPassword); } @Override - public boolean matches(String raw, String hashed) { - return encoder.matches(raw, hashed); + public boolean matches(String rawPassword, String hashedPassword) { + return encoder.matches(rawPassword, hashedPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordHasher.java b/modules/security/src/main/java/com/loopers/security/PasswordHasher.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordHasher.java rename to modules/security/src/main/java/com/loopers/security/PasswordHasher.java index 860d0c079..265a680bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordHasher.java +++ b/modules/security/src/main/java/com/loopers/security/PasswordHasher.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.security; public interface PasswordHasher { String hash(String rawPassword); diff --git a/settings.gradle.kts b/settings.gradle.kts index a2c303835..ff027592f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( ":modules:jpa", ":modules:redis", ":modules:kafka", + ":modules:security", ":supports:jackson", ":supports:logging", ":supports:monitoring",