diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..bd42c9782 --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,142 @@ +# 01-requirements.md +> 루프팩 감성 이커머스 – 요구사항 및 유스케이스 명세서 + +--- + +## 📘 1. 개요 +루프팩 이커머스는 여러 브랜드의 상품을 한 번에 주문하고, 좋아요와 포인트 결제를 통해 감성적 쇼핑 경험을 제공하는 플랫폼이다. 본 문서는 사용자 중심 시나리오 기반으로 **기능 요구사항**과 **유스케이스**를 정의하며, 이후 UML(시퀀스, 클래스, ERD)으로 확장된다. + +--- + +## 👥 2. 주요 액터 +| 액터 | 설명 | +|------|------| +| **User (사용자)** | 상품 탐색, 좋아요, 주문 등 주요 행위를 수행하는 주체 | +| **System (이커머스 시스템)** | 포인트 및 재고를 관리하고 주문 정보를 처리 | +| **External Service (외부 연동 시스템)** | 주문 정보를 전달받는 외부 서비스(Mock 처리 가능) | + +--- + +## 🧾 3. 유스케이스 목록 +| UC ID | 유스케이스명 | 주요 액터 | 설명 | 관계 | +|------|---------------|----------|------|------| +| UC-01 | 상품 목록 조회 | User | 브랜드/정렬 기준별 상품 목록을 조회한다 | - | +| UC-02 | 상품 상세 조회 | User | 특정 상품의 상세 정보를 확인한다 | includes(UC-01) | +| UC-03 | 상품 좋아요 등록/취소 | User | 상품에 좋아요를 누르거나 취소한다 | - | +| UC-04 | 주문 생성 | User | 여러 상품을 선택해 포인트로 결제하고 주문을 생성한다 | include(UC-02) | +| UC-04-1 | 포인트 결제 처리 | System | 주문 생성 중 포인트 차감을 수행한다 | include(UC-04) | +| UC-04-2 | 재고 차감 처리 | System | 주문 생성 중 상품 재고를 차감한다 | include(UC-04) | +| UC-04-3 | 외부 주문 전송 | System/External | 주문 생성 후 외부 시스템에 전송한다 | include(UC-04) | +| UC-05 | 주문 내역 조회 | User | 사용자의 주문 이력을 확인한다 | - | +| UC-06 | 단일 주문 상세 조회 | User | 특정 주문의 상세 정보를 확인한다 | include(UC-05) | + +--- + +## 🎛️ 3-1. 유스케이스 다이어그램 +유스케이스 다이어그램 + +--- + +## 🧩 4. 유스케이스 명세 + +### 🛍 UC-01 상품 목록 조회 +| 구분 | 내용 | +|------|------| +| **액터** | User | +| **기본 시나리오** | 1) 사용자가 상품 목록 페이지에 접근한다.
2) 정렬(latest/price_asc/likes_desc)과 필터(brandId)를 선택한다.
3) 시스템은 조건에 맞는 상품 목록을 반환한다.
4) 각 상품의 좋아요 수를 함께 표시한다. | +| **대안 시나리오** | 4a. 사용자가 로그인 된 경우 본인이 좋아요 했는지를 함께 표시한다. +| **예외 시나리오** | • 등록된 상품이 없는 경우 빈 배열 반환한다.
• 정렬 기준이 유효하지 않으면 기본(latest)으로 조회한다 | +| **후조건** | 상품 목록이 사용자 화면에 표시된다. | + +--- + +### 📄 UC-02 상품 상세 조회 +| 구분 | 내용 | +|------|------| +| **액터** | User | +| **사전조건** | • 요청 시 상품 ID가 파라미터로 전달된다.
• (선택) 사용자가 로그인된 상태이다.| +| **기본 시나리오** | 1) 사용자는 상품 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.
2) 시스템은 상품명, 가격, 재고, 좋아요 수, 브랜드 정보를 반환한다. | +| **예외 시나리오** | • 상품 ID가 존재하지 않거나 삭제된 상품인 경우 "상품을 찾을 수 없습니다" 메시지 반환
• 상품이 비공개 상태일 경우 "접근할 수 없는 상품입니다" 메시지 반환
• 상품 ID 형식이 잘못된 경우 "잘못된 요청입니다" 메시지 반환 | +| **후조건** | 상품 상세 정보가 표시된다. | + +--- + +### ❤️ UC-03 상품 좋아요 등록/취소 +| 구분 | 내용 | +|------|------| +| **액터** | User | +| **사전조건** | • 사용자는 로그인 상태이다. | +| **기본 시나리오** | 1) 사용자가 상세/목록 페이지에서 좋아요 버튼을 클릭한다.
2) 시스템은 해당 사용자의 좋아요 등록/취소를 처리한다.
3) 현재 좋아요 상태(좋아요 함/안 함)와 총 좋아요 수를 반환한다. | +| **예외 시나리오** | • 이미 좋아요한 상품에 다시 좋아요 요청 시 현재 상태 유지 (중복 방지)
• 좋아요하지 않은 상품에 취소 요청 시 현재 상태 유지 (중복 방지)
• 시스템 오류 시 요청 재시도 가능 | +| **후조건** | 좋아요 상태가 변경되고, 상품 목록/상세 정보에 반영된다. | +| **비고** | 동일 사용자는 동일 상품에 대해 하나의 좋아요만 등록 가능 (중복 방지) | + +--- + +### 🛒 UC-04 주문 생성 +| 구분 | 내용 | +|------|------| +| **액터** | User | +| **사전조건** | 상품이 존재하고 재고 및 포인트가 충분해야 함 | +| **기본 시나리오** | 1) 사용자가 여러 상품을 선택해 주문을 요청한다.
2) 시스템은 주문할 상품의 재고가 충분한지 확인한다.
3) 시스템은 사용자의 포인트 잔액이 주문 금액보다 충분한지 확인한다.
4) 모든 검증이 통과되면 주문 정보를 생성하고 재고를 차감한다.
5) 주문 금액만큼 포인트를 차감하고 주문 상태를 "완료"로 변경한다.
6) 주문 생성 결과(주문번호, 결제금액, 잔여 포인트)를 사용자에게 반환한다. | +| **예외 시나리오** | • 포인트 부족 → "결제 실패" 응답 및 포인트 충전 안내 메시지 표시
• 재고 부족 → "주문 불가 상품" 메시지 반환 및 재고 부족 안내
• 시스템 오류 → 주문은 저장되지만 상태를 "처리 중"으로 표시하여 나중에 재처리 가능 | +| **후조건** | 포인트와 재고가 차감되고, 주문 내역이 생성된다. | +| **비고** | UC-04-1~3을 포함하며, 모든 단계가 성공해야 주문이 완료되고, 중간에 실패하면 모든 변경사항이 취소됨. | + +#### UC-04-1 포인트 결제 처리 +| 항목 | 내용 | +|------|------| +| **액터** | System | +| **기능 요약** | 사용자의 포인트 잔액을 검증 후 주문 금액만큼 차감한다. | +| **예외 시나리오** | 포인트 잔액이 주문 금액보다 부족한 경우 "포인트 부족" 오류 메시지 반환 | +| **후조건** | 사용자의 포인트 잔액이 감소한다. | + +#### UC-04-2 재고 차감 처리 +| 항목 | 내용 | +|------|------| +| **액터** | System | +| **기능 요약** | 주문한 각 상품의 재고를 차감한다. | +| **예외 시나리오** | 재고 부족 시 해당 상품 주문 불가 처리 | +| **후조건** | 재고 수량이 감소한다. | + +#### UC-04-3 외부 주문 전송 +| 항목 | 내용 | +|------|------| +| **액터** | System / External Service | +| **기능 요약** | 생성된 주문을 외부 시스템으로 전송한다. | +| **예외 시나리오** | 외부 시스템 응답 지연 또는 오류 시 자동 재시도 또는 나중에 처리할 수 있도록 대기 목록에 저장 | +| **후조건** | 주문 정보가 외부 시스템에 전송되고 주문 상태가 "전송 완료"로 변경된다. | + +--- + +### 📦 UC-05 주문 내역 조회 +| 구분 | 내용 | +|------|------| +| **액터** | User | +| **사전조건** | 주문 내역이 존재 | +| **기본 시나리오** | 1) 사용자가 주문 내역 페이지를 연다.
2) 시스템은 유저의 모든 주문 목록을 반환한다. | +| **예외 시나리오** | • 주문 내역이 없을 경우 빈 배열 반환 | +| **후조건** | 주문 목록이 화면에 표시된다. | + +--- + +#### UC-06 단일 주문 상세 조회 +| 구분 | 내용 | +|------|------| +| **액터** | User | +| **사전조건** | 주문이 존재하고 해당 사용자의 주문이어야 함 | +| **기본 시나리오** | 1) 사용자는 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.
2) 시스템은 해당 주문의 상세 정보(주문번호, 상품 목록, 총액, 상태 등)를 반환한다. | +| **예외 시나리오** | • 주문이 존재하지 않을 경우 "주문을 찾을 수 없습니다" 메시지 반환
• 다른 사용자의 주문을 조회하려는 경우 "접근할 수 없는 주문입니다" 메시지 반환
• 주문 ID 형식이 잘못된 경우 "잘못된 요청입니다" 메시지 반환 | +| **후조건** | 주문 상세 정보가 화면에 표시된다. | + +--- + +## ⚙️ 5. 비기능 요구사항 +| 항목 | 내용 | +|------|------| +| **식별 방식** | 모든 요청은 사용자 ID를 통해 사용자를 식별 | +| **데이터 일관성** | 주문 처리 시 포인트 차감과 재고 차감은 함께 성공하거나 함께 취소됨 (부분 성공 방지) | +| **중복 방지** | 좋아요, 주문 요청은 동일한 요청을 여러 번 보내도 같은 결과가 나오도록 처리 | +| **성능 요구사항** | 상품 목록 조회 시 한 번에 보여줄 수 있는 상품 수를 제한하여 빠른 응답 제공 | +| **확장성 고려** | 좋아요 데이터를 활용하여 상품 추천이나 인기 랭킹 기능으로 확장 가능 | +| **오류 처리** | 외부 시스템 연동 실패 시 자동 재시도 또는 나중에 처리할 수 있도록 보류 상태로 관리 | diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..d829685a5 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,174 @@ +# 02-sequence-diagrams.md +> 루프팩 감성 이커머스 – 시퀀스 다이어그램 명세서 +> (도메인별 행위와 책임 중심 설계) + +--- + +## 🎯 개요 +이 문서는 **UC-03 (좋아요)** 와 **UC-04 (주문)** 의 핵심 시나리오를 비즈니스 관점에서 시각화한다. +기술적인 세부사항보다는 **사용자와 시스템 간의 상호작용 흐름**을 중심으로 설명하여, 비개발자도 쉽게 이해할 수 있도록 작성되었다. + +--- + +## ❤️ UC-03 상품 좋아요 등록/취소 + +### 1️⃣ 좋아요 등록 + +**시나리오**: 사용자가 상품에 좋아요를 누르는 과정 + +```mermaid +sequenceDiagram + autonumber + actor 사용자 + participant 웹사이트 + participant 좋아요시스템 + participant 상품정보 + + 사용자->>웹사이트: 좋아요 버튼 클릭 + 웹사이트->>좋아요시스템: 좋아요 요청 전송 + + alt 이미 좋아요한 상품인 경우 + 좋아요시스템->>좋아요시스템: 이미 좋아요 상태 확인 + 좋아요시스템-->>웹사이트: 좋아요 상태 유지 (변경 없음) + else 처음 좋아요하는 경우 + 좋아요시스템->>상품정보: 상품 존재 여부 확인 + 상품정보-->>좋아요시스템: 상품 정보 반환 + 좋아요시스템->>좋아요시스템: 좋아요 기록 저장 + 좋아요시스템->>상품정보: 좋아요 수 업데이트 + 상품정보-->>좋아요시스템: 업데이트 완료 + 좋아요시스템-->>웹사이트: 좋아요 등록 완료 + end + + 웹사이트-->>사용자: 좋아요 상태 표시 업데이트 +``` + +**설명**: +- 사용자가 좋아요 버튼을 클릭하면, 시스템은 먼저 해당 사용자가 이미 좋아요를 눌렀는지 확인합니다. +- 이미 좋아요한 경우: 추가 작업 없이 현재 상태를 유지합니다 (중복 방지). +- 처음 좋아요하는 경우: 좋아요 기록을 저장하고 상품의 좋아요 수를 증가시킵니다. + +### 2️⃣ 좋아요 취소 + +**시나리오**: 사용자가 좋아요를 취소하는 과정 + +```mermaid +sequenceDiagram + autonumber + actor 사용자 + participant 웹사이트 + participant 좋아요시스템 + participant 상품정보 + + 사용자->>웹사이트: 좋아요 취소 버튼 클릭 + 웹사이트->>좋아요시스템: 좋아요 취소 요청 전송 + + alt 좋아요하지 않은 상품인 경우 + 좋아요시스템->>좋아요시스템: 좋아요 기록 없음 확인 + 좋아요시스템-->>웹사이트: 좋아요 없음 상태 유지 (변경 없음) + else 이미 좋아요한 경우 + 좋아요시스템->>상품정보: 상품 존재 여부 확인 + 상품정보-->>좋아요시스템: 상품 정보 반환 + 좋아요시스템->>좋아요시스템: 좋아요 기록 삭제 + 좋아요시스템->>상품정보: 좋아요 수 감소 + 상품정보-->>좋아요시스템: 업데이트 완료 + 좋아요시스템-->>웹사이트: 좋아요 취소 완료 + end + + 웹사이트-->>사용자: 좋아요 상태 표시 업데이트 +``` + +**설명**: +- 사용자가 좋아요 취소 버튼을 클릭하면, 시스템은 해당 사용자가 좋아요를 눌렀는지 확인합니다. +- 좋아요하지 않은 경우: 추가 작업 없이 현재 상태를 유지합니다 (중복 방지). +- 좋아요한 경우: 좋아요 기록을 삭제하고 상품의 좋아요 수를 감소시킵니다. + +--- + +## 🛒 UC-04 주문 생성 + +### 1️⃣ 주문 생성 기본 흐름 (성공 케이스) + +**시나리오**: 사용자가 여러 상품을 선택하여 주문을 생성하는 과정 + +```mermaid +sequenceDiagram + autonumber + actor 사용자 + participant 쇼핑몰 + participant 주문시스템 + participant 상품재고 + participant 포인트계정 + participant 주문내역 + + 사용자->>쇼핑몰: 주문하기 버튼 클릭 + 쇼핑몰->>주문시스템: 주문 요청 전송 (상품 목록, 수량) + + Note over 주문시스템: 주문 처리 시작 (모든 작업이 성공하거나 모두 취소됨) + + 주문시스템->>상품재고: 주문할 상품의 재고 확인 + 상품재고-->>주문시스템: 재고 충분함 + + 주문시스템->>주문시스템: 주문 총액 계산 + + 주문시스템->>포인트계정: 포인트 잔액 확인 + 포인트계정-->>주문시스템: 포인트 충분함 + + 주문시스템->>상품재고: 주문 수량만큼 재고 차감 + 상품재고-->>주문시스템: 재고 차감 완료 + + 주문시스템->>포인트계정: 주문 금액만큼 포인트 차감 + 포인트계정-->>주문시스템: 포인트 차감 완료 + + 주문시스템->>주문내역: 주문 정보 저장 (상태: 완료) + 주문내역-->>주문시스템: 주문 저장 완료 + + Note over 주문시스템: 모든 작업 성공적으로 완료 + + 주문시스템-->>쇼핑몰: 주문 완료 (주문번호, 결제금액, 잔여 포인트) + 쇼핑몰-->>사용자: 주문 완료 화면 표시 +``` + +**설명**: +1. 사용자가 주문하기 버튼을 클릭하면, 주문 시스템이 주문 처리를 시작합니다. +2. **재고 확인**: 주문할 상품의 재고가 충분한지 확인합니다. +3. **포인트 확인**: 사용자의 포인트 잔액이 주문 금액보다 충분한지 확인합니다. +4. **재고 차감**: 재고가 충분하면 주문 수량만큼 재고를 차감합니다. +5. **포인트 차감**: 포인트가 충분하면 주문 금액만큼 포인트를 차감합니다. +6. **주문 저장**: 주문 정보를 저장하고 상태를 "완료"로 설정합니다. +7. **결과 반환**: 주문 번호, 결제 금액, 잔여 포인트를 사용자에게 반환합니다. + +**중요**: 모든 단계가 성공해야 주문이 완료됩니다. 중간에 실패하면 모든 변경사항이 취소됩니다 (예: 재고 차감 후 포인트 부족 시 재고도 원복). + +### 2️⃣ 주문 실패 시나리오 + +**시나리오**: 재고 부족 또는 포인트 부족으로 주문이 실패하는 경우 + +```mermaid +sequenceDiagram + autonumber + actor 사용자 + participant 쇼핑몰 + participant 주문시스템 + participant 상품재고 + participant 포인트계정 + + 사용자->>쇼핑몰: 주문하기 버튼 클릭 + 쇼핑몰->>주문시스템: 주문 요청 전송 + + alt 재고 부족인 경우 + 주문시스템->>상품재고: 주문할 상품의 재고 확인 + 상품재고-->>주문시스템: 재고 부족 (요청 수량 > 현재 재고) + 주문시스템-->>쇼핑몰: 주문 실패 (재고 부족) + 쇼핑몰-->>사용자: 재고 부족 안내 메시지 표시 + else 포인트 부족인 경우 + 주문시스템->>상품재고: 재고 확인 (성공) + 주문시스템->>포인트계정: 포인트 잔액 확인 + 포인트계정-->>주문시스템: 포인트 부족 (주문 금액 > 잔액) + 주문시스템-->>쇼핑몰: 주문 실패 (포인트 부족) + 쇼핑몰-->>사용자: 포인트 부족 안내 및 충전 안내 메시지 표시 + end +``` + +**설명**: +- **재고 부족**: 주문하려는 상품의 재고가 부족하면 주문이 실패합니다. 이 경우 아무것도 차감되지 않습니다. +- **포인트 부족**: 포인트 잔액이 주문 금액보다 부족하면 주문이 실패합니다. 재고는 이미 확인했지만, 포인트 부족으로 인해 주문이 취소되므로 재고도 차감되지 않습니다. diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..a087ad43e --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,407 @@ +# 03-class-diagram.md +> 루프팩 감성 이커머스 – 클래스 다이어그램 명세서 + +--- + +## 🎯 개요 +본 문서는 도메인별 행위에 맞춰 설계된 클래스 다이어그램을 정의한다. +각 도메인을 독립적인 Aggregate로 분리하여 일관성 경계를 명확히 한다. + +### 설계 원칙 +- **도메인 중심 설계 (DDD)**: 각 도메인이 자신의 책임과 행위를 명확히 가진다 +- **Aggregate 분리**: 각 도메인을 독립적인 Aggregate로 분리하여 일관성 경계를 명확히 한다 + - 예: User, Product, Order, Like, Brand를 각각 독립적인 Aggregate로 구성 +- **행위 중심**: 데이터 구조가 아닌 도메인의 행위와 책임을 우선한다 +- **Value Object 활용**: 불변 값 객체(OrderItem, Point)를 활용하여 도메인 로직을 캡슐화한다 + +--- + +## 📦 Aggregate 구분 다이어그램 (전체 개요) + +```mermaid +graph TB + subgraph "User Aggregate" + User[User
Aggregate Root] + Point[Point
Value Object] + User -->|embeds| Point + end + + subgraph "Product Aggregate" + Product[Product
Aggregate Root] + end + + subgraph "Order Aggregate" + Order[Order
Aggregate Root] + OrderItem[OrderItem
Value Object] + OrderStatus[OrderStatus
Enum] + Order -->|contains| OrderItem + Order -->|uses| OrderStatus + end + + subgraph "Brand Aggregate" + Brand[Brand
Aggregate Root] + end + + subgraph "Like Aggregate" + Like[Like
Aggregate Root] + end + + Order -.->|references by ID| User + Order -.->|references by ID| Product + Product -.->|references by ID| Brand + Like -.->|references by ID| User + Like -.->|references by ID| Product + + style User fill:#e1f5ff + style Product fill:#e1f5ff + style Order fill:#e1f5ff + style Brand fill:#e1f5ff + style Like fill:#e1f5ff + style Point fill:#fff4e1 + style OrderItem fill:#fff4e1 + style OrderStatus fill:#f0f0f0 +``` + +**Aggregate 경계 설명**: +- 각 Aggregate는 독립적인 일관성 경계를 가집니다. +- Aggregate 간 참조는 ID를 통해서만 이루어집니다 (직접 참조 금지). +- 하나의 트랜잭션은 하나의 Aggregate만 수정해야 합니다. +- 여러 Aggregate 간 협력이 필요한 경우 Application Service (Facade)에서 조율합니다. + +--- + +## 👤 User Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class User { + +Long id + +String userId + +String email + +LocalDate birthDate + +Gender gender + +Point point + +receivePoint(Point) + +deductPoint(Point) + } + + class Point { + +Long value + +add(Point) Point + +subtract(Point) Point + } + + class Gender { + <> + MALE + FEMALE + } + + User "1" *-- "1" Point : embeds + User "1" --> "1" Gender : uses +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **User** | Aggregate Root | 사용자 정보 관리 및 포인트 차감/충전 처리 | +| **Point** | Value Object | 포인트 값과 연산 로직을 캡슐화 (User에 Embedded) | +| **Gender** | Enum | 사용자 성별 정보 | + +### 주요 특징 +- `Point`는 `User`에 Embedded된 Value Object로, User의 생명주기와 함께 관리됩니다. +- User는 자신의 포인트를 직접 관리합니다 (`receivePoint()`, `deductPoint()`). +- User 생성 시 Point가 함께 초기화됩니다. + +--- + +## 🛍 Product Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Product { + +Long id + +String name + +Integer price + +Integer stock + +Long brandId + +decreaseStock(quantity) + +increaseStock(quantity) + } + + class Brand { + +Long id + +String name + } + + Product "*" ..> "1" Brand : references by ID +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Product** | Aggregate Root | 상품 정보 및 재고 관리 (재고 차감/증가 처리) | +| **Brand** | 외부 Aggregate | 브랜드 정보 (Product가 ID로만 참조) | + +### 주요 특징 +- Product는 Brand를 ID로만 참조하며, Brand는 독립적인 Aggregate입니다. +- 재고 관리 로직을 Product 내부에서 처리합니다 (`decreaseStock()`, `increaseStock()`). +- 주문 처리 시 재고 차감/증가가 발생합니다. + +--- + +## 📦 Order Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Order { + +Long id + +Long userId + +OrderStatus status + +Integer totalAmount + +List~OrderItem~ items + +complete() + +cancel() + } + + class OrderItem { + +Long productId + +String name + +Integer price + +Integer quantity + } + + class OrderStatus { + <> + PENDING + COMPLETED + CANCELED + } + + class User { + +Long id + +String userId + } + + class Product { + +Long id + +String name + } + + Order "1" --> "*" OrderItem : contains + Order "1" --> "1" OrderStatus : uses + Order "1" ..> "1" User : references by ID + OrderItem "1" ..> "1" Product : references by ID +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Order** | Aggregate Root | 주문의 상태, 총액, 주문 아이템 관리 및 상태 전이 처리 | +| **OrderItem** | Value Object | 주문 시점의 상품 정보 스냅샷 (JSON으로 저장) | +| **OrderStatus** | Enum | 주문의 생명주기 상태 표현 | +| **User** | 외부 Aggregate | 주문자 정보 (Order가 ID로만 참조) | +| **Product** | 외부 Aggregate | 주문된 상품 정보 (OrderItem이 ID로만 참조) | + +### 주요 특징 +- `OrderItem`은 JSON으로 저장되는 Value Object입니다. +- Order는 User ID와 Product ID를 참조하지만, 실제 Entity를 참조하지 않습니다. +- Order 상태 전이는 Order 내부에서 관리됩니다 (`complete()`, `cancel()`). +- 총액 계산은 Order 생성 시 자동으로 수행됩니다. + +### 상태 전이 다이어그램 + +```mermaid +stateDiagram-v2 + [*] --> PENDING: 주문 생성 + + PENDING --> COMPLETED: 주문 완료\n(포인트 차감, 재고 차감 완료) + PENDING --> CANCELED: 주문 취소\n(포인트 환불, 재고 복구) + + COMPLETED --> [*] + CANCELED --> [*] + + note right of PENDING + 초기 상태 + 주문 생성 직후 상태 + end note + + note right of COMPLETED + 최종 완료 상태 + 모든 처리가 완료된 상태 + end note + + note right of CANCELED + 취소 상태 + PENDING 상태에서만 취소 가능 + end note +``` + +--- + +## 🏷 Brand Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Brand { + +Long id + +String name + +String description + } +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Brand** | Aggregate Root | 브랜드 정보 관리 | + +### 주요 특징 +- Brand는 독립적인 Aggregate입니다. +- Product가 Brand를 참조하지만, Brand는 Product를 알지 못합니다. +- 단순한 정보 관리만 수행합니다. + +--- + +## ❤️ Like Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Like { + +Long id + +Long userId + +Long productId + } + + class LikeRepository { + <> + +save(Like) + +findByUserIdAndProductId(userId, productId) + +delete(Like) + +findAllByUserId(userId) + +countByProductIds(productIds) + } + + class LikeFacade { + +addLike(userId, productId) + +removeLike(userId, productId) + +getLikedProducts(userId) + } + + class User { + +Long id + +String userId + } + + class Product { + +Long id + +String name + } + + LikeFacade --> LikeRepository : uses + LikeFacade --> User : references + LikeFacade --> Product : references + LikeRepository ..> Like : manages + Like "1" ..> "1" User : references by ID + Like "1" ..> "1" Product : references by ID +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Like** | Aggregate Root | 사용자와 상품 간의 좋아요 관계를 나타내는 엔티티 | +| **LikeRepository** | Repository Interface | 좋아요 정보의 저장, 조회, 삭제를 담당하는 저장소 인터페이스 | +| **LikeFacade** | Application Service | 좋아요 추가, 삭제, 목록 조회를 처리하는 애플리케이션 서비스 | +| **User** | 외부 Aggregate | 좋아요를 누른 사용자 정보 (Like가 ID로만 참조) | +| **Product** | 외부 Aggregate | 좋아요 대상이 되는 상품 정보 (Like가 ID로만 참조) | + +### 주요 특징 +- Like는 User와 Product 간의 관계를 나타내는 독립적인 Aggregate입니다. +- User ID와 Product ID만 참조하며, 실제 Entity를 참조하지 않습니다. +- 좋아요 관계의 생명주기를 독립적으로 관리합니다. +- LikeFacade에서 중복 좋아요/취소 요청 시 현재 상태를 반환하여 멱등성을 보장합니다. + +--- + +## 🧭 상수 및 Enum 클래스 + +```mermaid +classDiagram + class SortType { + <> + LATEST + PRICE_ASC + LIKES_DESC + } + + class OrderStatus { + <> + PENDING + COMPLETED + CANCELED + } + + class Gender { + <> + MALE + FEMALE + } +``` + +### Enum 설명 + +| Enum | 사용 위치 | 설명 | +|------|-----------|------| +| **SortType** | 상품 목록 조회 | 상품 정렬 기준 (최신순, 가격 오름차순, 좋아요 내림차순) | +| **OrderStatus** | Order Aggregate | 주문 상태 (대기, 완료, 취소) | +| **Gender** | User Aggregate | 사용자 성별 (남성, 여성) | + +--- + +## 🔁 설계 의도 요약 + +| 설계 포인트 | 선택 근거 | +|--------------|-------------| +| **도메인 중심 (DDD)** | Entity가 스스로 상태를 관리하도록 설계 (ex. Product.decreaseStock(), Order.complete(), User.deductPoint()) | +| **Aggregate 분리** | User, Product, Order, Like, Brand를 독립적인 Aggregate로 분리하여 각각의 일관성 경계를 명확히 함 | +| **멱등성 보장** | LikeFacade에서 중복 좋아요/취소 요청 시 현재 상태를 반환하여 멱등성 보장 | +| **Enum 사용** | SortType, OrderStatus 등 도메인별 상수는 Enum으로 명확히 정의 | +| **Value Object 활용** | OrderItem, Point를 Value Object로 설계하여 불변성과 도메인 로직 캡슐화 | +| **ID 참조 원칙** | Aggregate 간 참조는 ID를 통해서만 이루어지며, 직접 Entity 참조를 금지하여 결합도 감소 | + +--- + +## 📝 Aggregate 간 협력 + +여러 Aggregate 간 협력이 필요한 경우 Application Service (Facade)에서 조율합니다. + +### 예시: 주문 생성 시 협력 + +- **PurchasingFacade**: Order, User, Product Aggregate 간의 협력을 조정 + - 주문 완료: Order.complete() + User.deductPoint() + Product.decreaseStock() + - 주문 취소: Order.cancel() + User.receivePoint() + Product.increaseStock() + +### 예시: 좋아요 처리 시 협력 + +- **LikeFacade**: Like, User, Product Aggregate 간의 협력을 조정 + - 좋아요 추가: Like 생성 + User/Product 존재 확인 + - 좋아요 취소: Like 삭제 + User/Product 존재 확인 diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..e3b7b6b05 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,91 @@ +# 04-erd.md +> 루프팩 감성 이커머스 – ERD(Entity Relationship Diagram) + +--- + +## 🎯 개요 +본 문서는 클래스 다이어그램을 관계형 데이터베이스 구조로 변환한 ERD를 정의한다. + +--- + +## 🧱 ERD + +```mermaid +erDiagram + USERS { + bigint id PK + varchar user_id + varchar email + date birthDate + datetime created_at + datetime updated_at + datetime deleted_at + } + + BRANDS { + bigint id PK + varchar name + varchar description + datetime created_at + datetime updated_at + datetime deleted_at + } + + PRODUCTS { + bigint id PK + varchar name + int price + int stock + bigint ref_brand_id FK + datetime created_at + datetime updated_at + datetime deleted_at + } + + LIKES { + bigint id PK + bigint ref_user_id FK + bigint ref_product_id FK + datetime created_at + datetime updated_at + datetime deleted_at + } + + ORDERS { + bigint id PK + bigint ref_user_id FK + int total_amount + json items "주문 아이템 배열: [{productId, name, price, quantity}]" + varchar status "OrderStatus enum 값 (PENDING, COMPLETED, CANCELED)" + datetime created_at + datetime updated_at + datetime deleted_at + } + + USERS ||--o{ LIKES : "좋아요" + USERS ||--o{ ORDERS : "주문" + + BRANDS ||--o{ PRODUCTS : "브랜드 상품" + PRODUCTS ||--o{ LIKES : "좋아요 대상" +``` + +--- + +## ⚙️ 제약조건 +| 테이블 | 제약조건 | 설명 | +|---------|-----------|------| +| **LIKES** | (ref_user_id, ref_product_id) UNIQUE | 동일 사용자-상품 중복 방지 | +| **ORDERS** | status IN ('PENDING', 'COMPLETED', 'CANCELED') | 주문 상태는 OrderStatus enum 값만 허용 | +| **ORDERS** | items JSON 형식: [{productId, name, price, quantity}] | 주문 아이템은 JSON 배열로 저장 | +| **USERS** | user_id UNIQUE | 사용자 ID는 고유해야 함 | +| **PRODUCTS** | stock >= 0 | 재고는 0 이상이어야 함 | +| **PRODUCTS** | price >= 0 | 가격은 0 이상이어야 함 | + +--- + +## 📝 데이터베이스 설계 참고사항 + +### Aggregate 경계 +- 각 테이블은 하나의 Aggregate Root에 해당합니다. +- Aggregate 간 참조는 외래키를 통한 ID 참조로만 이루어집니다. +--- diff --git a/.docs/design/use case diagram.png b/.docs/design/use case diagram.png new file mode 100644 index 000000000..bc2c3f4f7 Binary files /dev/null and b/.docs/design/use case diagram.png differ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..f4d3b583a 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:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -10,6 +11,18 @@ 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"]}") + + // feign client + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + + // resilience4j + implementation("io.github.resilience4j:resilience4j-spring-boot3") + implementation("io.github.resilience4j:resilience4j-core") // IntervalFunction을 위한 core 모듈 + implementation("io.github.resilience4j:resilience4j-circuitbreaker") + implementation("io.github.resilience4j:resilience4j-retry") + implementation("io.github.resilience4j:resilience4j-timelimiter") + implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현 + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") @@ -19,4 +32,5 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + testImplementation(testFixtures(project(":modules:kafka"))) } diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..a15cdca7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,10 +4,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling +@EnableAsync +@EnableFeignClients public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..94f1a63b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,84 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 브랜드 조회 파사드. + *

+ * 브랜드 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class BrandService { + private final BrandRepository brandRepository; + + /** + * 브랜드 ID로 브랜드를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 조회된 브랜드 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + /** + * 브랜드 ID 목록으로 브랜드 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param brandIds 조회할 브랜드 ID 목록 + * @return 조회된 브랜드 목록 + */ + @Transactional(readOnly = true) + public List getBrands(List brandIds) { + return brandRepository.findAllById(brandIds); + } + + /** + * 브랜드 정보를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 브랜드 정보 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + public BrandInfo getBrandInfo(Long brandId) { + Brand brand = getBrand(brandId); + return BrandInfo.from(brand); + } + + /** + * 브랜드 정보를 담는 레코드. + * + * @param id 브랜드 ID + * @param name 브랜드 이름 + */ + public record BrandInfo(Long id, String name) { + /** + * Brand 엔티티로부터 BrandInfo를 생성합니다. + * + * @param brand 브랜드 엔티티 + * @return 생성된 BrandInfo + */ + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java new file mode 100644 index 000000000..4ce66ca53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -0,0 +1,163 @@ +package com.loopers.application.catalog; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductCacheService; +import com.loopers.application.product.ProductService; +import com.loopers.application.ranking.RankingService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 상품 조회 파사드. + *

+ * 상품 목록 조회 및 상품 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CatalogFacade { + private final BrandService brandService; + private final ProductService productService; + private final ProductCacheService productCacheService; + private final ProductEventPublisher productEventPublisher; + private final RankingService rankingService; + + /** + * 상품 목록을 조회합니다. + *

+ * Redis 캐시를 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. + * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param brandId 브랜드 ID (선택) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 상품 수 + * @return 상품 목록 조회 결과 + */ + public ProductInfoList getProducts(Long brandId, String sort, int page, int size) { + // sort 기본값 처리 (컨트롤러와 동일하게 "latest" 사용) + String normalizedSort = (sort != null && !sort.isBlank()) ? sort : "latest"; + + // 캐시에서 조회 시도 + ProductInfoList cachedResult = productCacheService.getCachedProductList(brandId, normalizedSort, page, size); + if (cachedResult != null) { + return cachedResult; + } + + // 캐시에 없으면 DB에서 조회 + long totalCount = productService.countAll(brandId); + List products = productService.findAll(brandId, normalizedSort, page, size); + + if (products.isEmpty()) { + ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size); + // 캐시 저장 + productCacheService.cacheProductList(brandId, normalizedSort, page, size, emptyResult); + return emptyResult; + } + + // ✅ 배치 조회로 N+1 쿼리 문제 해결 + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해) + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 상품 정보 변환 (이미 조회한 Product 재사용) + List productsInfo = products.stream() + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("브랜드를 찾을 수 없습니다. (브랜드 ID: %d)", product.getBrandId())); + } + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount()); + return ProductInfo.withoutRank(productDetail); + }) + .toList(); + + ProductInfoList result = new ProductInfoList(productsInfo, totalCount, page, size); + + // 캐시 저장 + productCacheService.cacheProductList(brandId, normalizedSort, page, size, result); + + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) + return productCacheService.applyLikeCountDelta(result); + } + + /** + * 상품 정보를 조회합니다. + *

+ * Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. + * 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다. + * 랭킹 정보도 함께 조회하여 반환합니다. + *

+ * + * @param productId 상품 ID + * @return 상품 정보와 좋아요 수, 랭킹 순위 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public ProductInfo getProduct(Long productId) { + // 캐시에서 조회 시도 + ProductInfo cachedResult = productCacheService.getCachedProduct(productId); + if (cachedResult != null) { + // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행 + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); + + // 랭킹 정보 조회 (캐시된 결과에 랭킹 정보 추가) + LocalDate today = LocalDate.now(); + Long rank = rankingService.getProductRank(productId, today); + return ProductInfo.withRank(cachedResult.productDetail(), rank); + } + + // 캐시에 없으면 DB에서 조회 + Product product = productService.getProduct(productId); + + // 브랜드 조회 + Brand brand = brandService.getBrand(product.getBrandId()); + + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + Long likesCount = product.getLikeCount(); + + // ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달) + ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount); + + // 랭킹 정보 조회 + LocalDate today = LocalDate.now(); + Long rank = rankingService.getProductRank(productId, today); + + // 캐시에 저장 (랭킹 정보는 제외하고 저장 - 랭킹은 실시간으로 조회) + productCacheService.cacheProduct(productId, ProductInfo.withoutRank(productDetail)); + + // ✅ 상품 조회 이벤트 발행 (메트릭 집계용) + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); + + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) + ProductInfo deltaApplied = productCacheService.applyLikeCountDelta(ProductInfo.withoutRank(productDetail)); + return ProductInfo.withRank(deltaApplied.productDetail(), rank); + } + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java new file mode 100644 index 000000000..ec634bc0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.catalog; + +import com.loopers.domain.product.ProductDetail; + +/** + * 상품 상세 정보를 담는 레코드. + * + * @param productDetail 상품 상세 정보 (Product + Brand + 좋아요 수) + * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null) + */ +public record ProductInfo(ProductDetail productDetail, Long rank) { + /** + * 랭킹 정보 없이 ProductInfo를 생성합니다. + * + * @param productDetail 상품 상세 정보 + * @return ProductInfo (rank는 null) + */ + public static ProductInfo withoutRank(ProductDetail productDetail) { + return new ProductInfo(productDetail, null); + } + + /** + * 랭킹 정보와 함께 ProductInfo를 생성합니다. + * + * @param productDetail 상품 상세 정보 + * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null) + * @return ProductInfo + */ + public static ProductInfo withRank(ProductDetail productDetail, Long rank) { + return new ProductInfo(productDetail, rank); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java new file mode 100644 index 000000000..23f1a9345 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java @@ -0,0 +1,46 @@ +package com.loopers.application.catalog; + +import java.util.List; + +/** + * 상품 목록 조회 결과. + * + * @param products 상품 목록 (좋아요 수 포함) + * @param totalCount 전체 상품 수 + * @param page 현재 페이지 번호 + * @param size 페이지당 상품 수 + */ +public record ProductInfoList( + List products, + long totalCount, + int page, + int size +) { + /** + * 전체 페이지 수를 계산합니다. + * + * @return 전체 페이지 수 + */ + public int getTotalPages() { + return size > 0 ? (int) Math.ceil((double) totalCount / size) : 0; + } + + /** + * 다음 페이지가 있는지 확인합니다. + * + * @return 다음 페이지 존재 여부 + */ + public boolean hasNext() { + return (page + 1) * size < totalCount; + } + + /** + * 이전 페이지가 있는지 확인합니다. + * + * @return 이전 페이지 존재 여부 + */ + public boolean hasPrevious() { + return page > 0; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java new file mode 100644 index 000000000..9ebb38172 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java @@ -0,0 +1,30 @@ +package com.loopers.application.coupon; + +/** + * 쿠폰 적용 명령. + *

+ * 쿠폰 적용을 위한 명령 객체입니다. + *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + */ +public record ApplyCouponCommand( + Long userId, + String couponCode, + Integer subtotal +) { + public ApplyCouponCommand { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (subtotal == null || subtotal < 0) { + throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java new file mode 100644 index 000000000..92f6b8ba3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java @@ -0,0 +1,116 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import com.loopers.domain.order.OrderEvent; +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 쿠폰 이벤트 핸들러. + *

+ * 주문 생성 이벤트를 받아 쿠폰 사용 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: CouponService는 쿠폰 도메인 비즈니스 로직, CouponEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 쿠폰 도메인은 쿠폰 적용 이벤트만 발행하고, 주문 도메인은 자신의 상태를 관리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponEventHandler { + + private final CouponService couponService; + private final CouponEventPublisher couponEventPublisher; + + /** + * 주문 생성 이벤트를 처리하여 쿠폰을 사용하고 쿠폰 적용 이벤트를 발행합니다. + *

+ * 쿠폰 코드가 있는 경우에만 쿠폰 사용 처리를 수행합니다. + * 쿠폰 적용 후 CouponApplied 이벤트를 발행하여 주문 도메인이 자신의 상태를 업데이트하도록 합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + // 쿠폰 코드가 없는 경우 처리하지 않음 + if (event.couponCode() == null || event.couponCode().isBlank()) { + log.debug("쿠폰 코드가 없어 쿠폰 사용 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ OrderEvent.OrderCreated를 구독하여 쿠폰 적용 Command 실행 + // 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산) + Integer discountAmount = couponService.applyCoupon( + new ApplyCouponCommand( + event.userId(), + event.couponCode(), + event.subtotal() + ) + ); + + // ✅ 도메인 이벤트 발행: 쿠폰이 적용되었음 (과거 사실) + // 주문 도메인이 이 이벤트를 구독하여 자신의 상태를 업데이트함 + couponEventPublisher.publish(CouponEvent.CouponApplied.of( + event.orderId(), + event.userId(), + event.couponCode(), + discountAmount + )); + + log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), discountAmount); + } catch (CoreException e) { + // 비즈니스 예외 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 실패"; + log.error("쿠폰 적용 실패. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + throw e; + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + String failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)"; + log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + throw e; + } catch (Exception e) { + // 예상치 못한 오류 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 처리 중 오류 발생"; + log.error("쿠폰 적용 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java new file mode 100644 index 000000000..9a83d9df7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -0,0 +1,112 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 쿠폰 애플리케이션 서비스. + *

+ * 쿠폰 조회, 사용 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리 및 동시성 제어를 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CouponService { + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param command 쿠폰 적용 명령 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + @Transactional + public Integer applyCoupon(ApplyCouponCommand command) { + return applyCoupon(command.userId(), command.couponCode(), command.subtotal()); + } + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + @Transactional + private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { + // 쿠폰 존재 여부 확인 + Coupon coupon = couponRepository.findByCode(couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) + // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 + UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 쿠폰 사용 가능 여부 확인 + if (!userCoupon.isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); + } + + // 쿠폰 사용 처리 + userCoupon.use(); + + // 할인 금액 계산 (전략 패턴 사용) + Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); + + try { + // 사용자 쿠폰 저장 (version 체크 자동 수행) + // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 + userCouponRepository.save(userCoupon); + // ✅ flush()를 명시적으로 호출하여 Optimistic Lock 체크를 즉시 수행 + // flush() 없이는 트랜잭션 커밋 시점에 체크되므로, 여러 트랜잭션이 동시에 성공할 수 있음 + userCouponRepository.flush(); + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + throw new CoreException(ErrorType.CONFLICT, + String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); + } + + return discountAmount; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java new file mode 100644 index 000000000..cce249563 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java @@ -0,0 +1,270 @@ +package com.loopers.application.heart; + +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductService; +import com.loopers.application.user.UserService; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 좋아요 관리 파사드. + *

+ * 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ *

+ * EDA 원칙 준수: + *

    + *
  • 이벤트 기반: Like 도메인 이벤트만 발행하고, 다른 애그리거트를 직접 수정하지 않음
  • + *
  • 느슨한 결합: Product, User 애그리거트와의 직접적인 의존성 최소화
  • + *
  • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 이벤트 핸들러에서 처리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class HeartFacade { + private final LikeService likeService; + private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 + private final ProductService productService; // getLikedProducts 조회용으로만 사용 + + /** + * 상품에 좋아요를 추가합니다. + *

+ * 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다. + *

+ *

+ * 동시성 제어 전략: + *

    + *
  • UNIQUE 제약조건 사용: 데이터베이스 레벨에서 중복 삽입을 물리적으로 방지
  • + *
  • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없음
  • + *
  • 예외 처리: UNIQUE 제약조건 위반 시 DataIntegrityViolationException 처리하여 멱등성 보장
  • + *
+ *

+ *

+ * DBA 설득 근거 (유니크 인덱스 사용): + *

    + *
  • 트래픽 패턴: 좋아요는 고 QPS write-heavy 테이블이 아니며, 전체 서비스에서 차지하는 비중이 낮음
  • + *
  • 애플리케이션 레벨 한계: 동일 시점 동시 요청 시 select 시점엔 중복 없음 → insert 2번 발생 가능
  • + *
  • 데이터 무결성: DB만이 강한 무결성(Strong Consistency)을 제공할 수 있음
  • + *
  • 비즈니스 데이터 보호: 중복 좋아요로 인한 비즈니스 데이터 오염 방지
  • + *
+ *

+ *

+ * EDA 원칙: + *

    + *
  • 이벤트 기반: LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행
  • + *
  • 느슨한 결합: Product 애그리거트를 직접 조회/수정하지 않음. 이벤트 핸들러가 상품 좋아요 수를 업데이트
  • + *
  • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 ProductEventHandler에서 처리
  • + *
+ *

+ * + * @param userId 사용자 ID (String) + * @param productId 상품 ID + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + public void addLike(String userId, Long productId) { + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // Product 존재 여부 검증은 제거 (이벤트 핸들러에서 처리하거나, 외래키 제약조건으로 보장) + User user = loadUser(userId); + + // 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리) + // ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음 + // 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능 + Optional existingLike = likeService.getLike(user.getId(), productId); + if (existingLike.isPresent()) { + return; + } + + // 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능) + // ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지 + // ✅ LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + Like like = Like.of(user.getId(), productId); + try { + likeService.save(like); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장) + // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때, + // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생 + // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 + } + } + + /** + * 상품의 좋아요를 취소합니다. + *

+ * 멱등성을 보장합니다. 좋아요가 존재하지 않는 경우 아무 작업도 수행하지 않습니다. + *

+ *

+ * EDA 원칙: + *

    + *
  • 이벤트 기반: LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행
  • + *
  • 느슨한 결합: Product 애그리거트를 직접 조회/수정하지 않음. 이벤트 핸들러가 상품 좋아요 수를 업데이트
  • + *
  • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 ProductEventHandler에서 처리
  • + *
+ *

+ * + * @param userId 사용자 ID (String) + * @param productId 상품 ID + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + public void removeLike(String userId, Long productId) { + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // Product 존재 여부 검증은 제거 (이벤트 핸들러에서 처리하거나, 외래키 제약조건으로 보장) + User user = loadUser(userId); + + Optional like = likeService.getLike(user.getId(), productId); + if (like.isEmpty()) { + return; + } + + try { + // ✅ LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + likeService.delete(like.get()); + } catch (EmptyResultDataAccessException | ObjectOptimisticLockingFailureException e) { + // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능 + // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주 + } + } + + /** + * 사용자가 좋아요한 상품 목록을 조회합니다. + *

+ * 상품 정보 조회를 병렬로 처리하여 성능을 최적화합니다. + *

+ *

+ * 좋아요 수 조회 전략: + *

    + *
  • 이벤트 기반 집계: Product.likeCount 필드 사용 (LikeEvent로 실시간 업데이트)
  • + *
  • Strong Consistency: 이벤트 기반으로 실시간 반영
  • + *
  • 성능 최적화: COUNT(*) 쿼리 없이 컬럼만 읽으면 됨
  • + *
+ *

+ *

+ * EDA 원칙: + *

    + *
  • 조회 특성: 조회 쿼리는 이벤트로 처리하기 어려우므로 ProductService 의존 허용
  • + *
  • 최소 의존: 조회용으로만 사용하며, 수정 작업은 수행하지 않음
  • + *
+ *

+ * + * @param userId 사용자 ID (String) + * @return 좋아요한 상품 목록 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public List getLikedProducts(String userId) { + User user = loadUser(userId); + + // 사용자의 좋아요 목록 조회 + List likes = likeService.getLikesByUserId(user.getId()); + + if (likes.isEmpty()) { + return List.of(); + } + + // 상품 ID 목록 추출 + List productIds = likes.stream() + .map(Like::getProductId) + .toList(); + + // ✅ 배치 조회로 N+1 쿼리 문제 해결 + // ⚠️ 조회 특성상 ProductService 의존은 허용 (이벤트로 처리하기 어려움) + Map productMap = productService.getProducts(productIds).stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + // 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인 + if (productMap.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "일부 상품을 찾을 수 없습니다."); + } + + // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환 + // ✅ Product.likeCount 필드 사용 (이벤트 기반 실시간 집계된 값) + return likes.stream() + .map(like -> { + Product product = productMap.get(like.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId())); + } + // Product 엔티티의 likeCount 필드를 내부에서 사용 + return LikedProduct.from(product); + }) + .toList(); + } + + /** + * String userId를 Long id로 변환합니다. + *

+ * EDA 원칙에 따라 최소한의 UserService 의존만 사용합니다. + *

+ * + * @param userId 사용자 ID (String) + * @return 사용자 엔티티 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + private User loadUser(String userId) { + return userService.getUser(userId); + } + + /** + * 좋아요한 상품 정보. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record LikedProduct( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * Product로부터 LikedProduct를 생성합니다. + *

+ * Product.likeCount 필드를 사용하여 좋아요 수를 가져옵니다. + *

+ * + * @param product 상품 엔티티 + * @return 생성된 LikedProduct + * @throws IllegalArgumentException product가 null인 경우 + */ + public static LikedProduct from(Product product) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } + return new LikedProduct( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + product.getLikeCount() // ✅ Product.likeCount 필드 사용 (이벤트 기반 실시간 집계된 값) + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..a7c9874e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,87 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.like.LikeEventPublisher; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * 좋아요 애플리케이션 서비스. + *

+ * 좋아요 조회, 저장, 삭제 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리를 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class LikeService { + private final LikeRepository likeRepository; + private final LikeEventPublisher likeEventPublisher; + + /** + * 사용자 ID와 상품 ID로 좋아요를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 조회된 좋아요를 담은 Optional + */ + @Transactional(readOnly = true) + public Optional getLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + /** + * 좋아요를 저장합니다. + *

+ * 저장 성공 시 좋아요 추가 이벤트를 발행합니다. + *

+ * + * @param like 저장할 좋아요 + * @return 저장된 좋아요 + */ + @Transactional + public Like save(Like like) { + Like savedLike = likeRepository.save(like); + + // ✅ 도메인 이벤트 발행: 좋아요가 추가되었음 (과거 사실) + likeEventPublisher.publish(LikeEvent.LikeAdded.from(savedLike)); + + return savedLike; + } + + /** + * 좋아요를 삭제합니다. + *

+ * 삭제 전에 좋아요 취소 이벤트를 발행합니다. + *

+ * + * @param like 삭제할 좋아요 + */ + @Transactional + public void delete(Like like) { + // ✅ 도메인 이벤트 발행: 좋아요가 취소되었음 (과거 사실) + likeEventPublisher.publish(LikeEvent.LikeRemoved.from(like)); + + likeRepository.delete(like); + } + + /** + * 사용자 ID로 좋아요한 상품 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 좋아요 목록 + */ + @Transactional(readOnly = true) + public List getLikesByUserId(Long userId) { + return likeRepository.findAllByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java new file mode 100644 index 000000000..4bfb8e408 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -0,0 +1,42 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.List; + +/** + * 주문 생성 명령. + *

+ * 주문 생성을 위한 명령 객체입니다. + *

+ * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + */ +public record CreateOrderCommand( + Long userId, + List items, + String couponCode, + Integer subtotal, + Long usedPointAmount +) { + public CreateOrderCommand { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); + } + if (subtotal == null || subtotal < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 소계는 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 포인트 금액은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java new file mode 100644 index 000000000..10b18bd7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java @@ -0,0 +1,148 @@ +package com.loopers.application.order; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 주문 이벤트 핸들러. + *

+ * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아 주문 상태를 업데이트하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: OrderService는 주문 도메인 비즈니스 로직, OrderEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 주문 도메인은 자신의 상태만 관리하며, 다른 도메인의 이벤트를 구독하여 반응
  • + *
  • 느슨한 결합: UserService나 PurchasingFacade를 직접 참조하지 않고, 이벤트만 발행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventHandler { + + private final OrderService orderService; + + /** + * 결제 완료 이벤트를 처리하여 주문 상태를 COMPLETED로 업데이트합니다. + *

+ * 트랜잭션 전략: + *

    + *
  • AFTER_COMMIT: 원래 트랜잭션이 이미 커밋되었으므로 자동으로 새 트랜잭션이 생성됨
  • + *
+ *

+ * + * @param event 결제 완료 이벤트 + */ + @Transactional + public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { + Order order = orderService.getOrder(event.orderId()).orElse(null); + if (order == null) { + log.warn("결제 완료 이벤트 처리 시 주문을 찾을 수 없습니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 완료된 주문인 경우 처리하지 않음 + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 (race condition 방지) + // 예: 결제 타임아웃으로 인해 주문이 취소되었지만, 이후 PG 상태 확인에서 SUCCESS가 반환된 경우 + if (order.isCanceled()) { + log.warn("이미 취소된 주문입니다. 결제 완료 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + event.orderId(), event.transactionKey()); + return; + } + + // 주문 완료 처리 + orderService.completeOrder(event.orderId()); + log.info("결제 완료로 인한 주문 상태 업데이트 완료. (orderId: {}, transactionKey: {})", + event.orderId(), event.transactionKey()); + } + + /** + * 결제 실패 이벤트를 처리하여 주문을 취소합니다. + *

+ * 주문 상태만 CANCELED로 변경하고 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 OrderCanceled 이벤트를 구독하는 별도 핸들러에서 처리합니다. + *

+ *

+ * 트랜잭션 전략: + *

    + *
  • AFTER_COMMIT: 원래 트랜잭션이 이미 커밋되었으므로 자동으로 새 트랜잭션이 생성됨
  • + *
+ *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 도메인 경계 준수: 주문 도메인이 자신의 상태를 관리하며, 결제 실패 이벤트를 구독하여 반응
  • + *
  • 느슨한 결합: 리소스 원복은 별도 이벤트 핸들러에서 처리하여 도메인 간 결합 제거
  • + *
+ *

+ * + * @param event 결제 실패 이벤트 + */ + @Transactional + public void handlePaymentFailed(PaymentEvent.PaymentFailed event) { + Order order = orderService.getOrder(event.orderId()).orElse(null); + if (order == null) { + log.warn("결제 실패 이벤트 처리 시 주문을 찾을 수 없습니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 완료된 주문인 경우 처리하지 않음 (race condition 방지) + if (order.isCompleted()) { + log.warn("이미 완료된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 주문 취소 (OrderCanceled 이벤트 발행 포함) + // PaymentFailed 이벤트에 포함된 refundPointAmount 사용 + orderService.cancelOrder(event.orderId(), event.reason(), event.refundPointAmount()); + log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, reason: {}, refundPointAmount: {})", + event.orderId(), event.reason(), event.refundPointAmount()); + } + + + /** + * 쿠폰 적용 이벤트를 처리하여 주문에 할인 금액을 적용합니다. + *

+ * 쿠폰 도메인에서 쿠폰이 적용되었다는 이벤트를 받아 주문 도메인이 자신의 상태를 업데이트합니다. + *

+ * + * @param event 쿠폰 적용 이벤트 + */ + @Transactional + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + // 주문에 할인 금액 적용 (totalAmount 업데이트) + orderService.applyCouponDiscount(event.orderId(), event.discountAmount()); + + log.info("쿠폰 할인 금액이 주문에 적용되었습니다. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), event.discountAmount()); + } catch (Exception e) { + // 주문 업데이트 실패는 로그만 기록 (쿠폰은 이미 적용되었으므로) + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..eb71438ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,334 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.order.OrderEventPublisher; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 주문 애플리케이션 서비스. + *

+ * 주문의 기본 CRUD 및 상태 변경을 담당하는 애플리케이션 서비스입니다. + * Repository에 의존하며 트랜잭션 관리를 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderEventPublisher orderEventPublisher; + + /** + * 주문을 저장합니다. + * + * @param order 저장할 주문 + * @return 저장된 주문 + */ + @Transactional + public Order save(Order order) { + return orderRepository.save(order); + } + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Order getById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + /** + * 주문 ID로 주문을 조회합니다 (Optional 반환). + * + * @param orderId 주문 ID + * @return 조회된 주문 (없으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getOrder(Long orderId) { + return orderRepository.findById(orderId); + } + + /** + * 사용자 ID로 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 주문 목록 + */ + @Transactional(readOnly = true) + public List getOrdersByUserId(Long userId) { + return orderRepository.findAllByUserId(userId); + } + + /** + * 주문 상태로 주문 목록을 조회합니다. + * + * @param status 주문 상태 + * @return 해당 상태의 주문 목록 + */ + @Transactional(readOnly = true) + public List getOrdersByStatus(OrderStatus status) { + return orderRepository.findAllByStatus(status); + } + + /** + * 주문을 생성합니다. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param discountAmount 할인 금액 (선택) + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items, String couponCode, Integer discountAmount) { + Order order = Order.of(userId, items, couponCode, discountAmount); + return orderRepository.save(order); + } + + /** + * 주문을 생성합니다 (쿠폰 없음). + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items) { + Order order = Order.of(userId, items); + Order savedOrder = orderRepository.save(order); + + // 소계 계산 + Integer subtotal = calculateSubtotal(items); + + // ✅ 도메인 이벤트 발행: 주문이 생성되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCreated.from(savedOrder, subtotal, 0L)); + + return savedOrder; + } + + /** + * 주문을 생성합니다 (쿠폰 코드와 소계 포함). + *

+ * 주문 생성 후 OrderCreated 이벤트를 발행합니다. + *

+ * + * @param command 주문 생성 명령 + * @return 생성된 주문 + */ + @Transactional + public Order create(CreateOrderCommand command) { + // 쿠폰이 있어도 discountAmount는 0으로 설정 (CouponEventHandler가 이벤트를 받아 쿠폰 적용) + Order order = Order.of(command.userId(), command.items(), command.couponCode(), 0); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 생성되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCreated.from(savedOrder, command.subtotal(), command.usedPointAmount())); + + return savedOrder; + } + + /** + * 주문에 쿠폰 할인 금액을 적용합니다. + *

+ * 이벤트 핸들러에서 쿠폰 적용 후 호출됩니다. + *

+ * + * @param orderId 주문 ID + * @param discountAmount 할인 금액 + * @return 업데이트된 주문 + * @throws CoreException 주문을 찾을 수 없거나 할인을 적용할 수 없는 상태인 경우 + */ + @Transactional + public Order applyCouponDiscount(Long orderId, Integer discountAmount) { + Order order = getById(orderId); + order.applyDiscount(discountAmount); + return orderRepository.save(order); + } + + /** + * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 + */ + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + + /** + * 주문을 완료 상태로 변경합니다. + *

+ * 주문 완료 후 OrderCompleted 이벤트를 발행합니다. + *

+ * + * @param orderId 주문 ID + * @return 완료된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public Order completeOrder(Long orderId) { + Order order = getById(orderId); + order.complete(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 완료되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCompleted.from(savedOrder)); + + return savedOrder; + } + + /** + * 주문을 취소 상태로 변경합니다. + *

+ * 주문 취소 후 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 별도 이벤트 핸들러에서 처리합니다. + *

+ * + * @param orderId 주문 ID + * @param reason 취소 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @return 취소된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public Order cancelOrder(Long orderId, String reason, Long refundPointAmount) { + Order order = getById(orderId); + order.cancel(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 취소되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCanceled.from(savedOrder, reason, refundPointAmount)); + + return savedOrder; + } + + /** + * 주문을 취소 상태로 변경하고 재고를 원복하며 포인트를 환불합니다. + *

+ * 도메인 로직만 처리합니다. 사용자 조회, 상품 조회, Payment 조회는 애플리케이션 레이어에서 처리합니다. + *

+ * + * @param order 주문 엔티티 + * @param products 주문 아이템에 해당하는 상품 목록 (락이 이미 획득된 상태) + * @param user 사용자 엔티티 (락이 이미 획득된 상태) + * @param refundPointAmount 환불할 포인트 금액 + * @throws CoreException 주문 또는 사용자 정보가 null인 경우 + */ + @Transactional + public void cancelOrder(Order order, List products, User user, Long refundPointAmount) { + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // 주문 취소 + order.cancel(); + + // 재고 원복 + increaseStocksForOrderItems(order.getItems(), products); + + // 포인트 환불 + if (refundPointAmount > 0) { + user.receivePoint(Point.of(refundPointAmount)); + } + + orderRepository.save(order); + } + + /** + * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

+ * 주문 상태 변경 후 해당 이벤트(OrderCompleted 또는 OrderCanceled)를 발행합니다. + *

+ *

+ * 도메인 로직만 처리합니다. 사용자 조회, 트랜잭션 관리, 로깅은 애플리케이션 레이어에서 처리합니다. + *

+ * + * @param order 주문 엔티티 + * @param paymentStatus 결제 상태 + * @param reason 취소 사유 (FAILED인 경우 필수, 그 외 null 가능) + * @param refundPointAmount 환불할 포인트 금액 (FAILED인 경우 필수, 그 외 null 가능) + * @throws CoreException 주문이 null이거나 이미 완료/취소된 경우 + */ + @Transactional + public void updateStatusByPaymentResult(Order order, PaymentStatus paymentStatus, String reason, Long refundPointAmount) { + if (order == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 + if (order.isCompleted() || order.isCanceled()) { + return; + } + + if (paymentStatus == PaymentStatus.SUCCESS) { + // 결제 성공: 주문 완료 + order.complete(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 완료되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCompleted.from(savedOrder)); + } else if (paymentStatus == PaymentStatus.FAILED) { + // 결제 실패: 주문 취소 (재고 원복 및 포인트 환불은 이벤트 핸들러에서 처리) + if (reason == null || reason.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 실패 시 취소 사유는 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "환불할 포인트 금액은 0 이상이어야 합니다."); + } + + order.cancel(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 취소되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCanceled.from(savedOrder, reason, refundPointAmount)); + } + // PENDING 상태: 상태 유지 (아무 작업도 하지 않음) + } + + /** + * 주문 아이템에 대해 재고를 증가시킵니다. + * + * @param items 주문 아이템 목록 + * @param products 상품 목록 + */ + private void increaseStocksForOrderItems(List items, List products) { + Map productMap = products.stream() + .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product)); + + for (OrderItem item : items) { + Product product = productMap.get(item.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); + } + product.increaseStock(item.getQuantity()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java new file mode 100644 index 000000000..b44cfb43e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java @@ -0,0 +1,141 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Outbox Bridge Event Listener. + *

+ * ApplicationEvent를 구독하여 외부 시스템(Kafka)으로 전송해야 하는 이벤트를 + * Transactional Outbox Pattern을 통해 Outbox에 저장합니다. + *

+ *

+ * 표준 패턴: + *

    + *
  • EventPublisher는 ApplicationEvent만 발행 (단일 책임)
  • + *
  • 이 컴포넌트가 ApplicationEvent를 구독하여 Outbox에 저장 (관심사 분리)
  • + *
  • 트랜잭션 커밋 후(AFTER_COMMIT) 처리하여 에러 격리
  • + *
+ *

+ *

+ * 처리 이벤트: + *

    + *
  • LikeEvent: LikeAdded, LikeRemoved → like-events
  • + *
  • OrderEvent: OrderCreated → order-events
  • + *
  • ProductEvent: ProductViewed → product-events
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxBridgeEventListener { + + private final OutboxEventService outboxEventService; + + /** + * LikeAdded 이벤트를 Outbox에 저장합니다. + * + * @param event LikeAdded 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + outboxEventService.saveEvent( + "LikeAdded", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeAdded 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeAdded 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * LikeRemoved 이벤트를 Outbox에 저장합니다. + * + * @param event LikeRemoved 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + outboxEventService.saveEvent( + "LikeRemoved", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeRemoved 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeRemoved 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * OrderCreated 이벤트를 Outbox에 저장합니다. + * + * @param event OrderCreated 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + outboxEventService.saveEvent( + "OrderCreated", + event.orderId().toString(), + "Order", + event, + "order-events", + event.orderId().toString() + ); + log.debug("OrderCreated 이벤트를 Outbox에 저장: orderId={}", event.orderId()); + } catch (Exception e) { + log.error("OrderCreated 이벤트 Outbox 저장 실패: orderId={}", event.orderId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * ProductViewed 이벤트를 Outbox에 저장합니다. + * + * @param event ProductViewed 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductViewed(ProductEvent.ProductViewed event) { + try { + outboxEventService.saveEvent( + "ProductViewed", + event.productId().toString(), + "Product", + event, + "product-events", + event.productId().toString() + ); + log.debug("ProductViewed 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("ProductViewed 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java new file mode 100644 index 000000000..4c5f54820 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java @@ -0,0 +1,100 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Outbox 이벤트 저장 서비스. + *

+ * 도메인 트랜잭션과 같은 트랜잭션에서 Outbox에 이벤트를 저장합니다. + * Application 레이어에 위치하여 비즈니스 로직(이벤트 저장 결정)을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OutboxEventService { + + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + /** + * Kafka로 전송할 이벤트를 Outbox에 저장합니다. + *

+ * 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다. + * 집계 ID별로 순차적인 버전을 자동으로 부여합니다. + *

+ *

+ * 버전 충돌 시 최대 3회까지 재시도합니다. + * 유니크 제약 조건을 통해 경쟁 조건을 감지하고 재시도합니다. + *

+ * + * @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded") + * @param aggregateId 집계 ID (예: orderId, productId) + * @param aggregateType 집계 타입 (예: "Order", "Product") + * @param event 이벤트 객체 + * @param topic Kafka 토픽 이름 + * @param partitionKey 파티션 키 + */ + @Transactional + public void saveEvent( + String eventType, + String aggregateId, + String aggregateType, + Object event, + String topic, + String partitionKey + ) { + int maxRetries = 3; + for (int i = 0; i < maxRetries; i++) { + try { + String eventId = UUID.randomUUID().toString(); + String payload = objectMapper.writeValueAsString(event); + + // 집계 ID별 최신 버전 조회 후 +1 + Long latestVersion = outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + Long nextVersion = latestVersion + 1L; + + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .version(nextVersion) + .build(); + + outboxEventRepository.save(outboxEvent); + log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}, version={}", + eventType, aggregateId, topic, nextVersion); + return; // 성공 + } catch (DataIntegrityViolationException e) { + // 유니크 제약 조건 위반 (버전 충돌) + if (i == maxRetries - 1) { + log.error("Outbox 이벤트 저장 실패 (최대 재시도 횟수 초과): eventType={}, aggregateId={}, retryCount={}", + eventType, aggregateId, i + 1, e); + throw new RuntimeException("Outbox 이벤트 저장 실패: 버전 충돌", e); + } + log.warn("Outbox 이벤트 저장 재시도: eventType={}, aggregateId={}, retryCount={}", + eventType, aggregateId, i + 1); + } catch (Exception e) { + log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}", + eventType, aggregateId, e); + throw new RuntimeException("Outbox 이벤트 저장 실패", e); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java new file mode 100644 index 000000000..dc8b250a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java @@ -0,0 +1,234 @@ +package com.loopers.application.payment; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDateTime; + +/** + * 결제 이벤트 핸들러. + *

+ * 결제 요청 이벤트를 받아 Payment 생성 및 PG 결제 요청 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: PaymentService는 결제 도메인 비즈니스 로직, PaymentEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventHandler { + + private final PaymentService paymentService; + private final PaymentGateway paymentGateway; + + /** + * 결제 요청 이벤트를 처리하여 Payment를 생성하고 PG 결제를 요청합니다. + *

+ * 결제 금액이 0인 경우 PG 요청 없이 바로 완료 처리합니다. + *

+ * + * @param event 결제 요청 이벤트 + */ + @Transactional + public void handlePaymentRequested(PaymentEvent.PaymentRequested event) { + try { + // Payment 생성 + CardType cardTypeEnum = (event.cardType() != null && !event.cardType().isBlank()) + ? convertCardType(event.cardType()) + : null; + + Payment payment = paymentService.create( + event.orderId(), + event.userEntityId(), + event.totalAmount(), + event.usedPointAmount(), + cardTypeEnum, + event.cardNo(), + LocalDateTime.now() + ); + + // 결제 금액이 0인 경우 (포인트+쿠폰으로 전액 결제) + Long paidAmount = event.totalAmount() - event.usedPointAmount(); + if (paidAmount.equals(0L)) { + // PG 요청 없이 바로 완료 (PaymentCompleted 이벤트 발행) + paymentService.toSuccess(payment.getId(), LocalDateTime.now(), null); + log.info("포인트+쿠폰으로 전액 결제 완료. (orderId: {})", event.orderId()); + return; + } + + // PG 결제가 필요한 경우 + if (event.cardType() == null || event.cardType().isBlank() || + event.cardNo() == null || event.cardNo().isBlank()) { + log.error("카드 정보가 없어 PG 결제를 진행할 수 없습니다. (orderId: {})", event.orderId()); + throw new CoreException( + ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + + // PG 결제 요청 (트랜잭션 커밋 후 별도 트랜잭션에서 처리) + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + // 쿠폰 할인이 적용된 후의 최신 Payment 정보 조회 + // 쿠폰 할인이 적용되면 Payment.applyCouponDiscount에서 paidAmount가 재계산됨 + Payment payment = paymentService.getPaymentByOrderId(event.orderId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("주문 ID에 해당하는 결제를 찾을 수 없습니다. (orderId: %d)", event.orderId()))); + + // 이미 완료된 결제는 PG 요청 불필요 + if (payment.isCompleted()) { + log.info("결제가 이미 완료되어 PG 요청을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 최신 paidAmount 사용 (쿠폰 할인 적용 후 금액) + Long paidAmount = payment.getPaidAmount(); + + // paidAmount가 0이면 PG 요청 불필요 (이미 완료 처리됨) + if (paidAmount == 0L) { + log.info("결제 금액이 0이어서 PG 요청을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // ✅ PaymentEvent.PaymentRequested를 구독하여 결제 요청 Command 실행 + String callbackUrl = generateCallbackUrl(event.orderId()); + // ✅ RequestPaymentCommand (PaymentRequestCommand) 생성 및 실행 + PaymentRequestCommand command = new PaymentRequestCommand( + event.userId(), + event.orderId(), + event.cardType(), + event.cardNo(), + paidAmount, + callbackUrl + ); + // 도메인 계층으로 변환하여 PG 결제 요청 + PaymentRequest paymentRequest = command.toPaymentRequest(); + PaymentRequestResult result = paymentGateway.requestPayment(paymentRequest); + + if (result instanceof PaymentRequestResult.Success success) { + // 결제 성공: PaymentService.toSuccess가 PaymentCompleted 이벤트를 발행하고, + // OrderEventHandler가 이를 받아 주문 상태를 COMPLETED로 변경 + paymentService.getPaymentByOrderId(event.orderId()).ifPresent(p -> { + if (p.isPending()) { + paymentService.toSuccess(p.getId(), LocalDateTime.now(), success.transactionKey()); + } + }); + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", + event.orderId(), success.transactionKey()); + } else if (result instanceof PaymentRequestResult.Failure failure) { + // PG 요청 실패: PaymentService.toFailed가 PaymentFailed 이벤트를 발행하고, + // OrderEventHandler가 이를 받아 주문 취소 처리 + paymentService.getPaymentByOrderId(event.orderId()).ifPresent(p -> { + if (p.isPending()) { + paymentService.toFailed(p.getId(), failure.message(), + LocalDateTime.now(), null); + } + }); + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + event.orderId(), failure.errorCode(), failure.message()); + } + } catch (Exception e) { + log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", + event.orderId(), e); + } + } + } + ); + + log.info("결제 요청 처리 완료. (orderId: {}, totalAmount: {}, usedPointAmount: {})", + event.orderId(), event.totalAmount(), event.usedPointAmount()); + } catch (Exception e) { + log.error("결제 요청 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } + + /** + * 쿠폰 적용 이벤트를 처리하여 결제 금액을 업데이트합니다. + *

+ * 쿠폰 할인이 적용된 후 Order의 totalAmount가 업데이트되면, + * Payment의 totalAmount도 동기화하기 위해 호출됩니다. + *

+ *

+ * EDA 원칙: + *

    + *
  • 이벤트 구독: CouponEvent.CouponApplied 이벤트를 구독하여 결제 도메인 상태 업데이트
  • + *
  • 책임 분리: CouponEventHandler는 쿠폰 도메인만 관리하고, 결제 동기화는 이 핸들러에서 처리
  • + *
+ *

+ * + * @param event 쿠폰 적용 이벤트 + */ + @Transactional + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + // 결제 금액에 쿠폰 할인 적용 + paymentService.applyCouponDiscount(event.orderId(), event.discountAmount()); + + log.info("쿠폰 할인 금액이 결제에 적용되었습니다. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), event.discountAmount()); + } catch (CoreException e) { + // 결제를 찾을 수 없는 경우는 로그만 기록 (정상적인 경우일 수 있음) + if (e.getErrorType() == ErrorType.NOT_FOUND) { + log.debug("쿠폰 적용 시 결제를 찾을 수 없습니다. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + } else { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 예상치 못한 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } + + /** + * 카드 타입 문자열을 CardType enum으로 변환합니다. + * + * @param cardType 카드 타입 문자열 + * @return CardType enum + */ + private CardType convertCardType(String cardType) { + try { + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } + + /** + * 콜백 URL을 생성합니다. + * + * @param orderId 주문 ID + * @return 콜백 URL + */ + private String generateCallbackUrl(Long orderId) { + return String.format("/api/v1/payments/callback?orderId=%d", orderId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java new file mode 100644 index 000000000..83c71557e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java @@ -0,0 +1,73 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 결제 요청 명령. + *

+ * PG 결제 요청에 필요한 정보를 담는 명령 모델입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public record PaymentRequestCommand( + String userId, + Long orderId, + String cardType, + String cardNo, + Long amount, + String callbackUrl +) { + public PaymentRequestCommand { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 필수입니다."); + } + if (cardType == null || cardType.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardType은 필수입니다."); + } + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardNo는 필수입니다."); + } + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "amount는 0보다 커야 합니다."); + } + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "callbackUrl은 필수입니다."); + } + } + + /** + * 도메인 계층의 PaymentRequest로 변환합니다. + *

+ * 애플리케이션 계층의 Command를 도메인 계층의 Value Object로 변환하여 + * 도메인 서비스에 전달합니다. + *

+ * + * @return 도메인 계층의 PaymentRequest + */ + public PaymentRequest toPaymentRequest() { + return new PaymentRequest( + userId, + orderId, + cardType, + cardNo, + amount, + callbackUrl + ); + } + + @Override + public String toString() { + String maskedCardNo = cardNo != null && cardNo.length() > 4 + ? "****" + cardNo.substring(cardNo.length() - 4) + : "****"; + return "PaymentRequestCommand[userId=%s, orderId=%d, cardType=%s, cardNo=%s, amount=%d, callbackUrl=%s]" + .formatted(userId, orderId, cardType, maskedCardNo, amount, callbackUrl); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java new file mode 100644 index 000000000..5802d44a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java @@ -0,0 +1,482 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 결제 애플리케이션 서비스. + *

+ * 결제의 생성, 조회, 상태 변경 및 PG 연동을 담당하는 애플리케이션 서비스입니다. + * 도메인 로직은 Payment 엔티티에 위임하며, Service는 조회/저장, 트랜잭션 관리 및 PG 연동을 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final PaymentGateway paymentGateway; + private final PaymentEventPublisher paymentEventPublisher; + + @Value("${payment.callback.base-url}") + private String callbackBaseUrl; + + /** + * 카드 결제를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + CardType cardType, + String cardNo, + Long amount, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 포인트 결제를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 포인트와 카드 혼합 결제를 생성합니다. + *

+ * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + CardType cardType, + String cardNo, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, cardType, cardNo, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 결제를 SUCCESS 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + * 결제 완료 후 PaymentCompleted 이벤트를 발행합니다. + *

+ * + * @param paymentId 결제 ID + * @param completedAt PG 완료 시각 + * @param transactionKey 트랜잭션 키 (null 가능) + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional + public void toSuccess(Long paymentId, LocalDateTime completedAt, String transactionKey) { + Payment payment = getPayment(paymentId); + + // 이미 SUCCESS 상태인 경우 이벤트 발행하지 않음 (멱등성) + if (payment.isCompleted()) { + return; + } + + payment.toSuccess(completedAt); // Entity에 위임 + Payment savedPayment = paymentRepository.save(payment); + + // ✅ 도메인 이벤트 발행: 결제가 완료되었음 (과거 사실) + paymentEventPublisher.publish(PaymentEvent.PaymentCompleted.from(savedPayment, transactionKey)); + } + + /** + * 결제를 FAILED 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + * 결제 실패 후 PaymentFailed 이벤트를 발행합니다. + *

+ * + * @param paymentId 결제 ID + * @param failureReason 실패 사유 + * @param completedAt PG 완료 시각 + * @param transactionKey 트랜잭션 키 (null 가능) + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional + public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt, String transactionKey) { + Payment payment = getPayment(paymentId); + + // 이미 FAILED 상태인 경우 이벤트 발행하지 않음 (멱등성) + if (payment.getStatus() == PaymentStatus.FAILED) { + return; + } + + payment.toFailed(failureReason, completedAt); // Entity에 위임 + Payment savedPayment = paymentRepository.save(payment); + + // ✅ 도메인 이벤트 발행: 결제가 실패했음 (과거 사실) + paymentEventPublisher.publish(PaymentEvent.PaymentFailed.from(savedPayment, failureReason, transactionKey)); + } + + /** + * 결제 ID로 결제를 조회합니다. + * + * @param paymentId 결제 ID + * @return 조회된 Payment + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Payment getPayment(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다.")); + } + + /** + * 주문 ID로 결제를 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 Payment (없으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getPaymentByOrderId(Long orderId) { + return paymentRepository.findByOrderId(orderId); + } + + /** + * 사용자 ID로 결제 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 결제 목록 + */ + @Transactional(readOnly = true) + public List getPaymentsByUserId(Long userId) { + return paymentRepository.findAllByUserId(userId); + } + + /** + * 결제 상태로 결제 목록을 조회합니다. + * + * @param status 결제 상태 + * @return 해당 상태의 결제 목록 + */ + @Transactional(readOnly = true) + public List getPaymentsByStatus(PaymentStatus status) { + return paymentRepository.findAllByStatus(status); + } + + /** + * PG 결제 요청을 생성하고 전송합니다. + *

+ * 결제를 생성하고 PG에 결제 요청을 전송합니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId) + * @param userEntityId 사용자 엔티티 ID (Long - User.id) + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @return 결제 요청 결과 + */ + @Transactional + public PaymentRequestResult requestPayment( + Long orderId, + String userId, + Long userEntityId, + String cardType, + String cardNo, + Long amount + ) { + // 1. 카드 번호 유효성 검증 + validateCardNo(cardNo); + + // 2. 결제 생성 (User 엔티티의 id 사용) + Payment payment = create( + orderId, + userEntityId, + convertCardType(cardType), + cardNo, + amount, + LocalDateTime.now() + ); + + // 3. 결제 요청 명령 생성 (애플리케이션 계층) + String callbackUrl = generateCallbackUrl(orderId); + PaymentRequestCommand command = new PaymentRequestCommand( + userId, + orderId, + cardType, + cardNo, + amount, + callbackUrl + ); + + // 4. 도메인 계층으로 변환하여 PG 결제 요청 전송 + PaymentRequest paymentRequest = command.toPaymentRequest(); + PaymentRequestResult result = paymentGateway.requestPayment(paymentRequest); + + // 5. 결과 처리 + if (result instanceof PaymentRequestResult.Success success) { + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, success.transactionKey()); + return result; + } else if (result instanceof PaymentRequestResult.Failure failure) { + // 실패 분류 + PaymentFailureType failureType = PaymentFailureType.classify(failure.errorCode()); + if (failureType == PaymentFailureType.BUSINESS_FAILURE) { + // 비즈니스 실패: 결제 상태를 FAILED로 변경 + toFailed(payment.getId(), failure.message(), LocalDateTime.now(), null); + } + // 외부 시스템 장애는 PENDING 상태 유지 + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + orderId, failure.errorCode(), failure.message()); + return result; + } + + throw new IllegalStateException("알 수 없는 결제 결과 타입: " + result.getClass().getName()); + } + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 + */ + @Transactional(readOnly = true) + public PaymentStatus getPaymentStatus(String userId, Long orderId) { + return paymentGateway.getPaymentStatus(userId, orderId); + } + + /** + * PG 콜백을 처리합니다. + * + * @param orderId 주문 ID + * @param transactionKey 트랜잭션 키 + * @param status 결제 상태 + * @param reason 실패 사유 (실패 시) + */ + @Transactional + public void handleCallback(Long orderId, String transactionKey, PaymentStatus status, String reason) { + Optional paymentOpt = getPaymentByOrderId(orderId); + if (paymentOpt.isEmpty()) { + log.warn("콜백 처리 시 결제를 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + Payment payment = paymentOpt.get(); + + if (status == PaymentStatus.SUCCESS) { + toSuccess(payment.getId(), LocalDateTime.now(), transactionKey); + log.info("결제 콜백 처리 완료: SUCCESS. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } else if (status == PaymentStatus.FAILED) { + toFailed(payment.getId(), reason != null ? reason : "결제 실패", LocalDateTime.now(), transactionKey); + log.warn("결제 콜백 처리 완료: FAILED. (orderId: {}, transactionKey: {}, reason: {})", + orderId, transactionKey, reason); + } else { + // PENDING 상태: 상태 유지 + log.debug("결제 콜백 처리: PENDING 상태 유지. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } + } + + /** + * 타임아웃 후 결제 상태를 복구합니다. + *

+ * 타임아웃 발생 후 실제 결제 상태를 확인하여 결제 상태를 업데이트합니다. + *

+ * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param delayDuration 대기 시간 (PG 처리 시간 고려) + */ + public void recoverAfterTimeout(String userId, Long orderId, Duration delayDuration) { + try { + // 잠시 대기 후 상태 확인 (PG 처리 시간 고려) + if (delayDuration != null && !delayDuration.isZero()) { + Thread.sleep(delayDuration.toMillis()); + } + + // 결제 상태 조회 + PaymentStatus status = getPaymentStatus(userId, orderId); + Optional paymentOpt = getPaymentByOrderId(orderId); + + if (paymentOpt.isEmpty()) { + log.warn("복구 시 결제를 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + Payment payment = paymentOpt.get(); + + if (status == PaymentStatus.SUCCESS) { + toSuccess(payment.getId(), LocalDateTime.now(), null); + log.info("타임아웃 후 상태 확인 완료: SUCCESS. (orderId: {})", orderId); + } else if (status == PaymentStatus.FAILED) { + toFailed(payment.getId(), "타임아웃 후 상태 확인 실패", LocalDateTime.now(), null); + log.warn("타임아웃 후 상태 확인 완료: FAILED. (orderId: {})", orderId); + } else { + // PENDING 상태: 상태 유지 + log.debug("타임아웃 후 상태 확인: PENDING 상태 유지. (orderId: {})", orderId); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId); + } catch (Exception e) { + log.error("타임아웃 후 상태 확인 중 오류 발생. (orderId: {})", orderId, e); + } + } + + /** + * 타임아웃 후 결제 상태를 복구합니다 (기본 대기 시간: 1초). + * + * @param userId 사용자 ID + * @param orderId 주문 ID + */ + public void recoverAfterTimeout(String userId, Long orderId) { + recoverAfterTimeout(userId, orderId, Duration.ofSeconds(1)); + } + + /** + * 쿠폰 할인 금액을 적용하여 결제 금액을 업데이트합니다. + *

+ * 쿠폰 할인이 적용된 후 Order의 totalAmount가 업데이트되면, + * Payment의 totalAmount도 동기화하기 위해 호출됩니다. + *

+ * + * @param orderId 주문 ID + * @param discountAmount 할인 금액 + * @throws CoreException 결제를 찾을 수 없거나 할인 금액이 유효하지 않은 경우 + */ + @Transactional + public void applyCouponDiscount(Long orderId, Integer discountAmount) { + Payment payment = getPaymentByOrderId(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("주문 ID에 해당하는 결제를 찾을 수 없습니다. (orderId: %d)", orderId))); + + payment.applyCouponDiscount(discountAmount); + paymentRepository.save(payment); + } + + // 내부 헬퍼 메서드들 + + private CardType convertCardType(String cardType) { + try { + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } + + private String generateCallbackUrl(Long orderId) { + return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId); + } + + /** + * 카드 번호 유효성 검증을 수행합니다. + * + * @param cardNo 카드 번호 + * @throws CoreException 유효하지 않은 카드 번호인 경우 + */ + private void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + + // 공백/하이픈 제거 및 정규화 + String normalized = cardNo.replaceAll("[\\s-]", ""); + + // 길이 검증 (13-19자리) + if (normalized.length() < 13 || normalized.length() > 19) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length())); + } + + // 숫자만 포함하는지 검증 + if (!normalized.matches("\\d+")) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다."); + } + + // Luhn 알고리즘 체크섬 검증 + if (!isValidLuhn(normalized)) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)"); + } + } + + /** + * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다. + * + * @param cardNo 정규화된 카드 번호 (숫자만 포함) + * @return 유효한 경우 true, 그렇지 않으면 false + */ + private boolean isValidLuhn(String cardNo) { + int sum = 0; + boolean alternate = false; + + // 오른쪽에서 왼쪽으로 순회 + for (int i = cardNo.length() - 1; i >= 0; i--) { + int digit = Character.getNumericValue(cardNo.charAt(i)); + + if (alternate) { + digit *= 2; + if (digit > 9) { + digit = (digit % 10) + 1; + } + } + + sum += digit; + alternate = !alternate; + } + + return (sum % 10) == 0; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java new file mode 100644 index 000000000..343292349 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 재고 차감 명령. + *

+ * 재고 차감을 위한 명령 객체입니다. + *

+ * + * @param productId 상품 ID + * @param quantity 차감할 수량 + */ +public record DeductStockCommand( + Long productId, + Integer quantity +) { + public DeductStockCommand { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java new file mode 100644 index 000000000..32c4f915b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java @@ -0,0 +1,275 @@ +package com.loopers.application.product; + +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; +import com.loopers.cache.CacheKey; +import com.loopers.cache.CacheTemplate; +import com.loopers.cache.SimpleCacheKey; +import com.loopers.domain.product.ProductDetail; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 상품 조회 결과를 캐시하는 서비스. + *

+ * 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다. + *

+ *

+ * 캐시 전략: + *

    + *
  • 상품 목록: 첫 3페이지만 캐시하여 메모리 사용량 최적화
  • + *
  • 상품 상세: 모든 상품 상세 정보 캐시
  • + *
+ *

+ * + * @author Loopers + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductCacheService { + + private static final String CACHE_KEY_PREFIX_LIST = "product:list:"; + private static final String CACHE_KEY_PREFIX_DETAIL = "product:detail:"; + private static final Duration CACHE_TTL = Duration.ofMinutes(1); // 1분 TTL + + private final CacheTemplate cacheTemplate; + + /** + * 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta) + *

+ * 좋아요 추가/취소 시 델타를 저장하고, 캐시 조회 시 델타를 적용하여 반환합니다. + * 배치 집계 후에는 초기화됩니다. + *

+ */ + private final ConcurrentHashMap likeCountDeltaCache = new ConcurrentHashMap<>(); + + /** + * 상품 목록 조회 결과를 캐시에서 조회합니다. + *

+ * 페이지 번호와 관계없이 캐시를 확인하고, 캐시에 있으면 반환합니다. + * 캐시에 없으면 null을 반환하여 DB 조회를 유도합니다. + *

+ *

+ * 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다. + *

+ * + * @param brandId 브랜드 ID (null이면 전체) + * @param sort 정렬 기준 + * @param page 페이지 번호 + * @param size 페이지당 상품 수 + * @return 캐시된 상품 목록 (없으면 null) + */ + public ProductInfoList getCachedProductList(Long brandId, String sort, int page, int size) { + String cacheKey = buildListCacheKey(brandId, sort, page, size); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfoList.class + ); + + Optional cached = cacheTemplate.get(key); + if (cached.isEmpty()) { + return null; + } + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cached.get()); + } + + /** + * 상품 목록 조회 결과를 캐시에 저장합니다. + *

+ * 첫 3페이지인 경우에만 캐시에 저장합니다. + *

+ * + * @param brandId 브랜드 ID (null이면 전체) + * @param sort 정렬 기준 + * @param page 페이지 번호 + * @param size 페이지당 상품 수 + * @param productInfoList 캐시할 상품 목록 + */ + public void cacheProductList(Long brandId, String sort, int page, int size, ProductInfoList productInfoList) { + // 3페이지까지만 캐시 저장 + if (page > 2) { + return; + } + + String cacheKey = buildListCacheKey(brandId, sort, page, size); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfoList.class + ); + + cacheTemplate.put(key, productInfoList); + } + + /** + * 상품 상세 조회 결과를 캐시에서 조회합니다. + *

+ * 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다. + *

+ * + * @param productId 상품 ID + * @return 캐시된 상품 정보 (없으면 null) + */ + public ProductInfo getCachedProduct(Long productId) { + String cacheKey = buildDetailCacheKey(productId); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfo.class + ); + + Optional cached = cacheTemplate.get(key); + if (cached.isEmpty()) { + return null; + } + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cached.get()); + } + + /** + * 상품 상세 조회 결과를 캐시에 저장합니다. + * + * @param productId 상품 ID + * @param productInfo 캐시할 상품 정보 + */ + public void cacheProduct(Long productId, ProductInfo productInfo) { + String cacheKey = buildDetailCacheKey(productId); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfo.class + ); + + cacheTemplate.put(key, productInfo); + } + + /** + * 상품 목록 캐시 키를 생성합니다. + * + * @param brandId 브랜드 ID (null이면 "all") + * @param sort 정렬 기준 + * @param page 페이지 번호 + * @param size 페이지당 상품 수 + * @return 캐시 키 + */ + private String buildListCacheKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? "brand:" + brandId : "brand:all"; + String sortValue = sort != null ? sort : "latest"; + return String.format("%s%s:sort:%s:page:%d:size:%d", + CACHE_KEY_PREFIX_LIST, brandPart, sortValue, page, size); + } + + /** + * 상품 상세 캐시 키를 생성합니다. + * + * @param productId 상품 ID + * @return 캐시 키 + */ + private String buildDetailCacheKey(Long productId) { + return CACHE_KEY_PREFIX_DETAIL + productId; + } + + /** + * 좋아요 수 델타를 증가시킵니다. + *

+ * 좋아요 추가 시 호출됩니다. + *

+ * + * @param productId 상품 ID + */ + public void incrementLikeCountDelta(Long productId) { + likeCountDeltaCache.merge(productId, 1L, Long::sum); + } + + /** + * 좋아요 수 델타를 감소시킵니다. + *

+ * 좋아요 취소 시 호출됩니다. + *

+ * + * @param productId 상품 ID + */ + public void decrementLikeCountDelta(Long productId) { + likeCountDeltaCache.merge(productId, -1L, Long::sum); + } + + /** + * 모든 좋아요 수 델타를 초기화합니다. + *

+ * 배치 집계 후 호출됩니다. + *

+ */ + public void clearAllLikeCountDelta() { + likeCountDeltaCache.clear(); + } + + /** + * 상품 목록에 좋아요 수 델타를 적용합니다. + *

+ * DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다. + *

+ * + * @param productInfoList 상품 목록 + * @return 델타가 적용된 상품 목록 + */ + public ProductInfoList applyLikeCountDelta(ProductInfoList productInfoList) { + if (likeCountDeltaCache.isEmpty()) { + return productInfoList; + } + + List updatedProducts = productInfoList.products().stream() + .map(this::applyLikeCountDelta) + .collect(Collectors.toList()); + + return new ProductInfoList( + updatedProducts, + productInfoList.totalCount(), + productInfoList.page(), + productInfoList.size() + ); + } + + /** + * 상품 정보에 좋아요 수 델타를 적용합니다. + *

+ * DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다. + *

+ * + * @param productInfo 상품 정보 + * @return 델타가 적용된 상품 정보 + */ + public ProductInfo applyLikeCountDelta(ProductInfo productInfo) { + Long delta = likeCountDeltaCache.get(productInfo.productDetail().getId()); + if (delta == null || delta == 0) { + return productInfo; + } + + ProductDetail originalDetail = productInfo.productDetail(); + Long updatedLikesCount = originalDetail.getLikesCount() + delta; + + ProductDetail updatedDetail = ProductDetail.of( + originalDetail.getId(), + originalDetail.getName(), + originalDetail.getPrice(), + originalDetail.getStock(), + originalDetail.getBrandId(), + originalDetail.getBrandName(), + updatedLikesCount + ); + + return ProductInfo.withoutRank(updatedDetail); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java new file mode 100644 index 000000000..3cba362e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java @@ -0,0 +1,214 @@ +package com.loopers.application.product; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.Product; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 상품 이벤트 핸들러. + *

+ * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아 상품의 좋아요 수 및 재고를 업데이트하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: ProductService는 상품 도메인 비즈니스 로직, ProductEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 상품 도메인은 자신의 상태만 관리하며, 주문 생성/취소 이벤트를 구독하여 재고 관리
  • + *
  • EDA 원칙: LikeEvent를 구독하여 상품 좋아요 수 및 캐시를 업데이트
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventHandler { + + private final ProductService productService; + private final ProductCacheService productCacheService; + + /** + * 좋아요 추가 이벤트를 처리하여 상품의 좋아요 수를 증가시킵니다. + *

+ * EDA 원칙: + *

    + *
  • 이벤트 구독: LikeEvent.LikeAdded 이벤트를 구독하여 상품 도메인 상태 업데이트
  • + *
  • 책임 분리: HeartFacade는 이 핸들러의 존재를 모르며, 이벤트만 발행
  • + *
+ *

+ * + * @param event 좋아요 추가 이벤트 + */ + @Transactional + public void handleLikeAdded(LikeEvent.LikeAdded event) { + log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 증가 + productService.incrementLikeCount(event.productId()); + + // ✅ 캐시 델타 업데이트: 좋아요 추가 시 로컬 캐시의 델타 증가 + productCacheService.incrementLikeCountDelta(event.productId()); + + log.debug("좋아요 수 증가 완료: productId={}", event.productId()); + } + + /** + * 좋아요 취소 이벤트를 처리하여 상품의 좋아요 수를 감소시킵니다. + *

+ * EDA 원칙: + *

    + *
  • 이벤트 구독: LikeEvent.LikeRemoved 이벤트를 구독하여 상품 도메인 상태 업데이트
  • + *
  • 책임 분리: HeartFacade는 이 핸들러의 존재를 모르며, 이벤트만 발행
  • + *
+ *

+ * + * @param event 좋아요 취소 이벤트 + */ + @Transactional + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 감소 + productService.decrementLikeCount(event.productId()); + + // ✅ 캐시 델타 업데이트: 좋아요 취소 시 로컬 캐시의 델타 감소 + productCacheService.decrementLikeCountDelta(event.productId()); + + log.debug("좋아요 수 감소 완료: productId={}", event.productId()); + } + + /** + * 주문 생성 이벤트를 처리하여 재고를 차감합니다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 차감 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + if (event.orderItems() == null || event.orderItems().isEmpty()) { + log.debug("주문 아이템이 없어 재고 차감을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = event.orderItems().stream() + .map(OrderEvent.OrderCreated.OrderItemInfo::productId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + try { + Product product = productService.getProductForUpdate(productId); + productMap.put(productId, product); + } catch (Exception e) { + log.warn("상품 락 획득 실패. (orderId: {}, productId: {})", event.orderId(), productId); + // 상품이 없으면 해당 아이템은 건너뜀 + } + } + + // ✅ OrderEvent.OrderCreated를 구독하여 재고 차감 Command 실행 + for (OrderEvent.OrderCreated.OrderItemInfo itemInfo : event.orderItems()) { + Product product = productMap.get(itemInfo.productId()); + if (product == null) { + continue; + } + + // ✅ DeductStockCommand 생성 및 실행 + DeductStockCommand command = new DeductStockCommand(itemInfo.productId(), itemInfo.quantity()); + product.decreaseStock(command.quantity()); + } + + // 저장 + productService.saveAll(productMap.values().stream().toList()); + + log.info("주문 생성으로 인한 재고 차감 완료. (orderId: {})", event.orderId()); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리하여 재고를 원복합니다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

+ * + * @param event 주문 취소 이벤트 + */ + @Transactional + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + if (event.orderItems() == null || event.orderItems().isEmpty()) { + log.debug("주문 아이템이 없어 재고 원복을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = event.orderItems().stream() + .map(OrderEvent.OrderCanceled.OrderItemInfo::productId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + try { + Product product = productService.getProductForUpdate(productId); + productMap.put(productId, product); + } catch (Exception e) { + log.warn("상품 락 획득 실패. (orderId: {}, productId: {})", event.orderId(), productId); + // 상품이 없으면 해당 아이템은 건너뜀 + } + } + + // 재고 원복 + for (OrderEvent.OrderCanceled.OrderItemInfo itemInfo : event.orderItems()) { + Product product = productMap.get(itemInfo.productId()); + if (product == null) { + log.warn("상품을 찾을 수 없습니다. (orderId: {}, productId: {})", + event.orderId(), itemInfo.productId()); + continue; + } + product.increaseStock(itemInfo.quantity()); + } + + // 저장 + productService.saveAll(productMap.values().stream().toList()); + + log.info("주문 취소로 인한 재고 원복 완료. (orderId: {})", event.orderId()); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..36889a38f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,148 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 상품 애플리케이션 서비스. + *

+ * 상품 조회, 저장 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리를 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class ProductService { + private final ProductRepository productRepository; + + /** + * 상품 ID로 상품을 조회합니다. + * + * @param productId 조회할 상품 ID + * @return 조회된 상품 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + } + + /** + * 상품 ID 목록으로 상품 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param productIds 조회할 상품 ID 목록 + * @return 조회된 상품 목록 + */ + @Transactional(readOnly = true) + public List getProducts(List productIds) { + return productRepository.findAllById(productIds); + } + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감) + *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public Product getProductForUpdate(Long productId) { + return productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + } + + /** + * 상품 목록을 저장합니다. + * + * @param products 저장할 상품 목록 + */ + @Transactional + public void saveAll(List products) { + products.forEach(productRepository::save); + } + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 상품 수 + * @return 상품 목록 + */ + @Transactional(readOnly = true) + public List findAll(Long brandId, String sort, int page, int size) { + return productRepository.findAll(brandId, sort, page, size); + } + + /** + * 상품 목록의 총 개수를 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @return 상품 총 개수 + */ + @Transactional(readOnly = true) + public long countAll(Long brandId) { + return productRepository.countAll(brandId); + } + + /** + * 상품의 좋아요 수를 증가시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ *

+ * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *

+ * + * @param productId 상품 ID + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public void incrementLikeCount(Long productId) { + Product product = getProduct(productId); + product.incrementLikeCount(); + productRepository.save(product); + } + + /** + * 상품의 좋아요 수를 감소시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ *

+ * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *

+ * + * @param productId 상품 ID + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public void decrementLikeCount(Long productId) { + Product product = getProduct(productId); + product.decrementLikeCount(); + productRepository.save(product); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java new file mode 100644 index 000000000..48e9bdb58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java @@ -0,0 +1,46 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +/** + * 주문 정보를 담는 레코드. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 주문 금액 + * @param status 주문 상태 + * @param items 주문 아이템 목록 + */ +public record OrderInfo( + Long orderId, + Long userId, + Integer totalAmount, + OrderStatus status, + List items +) { + /** + * Order 엔티티로부터 OrderInfo를 생성합니다. + * + * @param order 주문 엔티티 + * @return 생성된 OrderInfo + */ + public static OrderInfo from(Order order) { + List itemInfos = order.getItems() == null + ? List.of() + : order.getItems().stream() + .map(OrderItemInfo::from) + .toList(); + + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalAmount(), + order.getStatus(), + itemInfos + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java new file mode 100644 index 000000000..903595a6c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java @@ -0,0 +1,34 @@ +package com.loopers.application.purchasing; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 주문 생성 요청 아이템 명령. + * + * @param productId 상품 ID + * @param quantity 수량 + * @param couponCode 쿠폰 코드 (선택) + */ +public record OrderItemCommand(Long productId, Integer quantity, String couponCode) { + public OrderItemCommand { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다."); + } + } + + /** + * 쿠폰 코드 없이 OrderItemCommand를 생성합니다. + * + * @param productId 상품 ID + * @param quantity 수량 + * @return 생성된 OrderItemCommand + */ + public static OrderItemCommand of(Long productId, Integer quantity) { + return new OrderItemCommand(productId, quantity, null); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java new file mode 100644 index 000000000..dffb90549 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java @@ -0,0 +1,34 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.OrderItem; + +/** + * 주문 아이템 정보를 담는 레코드. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param quantity 수량 + */ +public record OrderItemInfo( + Long productId, + String name, + Integer price, + Integer quantity +) { + /** + * OrderItem으로부터 OrderItemInfo를 생성합니다. + * + * @param item 주문 아이템 + * @return 생성된 OrderItemInfo + */ + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getName(), + item.getPrice(), + item.getQuantity() + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java new file mode 100644 index 000000000..bf76c3280 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -0,0 +1,609 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.application.order.CreateOrderCommand; +import com.loopers.application.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.application.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.application.user.UserService; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentEventPublisher; +import com.loopers.application.payment.PaymentService; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import feign.FeignException; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 구매 파사드. + *

+ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율하는 애플리케이션 서비스입니다. + * 여러 도메인 서비스를 조합하여 구매 유즈케이스를 처리합니다. + *

+ *

+ * EDA 원칙 준수: + *

    + *
  • 이벤트 기반: 도메인 이벤트만 발행하고, 다른 애그리거트를 직접 수정하지 않음
  • + *
  • 느슨한 결합: Product, User, Payment 애그리거트와의 직접적인 의존성 최소화
  • + *
  • 책임 분리: 주문 도메인만 관리하고, 재고/포인트/결제 처리는 이벤트 핸들러에서 처리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PurchasingFacade { + + private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 + private final ProductService productService; // 상품 조회용으로만 사용 (재고 검증은 이벤트 핸들러에서) + private final OrderService orderService; + private final PaymentService paymentService; // Payment 조회용으로만 사용 + private final PaymentEventPublisher paymentEventPublisher; // PaymentEvent 발행용 + + /** + * 주문을 생성한다. + *

+ * 1. 사용자 조회 및 존재 여부 검증
+ * 2. 상품 조회 (재고 검증은 이벤트 핸들러에서 처리)
+ * 3. 쿠폰 할인 적용
+ * 4. 주문 저장 및 OrderEvent.OrderCreated 이벤트 발행
+ * 5. 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행
+ *

+ *

+ * 결제 방식: + *

    + *
  • 포인트+쿠폰 전액 결제: paidAmount == 0이면 PG 요청 없이 바로 완료
  • + *
  • 혼합 결제: 포인트 일부 사용 + PG 결제 나머지 금액
  • + *
  • 카드만 결제: 포인트 사용 없이 카드로 전체 금액 결제
  • + *
+ *

+ *

+ * EDA 원칙: + *

    + *
  • 이벤트 기반: 재고 차감은 OrderEvent.OrderCreated를 구독하는 ProductEventHandler에서 처리
  • + *
  • 이벤트 기반: 포인트 차감은 OrderEvent.OrderCreated를 구독하는 PointEventHandler에서 처리
  • + *
  • 이벤트 기반: Payment 생성 및 PG 결제는 PaymentEvent.PaymentRequested를 구독하는 PaymentEventHandler에서 처리
  • + *
  • 느슨한 결합: Product, User, Payment 애그리거트를 직접 수정하지 않고 이벤트만 발행
  • + *
+ *

+ * + * @param userId 사용자 식별자 (로그인 ID) + * @param commands 주문 상품 정보 + * @param usedPoint 포인트 사용량 (선택, 기본값: 0) + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @return 생성된 주문 정보 + */ + @Transactional + public OrderInfo createOrder(String userId, List commands, Long usedPoint, String cardType, String cardNo) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (commands == null || commands.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); + } + + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // 포인트 검증은 PointEventHandler에서 처리 + User user = userService.getUser(userId); + + // ✅ EDA 원칙: ProductService는 상품 조회만 (재고 검증은 ProductEventHandler에서 처리) + List sortedProductIds = commands.stream() + .map(OrderItemCommand::productId) + .distinct() + .sorted() + .toList(); + + // 중복 상품 검증 + if (sortedProductIds.size() != commands.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품이 중복되었습니다."); + } + + // 상품 조회 (재고 검증은 이벤트 핸들러에서 처리) + Map productMap = new java.util.HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productService.getProduct(productId); + productMap.put(productId, product); + } + + // OrderItem 생성 및 재고 사전 검증 + List orderItems = new ArrayList<>(); + for (OrderItemCommand command : commands) { + Product product = productMap.get(command.productId()); + + // ✅ 재고 사전 검증 (읽기 전용 조회이므로 EDA 원칙 위반 아님) + // 재고 차감은 여전히 ProductEventHandler에서 처리 + int currentStock = product.getStock(); + if (currentStock < command.quantity()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("재고가 부족합니다. (현재 재고: %d, 요청 수량: %d)", currentStock, command.quantity())); + } + + orderItems.add(OrderItem.of( + product.getId(), + product.getName(), + product.getPrice(), + command.quantity() + )); + } + + // 쿠폰 코드 추출 + String couponCode = extractCouponCode(commands); + Integer subtotal = calculateSubtotal(orderItems); + + // 포인트 사용량 + Long usedPointAmount = Objects.requireNonNullElse(usedPoint, 0L); + + // ✅ CreateOrderCommand 생성 + CreateOrderCommand createOrderCommand = new CreateOrderCommand( + user.getId(), + orderItems, + couponCode, + subtotal, + usedPointAmount + ); + + // ✅ OrderService.create() 호출 → OrderEvent.OrderCreated 이벤트 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ CouponEventHandler가 OrderEvent.OrderCreated를 구독하여 쿠폰 적용 처리 + // ✅ PointEventHandler가 OrderEvent.OrderCreated를 구독하여 포인트 차감 처리 + Order savedOrder = orderService.create(createOrderCommand); + + // PG 결제 금액 계산 + // 주의: 쿠폰 할인은 비동기로 적용되므로, PaymentEvent.PaymentRequested 발행 시점에는 할인 전 금액(subtotal)을 사용 + // 쿠폰 할인이 적용된 후에는 OrderEventHandler가 주문의 totalAmount를 업데이트함 + Long totalAmount = subtotal.longValue(); // 쿠폰 할인 전 금액 사용 + Long paidAmount = totalAmount - usedPointAmount; + + // ✅ 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 + if (paidAmount.equals(0L)) { + // 포인트+쿠폰으로 전액 결제 완료된 경우 + // PaymentEventHandler가 Payment를 생성하고 바로 완료 처리 + paymentEventPublisher.publish(PaymentEvent.PaymentRequested.of( + savedOrder.getId(), + userId, + user.getId(), + totalAmount, + usedPointAmount, + null, + null + )); + log.debug("포인트+쿠폰으로 전액 결제 요청. (orderId: {})", savedOrder.getId()); + } else { + // PG 결제가 필요한 경우 + if (cardType == null || cardType.isBlank() || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + + paymentEventPublisher.publish(PaymentEvent.PaymentRequested.of( + savedOrder.getId(), + userId, + user.getId(), + totalAmount, + usedPointAmount, + cardType, + cardNo + )); + log.debug("PG 결제 요청. (orderId: {})", savedOrder.getId()); + } + + return OrderInfo.from(savedOrder); + } + + /** + * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. + *

+ * EDA 원칙: + *

    + *
  • 이벤트 기반: OrderService.cancelOrder()가 OrderEvent.OrderCanceled 이벤트를 발행
  • + *
  • 이벤트 기반: 재고 원복은 OrderEvent.OrderCanceled를 구독하는 ProductEventHandler에서 처리
  • + *
  • 이벤트 기반: 포인트 환불은 OrderEvent.OrderCanceled를 구독하는 PointEventHandler에서 처리
  • + *
  • 느슨한 결합: Product, User 애그리거트를 직접 수정하지 않고 이벤트만 발행
  • + *
+ *

+ * + * @param order 주문 엔티티 + * @param user 사용자 엔티티 + */ + @Transactional + public void cancelOrder(Order order, User user) { + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // 실제로 사용된 포인트만 환불 (Payment에서 확인) + Long refundPointAmount = paymentService.getPaymentByOrderId(order.getId()) + .map(Payment::getUsedPoint) + .orElse(0L); + + // ✅ OrderService.cancelOrder() 호출 → OrderEvent.OrderCanceled 이벤트 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCanceled를 구독하여 재고 원복 처리 + // ✅ PointEventHandler가 OrderEvent.OrderCanceled를 구독하여 포인트 환불 처리 + orderService.cancelOrder(order.getId(), "사용자 요청", refundPointAmount); + + log.info("주문 취소 처리 완료. (orderId: {}, refundPointAmount: {})", order.getId(), refundPointAmount); + } + + /** + * 사용자 ID로 주문 목록을 조회한다. + * + * @param userId 사용자 식별자 (로그인 ID) + * @return 주문 목록 + */ + @Transactional(readOnly = true) + public List getOrders(String userId) { + User user = userService.getUser(userId); + List orders = orderService.getOrdersByUserId(user.getId()); + return orders.stream() + .map(OrderInfo::from) + .toList(); + } + + /** + * 주문 ID로 단일 주문을 조회한다. + * + * @param userId 사용자 식별자 (로그인 ID) + * @param orderId 주문 ID + * @return 주문 정보 + */ + @Transactional(readOnly = true) + public OrderInfo getOrder(String userId, Long orderId) { + User user = userService.getUser(userId); + Order order = orderService.getById(orderId); + + if (!order.getUserId().equals(user.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + + return OrderInfo.from(order); + } + + + + /** + * 주문 명령에서 쿠폰 코드를 추출합니다. + * + * @param commands 주문 명령 목록 + * @return 쿠폰 코드 (없으면 null) + */ + private String extractCouponCode(List commands) { + return commands.stream() + .filter(cmd -> cmd.couponCode() != null && !cmd.couponCode().isBlank()) + .map(OrderItemCommand::couponCode) + .findFirst() + .orElse(null); + } + + + /** + * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 + */ + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + + + /** + * PaymentGatewayDto.TransactionStatus를 PaymentStatus 도메인 모델로 변환합니다. + * + * @param transactionStatus 인프라 계층의 TransactionStatus + * @return 도메인 모델의 PaymentStatus + */ + private PaymentStatus convertToPaymentStatus( + PaymentGatewayDto.TransactionStatus transactionStatus + ) { + return switch (transactionStatus) { + case SUCCESS -> PaymentStatus.SUCCESS; + case FAILED -> PaymentStatus.FAILED; + case PENDING -> PaymentStatus.PENDING; + }; + } + + /** + * PaymentStatus 도메인 모델을 PaymentGatewayDto.TransactionStatus로 변환합니다. + * + * @param paymentStatus 도메인 모델의 PaymentStatus + * @return 인프라 계층의 TransactionStatus + */ + private PaymentGatewayDto.TransactionStatus convertToInfraStatus(PaymentStatus paymentStatus) { + return switch (paymentStatus) { + case SUCCESS -> PaymentGatewayDto.TransactionStatus.SUCCESS; + case FAILED -> PaymentGatewayDto.TransactionStatus.FAILED; + case PENDING -> PaymentGatewayDto.TransactionStatus.PENDING; + }; + } + + /** + * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

+ * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다. + *

+ * + * @param orderId 주문 ID + * @param paymentStatus 결제 상태 (도메인 모델) + * @param transactionKey 트랜잭션 키 + * @param reason 실패 사유 (실패 시) + * @return 업데이트 성공 여부 (true: 성공, false: 실패) + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean updateOrderStatusByPaymentResult( + Long orderId, + PaymentStatus paymentStatus, + String transactionKey, + String reason + ) { + try { + Order order = orderService.getOrder(orderId).orElse(null); + + if (order == null) { + log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return false; + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환) + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (paymentStatus == PaymentStatus.SUCCESS) { + // 결제 성공: 주문 완료 + orderService.updateStatusByPaymentResult(order, paymentStatus, null, null); + log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } else if (paymentStatus == PaymentStatus.FAILED) { + // 결제 실패: 주문 취소 및 리소스 원복 + // 실제로 사용된 포인트만 환불 (Payment에서 확인) + Long refundPointAmount = paymentService.getPaymentByOrderId(order.getId()) + .map(Payment::getUsedPoint) + .orElse(0L); + + // 취소 사유 설정 (reason이 없으면 기본값 사용) + String cancelReason = (reason != null && !reason.isBlank()) + ? reason + : "결제 실패"; + + // 주문 취소 처리 (이벤트 발행 포함) + orderService.updateStatusByPaymentResult(order, paymentStatus, cancelReason, refundPointAmount); + log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {}, refundPointAmount: {})", + orderId, transactionKey, cancelReason, refundPointAmount); + return true; + } else { + // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환) + log.debug("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } + } catch (Exception e) { + log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e); + return false; + } + } + + + + /** + * PG 결제 콜백을 처리합니다. + *

+ * PG 시스템에서 결제 처리 완료 후 콜백으로 전송된 결제 결과를 받아 + * 주문 상태를 업데이트합니다. + *

+ *

+ * 보안 및 정합성 강화: + *

    + *
  • 콜백 정보를 직접 신뢰하지 않고 PG 조회 API로 교차 검증
  • + *
  • 불일치 시 PG 원장을 우선시하여 처리
  • + *
  • 콜백 정보와 PG 조회 결과가 일치하는지 검증
  • + *
+ *

+ *

+ * 처리 내용: + *

    + *
  • 결제 성공 (SUCCESS): 주문 상태를 COMPLETED로 변경
  • + *
  • 결제 실패 (FAILED): 주문 상태를 CANCELED로 변경하고 리소스 원복
  • + *
  • 결제 대기 (PENDING): 상태 유지 (추가 처리 없음)
  • + *
+ *

+ * + * @param orderId 주문 ID + * @param callbackRequest 콜백 요청 정보 + */ + @Transactional + public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) { + try { + // 주문 조회 + Order order; + try { + order = orderService.getById(orderId); + } catch (CoreException e) { + log.warn("콜백 처리 시 주문을 찾을 수 없습니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return; + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return; + } + + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return; + } + + // 콜백 정보와 PG 원장 교차 검증 + // 보안 및 정합성을 위해 PG 조회 API로 실제 결제 상태 확인 + PaymentGatewayDto.TransactionStatus verifiedStatus = verifyCallbackWithPgInquiry( + order.getUserId(), orderId, callbackRequest); + + // PaymentService를 통한 콜백 처리 (도메인 모델로 변환) + PaymentStatus paymentStatus = convertToPaymentStatus(verifiedStatus); + paymentService.handleCallback( + orderId, + callbackRequest.transactionKey(), + paymentStatus, + callbackRequest.reason() + ); + + // 주문 상태 업데이트 처리 + boolean updated = updateOrderStatusByPaymentResult( + orderId, + paymentStatus, + callbackRequest.transactionKey(), + callbackRequest.reason() + ); + + if (updated) { + log.info("PG 결제 콜백 처리 완료 (PG 원장 검증 완료). (orderId: {}, transactionKey: {}, status: {})", + orderId, callbackRequest.transactionKey(), verifiedStatus); + } else { + log.warn("PG 결제 콜백 처리 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {}, transactionKey: {}, status: {})", + orderId, callbackRequest.transactionKey(), verifiedStatus); + } + } catch (Exception e) { + log.error("콜백 처리 중 오류 발생. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey(), e); + throw e; // 콜백 실패는 재시도 가능하도록 예외를 다시 던짐 + } + } + + /** + * 콜백 정보를 PG 조회 API로 교차 검증합니다. + *

+ * 보안 및 정합성을 위해 콜백 정보를 직접 신뢰하지 않고, + * PG 원장(조회 API)을 기준으로 검증합니다. + *

+ *

+ * 검증 전략: + *

    + *
  • PG 조회 API로 실제 결제 상태 확인
  • + *
  • 콜백 정보와 PG 조회 결과 비교
  • + *
  • 불일치 시 PG 원장을 우선시하여 처리
  • + *
  • PG 조회 실패 시 콜백 정보를 사용하되 경고 로그 기록
  • + *
+ *

+ * + * @param userId 사용자 ID (Long) + * @param orderId 주문 ID + * @param callbackRequest 콜백 요청 정보 + * @return 검증된 결제 상태 (PG 원장 기준) + */ + private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( + Long userId, Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) { + + try { + // User의 userId (String)를 가져오기 위해 User 조회 + User user; + try { + user = userService.getUserById(userId); + } catch (CoreException e) { + log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})", + orderId, userId); + return callbackRequest.status(); // 사용자를 찾을 수 없으면 콜백 정보 사용 + } + + String userIdString = user.getUserId(); + + // PaymentService를 통한 결제 상태 조회 (PG 원장 기준) + PaymentStatus paymentStatus = paymentService.getPaymentStatus(userIdString, orderId); + + // 도메인 모델을 인프라 DTO로 변환 (검증 로직에서 사용) + PaymentGatewayDto.TransactionStatus pgStatus = convertToInfraStatus(paymentStatus); + PaymentGatewayDto.TransactionStatus callbackStatus = callbackRequest.status(); + + // 콜백 정보와 PG 조회 결과 비교 + if (pgStatus != callbackStatus) { + // 불일치 시 PG 원장을 우선시하여 처리 + log.warn("콜백 정보와 PG 원장이 불일치합니다. PG 원장을 우선시하여 처리합니다. " + + "(orderId: {}, transactionKey: {}, 콜백 상태: {}, PG 원장 상태: {})", + orderId, callbackRequest.transactionKey(), callbackStatus, pgStatus); + return pgStatus; // PG 원장 기준으로 처리 + } + + // 일치하는 경우: 정상 처리 + log.debug("콜백 정보와 PG 원장이 일치합니다. (orderId: {}, transactionKey: {}, 상태: {})", + orderId, callbackRequest.transactionKey(), pgStatus); + return pgStatus; + + } catch (FeignException e) { + // PG 조회 API 호출 실패: 콜백 정보를 사용하되 경고 로그 기록 + log.warn("콜백 검증 시 PG 조회 API 호출 중 Feign 예외 발생. 콜백 정보를 사용합니다. " + + "(orderId: {}, transactionKey: {}, status: {})", + orderId, callbackRequest.transactionKey(), e.status(), e); + return callbackRequest.status(); + } catch (Exception e) { + // 기타 예외: 콜백 정보를 사용하되 경고 로그 기록 + log.warn("콜백 검증 시 예상치 못한 오류 발생. 콜백 정보를 사용합니다. " + + "(orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey(), e); + return callbackRequest.status(); + } + } + + /** + * 결제 상태 확인 API를 통해 주문 상태를 복구합니다. + *

+ * 콜백이 오지 않았거나 타임아웃된 경우, PG 시스템의 결제 상태 확인 API를 호출하여 + * 실제 결제 상태를 확인하고 주문 상태를 업데이트합니다. + *

+ * + * @param userId 사용자 ID (String - PG API 요구사항) + * @param orderId 주문 ID + */ + @Transactional + public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { + try { + // PaymentService를 통한 타임아웃 복구 + paymentService.recoverAfterTimeout(userId, orderId); + + // 결제 상태 조회 + PaymentStatus paymentStatus = paymentService.getPaymentStatus(userId, orderId); + + // 주문 상태 업데이트 처리 + boolean updated = updateOrderStatusByPaymentResult(orderId, paymentStatus, null, null); + + if (!updated) { + log.warn("상태 복구 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {})", orderId); + } + + } catch (Exception e) { + log.error("상태 복구 중 오류 발생. (orderId: {})", orderId, e); + // 기타 오류도 로그만 기록 + } + } + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java new file mode 100644 index 000000000..f87a52422 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java @@ -0,0 +1,52 @@ +package com.loopers.application.ranking; + +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 랭킹 키 생성 유틸리티. + *

+ * Redis ZSET 랭킹 키를 생성합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class RankingKeyGenerator { + private static final String DAILY_KEY_PREFIX = "ranking:all:"; + private static final String HOURLY_KEY_PREFIX = "ranking:hourly:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH"); + + /** + * 일간 랭킹 키를 생성합니다. + *

+ * 예: ranking:all:20241215 + *

+ * + * @param date 날짜 + * @return 일간 랭킹 키 + */ + public String generateDailyKey(LocalDate date) { + String dateStr = date.format(DATE_FORMATTER); + return DAILY_KEY_PREFIX + dateStr; + } + + /** + * 시간 단위 랭킹 키를 생성합니다. + *

+ * 예: ranking:hourly:2024121514 + *

+ * + * @param dateTime 날짜 및 시간 + * @return 시간 단위 랭킹 키 + */ + public String generateHourlyKey(LocalDateTime dateTime) { + String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER); + return HOURLY_KEY_PREFIX + dateTimeStr; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java new file mode 100644 index 000000000..b6ebf5fc5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -0,0 +1,526 @@ +package com.loopers.application.ranking; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.zset.ZSetEntry; +import com.loopers.zset.RedisZSetTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 랭킹 조회 서비스. + *

+ * Redis ZSET에서 랭킹을 조회하고 상품 정보를 Aggregation하여 제공합니다. + *

+ *

+ * 설계 원칙: + *

    + *
  • Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
  • + *
  • 상품 정보 Aggregation: 상품 ID만이 아닌 상품 정보 포함
  • + *
  • 배치 조회: N+1 쿼리 문제 방지
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + private final RedisZSetTemplate zSetTemplate; + private final RankingKeyGenerator keyGenerator; + private final ProductService productService; + private final BrandService brandService; + private final RankingSnapshotService rankingSnapshotService; + private final com.loopers.domain.rank.ProductRankRepository productRankRepository; + + /** + * 랭킹을 조회합니다 (페이징). + *

+ * 기간별(일간/주간/월간) 랭킹을 조회합니다. + *

+ *

+ * 기간별 조회 방식: + *

    + *
  • DAILY: Redis ZSET에서 조회 (기존 방식)
  • + *
  • WEEKLY: Materialized View에서 조회
  • + *
  • MONTHLY: Materialized View에서 조회
  • + *
+ *

+ *

+ * Graceful Degradation (DAILY만 적용): + *

    + *
  • Redis 장애 시 스냅샷으로 Fallback
  • + *
  • 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
  • + *
+ *

+ * + * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate) + * @param periodType 기간 타입 (DAILY, WEEKLY, MONTHLY) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + @Transactional(readOnly = true) + public RankingsResponse getRankings(LocalDate date, PeriodType periodType, int page, int size) { + if (periodType == PeriodType.DAILY) { + // 일간 랭킹: 기존 Redis 방식 + return getRankings(date, page, size); + } else { + // 주간/월간 랭킹: Materialized View에서 조회 + return getRankingsFromMaterializedView(date, periodType, page, size); + } + } + + /** + * 랭킹을 조회합니다 (페이징) - 일간 랭킹 전용. + *

+ * ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다. + *

+ *

+ * Graceful Degradation: + *

    + *
  • Redis 장애 시 스냅샷으로 Fallback
  • + *
  • 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
  • + *
+ *

+ * + * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + @Transactional(readOnly = true) + public RankingsResponse getRankings(LocalDate date, int page, int size) { + try { + return getRankingsFromRedis(date, page, size); + } catch (DataAccessException e) { + log.warn("Redis 랭킹 조회 실패, 스냅샷으로 Fallback: date={}, error={}", + date, e.getMessage()); + // 스냅샷으로 Fallback 시도 + Optional snapshot = rankingSnapshotService.getSnapshot(date); + if (snapshot.isPresent()) { + log.info("스냅샷으로 랭킹 제공: date={}, itemCount={}", date, snapshot.get().items().size()); + return snapshot.get(); + } + + // 전날 스냅샷 시도 + Optional yesterdaySnapshot = rankingSnapshotService.getSnapshot(date.minusDays(1)); + if (yesterdaySnapshot.isPresent()) { + log.info("전날 스냅샷으로 랭킹 제공: date={}, itemCount={}", date, yesterdaySnapshot.get().items().size()); + return yesterdaySnapshot.get(); + } + + // 최종 Fallback: 기본 랭킹 (단순 조회, 계산 아님) + log.warn("스냅샷도 없음, 기본 랭킹(좋아요순)으로 Fallback: date={}", date); + return getDefaultRankings(page, size); + } catch (Exception e) { + log.error("랭킹 조회 중 예상치 못한 오류 발생, 기본 랭킹으로 Fallback: date={}", date, e); + return getDefaultRankings(page, size); + } + } + + /** + * Redis에서 랭킹을 조회합니다. + *

+ * 스케줄러에서 스냅샷 저장 시 호출하기 위해 public으로 제공합니다. + *

+ * + * @param date 날짜 + * @param page 페이지 번호 + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + * @throws DataAccessException Redis 접근 실패 시 + */ + public RankingsResponse getRankingsFromRedis(LocalDate date, int page, int size) { + String key = keyGenerator.generateDailyKey(date); + long start = (long) page * size; + long end = start + size - 1; + + // ZSET에서 Top N 조회 + List entries = zSetTemplate.getTopRankings(key, start, end); + + if (entries.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 상품 ID 추출 + List productIds = entries.stream() + .map(entry -> Long.parseLong(entry.member())) + .toList(); + + // 상품 정보 배치 조회 + List products = productService.getProducts(productIds); + + // 상품 ID → Product Map 생성 + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 (순위, 점수, 상품 정보 포함) + List rankingItems = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + ZSetEntry entry = entries.get(i); + Long productId = Long.parseLong(entry.member()); + Long rank = start + i + 1; // 1-based 순위 + + Product product = productMap.get(productId); + if (product == null) { + log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId); + continue; + } + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + productId, product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + product.getLikeCount() + ); + + rankingItems.add(new RankingItem( + rank, + entry.score(), + productDetail + )); + } + + // 전체 랭킹 개수 조회 (ZSET 크기) + Long totalSize = zSetTemplate.getSize(key); + boolean hasNext = (start + size) < totalSize; + + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 기본 랭킹(좋아요순)을 제공합니다. + *

+ * 최종 Fallback으로 사용됩니다. 랭킹을 새로 계산하는 것이 아니라 + * 이미 집계된 좋아요 수를 단순 조회하는 것이므로 DB 부하가 크지 않습니다. + *

+ * + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + private RankingsResponse getDefaultRankings(int page, int size) { + // 좋아요순으로 상품 조회 + List products = productService.findAll(null, "likes_desc", page, size); + long totalCount = productService.countAll(null); + + if (products.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 (좋아요 수를 점수로 사용) + List rankingItems = new ArrayList<>(); + long start = (long) page * size; + for (int i = 0; i < products.size(); i++) { + Product product = products.get(i); + Long rank = start + i + 1; // 1-based 순위 + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + product.getId(), product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + product.getLikeCount() + ); + + // 좋아요 수를 점수로 사용 + double score = product.getLikeCount() != null ? product.getLikeCount().doubleValue() : 0.0; + rankingItems.add(new RankingItem( + rank, + score, + productDetail + )); + } + + boolean hasNext = (start + size) < totalCount; + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 특정 상품의 순위를 조회합니다. + *

+ * 상품이 랭킹에 없으면 null을 반환합니다. + *

+ *

+ * Graceful Degradation: + *

    + *
  • Redis 장애 시 전날 랭킹으로 Fallback
  • + *
  • 전날 랭킹도 없으면 null 반환 (기본 랭킹에서는 순위 계산 불가)
  • + *
+ *

+ * + * @param productId 상품 ID + * @param date 날짜 + * @return 순위 (1부터 시작, 없으면 null) + */ + @Transactional(readOnly = true) + public Long getProductRank(Long productId, LocalDate date) { + try { + return getProductRankFromRedis(productId, date); + } catch (DataAccessException e) { + log.warn("Redis 상품 순위 조회 실패, 전날 랭킹으로 Fallback: productId={}, date={}, error={}", + productId, date, e.getMessage()); + // 전날 랭킹으로 Fallback 시도 + try { + LocalDate yesterday = date.minusDays(1); + return getProductRankFromRedis(productId, yesterday); + } catch (DataAccessException fallbackException) { + log.warn("전날 랭킹 조회도 실패: productId={}, date={}, error={}", + productId, date, fallbackException.getMessage()); + // 기본 랭킹에서는 순위 계산이 어려우므로 null 반환 + return null; + } + } catch (Exception e) { + log.error("상품 순위 조회 중 예상치 못한 오류 발생: productId={}, date={}", productId, date, e); + return null; + } + } + + /** + * Redis에서 상품 순위를 조회합니다. + * + * @param productId 상품 ID + * @param date 날짜 + * @return 순위 (1부터 시작, 없으면 null) + * @throws DataAccessException Redis 접근 실패 시 + */ + private Long getProductRankFromRedis(Long productId, LocalDate date) { + String key = keyGenerator.generateDailyKey(date); + Long rank = zSetTemplate.getRank(key, String.valueOf(productId)); + + if (rank == null) { + return null; + } + + // 0-based → 1-based 변환 + return rank + 1; + } + + /** + * Materialized View에서 주간/월간 랭킹을 조회합니다. + *

+ * Materialized View에 저장된 TOP 100 랭킹을 조회하고, 상품 정보를 Aggregation하여 반환합니다. + *

+ * + * @param date 기준 날짜 + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + private RankingsResponse getRankingsFromMaterializedView( + LocalDate date, + PeriodType periodType, + int page, + int size + ) { + // 기간 시작일 계산 + LocalDate periodStartDate; + if (periodType == PeriodType.WEEKLY) { + // 주간: 해당 주의 월요일 + periodStartDate = date.with(java.time.DayOfWeek.MONDAY); + } else { + // 월간: 해당 월의 1일 + periodStartDate = date.with(java.time.temporal.TemporalAdjusters.firstDayOfMonth()); + } + + // Materialized View에서 랭킹 조회 + com.loopers.domain.rank.ProductRank.PeriodType rankPeriodType = + periodType == PeriodType.WEEKLY + ? com.loopers.domain.rank.ProductRank.PeriodType.WEEKLY + : com.loopers.domain.rank.ProductRank.PeriodType.MONTHLY; + + List ranks = productRankRepository.findByPeriod( + rankPeriodType, periodStartDate, 100 + ); + + if (ranks.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 페이징 처리 + long start = (long) page * size; + long end = Math.min(start + size, ranks.size()); + + if (start >= ranks.size()) { + return RankingsResponse.empty(page, size); + } + + List pagedRanks = ranks.subList((int) start, (int) end); + + // 상품 ID 추출 + List productIds = pagedRanks.stream() + .map(com.loopers.domain.rank.ProductRank::getProductId) + .toList(); + + // 상품 정보 배치 조회 + List products = productService.getProducts(productIds); + + // 상품 ID → Product Map 생성 + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 (순위 재계산: 누락된 항목 제외 후 연속 순위 부여) + List rankingItems = new ArrayList<>(); + long currentRank = start + 1; // 1-based 순위 (페이지 시작 순위) + + for (com.loopers.domain.rank.ProductRank rank : pagedRanks) { + Long productId = rank.getProductId(); + Product product = productMap.get(productId); + + if (product == null) { + log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId); + continue; + } + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + productId, product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + rank.getLikeCount() + ); + + // 종합 점수 계산 (Materialized View에는 저장되지 않으므로 계산) + double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount()); + + rankingItems.add(new RankingItem( + currentRank++, // 연속 순위 부여 + score, + productDetail + )); + } + + boolean hasNext = end < ranks.size(); + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 종합 점수를 계산합니다. + *

+ * 가중치: + *

    + *
  • 좋아요: 0.3
  • + *
  • 판매량: 0.5
  • + *
  • 조회수: 0.2
  • + *
+ *

+ * + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @return 종합 점수 + */ + private double calculateScore(Long likeCount, Long salesCount, Long viewCount) { + return (likeCount != null ? likeCount : 0L) * 0.3 + + (salesCount != null ? salesCount : 0L) * 0.5 + + (viewCount != null ? viewCount : 0L) * 0.2; + } + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + DAILY, // 일간 + WEEKLY, // 주간 + MONTHLY // 월간 + } + + /** + * 랭킹 조회 결과. + * + * @param items 랭킹 항목 목록 + * @param page 현재 페이지 번호 + * @param size 페이지당 항목 수 + * @param hasNext 다음 페이지 존재 여부 + */ + public record RankingsResponse( + List items, + int page, + int size, + boolean hasNext + ) { + /** + * 빈 랭킹 조회 결과를 생성합니다. + */ + public static RankingsResponse empty(int page, int size) { + return new RankingsResponse(List.of(), page, size, false); + } + } + + /** + * 랭킹 항목 (순위, 점수, 상품 정보). + * + * @param rank 순위 (1부터 시작) + * @param score 점수 + * @param productDetail 상품 상세 정보 + */ + public record RankingItem( + Long rank, + Double score, + ProductDetail productDetail + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java new file mode 100644 index 000000000..c9bd2efab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java @@ -0,0 +1,103 @@ +package com.loopers.application.ranking; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 랭킹 스냅샷 서비스. + *

+ * Redis 장애 시 Fallback으로 사용하기 위한 랭킹 데이터 스냅샷을 인메모리에 저장합니다. + *

+ *

+ * 설계 원칙: + *

    + *
  • 인메모리 캐시: 구현이 간단하고 성능이 우수함
  • + *
  • 메모리 관리: 최근 7일치만 보관하여 메모리 사용량 제한
  • + *
  • 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +public class RankingSnapshotService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int MAX_SNAPSHOTS = 7; // 최근 7일치만 보관 + + private final Map snapshotCache = new ConcurrentHashMap<>(); + + /** + * 랭킹 스냅샷을 저장합니다. + * + * @param date 날짜 + * @param rankings 랭킹 조회 결과 + */ + public void saveSnapshot(LocalDate date, RankingService.RankingsResponse rankings) { + String key = date.format(DATE_FORMATTER); + snapshotCache.put(key, rankings); + log.debug("랭킹 스냅샷 저장: date={}, key={}, itemCount={}", date, key, rankings.items().size()); + + // 오래된 스냅샷 정리 (메모리 관리) + cleanupOldSnapshots(); + } + + /** + * 랭킹 스냅샷을 조회합니다. + * + * @param date 날짜 + * @return 랭킹 조회 결과 (없으면 empty) + */ + public Optional getSnapshot(LocalDate date) { + String key = date.format(DATE_FORMATTER); + RankingService.RankingsResponse snapshot = snapshotCache.get(key); + + if (snapshot != null) { + log.debug("랭킹 스냅샷 조회 성공: date={}, key={}, itemCount={}", date, key, snapshot.items().size()); + return Optional.of(snapshot); + } + + log.debug("랭킹 스냅샷 없음: date={}, key={}", date, key); + return Optional.empty(); + } + + /** + * 오래된 스냅샷을 정리합니다. + *

+ * 최근 7일치만 보관하여 메모리 사용량을 제한합니다. + *

+ */ + private void cleanupOldSnapshots() { + if (snapshotCache.size() <= MAX_SNAPSHOTS) { + return; + } + + // 가장 오래된 스냅샷 제거 + LocalDate today = LocalDate.now(ZoneId.of("UTC")); + LocalDate oldestDate = today.minusDays(MAX_SNAPSHOTS); + + snapshotCache.entrySet().removeIf(entry -> { + try { + LocalDate entryDate = LocalDate.parse(entry.getKey(), DATE_FORMATTER); + boolean shouldRemove = entryDate.isBefore(oldestDate); + if (shouldRemove) { + log.debug("오래된 스냅샷 제거: key={}", entry.getKey()); + } + return shouldRemove; + } catch (Exception e) { + log.warn("스냅샷 키 파싱 실패, 제거: key={}", entry.getKey(), e); + return true; // 파싱 실패한 키는 제거 + } + }); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/DeductPointCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/DeductPointCommand.java new file mode 100644 index 000000000..f18b91b03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/DeductPointCommand.java @@ -0,0 +1,22 @@ +package com.loopers.application.user; + +/** + * 포인트 차감 명령. + *

+ * 포인트 차감을 위한 명령 객체입니다. + *

+ * + * @param userId 사용자 ID + * @param usedPointAmount 사용할 포인트 금액 + */ +public record DeductPointCommand( + Long userId, + Long usedPointAmount +) { + public DeductPointCommand { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java new file mode 100644 index 000000000..73af4d9d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java @@ -0,0 +1,147 @@ +package com.loopers.application.user; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 포인트 이벤트 핸들러. + *

+ * 주문 생성 이벤트를 받아 포인트 사용 처리를 수행하고, 주문 취소 이벤트를 받아 포인트 환불 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: UserService는 사용자 도메인 비즈니스 로직, PointEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 느슨한 결합: PurchasingFacade는 UserService를 직접 참조하지 않고 이벤트로 처리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointEventHandler { + + private final UserService userService; + private final PointEventPublisher pointEventPublisher; + + /** + * 주문 생성 이벤트를 처리하여 포인트를 차감합니다. + *

+ * OrderEvent.OrderCreated를 구독하여 포인트 차감 Command를 실행합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + // 포인트 사용량이 없는 경우 처리하지 않음 + if (event.usedPointAmount() == null || event.usedPointAmount() == 0) { + log.debug("포인트 사용량이 없어 포인트 차감 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // 사용자 조회 (비관적 락 사용) + // OrderEvent.OrderCreated의 userId는 Long 타입 (User.id - PK) + User user = userService.getUserByIdForUpdate(event.userId()); + + // 포인트 잔액 검증 + Long userPointBalance = user.getPointValue(); + if (userPointBalance < event.usedPointAmount()) { + String failureReason = String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", + userPointBalance, event.usedPointAmount()); + log.error("포인트가 부족합니다. (orderId: {}, userId: {}, 현재 잔액: {}, 사용 요청 금액: {})", + event.orderId(), event.userId(), userPointBalance, event.usedPointAmount()); + + // 포인트 사용 실패 이벤트 발행 + pointEventPublisher.publish(PointEvent.PointUsedFailed.of( + event.orderId(), + event.userId(), + event.usedPointAmount(), + failureReason + )); + + throw new CoreException(ErrorType.BAD_REQUEST, failureReason); + } + + // ✅ OrderEvent.OrderCreated를 구독하여 포인트 차감 Command 실행 + DeductPointCommand command = new DeductPointCommand(event.userId(), event.usedPointAmount()); + user.deductPoint(Point.of(command.usedPointAmount())); + userService.save(user); + + log.info("포인트 차감 처리 완료. (orderId: {}, userId: {}, usedPointAmount: {})", + event.orderId(), event.userId(), event.usedPointAmount()); + } catch (CoreException e) { + // CoreException은 이미 이벤트가 발행되었거나 처리되었으므로 그대로 던짐 + throw e; + } catch (Exception e) { + // 예상치 못한 오류 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "포인트 차감 처리 중 오류 발생"; + log.error("포인트 차감 처리 중 오류 발생. (orderId: {}, userId: {}, usedPointAmount: {})", + event.orderId(), event.userId(), event.usedPointAmount(), e); + + pointEventPublisher.publish(PointEvent.PointUsedFailed.of( + event.orderId(), + event.userId(), + event.usedPointAmount(), + failureReason + )); + + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리하여 포인트를 환불합니다. + *

+ * 환불할 포인트 금액이 0보다 큰 경우에만 포인트 환불 처리를 수행합니다. + *

+ *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 포인트 환불 시 동시성 제어를 위해 getUserForUpdate 사용
  • + *
+ *

+ * + * @param event 주문 취소 이벤트 + */ + @Transactional + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + // 환불할 포인트 금액이 없는 경우 처리하지 않음 + if (event.refundPointAmount() == null || event.refundPointAmount() == 0) { + log.debug("환불할 포인트 금액이 없어 포인트 환불 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ 동시성 제어: 포인트 환불 시 Lost Update 방지를 위해 비관적 락 사용 + // OrderEvent.OrderCanceled의 userId는 Long 타입 (User.id - PK) + User user = userService.getUserByIdForUpdate(event.userId()); + + // 포인트 환불 + user.receivePoint(Point.of(event.refundPointAmount())); + userService.save(user); + + log.info("주문 취소로 인한 포인트 환불 완료. (orderId: {}, userId: {}, refundPointAmount: {})", + event.orderId(), event.userId(), event.refundPointAmount()); + } catch (Exception e) { + log.error("포인트 환불 처리 중 오류 발생. (orderId: {}, userId: {}, refundPointAmount: {})", + event.orderId(), event.userId(), event.refundPointAmount(), e); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java new file mode 100644 index 000000000..d7fb5c2f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -0,0 +1,191 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사용자 애플리케이션 서비스. + *

+ * 사용자 생성, 조회, 포인트 관리 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리 및 데이터 무결성 제약 조건을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class UserService { + private final UserRepository userRepository; + + /** + * 새로운 사용자를 생성합니다. + * + * @param userId 사용자 ID + * @param email 이메일 주소 + * @param birthDateStr 생년월일 (yyyy-MM-dd) + * @param gender 성별 + * @return 생성된 사용자 + * @throws CoreException 중복된 사용자 ID가 존재하거나 유효성 검증 실패 시 + */ + public User create(String userId, String email, String birthDateStr, Gender gender, Point point) { + User user = User.of(userId, email, birthDateStr, gender, point); + try { + return userRepository.save(user); + } catch (DataIntegrityViolationException e) { + if (e.getMessage() != null && e.getMessage().contains("user_id")) { + throw new CoreException(ErrorType.CONFLICT, "이미 가입된 ID입니다: " + userId); + } + throw new CoreException(ErrorType.CONFLICT, "데이터 무결성 제약 조건 위반"); + } + } + + /** + * 사용자 ID로 사용자를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public User getUser(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional + public User getUserForUpdate(String userId) { + User user = userRepository.findByUserIdForUpdate(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public User getUserById(Long id) { + User user = userRepository.findById(id); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. (비관적 락) + *

+ * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

+ * + * @param id 사용자 ID (PK) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional + public User getUserByIdForUpdate(Long id) { + User user = userRepository.findByIdForUpdate(id); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자를 저장합니다. + * + * @param user 저장할 사용자 + * @return 저장된 사용자 + */ + @Transactional + public User save(User user) { + return userRepository.save(user); + } + + /** + * 사용자의 포인트를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 포인트 정보 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public PointsInfo getPoints(String userId) { + User user = getUser(userId); + return PointsInfo.from(user); + } + + /** + * 사용자의 포인트를 충전합니다. + *

+ * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다. + * 비관적 락(PESSIMISTIC_WRITE)을 사용하여 동시 충전 요청 시 Lost Update를 방지합니다. + *

+ *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: SELECT ... FOR UPDATE로 해당 사용자 행에 배타적 락 설정
  • + *
  • Lost Update 방지: 동시 충전 요청이 들어와도 순차적으로 처리되어 모든 충전이 반영됨
  • + *
  • Lock 범위 최소화: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락
  • + *
+ *

+ * + * @param userId 충전할 사용자 ID + * @param amount 충전할 포인트 금액 + * @return 충전된 포인트 정보 + * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 + */ + @Transactional + public PointsInfo chargePoint(String userId, Long amount) { + User user = getUserForUpdate(userId); + Point point = Point.of(amount); + user.receivePoint(point); + User savedUser = save(user); + return PointsInfo.from(savedUser); + } + + /** + * 포인트 정보를 담는 레코드. + * + * @param userId 사용자 ID + * @param balance 포인트 잔액 + */ + public record PointsInfo(String userId, Long balance) { + /** + * User 엔티티로부터 PointsInfo를 생성합니다. + * + * @param user 사용자 엔티티 + * @return 생성된 PointsInfo + */ + public static PointsInfo from(User user) { + return new PointsInfo(user.getUserId(), user.getPointValue()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java new file mode 100644 index 000000000..ee554579f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java @@ -0,0 +1,129 @@ +package com.loopers.config; + +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.github.resilience4j.core.IntervalFunction; +import lombok.extern.slf4j.Slf4j; +import feign.FeignException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import java.net.SocketTimeoutException; + +/** + * Resilience4j Retry 설정 커스터마이징. + *

+ * 실무 권장 패턴에 따라 메서드별로 다른 Retry 정책을 적용합니다: + *

+ *

+ * Retry 정책: + *

    + *
  • 결제 요청 API (requestPayment): Retry 없음 (유저 요청 경로 - 빠른 실패)
  • + *
  • 조회 API (getTransactionsByOrder, getTransaction): Exponential Backoff 적용 (스케줄러 - 안전)
  • + *
+ *

+ *

+ * Exponential Backoff 전략 (조회 API용): + *

    + *
  • 초기 대기 시간: 500ms
  • + *
  • 배수(multiplier): 2 (각 재시도마다 2배씩 증가)
  • + *
  • 최대 대기 시간: 5초 (너무 길어지지 않도록 제한)
  • + *
  • 랜덤 jitter: 활성화 (thundering herd 문제 방지)
  • + *
+ *

+ *

+ * 재시도 시퀀스 예시 (조회 API): + *

    + *
  1. 1차 시도: 즉시 실행
  2. + *
  3. 2차 시도: 500ms 후 (500ms * 2^0)
  4. + *
  5. 3차 시도: 1000ms 후 (500ms * 2^1)
  6. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 유저 요청 경로: 긴 Retry는 스레드 점유 비용이 크므로 Retry 없이 빠르게 실패
  • + *
  • 스케줄러 경로: 비동기/배치 기반이므로 Retry가 안전하게 적용 가능 (Nice-to-Have 요구사항 충족)
  • + *
  • Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
  • + *
  • 일시적 오류 복구: 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구
  • + *
+ *

+ * + * @author Loopers + * @version 2.0 + */ +@Slf4j +@Configuration +public class Resilience4jRetryConfig { + + /** + * PaymentGatewayClient용 Retry 설정을 커스터마이징합니다. + *

+ * Exponential Backoff 전략을 적용하여 재시도 간격을 점진적으로 증가시킵니다. + *

+ * + * @return RetryRegistry (커스터마이징된 설정이 적용됨) + */ + @Bean + public RetryRegistry retryRegistry() { + RetryRegistry retryRegistry = io.github.resilience4j.retry.RetryRegistry.ofDefaults(); + // Exponential Backoff 설정 + // - 초기 대기 시간: 500ms + // - 배수: 2 (각 재시도마다 2배씩 증가) + // - 최대 대기 시간: 5초 + // - 랜덤 jitter: 활성화 (thundering herd 문제 방지) + IntervalFunction intervalFunction = IntervalFunction + .ofExponentialRandomBackoff( + Duration.ofMillis(500), // 초기 대기 시간 + 2.0, // 배수 (exponential multiplier) + Duration.ofSeconds(5) // 최대 대기 시간 + ); + + // RetryConfig 커스터마이징 + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(3) // 최대 재시도 횟수 (초기 시도 포함) + .intervalFunction(intervalFunction) // Exponential Backoff 적용 + .retryOnException(throwable -> { + // 일시적 오류만 재시도: 5xx 서버 오류, 타임아웃, 네트워크 오류 + if (throwable instanceof FeignException feignException) { + int status = feignException.status(); + // 5xx 서버 오류만 재시도 + if (status >= 500 && status < 600) { + log.debug("재시도 대상 예외: FeignException (status: {})", status); + return true; + } + return false; + } + if (throwable instanceof SocketTimeoutException || + throwable instanceof TimeoutException) { + log.debug("재시도 대상 예외: {}", throwable.getClass().getSimpleName()); + return true; + } + return false; + }) + // ignoreExceptions는 사용하지 않음 + // retryOnException에서 5xx만 재시도하고 4xx는 제외하므로, + // 별도로 ignoreExceptions를 설정할 필요가 없음 + .build(); + + // 결제 요청 API: 유저 요청 경로에서 사용되므로 Retry 비활성화 (빠른 실패) + // 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구됨 + RetryConfig noRetryConfig = RetryConfig.custom() + .maxAttempts(1) // 재시도 없음 (초기 시도만) + .build(); + retryRegistry.addConfiguration("paymentGatewayClient", noRetryConfig); + + // 스케줄러 전용 클라이언트: 비동기/배치 기반으로 Retry 적용 + // Exponential Backoff 적용하여 일시적 오류 자동 복구 + retryRegistry.addConfiguration("paymentGatewaySchedulerClient", retryConfig); + + log.debug("Resilience4j Retry 설정 완료:"); + log.debug(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)"); + log.debug(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)"); + + return retryRegistry; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..b0b1f55c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,63 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 브랜드 도메인 엔티티. + *

+ * 브랜드의 기본 정보(이름)를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "brand") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Brand extends BaseEntity { + @Column(name = "name", nullable = false) + private String name; + + /** + * Brand 인스턴스를 생성합니다. + * + * @param name 브랜드 이름 + * @throws CoreException name이 null이거나 공백일 경우 + */ + public Brand(String name) { + validateName(name); + this.name = name; + } + + /** + * 브랜드 이름의 유효성을 검증합니다. + * + * @param name 검증할 브랜드 이름 + * @throws CoreException name이 null이거나 공백일 경우 + */ + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + } + + /** + * Brand 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param name 브랜드 이름 + * @return 생성된 Brand 인스턴스 + */ + public static Brand of(String name) { + return new Brand(name); + } +} + 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..03d6db682 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,43 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; + +/** + * Brand 엔티티에 대한 저장소 인터페이스. + *

+ * 브랜드 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface BrandRepository { + /** + * 브랜드를 저장합니다. + * + * @param brand 저장할 브랜드 + * @return 저장된 브랜드 + */ + Brand save(Brand brand); + + /** + * 브랜드 ID로 브랜드를 조회합니다. + * + * @param brandId 조회할 브랜드 ID + * @return 조회된 브랜드를 담은 Optional + */ + Optional findById(Long brandId); + + /** + * 브랜드 ID 목록으로 브랜드 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param brandIds 조회할 브랜드 ID 목록 + * @return 조회된 브랜드 목록 + */ + List findAllById(List brandIds); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..b02c07333 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,137 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.discount.CouponDiscountStrategy; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 쿠폰 도메인 엔티티. + *

+ * 쿠폰의 기본 정보(코드, 타입, 할인 금액/비율)를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "coupon") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Coupon extends BaseEntity { + @Column(name = "code", unique = true, nullable = false, length = 50) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @Column(name = "discount_value", nullable = false) + private Integer discountValue; + + /** + * Coupon 인스턴스를 생성합니다. + * + * @param code 쿠폰 코드 (필수, 최대 50자) + * @param type 쿠폰 타입 (필수) + * @param discountValue 할인 값 (필수, 0 초과) + * - FIXED_AMOUNT: 할인 금액 + * - PERCENTAGE: 할인 비율 (0-100) + * @throws CoreException 유효성 검증 실패 시 + */ + public Coupon(String code, CouponType type, Integer discountValue) { + validateCode(code); + validateType(type); + validateDiscountValue(type, discountValue); + this.code = code; + this.type = type; + this.discountValue = discountValue; + } + + /** + * Coupon 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param code 쿠폰 코드 + * @param type 쿠폰 타입 + * @param discountValue 할인 값 + * @return 생성된 Coupon 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Coupon of(String code, CouponType type, Integer discountValue) { + return new Coupon(code, type, discountValue); + } + + /** + * 쿠폰 코드의 유효성을 검증합니다. + * + * @param code 검증할 쿠폰 코드 + * @throws CoreException code가 null, 공백이거나 50자를 초과할 경우 + */ + private void validateCode(String code) { + if (code == null || code.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 코드는 필수입니다."); + } + if (code.length() > 50) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 코드는 50자를 초과할 수 없습니다."); + } + } + + /** + * 쿠폰 타입의 유효성을 검증합니다. + * + * @param type 검증할 쿠폰 타입 + * @throws CoreException type이 null일 경우 + */ + private void validateType(CouponType type) { + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 타입은 필수입니다."); + } + } + + /** + * 할인 값의 유효성을 검증합니다. + * + * @param type 쿠폰 타입 + * @param discountValue 검증할 할인 값 + * @throws CoreException discountValue가 null이거나 유효하지 않을 경우 + */ + private void validateDiscountValue(CouponType type, Integer discountValue) { + if (discountValue == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 필수입니다."); + } + if (discountValue <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 0보다 커야 합니다."); + } + if (type == CouponType.PERCENTAGE && discountValue > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 쿠폰의 할인 비율은 100을 초과할 수 없습니다."); + } + } + + /** + * 주문 금액에 쿠폰을 적용하여 할인 금액을 계산합니다. + *

+ * 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 로직을 분리합니다. + * 새로운 쿠폰 타입이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있습니다. + *

+ * + * @param orderAmount 주문 금액 + * @param strategyFactory 할인 계산 전략 팩토리 + * @return 할인 금액 + * @throws CoreException orderAmount가 null이거나 0 이하일 경우 + */ + public Integer calculateDiscountAmount(Integer orderAmount, CouponDiscountStrategyFactory strategyFactory) { + if (orderAmount == null || orderAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 0보다 커야 합니다."); + } + + // 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 + CouponDiscountStrategy strategy = strategyFactory.getStrategy(this.type); + return strategy.calculateDiscountAmount(orderAmount, this.discountValue); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java new file mode 100644 index 000000000..10f8d6c9a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java @@ -0,0 +1,124 @@ +package com.loopers.domain.coupon; + +import java.time.LocalDateTime; + +/** + * 쿠폰 도메인 이벤트. + *

+ * 쿠폰 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class CouponEvent { + + /** + * 쿠폰 적용 이벤트. + *

+ * 쿠폰이 주문에 적용되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @param appliedAt 쿠폰 적용 시각 + */ + public record CouponApplied( + Long orderId, + Long userId, + String couponCode, + Integer discountAmount, + LocalDateTime appliedAt + ) { + public CouponApplied { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (discountAmount == null || discountAmount < 0) { + throw new IllegalArgumentException("discountAmount는 0 이상이어야 합니다."); + } + } + + /** + * 쿠폰 적용 정보로부터 CouponApplied 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @return CouponApplied 이벤트 + */ + public static CouponApplied of(Long orderId, Long userId, String couponCode, Integer discountAmount) { + return new CouponApplied( + orderId, + userId, + couponCode, + discountAmount, + LocalDateTime.now() + ); + } + } + + /** + * 쿠폰 적용 실패 이벤트. + *

+ * 쿠폰 적용에 실패했을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 + * @param failureReason 실패 사유 + * @param failedAt 실패 시각 + */ + public record CouponApplicationFailed( + Long orderId, + Long userId, + String couponCode, + String failureReason, + LocalDateTime failedAt + ) { + public CouponApplicationFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("failureReason는 필수입니다."); + } + } + + /** + * 쿠폰 적용 실패 정보로부터 CouponApplicationFailed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param failureReason 실패 사유 + * @return CouponApplicationFailed 이벤트 + */ + public static CouponApplicationFailed of(Long orderId, Long userId, String couponCode, String failureReason) { + return new CouponApplicationFailed( + orderId, + userId, + couponCode, + failureReason, + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java new file mode 100644 index 000000000..8269b35e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponEventPublisher { + + /** + * 쿠폰 적용 이벤트를 발행합니다. + * + * @param event 쿠폰 적용 이벤트 + */ + void publish(CouponEvent.CouponApplied event); + + /** + * 쿠폰 적용 실패 이벤트를 발행합니다. + * + * @param event 쿠폰 적용 실패 이벤트 + */ + void publish(CouponEvent.CouponApplicationFailed event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..6ffe52a9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * Coupon 엔티티에 대한 저장소 인터페이스. + *

+ * 쿠폰 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponRepository { + /** + * 쿠폰을 저장합니다. + * + * @param coupon 저장할 쿠폰 + * @return 저장된 쿠폰 + */ + Coupon save(Coupon coupon); + + /** + * 쿠폰 코드로 쿠폰을 조회합니다. + * + * @param code 쿠폰 코드 + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findByCode(String code); + + /** + * 쿠폰 ID로 쿠폰을 조회합니다. + * + * @param couponId 쿠폰 ID + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findById(Long couponId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..183cb31c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 타입. + *

+ * 정액 쿠폰과 정률 쿠폰을 구분합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public enum CouponType { + /** + * 정액 쿠폰: 고정 금액 할인 + */ + FIXED_AMOUNT, + + /** + * 정률 쿠폰: 비율 할인 + */ + PERCENTAGE +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java new file mode 100644 index 000000000..3f2e07322 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java @@ -0,0 +1,134 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 사용자 쿠폰 도메인 엔티티. + *

+ * 사용자가 소유한 쿠폰과 사용 여부를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "user_coupon", uniqueConstraints = { + @UniqueConstraint(name = "uk_user_coupon_user_coupon", columnNames = {"ref_user_id", "ref_coupon_id"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class UserCoupon extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ref_coupon_id", nullable = false) + private Coupon coupon; + + @Column(name = "is_used", nullable = false) + private Boolean isUsed; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + /** + * UserCoupon 인스턴스를 생성합니다. + * + * @param userId 사용자 ID (필수) + * @param coupon 쿠폰 (필수) + * @throws CoreException 유효성 검증 실패 시 + */ + public UserCoupon(Long userId, Coupon coupon) { + validateUserId(userId); + validateCoupon(coupon); + this.userId = userId; + this.coupon = coupon; + this.isUsed = false; + } + + /** + * UserCoupon 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param coupon 쿠폰 + * @return 생성된 UserCoupon 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static UserCoupon of(Long userId, Coupon coupon) { + return new UserCoupon(userId, coupon); + } + + /** + * 사용자 ID의 유효성을 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws CoreException userId가 null일 경우 + */ + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + /** + * 쿠폰의 유효성을 검증합니다. + * + * @param coupon 검증할 쿠폰 + * @throws CoreException coupon이 null일 경우 + */ + private void validateCoupon(Coupon coupon) { + if (coupon == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰은 필수입니다."); + } + } + + /** + * 쿠폰을 사용합니다. + *

+ * 이미 사용된 쿠폰은 다시 사용할 수 없습니다. + *

+ * + * @throws CoreException 이미 사용된 쿠폰일 경우 + */ + public void use() { + if (this.isUsed) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다."); + } + this.isUsed = true; + } + + /** + * 쿠폰이 사용 가능한지 확인합니다. + * + * @return 사용 가능하면 true, 아니면 false + */ + public boolean isAvailable() { + return !this.isUsed; + } + + /** + * 쿠폰 코드를 반환합니다. + * + * @return 쿠폰 코드 + */ + public String getCouponCode() { + return coupon.getCode(); + } + + /** + * 쿠폰을 반환합니다. + * + * @return 쿠폰 + */ + public Coupon getCoupon() { + return coupon; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java new file mode 100644 index 000000000..6a06032ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java @@ -0,0 +1,61 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * UserCoupon 엔티티에 대한 저장소 인터페이스. + *

+ * 사용자 쿠폰 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserCouponRepository { + /** + * 사용자 쿠폰을 저장합니다. + * + * @param userCoupon 저장할 사용자 쿠폰 + * @return 저장된 사용자 쿠폰 + */ + UserCoupon save(UserCoupon userCoupon); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + Optional findByUserIdAndCouponCode(Long userId, String couponCode); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. (낙관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 쿠폰 사용) + *

+ *

+ * Lock 전략: + *

    + *
  • OPTIMISTIC_LOCK: @Version 필드를 통한 낙관적 락 사용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 쿠폰 사용 시 Lost Update 방지, Hot Spot 대응
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode); + + /** + * 영속성 컨텍스트의 변경사항을 데이터베이스에 즉시 반영합니다. + *

+ * Optimistic Lock을 사용하는 경우, save() 후 flush()를 호출하여 + * version 체크를 즉시 수행하도록 합니다. + *

+ */ + void flush(); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java new file mode 100644 index 000000000..883ab15bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon.discount; + +/** + * 쿠폰 할인 계산 전략 인터페이스. + *

+ * 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 로직을 분리합니다. + * 새로운 쿠폰 타입이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있습니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponDiscountStrategy { + /** + * 주문 금액에 쿠폰을 적용하여 할인 금액을 계산합니다. + * + * @param orderAmount 주문 금액 + * @param discountValue 할인 값 (쿠폰 타입에 따라 의미가 다름) + * @return 할인 금액 + */ + Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java new file mode 100644 index 000000000..36bc98a7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java @@ -0,0 +1,53 @@ +package com.loopers.domain.coupon.discount; + +import com.loopers.domain.coupon.CouponType; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 쿠폰 할인 계산 전략 팩토리. + *

+ * 쿠폰 타입에 따라 적절한 할인 계산 전략을 반환합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class CouponDiscountStrategyFactory { + private final Map strategyMap; + + /** + * CouponDiscountStrategyFactory를 생성합니다. + * + * @param fixedAmountStrategy 정액 쿠폰 전략 + * @param percentageStrategy 정률 쿠폰 전략 + */ + public CouponDiscountStrategyFactory( + FixedAmountDiscountStrategy fixedAmountStrategy, + PercentageDiscountStrategy percentageStrategy + ) { + this.strategyMap = Map.of( + CouponType.FIXED_AMOUNT, fixedAmountStrategy, + CouponType.PERCENTAGE, percentageStrategy + ); + } + + /** + * 쿠폰 타입에 해당하는 할인 계산 전략을 반환합니다. + * + * @param type 쿠폰 타입 + * @return 할인 계산 전략 + * @throws IllegalArgumentException 지원하지 않는 쿠폰 타입인 경우 + */ + public CouponDiscountStrategy getStrategy(CouponType type) { + CouponDiscountStrategy strategy = strategyMap.get(type); + if (strategy == null) { + throw new IllegalArgumentException( + String.format("지원하지 않는 쿠폰 타입입니다. (타입: %s)", type)); + } + return strategy; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java new file mode 100644 index 000000000..f7a25a11a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.domain.coupon.discount; + +import org.springframework.stereotype.Component; + +/** + * 정액 쿠폰 할인 계산 전략. + *

+ * 고정 금액을 할인하며, 할인 금액이 주문 금액을 초과하지 않도록 보장합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class FixedAmountDiscountStrategy implements CouponDiscountStrategy { + /** + * {@inheritDoc} + *

+ * 정액 쿠폰: 할인 금액이 주문 금액을 초과하지 않도록 제한합니다. + *

+ * + * @param orderAmount 주문 금액 + * @param discountValue 할인 금액 + * @return 할인 금액 (주문 금액을 초과하지 않음) + */ + @Override + public Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue) { + // 할인 금액이 주문 금액을 초과하지 않도록 + return Math.min(discountValue, orderAmount); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java new file mode 100644 index 000000000..c8b0a87e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.domain.coupon.discount; + +import org.springframework.stereotype.Component; + +/** + * 정률 쿠폰 할인 계산 전략. + *

+ * 주문 금액의 일정 비율을 할인합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class PercentageDiscountStrategy implements CouponDiscountStrategy { + /** + * {@inheritDoc} + *

+ * 정률 쿠폰: 주문 금액의 할인 비율만큼 할인합니다. + *

+ * + * @param orderAmount 주문 금액 + * @param discountValue 할인 비율 (0-100) + * @return 할인 금액 + */ + @Override + public Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue) { + // 주문 금액의 할인 비율만큼 할인 + return (int) Math.round(orderAmount * discountValue / 100.0); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..d6d78bd1b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,62 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 좋아요 도메인 엔티티. + *

+ * 사용자와 상품 간의 좋아요 관계를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "`like`", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_like_user_product", + columnNames = {"ref_user_id", "ref_product_id"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Like extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + /** + * Like 인스턴스를 생성합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + */ + public Like(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + /** + * Like 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 생성된 Like 인스턴스 + */ + public static Like of(Long userId, Long productId) { + return new Like(userId, productId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java new file mode 100644 index 000000000..36778dab5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java @@ -0,0 +1,94 @@ +package com.loopers.domain.like; + +import java.time.LocalDateTime; + +/** + * 좋아요 도메인 이벤트. + *

+ * 좋아요 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class LikeEvent { + + /** + * 좋아요 추가 이벤트. + *

+ * 좋아요가 추가되었을 때 발행되는 이벤트입니다. + *

+ * + * @param userId 사용자 ID (Long - User.id) + * @param productId 상품 ID + * @param occurredAt 이벤트 발생 시각 + */ + public record LikeAdded( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + public LikeAdded { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * Like 엔티티로부터 LikeAdded 이벤트를 생성합니다. + * + * @param like 좋아요 엔티티 + * @return LikeAdded 이벤트 + */ + public static LikeAdded from(Like like) { + return new LikeAdded( + like.getUserId(), + like.getProductId(), + LocalDateTime.now() + ); + } + } + + /** + * 좋아요 취소 이벤트. + *

+ * 좋아요가 취소되었을 때 발행되는 이벤트입니다. + *

+ * + * @param userId 사용자 ID (Long - User.id) + * @param productId 상품 ID + * @param occurredAt 이벤트 발생 시각 + */ + public record LikeRemoved( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + public LikeRemoved { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * Like 엔티티로부터 LikeRemoved 이벤트를 생성합니다. + * + * @param like 좋아요 엔티티 + * @return LikeRemoved 이벤트 + */ + public static LikeRemoved from(Like like) { + return new LikeRemoved( + like.getUserId(), + like.getProductId(), + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java new file mode 100644 index 000000000..cc9e6bdf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +/** + * 좋아요 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface LikeEventPublisher { + + /** + * 좋아요 추가 이벤트를 발행합니다. + * + * @param event 좋아요 추가 이벤트 + */ + void publish(LikeEvent.LikeAdded event); + + /** + * 좋아요 취소 이벤트를 발행합니다. + * + * @param event 좋아요 취소 이벤트 + */ + void publish(LikeEvent.LikeRemoved event); +} + 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..96b09f43e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,67 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Like 엔티티에 대한 저장소 인터페이스. + *

+ * 좋아요 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface LikeRepository { + /** + * 좋아요를 저장합니다. + * + * @param like 저장할 좋아요 + * @return 저장된 좋아요 + */ + Like save(Like like); + + /** + * 사용자 ID와 상품 ID로 좋아요를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 조회된 좋아요를 담은 Optional + */ + Optional findByUserIdAndProductId(Long userId, Long productId); + + /** + * 좋아요를 삭제합니다. + * + * @param like 삭제할 좋아요 + */ + void delete(Like like); + + /** + * 사용자 ID로 좋아요한 상품 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 좋아요 목록 + */ + List findAllByUserId(Long userId); + + /** + * 상품별 좋아요 수를 집계합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + Map countByProductIds(List productIds); + + /** + * 모든 상품의 좋아요 수를 집계합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + Map countAllByProductIds(); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..87aaa96d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,204 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.List; + +/** + * 주문 도메인 엔티티. + *

+ * 주문의 상태, 총액, 주문 아이템을 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "`order`") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Order extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Integer totalAmount; + + @Column(name = "coupon_code", length = 50) + private String couponCode; + + @Column(name = "discount_amount") + private Integer discountAmount; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "items", nullable = false, columnDefinition = "json") + private List items; + + /** + * Order 인스턴스를 생성합니다. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param discountAmount 할인 금액 (선택) + * @throws CoreException items가 null이거나 비어있을 경우 + */ + public Order(Long userId, List items, String couponCode, Integer discountAmount) { + validateUserId(userId); + validateItems(items); + this.userId = userId; + // ✅ 방어적 복사로 불변 리스트 생성 (총액과 아이템의 일관성 보장) + List immutableItems = List.copyOf(items); + this.items = immutableItems; + Integer subtotal = calculateTotalAmount(immutableItems); + this.discountAmount = discountAmount != null ? discountAmount : 0; + this.totalAmount = Math.max(0, subtotal - this.discountAmount); + this.couponCode = couponCode; + this.status = OrderStatus.PENDING; + } + + /** + * Order 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @return 생성된 Order 인스턴스 + */ + public static Order of(Long userId, List items) { + return new Order(userId, items, null, null); + } + + /** + * Order 인스턴스를 생성하는 정적 팩토리 메서드 (쿠폰 포함). + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @return 생성된 Order 인스턴스 + */ + public static Order of(Long userId, List items, String couponCode, Integer discountAmount) { + return new Order(userId, items, couponCode, discountAmount); + } + + /** + * 주문 아이템 목록으로부터 총액을 계산합니다. + * + * @param items 주문 아이템 목록 + * @return 계산된 총액 + */ + private static Integer calculateTotalAmount(List items) { + return items.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + + /** + * 사용자 ID의 유효성을 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws CoreException userId가 null일 경우 + */ + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + /** + * 주문 아이템 목록의 유효성을 검증합니다. + * + * @param items 검증할 주문 아이템 목록 + * @throws CoreException items가 null이거나 비어있을 경우 + */ + private void validateItems(List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 필수이며 최소 1개 이상이어야 합니다."); + } + } + + /** + * 주문을 완료 상태로 변경합니다. + * 상태 변경만 수행하며, 포인트 차감은 도메인 서비스에서 처리합니다. + */ + public void complete() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("완료할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + this.status = OrderStatus.COMPLETED; + } + + /** + * 주문을 취소 상태로 변경합니다. + * 상태 변경만 수행하며, 포인트 환불은 도메인 서비스에서 처리합니다. + * PENDING 또는 COMPLETED 상태의 주문만 취소할 수 있습니다. + */ + public void cancel() { + if (this.status != OrderStatus.PENDING && this.status != OrderStatus.COMPLETED) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("취소할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + this.status = OrderStatus.CANCELED; + } + + /** + * 주문이 완료 상태인지 확인합니다. + * + * @return 완료 상태이면 true, 아니면 false + */ + public boolean isCompleted() { + return this.status == OrderStatus.COMPLETED; + } + + /** + * 주문이 취소 상태인지 확인합니다. + * + * @return 취소 상태이면 true, 아니면 false + */ + public boolean isCanceled() { + return this.status == OrderStatus.CANCELED; + } + + /** + * 주문이 대기 상태인지 확인합니다. + * + * @return 대기 상태이면 true, 아니면 false + */ + public boolean isPending() { + return this.status == OrderStatus.PENDING; + } + + /** + * 주문에 할인 금액을 적용합니다. + * PENDING 상태의 주문에만 할인 적용이 가능합니다. + * + * @param discountAmount 적용할 할인 금액 + * @throws CoreException PENDING 상태가 아니거나 할인 금액이 유효하지 않을 경우 + */ + public void applyDiscount(Integer discountAmount) { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("할인을 적용할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + if (discountAmount == null || discountAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0 이상이어야 합니다."); + } + this.discountAmount = discountAmount; + Integer subtotal = calculateTotalAmount(this.items); + this.totalAmount = Math.max(0, subtotal - this.discountAmount); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java new file mode 100644 index 000000000..313671be7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java @@ -0,0 +1,227 @@ +package com.loopers.domain.order; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 도메인 이벤트. + *

+ * 주문 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + *

+ * 주문이 생성되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 (null 가능) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @param orderItems 주문 아이템 목록 (재고 차감용) + * @param createdAt 이벤트 발생 시각 + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + Long usedPointAmount, + List orderItems, + LocalDateTime createdAt + ) { + /** + * 주문 아이템 정보 (재고 차감용). + * + * @param productId 상품 ID + * @param quantity 수량 + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + public OrderItemInfo { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + } + } + + public OrderCreated { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (subtotal == null || subtotal < 0) { + throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + if (orderItems == null) { + throw new IllegalArgumentException("orderItems는 필수입니다."); + } + } + + /** + * Order 엔티티로부터 OrderCreated 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @return OrderCreated 이벤트 + */ + public static OrderCreated from(Order order, Integer subtotal, Long usedPointAmount) { + List orderItemInfos = order.getItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + + return new OrderCreated( + order.getId(), + order.getUserId(), + order.getCouponCode(), + subtotal, + usedPointAmount, + orderItemInfos, + LocalDateTime.now() + ); + } + } + + /** + * 주문 완료 이벤트. + *

+ * 주문이 완료되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param totalAmount 주문 총액 + * @param completedAt 주문 완료 시각 + */ + public record OrderCompleted( + Long orderId, + Long userId, + Long totalAmount, + LocalDateTime completedAt + ) { + public OrderCompleted { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + } + + /** + * Order 엔티티로부터 OrderCompleted 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @return OrderCompleted 이벤트 + */ + public static OrderCompleted from(Order order) { + return new OrderCompleted( + order.getId(), + order.getUserId(), + order.getTotalAmount().longValue(), + LocalDateTime.now() + ); + } + } + + /** + * 주문 취소 이벤트. + *

+ * 주문이 취소되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param reason 취소 사유 + * @param orderItems 주문 아이템 목록 (재고 원복용) + * @param refundPointAmount 환불할 포인트 금액 + * @param canceledAt 주문 취소 시각 + */ + public record OrderCanceled( + Long orderId, + Long userId, + String reason, + List orderItems, + Long refundPointAmount, + LocalDateTime canceledAt + ) { + /** + * 주문 아이템 정보 (재고 원복용). + * + * @param productId 상품 ID + * @param quantity 수량 + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + public OrderItemInfo { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + } + } + + public OrderCanceled { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason은 필수입니다."); + } + if (orderItems == null) { + throw new IllegalArgumentException("orderItems는 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new IllegalArgumentException("refundPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * Order 엔티티와 환불 포인트 금액으로부터 OrderCanceled 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @param reason 취소 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @return OrderCanceled 이벤트 + */ + public static OrderCanceled from(Order order, String reason, Long refundPointAmount) { + List orderItemInfos = order.getItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + + return new OrderCanceled( + order.getId(), + order.getUserId(), + reason, + orderItemInfos, + refundPointAmount, + LocalDateTime.now() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java new file mode 100644 index 000000000..5be0e2027 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java @@ -0,0 +1,35 @@ +package com.loopers.domain.order; + +/** + * 주문 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface OrderEventPublisher { + + /** + * 주문 생성 이벤트를 발행합니다. + * + * @param event 주문 생성 이벤트 + */ + void publish(OrderEvent.OrderCreated event); + + /** + * 주문 완료 이벤트를 발행합니다. + * + * @param event 주문 완료 이벤트 + */ + void publish(OrderEvent.OrderCompleted event); + + /** + * 주문 취소 이벤트를 발행합니다. + * + * @param event 주문 취소 이벤트 + */ + void publish(OrderEvent.OrderCanceled event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..0b38e00b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 주문 아이템 Value Object. + *

+ * 주문에 포함된 상품 정보와 수량을 나타냅니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Getter +@EqualsAndHashCode +public class OrderItem { + private Long productId; + private String name; + private Integer price; + private Integer quantity; + + protected OrderItem() { + } + + /** + * OrderItem 인스턴스를 생성합니다. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param quantity 수량 + */ + public OrderItem(Long productId, String name, Integer price, Integer quantity) { + this.productId = productId; + this.name = name; + this.price = price; + this.quantity = quantity; + } + + /** + * OrderItem 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param quantity 수량 + * @return 생성된 OrderItem 인스턴스 + */ + public static OrderItem of(Long productId, String name, Integer price, Integer quantity) { + return new OrderItem(productId, name, price, quantity); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..10b80fc16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,47 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +/** + * 주문 저장소 인터페이스. + *

+ * Order 엔티티의 영속성 계층 접근을 추상화합니다. + *

+ */ +public interface OrderRepository { + + /** + * 주문을 저장합니다. + * + * @param order 저장할 주문 + * @return 저장된 주문 + */ + Order save(Order order); + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param orderId 조회할 주문 ID + * @return 조회된 주문 + */ + Optional findById(Long orderId); + + /** + * 사용자 ID로 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 주문 목록 + */ + List findAllByUserId(Long userId); + + /** + * 주문 상태로 주문 목록을 조회합니다. + * + * @param status 주문 상태 + * @return 해당 상태의 주문 목록 + */ + List findAllByStatus(OrderStatus status); +} + + 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..59fa340ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +/** + * 주문 상태 enum. + * + * @author Loopers + * @version 1.0 + */ +public enum OrderStatus { + PENDING, + COMPLETED, + CANCELED +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..973fdd4ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,123 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Outbox 이벤트 엔티티. + *

+ * Transactional Outbox Pattern을 구현하기 위한 엔티티입니다. + * 도메인 트랜잭션과 같은 트랜잭션에서 이벤트를 저장하고, + * 별도 프로세스가 이를 읽어 Kafka로 발행합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "outbox_event", + indexes = { + @Index(name = "idx_status_created", columnList = "status, created_at") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_aggregate_version", + columnNames = {"aggregate_id", "aggregate_type", "version"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 255) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "aggregate_id", nullable = false, length = 255) + private String aggregateId; + + @Column(name = "aggregate_type", nullable = false, length = 100) + private String aggregateType; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "topic", nullable = false, length = 255) + private String topic; + + @Column(name = "partition_key", length = 255) + private String partitionKey; + + @Column(name = "version") + private Long version; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 50) + private OutboxStatus status; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Builder + public OutboxEvent( + String eventId, + String eventType, + String aggregateId, + String aggregateType, + String payload, + String topic, + String partitionKey, + Long version + ) { + this.eventId = eventId; + this.eventType = eventType; + this.aggregateId = aggregateId; + this.aggregateType = aggregateType; + this.payload = payload; + this.topic = topic; + this.partitionKey = partitionKey; + this.version = version; + this.status = OutboxStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + /** + * 이벤트를 발행 완료 상태로 변경합니다. + */ + public void markAsPublished() { + this.status = OutboxStatus.PUBLISHED; + this.publishedAt = LocalDateTime.now(); + } + + /** + * 이벤트를 실패 상태로 변경합니다. + */ + public void markAsFailed() { + this.status = OutboxStatus.FAILED; + } + + /** + * Outbox 이벤트 상태. + */ + public enum OutboxStatus { + PENDING, // 발행 대기 중 + PUBLISHED, // 발행 완료 + FAILED // 발행 실패 + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..fbf574688 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,51 @@ +package com.loopers.domain.outbox; + +import java.util.List; + +/** + * OutboxEvent 저장소 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface OutboxEventRepository { + + /** + * Outbox 이벤트를 저장합니다. + * + * @param outboxEvent 저장할 Outbox 이벤트 + * @return 저장된 Outbox 이벤트 + */ + OutboxEvent save(OutboxEvent outboxEvent); + + /** + * 발행 대기 중인 이벤트 목록을 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + List findPendingEvents(int limit); + + /** + * ID로 Outbox 이벤트를 조회합니다. + * + * @param id Outbox 이벤트 ID + * @return 조회된 Outbox 이벤트 + */ + OutboxEvent findById(Long id); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + *

+ * 같은 집계에 대한 이벤트의 최신 버전을 조회하여 순차적인 버전 관리를 위해 사용됩니다. + *

+ * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + Long findLatestVersionByAggregateId(String aggregateId, String aggregateType); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java new file mode 100644 index 000000000..ea3ab7d43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java @@ -0,0 +1,11 @@ +package com.loopers.domain.payment; + +/** + * 카드 타입. + */ +public enum CardType { + SAMSUNG, + KB, + HYUNDAI +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java new file mode 100644 index 000000000..4a2c4c9e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -0,0 +1,374 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +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.time.LocalDateTime; + +/** + * 결제 도메인 엔티티. + *

+ * 결제의 상태, 금액, 포인트 사용 정보를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "payment", + uniqueConstraints = { + @UniqueConstraint(name = "uk_payment_order_id", columnNames = "ref_order_id") + }, + indexes = { + @Index(name = "idx_payment_order_id", columnList = "ref_order_id"), + @Index(name = "idx_payment_user_id", columnList = "ref_user_id"), + @Index(name = "idx_payment_status", columnList = "status") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Payment extends BaseEntity { + + @Column(name = "ref_order_id", nullable = false) + private Long orderId; + + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "used_point", nullable = false) + private Long usedPoint; + + @Column(name = "paid_amount", nullable = false) + private Long paidAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "card_type") + private CardType cardType; + + @Column(name = "card_no") + private String cardNo; + + @Column(name = "failure_reason", length = 500) + private String failureReason; + + @Column(name = "pg_requested_at", nullable = false) + private LocalDateTime pgRequestedAt; + + @Column(name = "pg_completed_at") + private LocalDateTime pgCompletedAt; + + /** + * 카드 결제용 Payment를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + CardType cardType, + String cardNo, + Long amount, + LocalDateTime requestedAt + ) { + validateOrderId(orderId); + validateUserId(userId); + validateCardType(cardType); + validateCardNo(cardNo); + validateAmount(amount); + validateRequestedAt(requestedAt); + + Payment payment = new Payment(); + payment.orderId = orderId; + payment.userId = userId; + payment.totalAmount = amount; + payment.usedPoint = 0L; + payment.paidAmount = amount; + payment.status = PaymentStatus.PENDING; + payment.cardType = cardType; + payment.cardNo = cardNo; + payment.pgRequestedAt = requestedAt; + + return payment; + } + + /** + * 포인트 결제용 Payment를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + LocalDateTime requestedAt + ) { + return of(orderId, userId, totalAmount, usedPoint, null, null, requestedAt); + } + + /** + * 포인트와 카드 혼합 결제용 Payment를 생성합니다. + *

+ * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + CardType cardType, + String cardNo, + LocalDateTime requestedAt + ) { + validateOrderId(orderId); + validateUserId(userId); + validateAmount(totalAmount); + validateUsedPoint(usedPoint); + validateRequestedAt(requestedAt); + + Long paidAmount = totalAmount - usedPoint; + validatePaidAmount(paidAmount); + + // paidAmount > 0이면 카드 정보 필수 + if (paidAmount > 0) { + if (cardType == null || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + } + + Payment payment = new Payment(); + payment.orderId = orderId; + payment.userId = userId; + payment.totalAmount = totalAmount; + payment.usedPoint = usedPoint; + payment.paidAmount = paidAmount; + payment.status = (paidAmount == 0L) ? PaymentStatus.SUCCESS : PaymentStatus.PENDING; + payment.cardType = cardType; // paidAmount > 0일 때만 설정 + payment.cardNo = cardNo; // paidAmount > 0일 때만 설정 + payment.pgRequestedAt = requestedAt; + + return payment; + } + + /** + * 결제를 SUCCESS 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + *

+ * + * @param completedAt PG 완료 시각 + * @throws CoreException PENDING 상태가 아닌 경우 (SUCCESS는 제외) + */ + public void toSuccess(LocalDateTime completedAt) { + if (status == PaymentStatus.SUCCESS) { + // 멱등성: 이미 성공 상태면 아무 작업도 하지 않음 + return; + } + if (status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 성공으로 전이할 수 있습니다."); + } + this.status = PaymentStatus.SUCCESS; + this.pgCompletedAt = completedAt; + } + + /** + * 결제를 FAILED 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + *

+ * + * @param failureReason 실패 사유 + * @param completedAt PG 완료 시각 + * @throws CoreException PENDING 상태가 아닌 경우 (FAILED는 제외) + */ + public void toFailed(String failureReason, LocalDateTime completedAt) { + if (status == PaymentStatus.FAILED) { + // 멱등성: 이미 실패 상태면 아무 작업도 하지 않음 + return; + } + if (status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 실패로 전이할 수 있습니다."); + } + this.status = PaymentStatus.FAILED; + this.failureReason = failureReason; + this.pgCompletedAt = completedAt; + } + + /** + * 결제가 완료되었는지 확인합니다. + * + * @return 완료 여부 + */ + public boolean isCompleted() { + return status.isCompleted(); + } + + /** + * 결제가 대기 상태인지 확인합니다. + * + * @return 대기 상태이면 true, 아니면 false + */ + public boolean isPending() { + return status == PaymentStatus.PENDING; + } + + /** + * 결제가 성공 상태인지 확인합니다. + * + * @return 성공 상태이면 true, 아니면 false + */ + public boolean isSuccess() { + return status.isSuccess(); + } + + /** + * 결제가 실패 상태인지 확인합니다. + * + * @return 실패 상태이면 true, 아니면 false + */ + public boolean isFailed() { + return status == PaymentStatus.FAILED; + } + + /** + * 쿠폰 할인 금액을 적용하여 결제 금액을 업데이트합니다. + *

+ * 쿠폰 할인이 적용된 후 Order의 totalAmount가 업데이트되면, + * Payment의 totalAmount도 동기화하기 위해 호출됩니다. + *

+ *

+ * 주의사항: + *

    + *
  • 이미 완료된 결제에는 할인을 적용할 수 없습니다.
  • + *
  • 할인 금액이 totalAmount를 초과할 수 없습니다.
  • + *
  • paidAmount는 자동으로 재계산됩니다.
  • + *
+ *

+ * + * @param discountAmount 할인 금액 + * @throws CoreException 결제가 완료되었거나 할인 금액이 유효하지 않은 경우 + */ + public void applyCouponDiscount(Integer discountAmount) { + if (discountAmount == null || discountAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0 이상이어야 합니다."); + } + + // 이미 완료된 결제에는 할인을 적용할 수 없음 + if (isCompleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 완료된 결제에는 할인을 적용할 수 없습니다."); + } + + // 할인 금액이 totalAmount를 초과할 수 없음 + if (discountAmount > totalAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("할인 금액(%d)이 결제 금액(%d)을 초과할 수 없습니다.", discountAmount, totalAmount)); + } + + // totalAmount에서 할인 금액 차감 + Long newTotalAmount = totalAmount - discountAmount; + if (newTotalAmount < 0) { + newTotalAmount = 0L; + } + + this.totalAmount = newTotalAmount; + + // paidAmount 재계산 (totalAmount - usedPoint) + Long newPaidAmount = newTotalAmount - usedPoint; + if (newPaidAmount < 0) { + newPaidAmount = 0L; + } + this.paidAmount = newPaidAmount; + + // paidAmount가 0이 되면 자동으로 완료 처리 + if (newPaidAmount == 0L && status == PaymentStatus.PENDING) { + this.status = PaymentStatus.SUCCESS; + } + } + + private static void validateOrderId(Long orderId) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다."); + } + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + private static void validateCardType(CardType cardType) { + if (cardType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다."); + } + } + + private static void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + } + + private static void validateAmount(Long amount) { + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 0보다 커야 합니다."); + } + } + + private static void validateUsedPoint(Long usedPoint) { + if (usedPoint == null || usedPoint < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 포인트는 0 이상이어야 합니다."); + } + } + + private static void validatePaidAmount(Long paidAmount) { + if (paidAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트와 쿠폰 할인의 합이 주문 금액을 초과합니다."); + } + } + + private static void validateRequestedAt(LocalDateTime requestedAt) { + if (requestedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "PG 요청 시각은 필수입니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java new file mode 100644 index 000000000..01e58027a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java @@ -0,0 +1,266 @@ +package com.loopers.domain.payment; + +import java.time.LocalDateTime; + +/** + * 결제 도메인 이벤트. + *

+ * 결제 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ */ +public class PaymentEvent { + + /** + * 결제 완료 이벤트. + *

+ * 결제가 성공적으로 완료되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param paymentId 결제 ID + * @param transactionKey 트랜잭션 키 (null 가능 - PG 응답 전에는 없을 수 있음) + * @param completedAt 결제 완료 시각 + */ + public record PaymentCompleted( + Long orderId, + Long paymentId, + String transactionKey, + LocalDateTime completedAt + ) { + public PaymentCompleted { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId는 필수입니다."); + } + } + + /** + * Payment 엔티티와 transactionKey로부터 PaymentCompleted 이벤트를 생성합니다. + * + * @param payment 결제 엔티티 + * @param transactionKey 트랜잭션 키 (null 가능) + * @return PaymentCompleted 이벤트 + */ + public static PaymentCompleted from(Payment payment, String transactionKey) { + return new PaymentCompleted( + payment.getOrderId(), + payment.getId(), + transactionKey, + payment.getPgCompletedAt() != null ? payment.getPgCompletedAt() : LocalDateTime.now() + ); + } + } + + /** + * 결제 실패 이벤트. + *

+ * 결제가 실패했을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param paymentId 결제 ID + * @param transactionKey 트랜잭션 키 (null 가능) + * @param reason 실패 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @param failedAt 결제 실패 시각 + */ + public record PaymentFailed( + Long orderId, + Long paymentId, + String transactionKey, + String reason, + Long refundPointAmount, + LocalDateTime failedAt + ) { + public PaymentFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId는 필수입니다."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason은 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new IllegalArgumentException("refundPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * Payment 엔티티와 transactionKey로부터 PaymentFailed 이벤트를 생성합니다. + * + * @param payment 결제 엔티티 + * @param reason 실패 사유 + * @param transactionKey 트랜잭션 키 (null 가능) + * @return PaymentFailed 이벤트 + */ + public static PaymentFailed from(Payment payment, String reason, String transactionKey) { + return new PaymentFailed( + payment.getOrderId(), + payment.getId(), + transactionKey, + reason, + payment.getUsedPoint(), + payment.getPgCompletedAt() != null ? payment.getPgCompletedAt() : LocalDateTime.now() + ); + } + } + + /** + * 결제 요청 이벤트. + *

+ * 주문에 대한 결제를 요청할 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId, PG 요청용) + * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @param occurredAt 이벤트 발생 시각 + */ + public record PaymentRequested( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo, + LocalDateTime occurredAt + ) { + public PaymentRequested { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (userEntityId == null) { + throw new IllegalArgumentException("userEntityId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * 결제 요청 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId) + * @param userEntityId 사용자 엔티티 ID (Long - User.id) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @return PaymentRequested 이벤트 + */ + public static PaymentRequested of( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo + ) { + return new PaymentRequested( + orderId, + userId, + userEntityId, + totalAmount, + usedPointAmount, + cardType, + cardNo, + LocalDateTime.now() + ); + } + + /** + * 마스킹된 카드 번호를 반환합니다. + *

+ * PII 보호를 위해 카드 번호의 중간 부분을 마스킹합니다. + * 마지막 4자리만 표시하고 나머지는 *로 마스킹합니다. + * 예: "4111-1234-5678-9010" -> "****-****-****-9010" + * "4111123456789010" -> "************9010" + *

+ * + * @return 마스킹된 카드 번호 (cardNo가 null이거나 비어있으면 null 반환) + */ + public String maskedCardNo() { + if (cardNo == null || cardNo.isBlank()) { + return null; + } + + // 숫자만 추출 + String digitsOnly = cardNo.replaceAll("[^0-9]", ""); + + if (digitsOnly.length() < 4) { + // 카드 번호가 너무 짧으면 전체 마스킹 + return "****"; + } + + // 마지막 4자리 추출 + String lastFour = digitsOnly.substring(digitsOnly.length() - 4); + + // 원본에 하이픈이 있었다면 하이픈 패턴 유지 + if (cardNo.contains("-")) { + // 하이픈으로 구분된 각 부분 처리 + String[] parts = cardNo.split("-"); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + result.append("-"); + } + + String part = parts[i].replaceAll("[^0-9]", ""); + if (i == parts.length - 1 && part.length() >= 4) { + // 마지막 부분은 마지막 4자리만 표시 + result.append("*".repeat(part.length() - 4)).append(lastFour); + } else { + // 중간 부분은 모두 마스킹 + result.append("*".repeat(part.length())); + } + } + return result.toString(); + } else { + // 하이픈이 없으면 마스킹된 부분과 마지막 4자리만 반환 + int maskedLength = digitsOnly.length() - 4; + return "*".repeat(maskedLength) + lastFour; + } + } + + /** + * 로깅 및 이벤트 저장 시 사용할 수 있도록 마스킹된 정보를 포함한 문자열을 반환합니다. + *

+ * PII 보호를 위해 cardNo는 마스킹된 버전으로 출력됩니다. + *

+ * + * @return 마스킹된 정보를 포함한 문자열 표현 + */ + @Override + public String toString() { + return String.format( + "PaymentRequested[orderId=%d, userId='%s', userEntityId=%d, totalAmount=%d, usedPointAmount=%d, cardType='%s', cardNo='%s', occurredAt=%s]", + orderId, + userId, + userEntityId, + totalAmount, + usedPointAmount, + cardType, + maskedCardNo(), + occurredAt + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java new file mode 100644 index 000000000..f8f6e2687 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java @@ -0,0 +1,35 @@ +package com.loopers.domain.payment; + +/** + * 결제 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface PaymentEventPublisher { + + /** + * 결제 완료 이벤트를 발행합니다. + * + * @param event 결제 완료 이벤트 + */ + void publish(PaymentEvent.PaymentCompleted event); + + /** + * 결제 실패 이벤트를 발행합니다. + * + * @param event 결제 실패 이벤트 + */ + void publish(PaymentEvent.PaymentFailed event); + + /** + * 결제 요청 이벤트를 발행합니다. + * + * @param event 결제 요청 이벤트 + */ + void publish(PaymentEvent.PaymentRequested event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java new file mode 100644 index 000000000..4a41411aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java @@ -0,0 +1,80 @@ +package com.loopers.domain.payment; + +import java.util.Set; + +/** + * 결제 실패 유형. + *

+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public enum PaymentFailureType { + /** + * 비즈니스 실패: 주문 취소 필요 + * 예: 카드 한도 초과, 잘못된 카드 번호 등 + */ + BUSINESS_FAILURE, + + /** + * 외부 시스템 장애: 주문 PENDING 상태 유지 + * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등 + */ + EXTERNAL_SYSTEM_FAILURE; + + private static final Set BUSINESS_FAILURE_CODES = Set.of( + "LIMIT_EXCEEDED", + "INVALID_CARD", + "CARD_ERROR", + "INSUFFICIENT_FUNDS", + "PAYMENT_FAILED" + ); + + private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN"; + + /** + * 오류 코드를 기반으로 결제 실패 유형을 분류합니다. + *

+ * 비즈니스 실패 예시: + *

    + *
  • 카드 한도 초과 (LIMIT_EXCEEDED)
  • + *
  • 잘못된 카드 번호 (INVALID_CARD)
  • + *
  • 카드 오류 (CARD_ERROR)
  • + *
  • 잔액 부족 (INSUFFICIENT_FUNDS)
  • + *
+ *

+ *

+ * 외부 시스템 장애 예시: + *

    + *
  • CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)
  • + *
  • 서버 오류 (5xx)
  • + *
  • 타임아웃
  • + *
  • 네트워크 오류
  • + *
+ *

+ * + * @param errorCode 오류 코드 + * @return 결제 실패 유형 + */ + public static PaymentFailureType classify(String errorCode) { + if (errorCode == null) { + return EXTERNAL_SYSTEM_FAILURE; + } + + // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주 + if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) { + return EXTERNAL_SYSTEM_FAILURE; + } + + // 명확한 비즈니스 실패 오류 코드만 취소 처리 + boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream() + .anyMatch(errorCode::contains); + + return isBusinessFailure + ? BUSINESS_FAILURE + : EXTERNAL_SYSTEM_FAILURE; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java new file mode 100644 index 000000000..a2d42ca88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java @@ -0,0 +1,35 @@ +package com.loopers.domain.payment; + +/** + * 결제 게이트웨이 인터페이스. + *

+ * 도메인 계층에 정의하여 DIP를 준수합니다. + * 인프라 계층이 이 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface PaymentGateway { + /** + * PG 결제 요청을 전송합니다. + * + * @param request 결제 요청 값 객체 + * @return 결제 요청 결과 + */ + PaymentRequestResult requestPayment(PaymentRequest request); + + /** + * 결제 상태를 조회합니다. + *

+ * 주의: userId는 PG 시스템이 요구하는 사용자 식별자(String)입니다. + * 도메인 모델의 User.id(Long)와는 다른 값입니다. + *

+ * + * @param userId 사용자 ID (PG 시스템이 요구하는 String 형식의 사용자 식별자) + * @param orderId 주문 ID + * @return 결제 상태 + */ + PaymentStatus getPaymentStatus(String userId, Long orderId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 000000000..7d3a73868 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,54 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +/** + * 결제 저장소 인터페이스. + *

+ * Payment 엔티티의 영속성 계층 접근을 추상화합니다. + *

+ */ +public interface PaymentRepository { + + /** + * 결제를 저장합니다. + * + * @param payment 저장할 결제 + * @return 저장된 결제 + */ + Payment save(Payment payment); + + /** + * 결제 ID로 결제를 조회합니다. + * + * @param paymentId 조회할 결제 ID + * @return 조회된 결제 + */ + Optional findById(Long paymentId); + + /** + * 주문 ID로 결제를 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 결제 + */ + Optional findByOrderId(Long orderId); + + /** + * 사용자 ID로 결제 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 결제 목록 + */ + List findAllByUserId(Long userId); + + /** + * 결제 상태로 결제 목록을 조회합니다. + * + * @param status 결제 상태 + * @return 해당 상태의 결제 목록 + */ + List findAllByStatus(PaymentStatus status); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java new file mode 100644 index 000000000..4aa51dc7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.payment; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 결제 요청 값 객체. + *

+ * PG 결제 요청에 필요한 정보를 담는 도메인 값 객체입니다. + * 도메인 계층에 위치하여 DIP를 준수합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public record PaymentRequest( + String userId, + Long orderId, + String cardType, + String cardNo, + Long amount, + String callbackUrl +) { + public PaymentRequest { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 필수입니다."); + } + if (cardType == null || cardType.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardType은 필수입니다."); + } + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardNo는 필수입니다."); + } + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "amount는 0보다 커야 합니다."); + } + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "callbackUrl은 필수입니다."); + } + } + + @Override + public String toString() { + String maskedCardNo = cardNo != null && cardNo.length() > 4 + ? "****" + cardNo.substring(cardNo.length() - 4) + : "****"; + return "PaymentRequest[userId=%s, orderId=%d, cardType=%s, cardNo=%s, amount=%d, callbackUrl=%s]" + .formatted(userId, orderId, cardType, maskedCardNo, amount, callbackUrl); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java new file mode 100644 index 000000000..9c62dee8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java @@ -0,0 +1,30 @@ +package com.loopers.domain.payment; + +/** + * 결제 요청 결과. + *

+ * PG 결제 요청의 결과를 나타내는 도메인 모델입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public sealed interface PaymentRequestResult { + /** + * 결제 요청 성공. + * + * @param transactionKey 트랜잭션 키 + */ + record Success(String transactionKey) implements PaymentRequestResult {} + + /** + * 결제 요청 실패. + * + * @param errorCode 오류 코드 + * @param message 오류 메시지 + * @param isTimeout 타임아웃 여부 + * @param isRetryable 재시도 가능 여부 + */ + record Failure(String errorCode, String message, boolean isTimeout, boolean isRetryable) implements PaymentRequestResult {} +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java new file mode 100644 index 000000000..792362f02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java @@ -0,0 +1,55 @@ +package com.loopers.domain.payment; + +import java.util.function.Function; + +/** + * 결제 결과 도메인 모델. + *

+ * 결제 요청의 성공/실패 결과를 표현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public sealed interface PaymentResult { + + /** + * 성공 결과. + */ + record Success(String transactionKey) implements PaymentResult { + } + + /** + * 실패 결과. + */ + record Failure( + String errorCode, + String message, + boolean isTimeout, + boolean isServerError, + boolean isClientError + ) implements PaymentResult { + } + + /** + * 결과에 따라 처리합니다. + * + * @param successHandler 성공 시 처리 함수 + * @param failureHandler 실패 시 처리 함수 + * @param 반환 타입 + * @return 처리 결과 + */ + default T handle( + Function successHandler, + Function failureHandler + ) { + if (this instanceof Success success) { + return successHandler.apply(success); + } else if (this instanceof Failure failure) { + return failureHandler.apply(failure); + } else { + throw new IllegalStateException("Unknown PaymentResult type: " + this.getClass()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java new file mode 100644 index 000000000..7335929c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,29 @@ +package com.loopers.domain.payment; + +/** + * 결제 상태. + */ +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED; + + /** + * 결제가 완료되었는지 확인합니다. + * + * @return 완료 여부 (SUCCESS 또는 FAILED) + */ + public boolean isCompleted() { + return this == SUCCESS || this == FAILED; + } + + /** + * 결제가 성공했는지 확인합니다. + * + * @return 성공 여부 + */ + public boolean isSuccess() { + return this == SUCCESS; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..3ad8c8e7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,228 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 상품 도메인 엔티티. + *

+ * 상품의 기본 정보(이름, 가격, 재고, 브랜드)를 관리하며, + * 주문 시 재고 차감 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "product", + indexes = { + // 브랜드 필터 + 좋아요 순 정렬 최적화 (복합 인덱스: ref_brand_id, like_count) + @Index(name = "idx_product_brand_likes", columnList = "ref_brand_id,like_count"), + // 브랜드 필터 + 최신순 정렬 최적화 (복합 인덱스: ref_brand_id, created_at) + @Index(name = "idx_product_brand_created", columnList = "ref_brand_id,created_at"), + // 브랜드 필터 + 가격순 정렬 최적화 (복합 인덱스: ref_brand_id, price) + @Index(name = "idx_product_brand_price", columnList = "ref_brand_id,price"), + // 전체 조회 + 좋아요 순 정렬 최적화 + @Index(name = "idx_product_likes", columnList = "like_count"), + // 전체 조회 + 가격순 정렬 최적화 + @Index(name = "idx_product_price", columnList = "price"), + // 전체 조회 + 최신순 정렬 최적화 + @Index(name = "idx_product_created", columnList = "created_at") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Product extends BaseEntity { + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "ref_brand_id", nullable = false) + private Long brandId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * Product 인스턴스를 생성합니다. + * + * @param name 상품 이름 (필수) + * @param price 상품 가격 (필수, 0 이상) + * @param stock 상품 재고 (필수, 0 이상) + * @param brandId 브랜드 ID (필수) + * @throws CoreException 유효성 검증 실패 시 + */ + public Product(String name, Integer price, Integer stock, Long brandId) { + validateName(name); + validatePrice(price); + validateStock(stock); + validateBrandId(brandId); + this.name = name; + this.price = price; + this.stock = stock; + this.brandId = brandId; + this.likeCount = 0L; + } + + /** + * Product 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @return 생성된 Product 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Product of(String name, Integer price, Integer stock, Long brandId) { + return new Product(name, price, stock, brandId); + } + + /** + * 재고를 차감합니다. + * 재고는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다. + * + * @param quantity 차감할 수량 (0보다 커야 함) + * @throws CoreException quantity가 null, 0 이하이거나 재고가 부족할 경우 + */ + public void decreaseStock(Integer quantity) { + validateQuantity(quantity); + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("재고가 부족합니다. (현재 재고: %d, 요청 수량: %d)", this.stock, quantity)); + } + this.stock -= quantity; + } + + /** + * 재고를 증가시킵니다. + * 주문 취소 시 재고를 원복하는 데 사용됩니다. + * + * @param quantity 증가시킬 수량 (0보다 커야 함) + * @throws CoreException quantity가 null이거나 0 이하일 경우 + */ + public void increaseStock(Integer quantity) { + validateQuantity(quantity); + this.stock += quantity; + } + + /** + * 상품 이름의 유효성을 검증합니다. + * + * @param name 검증할 상품 이름 + * @throws CoreException name이 null이거나 공백일 경우 + */ + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다."); + } + } + + /** + * 상품 가격의 유효성을 검증합니다. + * + * @param price 검증할 상품 가격 + * @throws CoreException price가 null이거나 0 미만일 경우 + */ + private void validatePrice(Integer price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0 이상이어야 합니다."); + } + } + + /** + * 상품 재고의 유효성을 검증합니다. + * + * @param stock 검증할 상품 재고 + * @throws CoreException stock이 null이거나 0 미만일 경우 + */ + private void validateStock(Integer stock) { + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 0 이상이어야 합니다."); + } + } + + /** + * 브랜드 ID의 유효성을 검증합니다. + * + * @param brandId 검증할 브랜드 ID + * @throws CoreException brandId가 null일 경우 + */ + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + } + + /** + * 수량의 유효성을 검증합니다. + * + * @param quantity 검증할 수량 + * @throws CoreException quantity가 null이거나 0 이하일 경우 + */ + private void validateQuantity(Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0보다 커야 합니다."); + } + } + + /** + * 좋아요 수를 증가시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ * + * @throws CoreException 좋아요 수가 음수가 되는 경우 + */ + public void incrementLikeCount() { + if (this.likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 음수가 될 수 없습니다."); + } + this.likeCount++; + } + + /** + * 좋아요 수를 감소시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ *

+ * 멱등성 보장: 좋아요 수가 0인 경우에도 예외를 던지지 않고 그대로 유지합니다. + * 이는 동시성 상황에서 이미 삭제된 좋아요에 대한 이벤트가 중복 처리될 수 있기 때문입니다. + *

+ */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + // likeCount가 0인 경우는 이미 삭제된 상태이므로 그대로 유지 (멱등성 보장) + } + + /** + * 좋아요 수를 업데이트합니다. + *

+ * 배치 집계나 초기화 시 사용됩니다. + *

+ * + * @param likeCount 업데이트할 좋아요 수 (0 이상) + * @throws CoreException likeCount가 null이거나 음수일 경우 + */ + public void updateLikeCount(Long likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 이상이어야 합니다."); + } + this.likeCount = likeCount; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java new file mode 100644 index 000000000..24a7e3c4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java @@ -0,0 +1,91 @@ +package com.loopers.domain.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 상품 상세 정보 Value Object. + *

+ * 상품 상세 조회 시 Product, Brand 정보, 좋아요 수를 조합한 결과를 나타냅니다. + * 값으로 식별되며 불변성을 가집니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Getter +@EqualsAndHashCode +public class ProductDetail { + private final Long id; + private final String name; + private final Integer price; + private final Integer stock; + private final Long brandId; + private final String brandName; + private final Long likesCount; + + private ProductDetail(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) { + this.id = id; + this.name = name; + this.price = price; + this.stock = stock; + this.brandId = brandId; + this.brandName = brandName; + this.likesCount = likesCount; + } + + /** + * ProductDetail 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param id 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + * @return 생성된 ProductDetail 인스턴스 + */ + public static ProductDetail of(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) { + return new ProductDetail(id, name, price, stock, brandId, brandName, likesCount); + } + + /** + * Product, 브랜드 이름, 좋아요 수로부터 ProductDetail을 생성하는 정적 팩토리 메서드. + *

+ * 상품 상세 조회 시 Product와 브랜드 이름, 좋아요 수를 조합하여 ProductDetail을 생성합니다. + *

+ *

+ * Aggregate 경계 준수: Brand Aggregate 엔티티 대신 필요한 값(brandName)만 전달하여 + * Aggregate 간 직접 참조를 피합니다. + *

+ * + * @param product 상품 엔티티 + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + * @return 생성된 ProductDetail 인스턴스 + * @throws IllegalArgumentException product, brandName, likesCount가 null인 경우 + */ + public static ProductDetail from(Product product, String brandName, Long likesCount) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } + if (brandName == null) { + throw new IllegalArgumentException("브랜드 이름은 필수입니다."); + } + if (likesCount == null) { + throw new IllegalArgumentException("좋아요 수는 null일 수 없습니다."); + } + + return ProductDetail.of( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + brandName, + likesCount + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java new file mode 100644 index 000000000..ef2614040 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java @@ -0,0 +1,53 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import org.springframework.stereotype.Component; + +/** + * 상품 상세 정보 조합 도메인 서비스. + *

+ * 상품 상세 조회 시 Product와 Brand 정보, 좋아요 수를 조합하는 도메인 로직을 처리합니다. + * 도메인 간 협력 로직(Product + Brand + Like)을 담당합니다. + *

+ *

+ * 상태가 없고, 도메인 객체의 협력 중심으로 설계되었습니다. + * Repository 의존성 없이 도메인 객체(Product, Brand, likesCount)를 파라미터로 받아 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class ProductDetailService { + + /** + * 상품과 브랜드 정보, 좋아요 수를 조합하여 상품 상세 정보를 생성합니다. + * + * @param product 상품 엔티티 + * @param brand 브랜드 엔티티 + * @param likesCount 좋아요 수 + * @return 조합된 상품 상세 정보 + */ + public ProductDetail combineProductAndBrand(Product product, Brand brand, Long likesCount) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } + if (brand == null) { + throw new IllegalArgumentException("브랜드 정보는 필수입니다."); + } + if (likesCount == null) { + throw new IllegalArgumentException("좋아요 수는 null일 수 없습니다."); + } + + return ProductDetail.of( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + brand.getName(), + likesCount + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java new file mode 100644 index 000000000..054303b09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java @@ -0,0 +1,59 @@ +package com.loopers.domain.product; + +import java.time.LocalDateTime; + +/** + * 상품 도메인 이벤트. + *

+ * 상품 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + *

+ * 상품 상세 페이지가 조회되었을 때 발행되는 이벤트입니다. + * 메트릭 집계를 위해 사용됩니다. + *

+ * + * @param productId 상품 ID + * @param userId 사용자 ID (null 가능 - 비로그인 사용자) + * @param occurredAt 이벤트 발생 시각 + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + public ProductViewed { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * 상품 ID로부터 ProductViewed 이벤트를 생성합니다. + * + * @param productId 상품 ID + * @return ProductViewed 이벤트 + */ + public static ProductViewed from(Long productId) { + return new ProductViewed(productId, null, LocalDateTime.now()); + } + + /** + * 상품 ID와 사용자 ID로부터 ProductViewed 이벤트를 생성합니다. + * + * @param productId 상품 ID + * @param userId 사용자 ID + * @return ProductViewed 이벤트 + */ + public static ProductViewed from(Long productId, Long userId) { + return new ProductViewed(productId, userId, LocalDateTime.now()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java new file mode 100644 index 000000000..0cc60f495 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java @@ -0,0 +1,21 @@ +package com.loopers.domain.product; + +/** + * 상품 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductEventPublisher { + + /** + * 상품 상세 페이지 조회 이벤트를 발행합니다. + * + * @param event 상품 조회 이벤트 + */ + void publish(ProductEvent.ProductViewed event); +} 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..425ce977c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,102 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +/** + * Product 엔티티에 대한 저장소 인터페이스. + *

+ * 상품 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductRepository { + /** + * 상품을 저장합니다. + * + * @param product 저장할 상품 + * @return 저장된 상품 + */ + Product save(Product product); + + /** + * 상품 ID로 상품을 조회합니다. + * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + Optional findById(Long productId); + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: PK(id) 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 재고 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + Optional findByIdForUpdate(Long productId); + + /** + * 상품 ID 목록으로 상품 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param productIds 조회할 상품 ID 목록 + * @return 조회된 상품 목록 + */ + List findAllById(List productIds); + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 상품 수 + * @return 상품 목록 + */ + List findAll(Long brandId, String sort, int page, int size); + + /** + * 상품 목록의 총 개수를 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @return 상품 총 개수 + */ + long countAll(Long brandId); + + /** + * 상품의 좋아요 수를 업데이트합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @param productId 상품 ID + * @param likeCount 업데이트할 좋아요 수 + */ + void updateLikeCount(Long productId, Long likeCount); + + /** + * 모든 상품 ID를 조회합니다. + *

+ * Spring Batch Reader에서 사용됩니다. + *

+ * + * @return 모든 상품 ID 목록 + */ + List findAllProductIds(); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java new file mode 100644 index 000000000..22dfd22c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -0,0 +1,120 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 상품 랭킹 Materialized View 엔티티. + *

+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *

+ *

+ * Materialized View 설계: + *

    + *
  • 테이블: `mv_product_rank` (단일 테이블)
  • + *
  • 주간 랭킹: period_type = WEEKLY
  • + *
  • 월간 랭킹: period_type = MONTHLY
  • + *
  • TOP 100만 저장하여 조회 성능 최적화
  • + *
+ *

+ *

+ * 인덱스 전략: + *

    + *
  • 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
  • + *
  • 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "mv_product_rank", + indexes = { + @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"), + @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간) + */ + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false, length = 20) + private PeriodType periodType; + + /** + * 기간 시작일 + *
    + *
  • 주간: 해당 주의 월요일 (ISO 8601 기준)
  • + *
  • 월간: 해당 월의 1일
  • + *
+ */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 랭킹 (1-100) + */ + @Column(name = "rank", nullable = false) + private Integer rank; + + /** + * 좋아요 수 + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + WEEKLY, // 주간 + MONTHLY // 월간 + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java new file mode 100644 index 000000000..92b1529e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank 도메인 Repository 인터페이스. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

+ */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit); + + /** + * 특정 기간의 특정 상품 랭킹을 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 Optional.empty()) + */ + Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java new file mode 100644 index 000000000..7616a497f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -0,0 +1,14 @@ +package com.loopers.domain.user; + +/** + * 사용자의 성별을 나타내는 열거형. + * + * @author Loopers + * @version 1.0 + */ +public enum Gender { + MALE, + FEMALE +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java new file mode 100644 index 000000000..5754b0dad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java @@ -0,0 +1,98 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 포인트 Value Object. + *

+ * 사용자의 포인트 잔액을 나타내는 불변 값 객체입니다. + * 값으로 식별되며 불변성을 가집니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Embeddable +@Getter +@EqualsAndHashCode +public class Point { + @Column(name = "balance", nullable = false) + private Long value; + + protected Point() { + this.value = 0L; // JPA를 위한 기본 생성자 + } + + /** + * Point 인스턴스를 생성합니다. + * + * @param value 포인트 값 (0 이상이어야 함) + * @throws CoreException value가 null이거나 음수일 경우 + */ + public Point(Long value) { + validateValue(value); + this.value = value; + } + + /** + * Point 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param value 포인트 값 + * @return 생성된 Point 인스턴스 + * @throws CoreException value가 null이거나 음수일 경우 + */ + public static Point of(Long value) { + return new Point(value); + } + + /** + * 포인트를 더한 새로운 Point 인스턴스를 반환합니다. + * + * @param other 더할 포인트 + * @return 새로운 Point 인스턴스 + * @throws CoreException other가 null일 경우 + */ + public Point add(Point other) { + if (other == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 null일 수 없습니다."); + } + return new Point(this.value + other.value); + } + + /** + * 포인트를 뺀 새로운 Point 인스턴스를 반환합니다. + * 포인트는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다. + * + * @param other 뺄 포인트 + * @return 새로운 Point 인스턴스 + * @throws CoreException other가 null이거나 잔액이 부족할 경우 + */ + public Point subtract(Point other) { + if (other == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 null일 수 없습니다."); + } + if (this.value < other.value) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("포인트가 부족합니다. (현재 잔액: %d, 요청 금액: %d)", this.value, other.value)); + } + return new Point(this.value - other.value); + } + + /** + * 포인트 값의 유효성을 검증합니다. + * + * @param value 검증할 포인트 값 + * @throws CoreException value가 null이거나 음수일 경우 + */ + private void validateValue(Long value) { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java new file mode 100644 index 000000000..8bba3b8a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java @@ -0,0 +1,114 @@ +package com.loopers.domain.user; + +import java.time.LocalDateTime; + +/** + * 포인트 도메인 이벤트. + *

+ * 포인트 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ */ +public class PointEvent { + + /** + * 포인트 사용 이벤트. + *

+ * 주문에서 포인트를 사용할 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용할 포인트 금액 + * @param occurredAt 이벤트 발생 시각 + */ + public record PointUsed( + Long orderId, + Long userId, + Long usedPointAmount, + LocalDateTime occurredAt + ) { + public PointUsed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * OrderCreated 이벤트로부터 PointUsed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param usedPointAmount 사용할 포인트 금액 + * @return PointUsed 이벤트 + */ + public static PointUsed of(Long orderId, Long userId, Long usedPointAmount) { + return new PointUsed( + orderId, + userId, + usedPointAmount, + LocalDateTime.now() + ); + } + } + + /** + * 포인트 사용 실패 이벤트. + *

+ * 포인트 사용에 실패했을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용 요청 포인트 금액 + * @param failureReason 실패 사유 + * @param failedAt 실패 시각 + */ + public record PointUsedFailed( + Long orderId, + Long userId, + Long usedPointAmount, + String failureReason, + LocalDateTime failedAt + ) { + public PointUsedFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("failureReason는 필수입니다."); + } + } + + /** + * 포인트 사용 실패 정보로부터 PointUsedFailed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param usedPointAmount 사용 요청 포인트 금액 + * @param failureReason 실패 사유 + * @return PointUsedFailed 이벤트 + */ + public static PointUsedFailed of(Long orderId, Long userId, Long usedPointAmount, String failureReason) { + return new PointUsedFailed( + orderId, + userId, + usedPointAmount, + failureReason, + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java new file mode 100644 index 000000000..8b01a7bca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.user; + +/** + * 포인트 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface PointEventPublisher { + + /** + * 포인트 사용 이벤트를 발행합니다. + * + * @param event 포인트 사용 이벤트 + */ + void publish(PointEvent.PointUsed event); + + /** + * 포인트 사용 실패 이벤트를 발행합니다. + * + * @param event 포인트 사용 실패 이벤트 + */ + void publish(PointEvent.PointUsedFailed event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 000000000..b3ed48869 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,178 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EnumType; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.regex.Pattern; + +/** + * 사용자 도메인 엔티티. + *

+ * 사용자의 기본 정보(ID, 이메일, 생년월일, 성별)를 관리하며, + * 각 필드에 대한 유효성 검증을 수행합니다. + *

+ * + *

검증 규칙

+ *
    + *
  • userId: 영문 및 숫자 조합, 최대 10자
  • + *
  • email: 유효한 이메일 형식
  • + *
  • birthDate: yyyy-MM-dd 형식
  • + *
+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class User extends BaseEntity { + @Column(name = "user_id", unique = true, nullable = false, length = 10) + private String userId; + + private String email; + + private LocalDate birthDate; + + @Enumerated(EnumType.STRING) + private Gender gender; + + @Embedded + private Point point; + + private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); + /** + * 사용자 ID의 유효성을 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws CoreException userId가 null, 공백이거나 형식에 맞지 않을 경우 + */ + private void validateUserId(String userId) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "ID는 필수입니다."); + } + if (!USER_ID_PATTERN.matcher(userId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "ID는 영문 및 숫자 10자 이내여야 합니다."); + } + } + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + /** + * 이메일의 유효성을 검증합니다. + * + * @param email 검증할 이메일 주소 + * @throws CoreException email이 null, 공백이거나 형식에 맞지 않을 경우 + */ + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + } + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + /** + * 생년월일의 유효성을 검증합니다. + * + * @param birthDate 검증할 생년월일 문자열 + * @throws CoreException birthDate가 null, 공백이거나 yyyy-MM-dd 형식이 아닐 경우 + */ + private static void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + try { + LocalDate.parse(birthDate, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 yyyy-MM-dd 형식이어야 합니다."); + } + } + /** + * 사용자를 생성합니다. + * + * @param userId 사용자 ID (영문 및 숫자, 최대 10자) + * @param email 이메일 주소 + * @param birthDateStr 생년월일 (yyyy-MM-dd 형식) + * @param gender 성별 + * @throws CoreException userId, email, birthDate가 유효하지 않을 경우 + */ + public User (String userId, String email, String birthDateStr, Gender gender, Point point) { + validateUserId(userId); + validateEmail(email); + validateBirthDate(birthDateStr); + + this.userId = userId; + this.email = email; + this.birthDate = LocalDate.parse(birthDateStr); + this.gender = gender; + this.point = point; + } + /** + * User 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param email 이메일 주소 + * @param birthDate 생년월일 문자열 + * @param gender 성별 + * @return 생성된 User 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static User of(String userId, String email, String birthDate, Gender gender, Point point) { + return new User(userId, email, birthDate, gender, point); + } + + /** + * 포인트를 반환합니다. + * + * @return 포인트 Value Object + */ + public Point getPoint() { + return this.point; + } + + /** + * 포인트를 받습니다 (충전/환불). + * + * @param point 받을 포인트 + * @throws CoreException point가 null일 경우 + */ + public void receivePoint(Point point) { + this.point = this.point.add(point); + } + + /** + * 포인트를 차감합니다. + * 포인트는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다. + * + * @param point 차감할 포인트 + * @throws CoreException point가 null이거나 잔액이 부족할 경우 + */ + public void deductPoint(Point point) { + this.point = this.point.subtract(point); + } + + /** + * 포인트 값을 반환합니다. + * + * @return 포인트 값 + */ + public Long getPointValue() { + return this.point.getValue(); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..1a057041f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,74 @@ +package com.loopers.domain.user; + +/** + * User 엔티티에 대한 저장소 인터페이스. + *

+ * 사용자 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserRepository { + /** + * 사용자를 저장합니다. + * + * @param user 저장할 사용자 + * @return 저장된 사용자 + */ + User save(User user); + + /** + * 사용자 ID로 사용자를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자, 없으면 null + */ + User findByUserId(String userId); + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 포인트 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 포인트 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자, 없으면 null + */ + User findByUserIdForUpdate(String userId); + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자, 없으면 null + */ + User findById(Long id); + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 포인트 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: PK(id) 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 포인트 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param id 사용자 ID (PK) + * @return 조회된 사용자, 없으면 null + */ + User findByIdForUpdate(Long id); +} 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..1d28a2376 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Brand 엔티티를 위한 Spring Data JPA 리포지토리. + */ +public interface BrandJpaRepository extends JpaRepository { +} + 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..79f186e9a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * BrandRepository의 JPA 구현체. + */ +@RequiredArgsConstructor +@Repository +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long brandId) { + return brandJpaRepository.findById(brandId); + } + + @Override + public List findAllById(List brandIds) { + return brandJpaRepository.findAllById(brandIds); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java new file mode 100644 index 000000000..7e7c4c8c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * CouponEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 쿠폰 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class CouponEventPublisherImpl implements CouponEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(CouponEvent.CouponApplied event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(CouponEvent.CouponApplicationFailed event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..bd4eb6ee9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * Coupon 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponJpaRepository extends JpaRepository { + /** + * 쿠폰 코드로 쿠폰을 조회합니다. + * + * @param code 쿠폰 코드 + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findByCode(String code); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..3f4af5fdb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * CouponRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 Coupon 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + private final CouponJpaRepository couponJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public Coupon save(Coupon coupon) { + return couponJpaRepository.save(coupon); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByCode(String code) { + return couponJpaRepository.findByCode(code); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findById(Long couponId) { + return couponJpaRepository.findById(couponId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java new file mode 100644 index 000000000..710f74a74 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCoupon; +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; + +/** + * UserCoupon 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserCouponJpaRepository extends JpaRepository { + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + @Query("SELECT uc FROM UserCoupon uc JOIN uc.coupon c WHERE uc.userId = :userId AND c.code = :couponCode") + Optional findByUserIdAndCouponCode(@Param("userId") Long userId, @Param("couponCode") String couponCode); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + *

+ * Optimistic Lock을 사용하여 동시성 제어를 보장합니다. + * UserCoupon 엔티티의 @Version 필드를 통해 자동으로 낙관적 락이 적용됩니다. + *

+ *

+ * Lock 전략: + *

    + *
  • OPTIMISTIC_LOCK 선택 근거: 쿠폰 사용 시 Lost Update 방지, Hot Spot 대응
  • + *
  • @Version 필드: 엔티티에 version 필드가 있어 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. 일반 조회로 UserCoupon 엔티티 로드 (version 포함)
  2. + *
  3. 쿠폰 사용 처리 (isUsed = true)
  4. + *
  5. 저장 시 version 체크 → 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생
  6. + *
  7. 예외 발생 시 쿠폰 사용 실패 처리
  8. + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + @Query("SELECT uc FROM UserCoupon uc JOIN uc.coupon c WHERE uc.userId = :userId AND c.code = :couponCode") + Optional findByUserIdAndCouponCodeForUpdate(@Param("userId") Long userId, @Param("couponCode") String couponCode); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java new file mode 100644 index 000000000..314eb130d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java @@ -0,0 +1,57 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * UserCouponRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 UserCoupon 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class UserCouponRepositoryImpl implements UserCouponRepository { + private final UserCouponJpaRepository userCouponJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public UserCoupon save(UserCoupon userCoupon) { + return userCouponJpaRepository.save(userCoupon); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByUserIdAndCouponCode(Long userId, String couponCode) { + return userCouponJpaRepository.findByUserIdAndCouponCode(userId, couponCode); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode) { + return userCouponJpaRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode); + } + + /** + * {@inheritDoc} + */ + @Override + public void flush() { + userCouponJpaRepository.flush(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java new file mode 100644 index 000000000..ad27ee294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.like.LikeEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * LikeEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 좋아요 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class LikeEventPublisherImpl implements LikeEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(LikeEvent.LikeAdded event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(LikeEvent.LikeRemoved event) { + applicationEventPublisher.publishEvent(event); + } +} + 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..61d335de8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Like 엔티티를 위한 Spring Data JPA 리포지토리. + */ +public interface LikeJpaRepository extends JpaRepository { + /** + * 사용자 ID와 상품 ID로 좋아요를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 조회된 좋아요를 담은 Optional + */ + Optional findByUserIdAndProductId(Long userId, Long productId); + + /** + * 사용자 ID로 좋아요 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 좋아요 목록 + */ + List findAllByUserId(Long userId); + + /** + * 상품별 좋아요 수를 집계합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID와 좋아요 수의 쌍 목록 + */ + @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIds(@Param("productIds") List productIds); + + /** + * 모든 상품의 좋아요 수를 집계합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 상품 ID와 좋아요 수의 쌍 목록 + */ + @Query("SELECT l.productId, COUNT(l) FROM Like l GROUP BY l.productId") + List countAllByProductIds(); + + /** + * 상품별 좋아요 수를 Map으로 변환합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + default Map countByProductIdsAsMap(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + return countByProductIds(productIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + } +} + 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..113d524f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * LikeRepository의 JPA 구현체. + */ +@RequiredArgsConstructor +@Repository +public class LikeRepositoryImpl implements LikeRepository { + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public List findAllByUserId(Long userId) { + return likeJpaRepository.findAllByUserId(userId); + } + + @Override + public Map countByProductIds(List productIds) { + return likeJpaRepository.countByProductIdsAsMap(productIds); + } + + @Override + public Map countAllByProductIds() { + return likeJpaRepository.countAllByProductIds().stream() + .collect(java.util.stream.Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java new file mode 100644 index 000000000..526c4dbb8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.order.OrderEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * OrderEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 주문 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class OrderEventPublisherImpl implements OrderEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(OrderEvent.OrderCreated event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(OrderEvent.OrderCompleted event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(OrderEvent.OrderCanceled event) { + applicationEventPublisher.publishEvent(event); + } +} 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..2e435b981 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * Order JPA Repository. + */ +public interface OrderJpaRepository extends JpaRepository { + List findAllByUserId(Long userId); + + List findAllByStatus(OrderStatus status); +} + + 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..e6158698f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * OrderRepository의 JPA 구현체. + */ +@RequiredArgsConstructor +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long orderId) { + return orderJpaRepository.findById(orderId); + } + + @Override + public List findAllByUserId(Long userId) { + return orderJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAllByStatus(OrderStatus status) { + return orderJpaRepository.findAllByStatus(status); + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..1703e9e15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * OutboxEvent JPA Repository. + */ +public interface OutboxEventJpaRepository extends JpaRepository { + + /** + * 발행 대기 중인 이벤트 목록을 생성 시간 순으로 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + @Query(value = "SELECT * FROM outbox_event e " + + "WHERE e.status = 'PENDING' " + + "ORDER BY e.created_at ASC " + + "LIMIT :limit", nativeQuery = true) + List findPendingEvents(@Param("limit") int limit); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + @Query("SELECT COALESCE(MAX(e.version), 0L) FROM OutboxEvent e " + + "WHERE e.aggregateId = :aggregateId AND e.aggregateType = :aggregateType") + Long findLatestVersionByAggregateId( + @Param("aggregateId") String aggregateId, + @Param("aggregateType") String aggregateType + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..1cc0500e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java @@ -0,0 +1,133 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Outbox 이벤트 발행 프로세스. + *

+ * 주기적으로 Outbox에서 발행 대기 중인 이벤트를 읽어 Kafka로 발행합니다. + * Transactional Outbox Pattern의 Polling 프로세스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private static final int BATCH_SIZE = 100; + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + /** + * 발행 대기 중인 Outbox 이벤트를 Kafka로 발행합니다. + *

+ * 1초마다 실행되어 PENDING 상태의 이벤트를 처리합니다. + *

+ */ + @Scheduled(fixedDelay = 1000) // 1초마다 실행 + @Transactional + public void publishPendingEvents() { + try { + List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE); + + if (pendingEvents.isEmpty()) { + return; + } + + log.debug("Outbox 이벤트 발행 시작: count={}", pendingEvents.size()); + + for (OutboxEvent event : pendingEvents) { + try { + publishEvent(event); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 요청 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + event.markAsFailed(); + outboxEventRepository.save(event); + // 개별 이벤트 실패는 계속 진행 + } + } + + log.debug("Outbox 이벤트 발행 완료: count={}", pendingEvents.size()); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 프로세스 실패", e); + // 프로세스 실패는 다음 스케줄에서 재시도 + } + } + + /** + * Outbox 이벤트를 Kafka로 발행합니다. + *

+ * 멱등성 처리를 위해 `eventId`를 Kafka 메시지 헤더에 포함시킵니다. + *

+ * + * @param event 발행할 Outbox 이벤트 + */ + private void publishEvent(OutboxEvent event) { + try { + // JSON 문자열을 Map으로 역직렬화하여 Kafka로 전송 + // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다 + Object payload = objectMapper.readValue(event.getPayload(), Object.class); + + // Kafka 메시지 헤더에 eventId, eventType, version 추가 (멱등성 및 버전 비교 처리용) + var messageBuilder = MessageBuilder + .withPayload(payload) + .setHeader(KafkaHeaders.KEY, event.getPartitionKey()) + .setHeader("eventId", event.getEventId()) + .setHeader("eventType", event.getEventType()); + + // version이 있으면 헤더에 추가 + if (event.getVersion() != null) { + messageBuilder.setHeader("version", event.getVersion()); + } + + var message = messageBuilder.build(); + + // Kafka로 비동기 발행 (콜백에서 상태 업데이트) + kafkaTemplate.send(event.getTopic(), message) + .whenComplete((result, ex) -> handleSendResult(event, result, ex)); + } catch (Exception e) { + log.error("Kafka 이벤트 발행 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } + + /** + * Kafka 전송 결과를 처리합니다. + */ + private void handleSendResult(OutboxEvent event, SendResult result, Throwable ex) { + try { + if (ex != null) { + log.error("Kafka 전송 실패: eventId={}, topic={}", event.getEventId(), event.getTopic(), ex); + event.markAsFailed(); + } else { + log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}", + event.getEventId(), event.getTopic()); + event.markAsPublished(); + } + outboxEventRepository.save(event); + } catch (Exception e) { + log.error("Outbox 이벤트 상태 업데이트 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..2b7d81b3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * OutboxEventRepository의 JPA 구현체. + */ +@Component +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent outboxEvent) { + return outboxEventJpaRepository.save(outboxEvent); + } + + @Override + public List findPendingEvents(int limit) { + return outboxEventJpaRepository.findPendingEvents(limit); + } + + @Override + public OutboxEvent findById(Long id) { + return outboxEventJpaRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("OutboxEvent not found: " + id)); + } + + @Override + public Long findLatestVersionByAggregateId(String aggregateId, String aggregateType) { + return outboxEventJpaRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java new file mode 100644 index 000000000..22fabd259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.payment; + +import java.time.Duration; + +/** + * 지연 제공자 인터페이스. + *

+ * 테스트 가능성을 위해 Thread.sleep을 추상화합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface DelayProvider { + + /** + * 지정된 시간만큼 대기합니다. + * + * @param duration 대기 시간 + * @throws InterruptedException 인터럽트 발생 시 + */ + void delay(Duration duration) throws InterruptedException; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java new file mode 100644 index 000000000..dfdfca597 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * PaymentEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 결제 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentEventPublisherImpl implements PaymentEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(PaymentEvent.PaymentCompleted event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PaymentEvent.PaymentFailed event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PaymentEvent.PaymentRequested event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java new file mode 100644 index 000000000..cf6e1b2d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java @@ -0,0 +1,84 @@ +package com.loopers.infrastructure.payment; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * PG 결제 게이트웨이 FeignClient. + *

+ * CircuitBreaker, Bulkhead가 적용되어 있습니다. + *

+ *

+ * Bulkhead 패턴: + *

    + *
  • 동시 호출 최대 20개로 제한 (Building Resilient Distributed Systems: 격벽 패턴)
  • + *
  • PG 호출 실패가 다른 API에 영향을 주지 않도록 격리
  • + *
+ *

+ *

+ * Retry 정책: + *

    + *
  • 결제 요청 API (requestPayment): 5xx 서버 오류만 재시도, 4xx 클라이언트 오류는 재시도하지 않음
  • + *
  • 조회 API (getTransactionsByOrder, getTransaction): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)
  • + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 5xx 서버 오류: 일시적 오류이므로 재시도하여 복구 가능
  • + *
  • 4xx 클라이언트 오류: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음
  • + *
  • Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
  • + *
+ *

+ */ +@FeignClient( + name = "paymentGatewayClient", + url = "${payment-gateway.url}", + path = "/api/v1/payments" +) +public interface PaymentGatewayClient { + + /** + * 결제 요청. + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param request 결제 요청 정보 + * @return 결제 응답 + */ + @PostMapping + PaymentGatewayDto.ApiResponse requestPayment( + @RequestHeader("X-USER-ID") String userId, + @RequestBody PaymentGatewayDto.PaymentRequest request + ); + + /** + * 결제 정보 확인 (트랜잭션 키로 조회). + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param transactionKey 트랜잭션 키 + * @return 결제 상세 정보 + */ + @GetMapping("/{transactionKey}") + PaymentGatewayDto.ApiResponse getTransaction( + @RequestHeader("X-USER-ID") String userId, + @PathVariable("transactionKey") String transactionKey + ); + + /** + * 주문에 엮인 결제 정보 조회. + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param orderId 주문 ID + * @return 주문별 결제 목록 + */ + @GetMapping + PaymentGatewayDto.ApiResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java new file mode 100644 index 000000000..4ca22424f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java @@ -0,0 +1,105 @@ +package com.loopers.infrastructure.payment; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * PG 결제 게이트웨이 DTO. + */ +public class PaymentGatewayDto { + + /** + * PG 결제 요청 DTO. + */ + public record PaymentRequest( + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("callbackUrl") String callbackUrl + ) { + } + + /** + * PG 결제 응답 DTO. + */ + public record TransactionResponse( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG 결제 상세 응답 DTO. + */ + public record TransactionDetailResponse( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG 주문별 결제 목록 응답 DTO. + */ + public record OrderResponse( + @JsonProperty("orderId") String orderId, + @JsonProperty("transactions") java.util.List transactions + ) { + } + + /** + * 카드 타입. + */ + public enum CardType { + SAMSUNG, + KB, + HYUNDAI + } + + /** + * 거래 상태. + */ + public enum TransactionStatus { + PENDING, + SUCCESS, + FAILED + } + + /** + * PG 콜백 요청 DTO (PG에서 보내는 TransactionInfo). + */ + public record CallbackRequest( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG API 응답 래퍼. + */ + public record ApiResponse( + @JsonProperty("meta") Metadata meta, + @JsonProperty("data") T data + ) { + public record Metadata( + @JsonProperty("result") Result result, + @JsonProperty("errorCode") String errorCode, + @JsonProperty("message") String message + ) { + public enum Result { + SUCCESS, + FAIL + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java new file mode 100644 index 000000000..8eddd36da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java @@ -0,0 +1,148 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.domain.payment.PaymentStatus; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * PaymentGateway 인터페이스의 구현체. + *

+ * 도메인 계층의 PaymentGateway 인터페이스를 구현합니다. + * 인프라 관심사(FeignClient 호출, 예외 처리)를 도메인 모델로 변환합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentGatewayImpl implements PaymentGateway { + + private final PaymentGatewayClient paymentGatewayClient; + private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + private final PaymentGatewayMetrics metrics; + + /** + * PG 결제 요청을 전송합니다. + * + * @param request 결제 요청 값 객체 + * @return 결제 요청 결과 + */ + @Override + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback") + public PaymentRequestResult requestPayment(PaymentRequest request) { + PaymentGatewayDto.PaymentRequest dtoRequest = toDto(request); + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.requestPayment(request.userId(), dtoRequest); + + return toDomainResult(response, request.orderId()); + } + + /** + * Circuit Breaker fallback 메서드. + * + * @param request 결제 요청 값 객체 + * @param t 발생한 예외 + * @return 결제 대기 상태의 실패 결과 + */ + public PaymentRequestResult fallback(PaymentRequest request, Throwable t) { + log.warn("Circuit Breaker fallback 호출됨. (orderId: {}, exception: {})", + request.orderId(), t.getClass().getSimpleName(), t); + metrics.recordFallback("paymentGatewayClient"); + return new PaymentRequestResult.Failure( + "CIRCUIT_BREAKER_OPEN", + "결제 대기 상태", + false, + false + ); + } + + /** + * Circuit Breaker fallback 메서드 (결제 상태 조회). + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param t 발생한 예외 + * @return PENDING 상태 반환 + */ + public PaymentStatus getPaymentStatusFallback(String userId, Long orderId, Throwable t) { + log.warn("Circuit Breaker fallback 호출됨 (결제 상태 조회). (orderId: {}, exception: {})", + orderId, t.getClass().getSimpleName(), t); + metrics.recordFallback("paymentGatewaySchedulerClient"); + return PaymentStatus.PENDING; + } + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 (SUCCESS, FAILED, PENDING) + */ + @Override + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentStatusFallback") + public PaymentStatus getPaymentStatus(String userId, Long orderId) { + // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) + String orderIdString = String.format("%06d", orderId); + PaymentGatewayDto.ApiResponse response = + paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderIdString); + + if (response == null || response.meta() == null + || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + || response.data() == null || response.data().transactions() == null + || response.data().transactions().isEmpty()) { + return PaymentStatus.PENDING; + } + + // 가장 최근 트랜잭션의 상태 반환 + PaymentGatewayDto.TransactionResponse latestTransaction = + response.data().transactions().get(response.data().transactions().size() - 1); + return convertToPaymentStatus(latestTransaction.status()); + } + + private PaymentGatewayDto.PaymentRequest toDto(PaymentRequest request) { + return new PaymentGatewayDto.PaymentRequest( + String.format("%06d", request.orderId()), // 주문 ID를 6자리 이상 문자열로 변환 + PaymentGatewayDto.CardType.valueOf(request.cardType().toUpperCase()), + request.cardNo(), + request.amount(), + request.callbackUrl() + ); + } + + private PaymentRequestResult toDomainResult( + PaymentGatewayDto.ApiResponse response, + Long orderId + ) { + if (response != null && response.meta() != null + && response.meta().result() == PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + && response.data() != null) { + String transactionKey = response.data().transactionKey(); + metrics.recordSuccess("paymentGatewayClient"); + return new PaymentRequestResult.Success(transactionKey); + } else { + String errorCode = response != null && response.meta() != null + ? response.meta().errorCode() : "UNKNOWN"; + String message = response != null && response.meta() != null + ? response.meta().message() : "응답이 null입니다."; + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + orderId, errorCode, message); + return new PaymentRequestResult.Failure(errorCode, message, false, false); + } + } + + private PaymentStatus convertToPaymentStatus(PaymentGatewayDto.TransactionStatus status) { + return switch (status) { + case SUCCESS -> PaymentStatus.SUCCESS; + case FAILED -> PaymentStatus.FAILED; + case PENDING -> PaymentStatus.PENDING; + }; + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java new file mode 100644 index 000000000..72bc0b96b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java @@ -0,0 +1,85 @@ +package com.loopers.infrastructure.payment; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 결제 게이트웨이 메트릭. + *

+ * PG 서버 오류, 타임아웃, Fallback 등의 이벤트를 Prometheus 메트릭으로 기록합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentGatewayMetrics { + + private final MeterRegistry meterRegistry; + + /** + * PG 서버 오류(5xx) 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 (paymentGatewayClient, paymentGatewaySchedulerClient) + * @param status HTTP 상태 코드 + */ + public void recordServerError(String clientName, int status) { + meterRegistry.counter( + "payment.gateway.server.error", + "client", clientName, + "status", String.valueOf(status) + ).increment(); + } + + /** + * PG 타임아웃 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordTimeout(String clientName) { + meterRegistry.counter( + "payment.gateway.timeout", + "client", clientName + ).increment(); + } + + /** + * PG 클라이언트 오류(4xx) 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + * @param status HTTP 상태 코드 + */ + public void recordClientError(String clientName, int status) { + meterRegistry.counter( + "payment.gateway.client.error", + "client", clientName, + "status", String.valueOf(status) + ).increment(); + } + + /** + * Fallback 호출 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordFallback(String clientName) { + meterRegistry.counter( + "payment.gateway.fallback", + "client", clientName + ).increment(); + } + + /** + * PG 결제 요청 성공 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordSuccess(String clientName) { + meterRegistry.counter( + "payment.gateway.request.success", + "client", clientName + ).increment(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java new file mode 100644 index 000000000..01451ab62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.payment; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * PG 결제 게이트웨이 FeignClient (스케줄러 전용). + *

+ * 스케줄러에서 사용하는 조회 API에 Retry를 적용합니다. + *

+ *

+ * Retry 정책: + *

    + *
  • Exponential Backoff 적용: 초기 500ms → 1000ms (최대 5초)
  • + *
  • 최대 재시도 횟수: 3회 (초기 시도 포함)
  • + *
  • 재시도 대상: 5xx 서버 오류, 타임아웃, 네트워크 오류
  • + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 비동기/배치 기반: 스케줄러는 배치 작업이므로 Retry가 안전하게 적용 가능
  • + *
  • 일시적 오류 복구: 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구
  • + *
  • 유저 요청 스레드 점유 없음: 스케줄러 스레드에서 실행되므로 유저 경험에 영향 없음
  • + *
+ *

+ */ +@FeignClient( + name = "paymentGatewaySchedulerClient", + url = "${payment-gateway.url}", + path = "/api/v1/payments" +) +public interface PaymentGatewaySchedulerClient { + + /** + * 결제 정보 확인 (트랜잭션 키로 조회). + *

+ * 스케줄러에서 사용하며, Retry가 적용됩니다. + *

+ * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param transactionKey 트랜잭션 키 + * @return 결제 상세 정보 + */ + @GetMapping("/{transactionKey}") + PaymentGatewayDto.ApiResponse getTransaction( + @RequestHeader("X-USER-ID") String userId, + @PathVariable("transactionKey") String transactionKey + ); + + /** + * 주문에 엮인 결제 정보 조회. + *

+ * 스케줄러에서 사용하며, Retry가 적용됩니다. + *

+ * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param orderId 주문 ID + * @return 주문별 결제 목록 + */ + @GetMapping + PaymentGatewayDto.ApiResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 000000000..a34757237 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Payment JPA Repository. + */ +public interface PaymentJpaRepository extends JpaRepository { + Optional findByOrderId(Long orderId); + + List findAllByUserId(Long userId); + + List findAllByStatus(PaymentStatus status); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 000000000..9b38ff60a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Payment Repository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public Payment save(Payment payment) { + return paymentJpaRepository.save(payment); + } + + @Override + public Optional findById(Long paymentId) { + return paymentJpaRepository.findById(paymentId); + } + + @Override + public Optional findByOrderId(Long orderId) { + return paymentJpaRepository.findByOrderId(orderId); + } + + @Override + public List findAllByUserId(Long userId) { + return paymentJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAllByStatus(PaymentStatus status) { + return paymentJpaRepository.findAllByStatus(status); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java new file mode 100644 index 000000000..d31ef49d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.payment; + +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Thread.sleep을 사용하는 DelayProvider 구현체. + * + * @author Loopers + * @version 1.0 + */ +@Component +public class ThreadDelayProvider implements DelayProvider { + + @Override + public void delay(Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java new file mode 100644 index 000000000..7e8cd2640 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * ProductEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 상품 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ *

+ * 표준 패턴: + *

    + *
  • ApplicationEvent만 발행 (단일 책임 원칙)
  • + *
  • Kafka 전송은 OutboxBridgeEventListener가 처리 (관심사 분리)
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductEventPublisherImpl implements ProductEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(ProductEvent.ProductViewed event) { + applicationEventPublisher.publishEvent(event); + } +} 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..d9913a180 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Product 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductJpaRepository extends JpaRepository { + /** + * 브랜드 ID로 상품을 조회합니다. + * + * @param brandId 브랜드 ID + * @param pageable 페이징 정보 + * @return 상품 페이지 + */ + Page findByBrandId(Long brandId, Pageable pageable); + + /** + * 전체 상품을 조회합니다. + * + * @param pageable 페이징 정보 + * @return 상품 페이지 + */ + Page findAll(Pageable pageable); + + /** + * 브랜드 ID로 상품 개수를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 상품 개수 + */ + long countByBrandId(Long brandId); + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 재고 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: PK(id) 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: PK는 자동으로 인덱스가 생성되어 Lock 범위 최소화
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
  2. + *
  3. 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
  4. + *
  5. 재고 차감 후 트랜잭션 커밋 → 락 해제
  6. + *
  7. 대기 중이던 트랜잭션이 최신 값을 읽어 처리
  8. + *
+ *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :productId") + Optional findByIdForUpdate(@Param("productId") Long productId); + + /** + * 모든 상품 ID를 조회합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 모든 상품 ID 목록 + */ + @Query("SELECT p.id FROM Product p") + List findAllProductIds(); +} + 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..29cc890f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,113 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 Product 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findById(Long productId) { + return productJpaRepository.findById(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByIdForUpdate(Long productId) { + return productJpaRepository.findByIdForUpdate(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAllById(List productIds) { + return productJpaRepository.findAllById(productIds); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAll(Long brandId, String sort, int page, int size) { + Pageable pageable = createPageable(sort, page, size); + Page productPage = brandId != null + ? productJpaRepository.findByBrandId(brandId, pageable) + : productJpaRepository.findAll(pageable); + return productPage.getContent(); + } + + /** + * {@inheritDoc} + */ + @Override + public long countAll(Long brandId) { + return brandId != null + ? productJpaRepository.countByBrandId(brandId) + : productJpaRepository.count(); + } + + /** + * {@inheritDoc} + */ + @Override + public void updateLikeCount(Long productId, Long likeCount) { + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException( + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + product.updateLikeCount(likeCount); + productJpaRepository.save(product); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAllProductIds() { + return productJpaRepository.findAllProductIds(); + } + + private Pageable createPageable(String sort, int page, int size) { + Sort sortObj = switch (sort != null ? sort : "latest") { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); // ✅ Product.likeCount 필드로 정렬 + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + return PageRequest.of(page, size, sortObj); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java new file mode 100644 index 000000000..d995ff486 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank Repository 구현체. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

+ */ +@Slf4j +@Repository +@Transactional(readOnly = true) +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " + + "ORDER BY pr.rank ASC"; + + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setMaxResults(limit) + .getResultList(); + } + + @Override + public Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType " + + "AND pr.periodStartDate = :periodStartDate " + + "AND pr.productId = :productId"; + + try { + ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(rank); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java new file mode 100644 index 000000000..048e91755 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java @@ -0,0 +1,123 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.purchasing.PurchasingFacade; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.infrastructure.user.UserJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 결제 상태 복구 스케줄러. + *

+ * 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여 + * PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다. + *

+ *

+ * 동작 원리: + *

    + *
  1. 주기적으로 실행 (기본: 1분마다)
  2. + *
  3. PENDING 상태인 주문들을 조회
  4. + *
  5. 각 주문에 대해 PG 결제 상태 확인 API 호출
  6. + *
  7. 결제 상태에 따라 주문 상태 업데이트
  8. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 주기적 복구: 콜백이 오지 않아도 자동으로 상태 복구
  • + *
  • Eventually Consistent: 약간의 지연 허용 가능
  • + *
  • 안전한 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
  • + *
  • 성능 고려: 배치로 처리하여 PG 시스템 부하 최소화
  • + *
+ *

+ *

+ * 레이어 위치 근거: + *

    + *
  • 스케줄링은 기술적 관심사이므로 Infrastructure Layer에 위치
  • + *
  • 비즈니스 로직은 Application Layer의 PurchasingFacade에 위임
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentRecoveryScheduler { + + private final OrderRepository orderRepository; + private final UserJpaRepository userJpaRepository; + private final PurchasingFacade purchasingFacade; + + /** + * PENDING 상태인 주문들의 결제 상태를 복구합니다. + *

+ * 1분마다 실행되어 PENDING 상태인 주문들을 조회하고, + * 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다. + *

+ *

+ * 처리 전략: + *

    + *
  • 배치 처리: 한 번에 여러 주문 처리
  • + *
  • 독립적 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
  • + *
  • 안전한 예외 처리: 개별 주문 처리 실패 시에도 계속 진행
  • + *
+ *

+ */ + @Scheduled(fixedDelay = 60000) // 1분마다 실행 + public void recoverPendingOrders() { + try { + log.debug("결제 상태 복구 스케줄러 시작"); + + // PENDING 상태인 주문들 조회 + List pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING); + + if (pendingOrders.isEmpty()) { + log.debug("복구할 PENDING 상태 주문이 없습니다."); + return; + } + + log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size()); + + int successCount = 0; + int failureCount = 0; + + // 각 주문에 대해 결제 상태 확인 및 복구 + for (Order order : pendingOrders) { + try { + // Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴 + var userOptional = userJpaRepository.findById(order.getUserId()); + if (userOptional.isEmpty()) { + log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})", + order.getId(), order.getUserId()); + failureCount++; + continue; + } + + String userId = userOptional.get().getUserId(); + + // 결제 상태 확인 및 복구 + purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId()); + successCount++; + } catch (Exception e) { + // 개별 주문 처리 실패 시에도 계속 진행 + log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e); + failureCount++; + } + } + + log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount); + + } catch (Exception e) { + log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java new file mode 100644 index 000000000..3adefd9be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.ranking.RankingService; +import com.loopers.application.ranking.RankingSnapshotService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 랭킹 스냅샷 저장 스케줄러. + *

+ * 주기적으로 랭킹 결과를 스냅샷으로 저장하여, Redis 장애 시 Fallback으로 사용할 수 있도록 합니다. + *

+ *

+ * 설계 원칙: + *

    + *
  • 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
  • + *
  • 주기적 저장: 1시간마다 최신 랭킹을 스냅샷으로 저장
  • + *
  • 에러 처리: 스냅샷 저장 실패 시에도 다음 스케줄에서 재시도
  • + *
+ *

+ *

+ * 주기 선택 근거: + *

    + *
  • 비용 대비 효과: 1시간 주기가 리소스 사용량이 1/12로 감소하면서도 사용자 체감 차이는 거의 없음
  • + *
  • 랭킹의 성격: 비즈니스 결정이 아닌 조회용 파생 데이터이므로 1시간 전 데이터도 충분히 유용함
  • + *
  • 운영 관점: 스케줄러 실행 빈도가 낮아 모니터링 부담 감소
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingSnapshotScheduler { + + private final RankingService rankingService; + private final RankingSnapshotService rankingSnapshotService; + + /** + * 랭킹 스냅샷을 저장합니다. + *

+ * 1시간마다 실행되어 오늘의 랭킹을 스냅샷으로 저장합니다. + *

+ */ + @Scheduled(fixedRate = 3600000) // 1시간마다 (3600000ms = 1시간) + public void saveRankingSnapshot() { + LocalDate today = LocalDate.now(ZoneId.of("UTC")); + try { + // 상위 100개 랭킹을 스냅샷으로 저장 (대부분의 사용자가 상위 100개 이내만 조회) + // Redis가 정상일 때만 스냅샷 저장 (예외 발생 시 스킵) + RankingService.RankingsResponse rankings = rankingService.getRankingsFromRedis(today, 0, 100); + + rankingSnapshotService.saveSnapshot(today, rankings); + + log.debug("랭킹 스냅샷 저장 완료: date={}, itemCount={}", today, rankings.items().size()); + } catch (org.springframework.dao.DataAccessException e) { + log.warn("Redis 장애로 인한 랭킹 스냅샷 저장 실패: date={}, error={}", today, e.getMessage()); + // Redis 장애 시 스냅샷 저장 스킵 (다음 스케줄에서 재시도) + } catch (Exception e) { + log.warn("랭킹 스냅샷 저장 실패: date={}", today, e); + // 스냅샷 저장 실패는 다음 스케줄에서 재시도 + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java new file mode 100644 index 000000000..5bed86f2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * PointEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 포인트 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PointEventPublisherImpl implements PointEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(PointEvent.PointUsed event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PointEvent.PointUsedFailed event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..a69a17925 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,81 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * User 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능과 + * 사용자 ID 기반 조회 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserJpaRepository extends JpaRepository { + /** + * 사용자 ID로 사용자를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자를 담은 Optional + */ + Optional findByUserId(String userId); + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 포인트 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: UNIQUE 제약조건으로 인덱스가 자동 생성되어 Lock 범위 최소화
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
  2. + *
  3. 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
  4. + *
  5. 포인트 차감 후 트랜잭션 커밋 → 락 해제
  6. + *
  7. 대기 중이던 트랜잭션이 최신 값을 읽어 처리
  8. + *
+ *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자를 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.userId = :userId") + Optional findByUserIdForUpdate(@Param("userId") String userId); + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 포인트 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: PK(id) 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: PK는 자동으로 인덱스가 생성되어 Lock 범위 최소화
  • + *
+ *

+ * + * @param id 사용자 ID (PK) + * @return 조회된 사용자를 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..8beff4c6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,62 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * UserRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 User 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + /** + * {@inheritDoc} + */ + @Override + public User findByUserId(String userId) { + return userJpaRepository.findByUserId(userId).orElse(null); + } + + /** + * {@inheritDoc} + */ + @Override + public User findByUserIdForUpdate(String userId) { + return userJpaRepository.findByUserIdForUpdate(userId).orElse(null); + } + + /** + * {@inheritDoc} + */ + @Override + public User findById(Long id) { + return userJpaRepository.findById(id).orElse(null); + } + + /** + * {@inheritDoc} + */ + @Override + public User findByIdForUpdate(Long id) { + return userJpaRepository.findByIdForUpdate(id).orElse(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..7f2948f2a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -9,6 +9,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -19,16 +21,47 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; - +import java.util.stream.Stream; + +/** + * 전역 API 예외 처리 핸들러. + *

+ * 애플리케이션 전역에서 발생하는 예외를 가로채어 + * 일관된 형식의 에러 응답을 생성합니다. + *

+ * + *

처리하는 예외 유형

+ *
    + *
  • CoreException: 도메인 비즈니스 로직 예외
  • + *
  • Validation 예외: 요청 데이터 검증 실패
  • + *
  • HTTP 메시지 변환 예외: JSON 파싱 오류
  • + *
  • 기타 예상치 못한 예외
  • + *
+ * + * @author Loopers + * @version 1.0 + */ @RestControllerAdvice @Slf4j public class ApiControllerAdvice { + /** + * CoreException을 처리합니다. + * + * @param e 발생한 CoreException + * @return 에러 응답 + */ @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); return failureResponse(e.getErrorType(), e.getCustomMessage()); } + /** + * 요청 파라미터 타입 불일치 예외를 처리합니다. + * + * @param e 발생한 MethodArgumentTypeMismatchException + * @return BAD_REQUEST 에러 응답 + */ @ExceptionHandler public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { String name = e.getName(); @@ -38,6 +71,12 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + /** + * 필수 요청 파라미터 누락 예외를 처리합니다. + * + * @param e 발생한 MissingServletRequestParameterException + * @return BAD_REQUEST 에러 응답 + */ @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); @@ -46,6 +85,45 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + /** + * 필수 요청 헤더 누락 예외를 처리합니다. + * + * @param e 발생한 MissingRequestHeaderException + * @return BAD_REQUEST 에러 응답 + */ + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String name = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", name); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + /** + * 요청 데이터 유효성 검증 실패 예외를 처리합니다. + * + * @param e 발생한 MethodArgumentNotValidException + * @return BAD_REQUEST 에러 응답 (검증 실패 필드 정보 포함) + */ + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = Stream.concat( + e.getBindingResult().getFieldErrors().stream() + .map(err -> String.format("필드 '%s' %s", err.getField(), err.getDefaultMessage())), + e.getBindingResult().getGlobalErrors().stream() + .map(err -> String.format("객체 '%s' %s", err.getObjectName(), err.getDefaultMessage())) + ) + .filter(str -> str != null && !str.isBlank()) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, message.isBlank() ? null : message); + } + + /** + * HTTP 메시지 읽기 실패 예외를 처리합니다. + * JSON 파싱 오류, 타입 불일치 등을 처리합니다. + * + * @param e 발생한 HttpMessageNotReadableException + * @return BAD_REQUEST 에러 응답 + */ @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; @@ -53,15 +131,15 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc if (rootCause instanceof InvalidFormatException invalidFormat) { String fieldName = invalidFormat.getPath().stream() - .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") - .collect(Collectors.joining(".")); + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); String valueIndicationMessage = ""; if (invalidFormat.getTargetType().isEnum()) { Class enumClass = invalidFormat.getTargetType(); String enumValues = Arrays.stream(enumClass.getEnumConstants()) - .map(Object::toString) - .collect(Collectors.joining(", ")); + .map(Object::toString) + .collect(Collectors.joining(", ")); valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]"; } @@ -69,20 +147,20 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc Object value = invalidFormat.getValue(); errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s", - fieldName, value, expectedType, valueIndicationMessage); + fieldName, value, expectedType, valueIndicationMessage); } else if (rootCause instanceof MismatchedInputException mismatchedInput) { String fieldPath = mismatchedInput.getPath().stream() - .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") - .collect(Collectors.joining(".")); + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath); } else if (rootCause instanceof JsonMappingException jsonMapping) { String fieldPath = jsonMapping.getPath().stream() - .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") - .collect(Collectors.joining(".")); + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s", - fieldPath, jsonMapping.getOriginalMessage()); + fieldPath, jsonMapping.getOriginalMessage()); } else { errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."; @@ -91,6 +169,12 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc return failureResponse(ErrorType.BAD_REQUEST, errorMessage); } + /** + * 서버 웹 입력 예외를 처리합니다. + * + * @param e 발생한 ServerWebInputException + * @return BAD_REQUEST 에러 응답 + */ @ExceptionHandler public ResponseEntity> handleBadRequest(ServerWebInputException e) { String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); @@ -102,25 +186,50 @@ public ResponseEntity> handleBadRequest(ServerWebInputException e } } + /** + * 리소스를 찾을 수 없는 예외를 처리합니다. + * + * @param e 발생한 NoResourceFoundException + * @return NOT_FOUND 에러 응답 + */ @ExceptionHandler public ResponseEntity> handleNotFound(NoResourceFoundException e) { return failureResponse(ErrorType.NOT_FOUND, null); } + /** + * 예상치 못한 모든 예외를 처리합니다. + * + * @param e 발생한 Throwable + * @return INTERNAL_ERROR 에러 응답 + */ @ExceptionHandler public ResponseEntity> handle(Throwable e) { log.error("Exception : {}", e.getMessage(), e); return failureResponse(ErrorType.INTERNAL_ERROR, null); } + /** + * 에러 메시지에서 누락된 파라미터명을 추출합니다. + * + * @param message 에러 메시지 + * @return 추출된 파라미터명 + */ private String extractMissingParameter(String message) { Pattern pattern = Pattern.compile("'(.+?)'"); Matcher matcher = pattern.matcher(message); return matcher.find() ? matcher.group(1) : ""; } + /** + * 에러 타입과 메시지를 기반으로 실패 응답을 생성합니다. + * + * @param errorType 에러 타입 + * @param errorMessage 에러 메시지 + * @return 에러 응답 + */ private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { return ResponseEntity.status(errorType.getStatus()) - .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); + .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b529..0eba8be22 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -19,6 +19,10 @@ public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } + public static ApiResponse successVoid() { + return new ApiResponse<>(Metadata.success(), null); + } + public static ApiResponse success(T data) { return new ApiResponse<>(Metadata.success(), data); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java new file mode 100644 index 000000000..e62516e29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 브랜드 조회 API v1 컨트롤러. + *

+ * 브랜드 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandService brandService; + + /** + * 브랜드 정보를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 브랜드 정보를 담은 API 응답 + */ + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandService.BrandInfo brandInfo = brandService.getBrandInfo(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brandInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java new file mode 100644 index 000000000..53e8fa13a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.brand.BrandService; + +/** + * 브랜드 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class BrandV1Dto { + /** + * 브랜드 정보 응답 데이터. + * + * @param brandId 브랜드 ID + * @param name 브랜드 이름 + */ + public record BrandResponse(Long brandId, String name) { + /** + * BrandInfo로부터 BrandResponse를 생성합니다. + * + * @param brandInfo 브랜드 정보 + * @return 생성된 응답 객체 + */ + public static BrandResponse from(BrandService.BrandInfo brandInfo) { + return new BrandResponse(brandInfo.id(), brandInfo.name()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java new file mode 100644 index 000000000..c275b3b7d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogFacade; +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 상품 조회 API v1 컨트롤러. + *

+ * 상품 목록 조회 및 상품 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final CatalogFacade catalogFacade; + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (선택) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (기본값 0) + * @param size 페이지당 상품 수 (기본값 20) + * @return 상품 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + ProductInfoList result = catalogFacade.getProducts(brandId, sort, page, size); + return ApiResponse.success(ProductV1Dto.ProductsResponse.from(result)); + } + + /** + * 상품 정보를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 정보를 담은 API 응답 + */ + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo productInfo = catalogFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(productInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java new file mode 100644 index 000000000..7df592db6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java @@ -0,0 +1,98 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; + +import java.util.List; + +/** + * 상품 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class ProductV1Dto { + /** + * 상품 정보 응답 데이터. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null) + */ + public record ProductResponse( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount, + Long rank + ) { + /** + * ProductInfo로부터 ProductResponse를 생성합니다. + * + * @param productInfo 상품 상세 정보 + * @return 생성된 응답 객체 + */ + public static ProductResponse from(ProductInfo productInfo) { + var detail = productInfo.productDetail(); + return new ProductResponse( + detail.getId(), + detail.getName(), + detail.getPrice(), + detail.getStock(), + detail.getBrandId(), + detail.getLikesCount(), + productInfo.rank() + ); + } + } + + /** + * 상품 목록 응답 데이터. + * + * @param products 상품 목록 + * @param totalCount 전체 상품 수 + * @param page 현재 페이지 번호 + * @param size 페이지당 상품 수 + * @param totalPages 전체 페이지 수 + * @param hasNext 다음 페이지 존재 여부 + * @param hasPrevious 이전 페이지 존재 여부 + */ + public record ProductsResponse( + List products, + long totalCount, + int page, + int size, + int totalPages, + boolean hasNext, + boolean hasPrevious + ) { + /** + * ProductInfoList로부터 ProductsResponse를 생성합니다. + * + * @param result 상품 목록 조회 결과 + * @return 생성된 응답 객체 + */ + public static ProductsResponse from(ProductInfoList result) { + List productResponses = result.products().stream() + .map(ProductResponse::from) + .toList(); + + return new ProductsResponse( + productResponses, + result.totalCount(), + result.page(), + result.size(), + result.getTotalPages(), + result.hasNext(), + result.hasPrevious() + ); + } + } +} + 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..640c909e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,76 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.heart.HeartFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 좋아요 API v1 컨트롤러. + *

+ * 상품 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/like/products") +public class LikeV1Controller { + + private final HeartFacade heartFacade; + + /** + * 상품에 좋아요를 추가합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param productId 상품 ID + * @return 성공 응답 + */ + @PostMapping("/{productId}") + public ApiResponse addLike( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long productId + ) { + heartFacade.addLike(userId, productId); + return ApiResponse.success(null); + } + + /** + * 상품의 좋아요를 취소합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param productId 상품 ID + * @return 성공 응답 + */ + @DeleteMapping("/{productId}") + public ApiResponse removeLike( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long productId + ) { + heartFacade.removeLike(userId, productId); + return ApiResponse.success(null); + } + + /** + * 사용자가 좋아요한 상품 목록을 조회합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @return 좋아요한 상품 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getLikedProducts( + @RequestHeader("X-USER-ID") String userId + ) { + var likedProducts = heartFacade.getLikedProducts(userId); + return ApiResponse.success(LikeV1Dto.LikedProductsResponse.from(likedProducts)); + } +} + 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..e154c036b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.heart.HeartFacade; + +import java.util.List; + +/** + * 좋아요 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class LikeV1Dto { + /** + * 좋아요한 상품 목록 응답 데이터. + * + * @param products 좋아요한 상품 목록 + */ + public record LikedProductsResponse( + List products + ) { + /** + * LikeFacade.LikedProduct 목록으로부터 LikedProductsResponse를 생성합니다. + * + * @param likedProducts 좋아요한 상품 목록 + * @return 생성된 응답 객체 + */ + public static LikedProductsResponse from(List likedProducts) { + return new LikedProductsResponse( + likedProducts.stream() + .map(LikedProductResponse::from) + .toList() + ); + } + } + + /** + * 좋아요한 상품 정보 응답 데이터. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record LikedProductResponse( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * LikeFacade.LikedProduct로부터 LikedProductResponse를 생성합니다. + * + * @param likedProduct 좋아요한 상품 정보 + * @return 생성된 응답 객체 + */ + public static LikedProductResponse from(HeartFacade.LikedProduct likedProduct) { + return new LikedProductResponse( + likedProduct.productId(), + likedProduct.name(), + likedProduct.price(), + likedProduct.stock(), + likedProduct.brandId(), + likedProduct.likesCount() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java new file mode 100644 index 000000000..e0ffa6f26 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.pointwallet; + +import com.loopers.application.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 포인트 관리 API v1 컨트롤러. + *

+ * 사용자의 포인트 조회 및 충전 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class PointWalletV1Controller { + + private final UserService userService; + + /** + * 현재 사용자의 포인트를 조회합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @return 포인트 정보를 담은 API 응답 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @GetMapping("/me/points") + public ApiResponse getMyPoints( + @RequestHeader("X-USER-ID") String userId + ) { + UserService.PointsInfo pointsInfo = userService.getPoints(userId); + return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo)); + } + + /** + * 현재 사용자의 포인트를 충전합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param request 충전 요청 데이터 (amount) + * @return 충전된 포인트 정보를 담은 API 응답 + * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 + */ + @PostMapping("/me/points/charge") + public ApiResponse chargePoints( + @RequestHeader("X-USER-ID") String userId, + @Valid @RequestBody PointWalletV1Dto.ChargeRequest request + ) { + UserService.PointsInfo pointsInfo = userService.chargePoint(userId, request.amount()); + return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java new file mode 100644 index 000000000..c6e7c97b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.pointwallet; + +import com.loopers.application.user.UserService; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +/** + * 포인트 관리 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class PointWalletV1Dto { + /** + * 포인트 정보 응답 데이터. + * + * @param userId 사용자 ID + * @param balance 포인트 잔액 + */ + public record PointsResponse(String userId, Long balance) { + /** + * PointsInfo로부터 PointsResponse를 생성합니다. + * + * @param pointsInfo 포인트 정보 + * @return 생성된 응답 객체 + */ + public static PointsResponse from(UserService.PointsInfo pointsInfo) { + return new PointsResponse(pointsInfo.userId(), pointsInfo.balance()); + } + } + + /** + * 포인트 충전 요청 데이터. + * + * @param amount 충전할 포인트 금액 (필수, 0보다 커야 함) + */ + public record ChargeRequest( + @NotNull(message = "포인트는 필수입니다.") + @Positive(message = "포인트는 0보다 큰 값이어야 합니다.") + Long amount + ) {} +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java new file mode 100644 index 000000000..937a9cf20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java @@ -0,0 +1,114 @@ +package com.loopers.interfaces.api.purchasing; + +import com.loopers.application.purchasing.OrderInfo; +import com.loopers.application.purchasing.PurchasingFacade; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 주문 API v1 컨트롤러. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class PurchasingV1Controller { + + private final PurchasingFacade purchasingFacade; + + /** + * 주문을 생성한다. + * + * @param userId X-USER-ID 헤더 + * @param request 주문 생성 요청 + * @return 생성된 주문 정보 + */ + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-USER-ID") String userId, + @Valid @RequestBody PurchasingV1Dto.CreateRequest request + ) { + OrderInfo orderInfo = purchasingFacade.createOrder( + userId, + request.toCommands(), + request.payment().usedPoint(), + request.payment().cardType(), + request.payment().cardNo() + ); + return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); + } + + /** + * 현재 사용자의 주문 목록을 조회한다. + * + * @param userId X-USER-ID 헤더 + * @return 주문 목록 + */ + @GetMapping + public ApiResponse getOrders( + @RequestHeader("X-USER-ID") String userId + ) { + List orderInfos = purchasingFacade.getOrders(userId); + return ApiResponse.success(PurchasingV1Dto.OrdersResponse.from(orderInfos)); + } + + /** + * 현재 사용자의 단일 주문을 조회한다. + * + * @param userId X-USER-ID 헤더 + * @param orderId 주문 ID + * @return 주문 상세 정보 + */ + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long orderId + ) { + OrderInfo orderInfo = purchasingFacade.getOrder(userId, orderId); + return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); + } + + /** + * PG 결제 콜백을 처리합니다. + * + * @param orderId 주문 ID + * @param callbackRequest 콜백 요청 정보 + * @return 성공 응답 + */ + @PostMapping("/{orderId}/callback") + public ApiResponse handlePaymentCallback( + @PathVariable Long orderId, + @RequestBody PaymentGatewayDto.CallbackRequest callbackRequest + ) { + purchasingFacade.handlePaymentCallback(orderId, callbackRequest); + return ApiResponse.successVoid(); + } + + /** + * 결제 상태 확인 API를 통해 주문 상태를 복구합니다. + * + * @param userId X-USER-ID 헤더 + * @param orderId 주문 ID + * @return 성공 응답 + */ + @PostMapping("/{orderId}/recover") + public ApiResponse recoverOrderStatus( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long orderId + ) { + purchasingFacade.recoverOrderStatusByPaymentCheck(userId, orderId); + return ApiResponse.successVoid(); + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java new file mode 100644 index 000000000..f2e552d09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java @@ -0,0 +1,133 @@ +package com.loopers.interfaces.api.purchasing; + +import com.loopers.application.purchasing.OrderInfo; +import com.loopers.application.purchasing.OrderItemCommand; +import com.loopers.application.purchasing.OrderItemInfo; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public final class PurchasingV1Dto { + + private PurchasingV1Dto() { + } + + /** + * 주문 생성 요청 DTO. + */ + public record CreateRequest( + @NotEmpty(message = "주문 상품은 1개 이상이어야 합니다.") + List<@Valid ItemRequest> items, + @Valid PaymentRequest payment + ) { + public List toCommands() { + return items.stream() + .map(item -> OrderItemCommand.of(item.productId(), item.quantity())) + .toList(); + } + } + + /** + * 결제 정보 요청 DTO. + */ + public record PaymentRequest( + Long usedPoint, // 포인트 사용량 (선택, 기본값: 0) + String cardType, // 카드 타입 (paidAmount > 0일 때만 필수) + String cardNo // 카드 번호 (paidAmount > 0일 때만 필수) + ) { + } + + /** + * 주문 생성 요청 아이템 DTO. + */ + public record ItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @NotNull(message = "상품 수량은 필수입니다.") + @Min(value = 1, message = "상품 수량은 1개 이상이어야 합니다.") + Integer quantity + ) { + } + + /** + * 주문 응답 DTO. + */ + public record OrderResponse( + Long orderId, + Long userId, + Integer totalAmount, + OrderStatus status, + List items + ) { + /** + * OrderInfo로부터 OrderResponse를 생성합니다. + * + * @param orderInfo 주문 정보 + * @return 생성된 응답 객체 + */ + public static OrderResponse from(OrderInfo orderInfo) { + List itemResponses = orderInfo.items().stream() + .map(OrderItemResponse::from) + .toList(); + + return new OrderResponse( + orderInfo.orderId(), + orderInfo.userId(), + orderInfo.totalAmount(), + orderInfo.status(), + itemResponses + ); + } + } + + /** + * 주문 아이템 응답 DTO. + */ + public record OrderItemResponse( + Long productId, + String name, + Integer price, + Integer quantity + ) { + /** + * OrderItemInfo로부터 OrderItemResponse를 생성합니다. + * + * @param itemInfo 주문 아이템 정보 + * @return 생성된 응답 객체 + */ + public static OrderItemResponse from(OrderItemInfo itemInfo) { + return new OrderItemResponse( + itemInfo.productId(), + itemInfo.name(), + itemInfo.price(), + itemInfo.quantity() + ); + } + } + + /** + * 주문 목록 응답 DTO. + */ + public record OrdersResponse(List orders) { + /** + * OrderInfo 목록으로부터 OrdersResponse를 생성합니다. + * + * @param orderInfos 주문 정보 목록 + * @return 생성된 응답 객체 + */ + public static OrdersResponse from(List orderInfos) { + return new OrdersResponse( + orderInfos.stream() + .map(OrderResponse::from) + .toList() + ); + } + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..2a34d7f21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,124 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +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.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 랭킹 조회 API v1 컨트롤러. + *

+ * 랭킹 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller { + + private final RankingService rankingService; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 랭킹을 조회합니다. + *

+ * 기간별(일간/주간/월간) 랭킹을 페이징하여 조회합니다. + *

+ *

+ * 기간 타입: + *

    + *
  • DAILY: 일간 랭킹 (Redis ZSET에서 조회)
  • + *
  • WEEKLY: 주간 랭킹 (Materialized View에서 조회)
  • + *
  • MONTHLY: 월간 랭킹 (Materialized View에서 조회)
  • + *
+ *

+ * + * @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜) + * @param period 기간 타입 (DAILY, WEEKLY, MONTHLY, 기본값: DAILY) + * @param page 페이지 번호 (기본값: 0) + * @param size 페이지당 항목 수 (기본값: 20) + * @return 랭킹 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getRankings( + @RequestParam(required = false) String date, + @RequestParam(required = false, defaultValue = "DAILY") String period, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + // 날짜 파라미터 검증 및 기본값 처리 + LocalDate targetDate = parseDate(date); + + // 기간 타입 파싱 및 검증 + RankingService.PeriodType periodType = parsePeriodType(period); + + // 페이징 검증 + if (page < 0) { + page = 0; + } + if (size < 1) { + size = 20; + } + if (size > 100) { + size = 100; // 최대 100개로 제한 + } + + RankingService.RankingsResponse result = rankingService.getRankings(targetDate, periodType, page, size); + return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result)); + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + *

+ * 날짜가 없거나 파싱 실패 시 오늘 날짜를 반환합니다. + *

+ * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 (실패 시 오늘 날짜) + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return LocalDate.now(ZoneId.of("UTC")); + } + + try { + return LocalDate.parse(dateStr, DATE_FORMATTER); + } catch (DateTimeParseException e) { + // 파싱 실패 시 오늘 날짜 반환 (UTC 기준) + return LocalDate.now(ZoneId.of("UTC")); + } + } + + /** + * 기간 타입 문자열을 PeriodType으로 파싱합니다. + *

+ * 파싱 실패 시 DAILY를 반환합니다. + *

+ * + * @param periodStr 기간 타입 문자열 (DAILY, WEEKLY, MONTHLY) + * @return 파싱된 기간 타입 (실패 시 DAILY) + */ + private RankingService.PeriodType parsePeriodType(String periodStr) { + if (periodStr == null || periodStr.isBlank()) { + return RankingService.PeriodType.DAILY; + } + + try { + return RankingService.PeriodType.valueOf(periodStr.toUpperCase()); + } catch (IllegalArgumentException e) { + // 파싱 실패 시 DAILY 반환 + return RankingService.PeriodType.DAILY; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..45ac64ab0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,94 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingService; +import com.loopers.domain.product.ProductDetail; + +import java.util.List; + +/** + * 랭킹 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class RankingV1Dto { + /** + * 랭킹 항목 응답 데이터. + * + * @param rank 순위 (1부터 시작) + * @param score 점수 + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + */ + public record RankingItemResponse( + Long rank, + Double score, + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + String brandName, + Long likesCount + ) { + /** + * RankingService.RankingItem으로부터 RankingItemResponse를 생성합니다. + * + * @param item 랭킹 항목 + * @return 생성된 응답 객체 + */ + public static RankingItemResponse from(RankingService.RankingItem item) { + ProductDetail detail = item.productDetail(); + return new RankingItemResponse( + item.rank(), + item.score(), + detail.getId(), + detail.getName(), + detail.getPrice(), + detail.getStock(), + detail.getBrandId(), + detail.getBrandName(), + detail.getLikesCount() + ); + } + } + + /** + * 랭킹 목록 응답 데이터. + * + * @param items 랭킹 항목 목록 + * @param page 현재 페이지 번호 + * @param size 페이지당 항목 수 + * @param hasNext 다음 페이지 존재 여부 + */ + public record RankingsResponse( + List items, + int page, + int size, + boolean hasNext + ) { + /** + * RankingService.RankingsResponse로부터 RankingsResponse를 생성합니다. + * + * @param response 랭킹 조회 결과 + * @return 생성된 응답 객체 + */ + public static RankingsResponse from(RankingService.RankingsResponse response) { + List items = response.items().stream() + .map(RankingItemResponse::from) + .toList(); + + return new RankingsResponse( + items, + response.page(), + response.size(), + response.hasNext() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java new file mode 100644 index 000000000..4945b507d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.signup; + +import com.loopers.application.user.UserService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 회원가입 API v1 컨트롤러. + *

+ * 사용자 회원가입 요청을 처리하는 REST API를 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/signup") +public class SignUpV1Controller { + + private final UserService userService; + + /** + * 회원가입을 처리합니다. + * + * @param request 회원가입 요청 데이터 (userId, email, birthDate, gender) + * @return 생성된 사용자 정보를 담은 API 응답 + * @throws CoreException gender 값이 유효하지 않거나, 유효성 검증 실패 또는 중복 ID 존재 시 + */ + @PostMapping + public ApiResponse signUp( + @Valid @RequestBody SignUpV1Dto.SignUpRequest request + ) { + Gender gender = parseGender(request.gender()); + User user = userService.create( + request.userId(), + request.email(), + request.birthDate(), + gender, + Point.of(0L) + ); + SignUpV1Dto.SignupResponse response = SignUpV1Dto.SignupResponse.from(user); + return ApiResponse.success(response); + } + + /** + * 성별 문자열을 Gender enum으로 변환합니다. + * + * @param genderStr 성별 문자열 (MALE 또는 FEMALE) + * @return Gender enum + * @throws CoreException 유효하지 않은 성별 값인 경우 + */ + private Gender parseGender(String genderStr) { + try { + return Gender.valueOf(genderStr.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("유효하지 않은 성별입니다. (gender: %s)", genderStr) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java new file mode 100644 index 000000000..caa4cc492 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.signup; + +import com.loopers.domain.user.User; +import jakarta.validation.constraints.NotBlank; + +/** + * 회원가입 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class SignUpV1Dto { + /** + * 회원가입 요청 데이터. + * + * @param userId 사용자 ID (필수) + * @param email 이메일 주소 (필수) + * @param birthDate 생년월일 (필수, yyyy-MM-dd) + * @param gender 성별 (필수, MALE 또는 FEMALE) + */ + public record SignUpRequest( + @NotBlank String userId, + @NotBlank String email, + @NotBlank String birthDate, + @NotBlank String gender + ) {} + + /** + * 회원가입 응답 데이터. + * + * @param id 사용자 엔티티 ID + * @param userId 사용자 ID + * @param email 이메일 주소 + * @param birthDate 생년월일 + * @param gender 성별 + */ + public record SignupResponse(Long id, String userId, String email, String birthDate, String gender) { + /** + * User 엔티티로부터 SignupResponse를 생성합니다. + * + * @param user 사용자 엔티티 + * @return 생성된 응답 객체 + */ + public static SignupResponse from(User user) { + return new SignupResponse( + user.getId(), + user.getUserId(), + user.getEmail(), + user.getBirthDate().toString(), + user.getGender() != null ? user.getGender().name() : null + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java new file mode 100644 index 000000000..4b716b745 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.userinfo; + +import com.loopers.application.user.UserService; +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 사용자 정보 조회 API v1 컨트롤러. + *

+ * 인증된 사용자의 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class UserInfoV1Controller { + + private final UserService userService; + + /** + * 현재 사용자의 정보를 조회합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @return 사용자 정보를 담은 API 응답 + * @throws com.loopers.support.error.CoreException 사용자를 찾을 수 없는 경우 + */ + @GetMapping("/me") + public ApiResponse getMyInfo( + @RequestHeader("X-USER-ID") String userId + ) { + User user = userService.getUser(userId); + return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(user)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java new file mode 100644 index 000000000..d79ce9b6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.userinfo; + +import com.loopers.domain.user.User; + +/** + * 사용자 정보 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class UserInfoV1Dto { + /** + * 사용자 정보 응답 데이터. + * + * @param userId 사용자 ID + * @param email 이메일 주소 + * @param birthDate 생년월일 (문자열) + * @param gender 성별 + */ + public record UserInfoResponse( + String userId, + String email, + String birthDate, + String gender + ) { + /** + * User 엔티티로부터 UserInfoResponse를 생성합니다. + * + * @param user 사용자 엔티티 + * @return 생성된 응답 객체 + */ + public static UserInfoResponse from(User user) { + return new UserInfoResponse( + user.getUserId(), + user.getEmail(), + user.getBirthDate().toString(), + user.getGender() != null ? user.getGender().name() : null + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java new file mode 100644 index 000000000..afd36ab0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.event.coupon; + +import com.loopers.application.coupon.CouponEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 쿠폰 이벤트 리스너. + *

+ * 주문 생성 이벤트를 받아서 쿠폰 사용 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponEventListener { + + private final CouponEventHandler couponEventHandler; + private final CouponEventPublisher couponEventPublisher; + + /** + * 주문 생성 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 쿠폰 사용 처리를 수행합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // ✅ 도메인 이벤트 발행: 쿠폰 적용이 실패했음 (과거 사실) + // 이벤트 핸들러에서 예외가 발생했으므로 실패 이벤트를 발행 + + // Optimistic Locking 실패는 정상적인 동시성 제어 결과이므로 별도 처리 + String failureReason; + if (e instanceof ObjectOptimisticLockingFailureException || + e instanceof OptimisticLockingFailureException) { + failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)"; + } else { + failureReason = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + } + + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + + // Optimistic Locking 실패는 정상적인 동시성 제어 결과이므로 WARN 레벨로 로깅 + if (e instanceof ObjectOptimisticLockingFailureException || + e instanceof OptimisticLockingFailureException) { + log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + } else { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + } + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java new file mode 100644 index 000000000..1cb371d46 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java @@ -0,0 +1,81 @@ +package com.loopers.interfaces.event.data; + +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 데이터 플랫폼 전송 이벤트 리스너. + *

+ * 주문 완료/취소 이벤트를 받아 데이터 플랫폼에 전송합니다. + *

+ *

+ * 트랜잭션 전략: + *

    + *
  • AFTER_COMMIT: 주문 트랜잭션이 커밋된 후에 실행되어 데이터 일관성 보장
  • + *
  • @Async: 비동기로 실행하여 주문 처리 성능에 영향을 주지 않음
  • + *
+ *

+ *

+ * 주의사항: + *

    + *
  • 데이터 플랫폼 전송 실패는 로그만 기록 (주문 처리에는 영향 없음)
  • + *
  • 재시도는 외부 시스템(메시지 큐 등)에서 처리하거나 별도 스케줄러로 처리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DataEventListener { + + // TODO: 데이터 플랫폼 전송 클라이언트 주입 + // private final DataPlatformClient dataPlatformClient; + + /** + * 주문 완료 이벤트를 처리하여 데이터 플랫폼에 전송합니다. + * + * @param event 주문 완료 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCompleted(OrderEvent.OrderCompleted event) { + try { + // TODO: 데이터 플랫폼에 주문 완료 데이터 전송 + // dataPlatformClient.sendOrderCompleted(event); + + log.info("주문 완료 데이터 플랫폼 전송 완료. (orderId: {}, userId: {}, totalAmount: {})", + event.orderId(), event.userId(), event.totalAmount()); + } catch (Exception e) { + // 데이터 플랫폼 전송 실패는 로그만 기록 + log.error("주문 완료 데이터 플랫폼 전송 중 오류 발생. (orderId: {})", event.orderId(), e); + } + } + + /** + * 주문 취소 이벤트를 처리하여 데이터 플랫폼에 전송합니다. + * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + // TODO: 데이터 플랫폼에 주문 취소 데이터 전송 + // dataPlatformClient.sendOrderCanceled(event); + + log.info("주문 취소 데이터 플랫폼 전송 완료. (orderId: {}, userId: {}, reason: {})", + event.orderId(), event.userId(), event.reason()); + } catch (Exception e) { + // 데이터 플랫폼 전송 실패는 로그만 기록 + log.error("주문 취소 데이터 플랫폼 전송 중 오류 발생. (orderId: {})", event.orderId(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java new file mode 100644 index 000000000..ec3001f3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.event.order; + +import com.loopers.application.order.OrderEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 주문 이벤트 리스너. + *

+ * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아서 주문 상태를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventListener { + + private final OrderEventHandler orderEventHandler; + + /** + * 결제 완료 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 실행되어 주문 상태를 COMPLETED로 업데이트합니다. + *

+ * + * @param event 결제 완료 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { + try { + orderEventHandler.handlePaymentCompleted(event); + } catch (Exception e) { + log.error("결제 완료 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 쿠폰 적용 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 주문에 할인 금액을 적용합니다. + *

+ * + * @param event 쿠폰 적용 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + orderEventHandler.handleCouponApplied(event); + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 결제 실패 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 주문 취소 처리를 수행합니다. + *

+ * + * @param event 결제 실패 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentFailed(PaymentEvent.PaymentFailed event) { + try { + orderEventHandler.handlePaymentFailed(event); + } catch (Exception e) { + log.error("결제 실패 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java new file mode 100644 index 000000000..a3d6f6202 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java @@ -0,0 +1,76 @@ +package com.loopers.interfaces.event.payment; + +import com.loopers.application.payment.PaymentEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 결제 이벤트 리스너. + *

+ * 결제 요청 이벤트를 받아서 Payment 생성 및 PG 결제 요청 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventListener { + + private final PaymentEventHandler paymentEventHandler; + + /** + * 결제 요청 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 Payment 생성 및 PG 결제 요청 처리를 수행합니다. + *

+ * + * @param event 결제 요청 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentRequested(PaymentEvent.PaymentRequested event) { + try { + paymentEventHandler.handlePaymentRequested(event); + } catch (Exception e) { + log.error("결제 요청 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 쿠폰 적용 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 결제 금액에 쿠폰 할인을 적용합니다. + *

+ * + * @param event 쿠폰 적용 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + paymentEventHandler.handleCouponApplied(event); + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java new file mode 100644 index 000000000..d38dc84e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java @@ -0,0 +1,131 @@ +package com.loopers.interfaces.event.product; + +import com.loopers.application.product.ProductEventHandler; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 상품 이벤트 리스너. + *

+ * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아서 상품의 좋아요 수 및 재고를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ *

+ * EDA 원칙: + *

    + *
  • 느슨한 결합: HeartFacade는 이 리스너의 존재를 모름
  • + *
  • 비동기 처리: @Async로 집계 처리를 비동기로 실행
  • + *
  • 이벤트 기반: 좋아요 추가/취소 이벤트를 구독하여 상품의 좋아요 수 업데이트
  • + *
+ *

+ *

+ * 집계 전략: + *

    + *
  • 이벤트 기반 실시간 집계: 좋아요 추가/취소 시 즉시 Product.likeCount 업데이트
  • + *
  • Strong Consistency: 이벤트 기반으로 실시간 반영
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventListener { + + private final ProductEventHandler productEventHandler; + + /** + * 좋아요 추가 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 증가시킵니다. + *

+ * + * @param event 좋아요 추가 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.LikeAdded event) { + try { + productEventHandler.handleLikeAdded(event); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 좋아요 취소 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 감소시킵니다. + *

+ * + * @param event 좋아요 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.LikeRemoved event) { + try { + productEventHandler.handleLikeRemoved(event); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 생성 이벤트를 처리합니다. + *

+ * 주문 생성과 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 차감합니다. + * 재고 차감은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + productEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 재고 차감 실패 시 주문 생성도 롤백되어야 하므로 예외를 다시 던짐 + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리합니다. + *

+ * 주문 취소와 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 원복합니다. + * 재고 원복은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. + *

+ * + * @param event 주문 취소 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + productEventHandler.handleOrderCanceled(event); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 재고 원복 실패 시 주문 취소도 롤백되어야 하므로 예외를 다시 던짐 + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java new file mode 100644 index 000000000..c221c01b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.event.user; + +import com.loopers.application.user.PointEventHandler; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 포인트 이벤트 리스너. + *

+ * 포인트 사용 이벤트와 주문 취소 이벤트를 받아서 포인트 사용/환불 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointEventListener { + + private final PointEventHandler pointEventHandler; + + /** + * 주문 생성 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 포인트 사용 처리를 수행합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + pointEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 취소 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 포인트 환불 처리를 수행합니다. + *

+ * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + pointEventHandler.handleOrderCanceled(event); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..0856b8d81 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -20,10 +20,113 @@ spring: config: import: - jpa.yml + - kafka.yml - redis.yml - logging.yml - monitoring.yml +payment-gateway: + url: http://localhost:8082 + +payment: + callback: + base-url: ${PAYMENT_CALLBACK_BASE_URL:http://localhost:8080} + +feign: + client: + config: + default: + connectTimeout: 2000 # 연결 타임아웃 (2초) + readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려 + paymentGatewayClient: + connectTimeout: 2000 # 연결 타임아웃 (2초) + readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려 + loggerLevel: full # 로깅 레벨 (디버깅용) + paymentGatewaySchedulerClient: + connectTimeout: 2000 # 연결 타임아웃 (2초) + readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려 + loggerLevel: full # 로깅 레벨 (디버깅용) + circuitbreaker: + enabled: false # FeignClient 자동 Circuit Breaker 비활성화 (어댑터 레벨에서 pgCircuit 사용) + resilience4j: + enabled: true # Resilience4j 활성화 + +resilience4j: + circuitbreaker: + configs: + default: + registerHealthIndicator: true + slidingWindowSize: 20 # 슬라이딩 윈도우 크기 (Building Resilient Distributed Systems 권장: 20~50) + minimumNumberOfCalls: 1 # 최소 호출 횟수 (첫 호출부터 통계 수집하여 메트릭 즉시 노출) + permittedNumberOfCallsInHalfOpenState: 3 # Half-Open 상태에서 허용되는 호출 수 + automaticTransitionFromOpenToHalfOpenEnabled: true # 자동으로 Half-Open으로 전환 + waitDurationInOpenState: 10s # Open 상태 유지 시간 (10초 후 Half-Open으로 전환) + failureRateThreshold: 50 # 실패율 임계값 (50% 이상 실패 시 Open) + slowCallRateThreshold: 50 # 느린 호출 비율 임계값 (50% 이상 느리면 Open) - Release It! 권장: 50~70% + slowCallDurationThreshold: ${RESILIENCE4J_CIRCUITBREAKER_SCHEDULER_SLOW_CALL_DURATION_THRESHOLD:2s} # 느린 호출 기준 시간 (2초 이상) - Building Resilient Distributed Systems 권장: 2s (환경 변수로 동적 설정 가능) + recordExceptions: + - feign.FeignException + - feign.FeignException$InternalServerError + - feign.FeignException$ServiceUnavailable + - feign.FeignException$GatewayTimeout + - feign.FeignException$BadGateway + - java.net.SocketTimeoutException + - java.util.concurrent.TimeoutException + ignoreExceptions: [] # 모든 예외를 기록 (무시할 예외 없음) + instances: + pgCircuit: + baseConfig: default + slidingWindowSize: 20 # Building Resilient Distributed Systems 권장: 20 (과제 권장값) + minimumNumberOfCalls: 1 # 첫 호출부터 통계 수집하여 메트릭 즉시 노출 + waitDurationInOpenState: 10s + failureRateThreshold: 50 + slowCallRateThreshold: 50 # 느린 호출 비율 임계값 (50% 이상 느리면 Open) - Release It! 권장: 50~70% + slowCallDurationThreshold: ${RESILIENCE4J_CIRCUITBREAKER_PAYMENT_GATEWAY_SLOW_CALL_DURATION_THRESHOLD:2s} # 느린 호출 기준 시간 (2초 이상) - Building Resilient Distributed Systems 권장: 2s (환경 변수로 동적 설정 가능) + retry: + configs: + default: + maxAttempts: 3 # 최대 재시도 횟수 (초기 시도 포함) + waitDuration: 500ms # 재시도 대기 시간 (기본값, paymentGatewayClient는 Java Config에서 Exponential Backoff 적용) + retryExceptions: + # 일시적 오류만 재시도: 5xx 서버 오류, 타임아웃, 네트워크 오류 + - feign.FeignException$InternalServerError # 500 에러 + - feign.FeignException$ServiceUnavailable # 503 에러 + - feign.FeignException$GatewayTimeout # 504 에러 + - java.net.SocketTimeoutException + - java.util.concurrent.TimeoutException + ignoreExceptions: + # 클라이언트 오류(4xx)는 재시도하지 않음: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음 + - feign.FeignException$BadRequest # 400 에러 + - feign.FeignException$Unauthorized # 401 에러 + - feign.FeignException$Forbidden # 403 에러 + - feign.FeignException$NotFound # 404 에러 + timelimiter: + configs: + default: + timeoutDuration: 6s # 타임아웃 시간 (Feign readTimeout과 동일) + cancelRunningFuture: true # 실행 중인 Future 취소 + instances: + paymentGatewayClient: + baseConfig: default + timeoutDuration: 6s + paymentGatewaySchedulerClient: + baseConfig: default + timeoutDuration: 6s + bulkhead: + configs: + default: + maxConcurrentCalls: 20 # 동시 호출 최대 수 (Building Resilient Distributed Systems: 격벽 패턴) + maxWaitDuration: 5s # 대기 시간 (5초 초과 시 BulkheadFullException 발생) + instances: + paymentGatewayClient: + baseConfig: default + maxConcurrentCalls: 20 # PG 호출용 전용 격벽: 동시 호출 최대 20개로 제한 + maxWaitDuration: 5s + paymentGatewaySchedulerClient: + baseConfig: default + maxConcurrentCalls: 10 # 스케줄러용 격벽: 동시 호출 최대 10개로 제한 (배치 작업이므로 더 보수적) + maxWaitDuration: 5s + springdoc: use-fqn: true swagger-ui: diff --git a/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java new file mode 100644 index 000000000..bb78c71b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java @@ -0,0 +1,230 @@ +package com.loopers.application.catalog; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductCacheService; +import com.loopers.application.product.ProductService; +import com.loopers.application.ranking.RankingService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +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.lang.reflect.Field; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * CatalogFacade 테스트. + *

+ * 상품 조회 시 랭킹 정보가 포함되는지 검증합니다. + * 캐시 히트/미스의 세부 로직은 ProductCacheService 테스트에서 검증합니다. + *

+ */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CatalogFacade 상품 조회 랭킹 정보 포함 테스트") +class CatalogFacadeTest { + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + @Mock + private ProductCacheService productCacheService; + + @Mock + private ProductEventPublisher productEventPublisher; + + @Mock + private RankingService rankingService; + + @InjectMocks + private CatalogFacade catalogFacade; + + private static final Long PRODUCT_ID = 1L; + private static final Long BRAND_ID = 10L; + private static final String BRAND_NAME = "테스트 브랜드"; + private static final String PRODUCT_NAME = "테스트 상품"; + private static final Integer PRODUCT_PRICE = 10000; + private static final Integer PRODUCT_STOCK = 10; + private static final Long LIKES_COUNT = 5L; + + /** + * Product에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Product product, Long id) { + try { + Field idField = product.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product ID", e); + } + } + + /** + * Brand에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Brand brand, Long id) { + try { + Field idField = brand.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brand, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Brand ID", e); + } + } + + @Test + @DisplayName("캐시 히트 시 랭킹 정보가 포함된다") + void getProduct_withCacheHit_includesRanking() { + // arrange + ProductDetail cachedProductDetail = ProductDetail.of( + PRODUCT_ID, + PRODUCT_NAME, + PRODUCT_PRICE, + PRODUCT_STOCK, + BRAND_ID, + BRAND_NAME, + LIKES_COUNT + ); + ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail); + Long expectedRank = 3L; + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(cachedProductInfo); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(expectedRank); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isEqualTo(expectedRank); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class)); + verify(productService, never()).getProduct(any()); + } + + @Test + @DisplayName("캐시 히트 시 랭킹에 없는 상품은 null을 반환한다") + void getProduct_withCacheHit_noRanking_returnsNull() { + // arrange + ProductDetail cachedProductDetail = ProductDetail.of( + PRODUCT_ID, + PRODUCT_NAME, + PRODUCT_PRICE, + PRODUCT_STOCK, + BRAND_ID, + BRAND_NAME, + LIKES_COUNT + ); + ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail); + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(cachedProductInfo); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(null); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isNull(); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + } + + @Test + @DisplayName("캐시 미스 시 랭킹 정보가 포함된다") + void getProduct_withCacheMiss_includesRanking() { + // arrange + Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID); + setId(product, PRODUCT_ID); + + // Product.likeCount 설정 (리플렉션 사용) + try { + Field likeCountField = Product.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(product, LIKES_COUNT); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product likeCount", e); + } + + Brand brand = Brand.of(BRAND_NAME); + setId(brand, BRAND_ID); + + Long expectedRank = 5L; + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(null); + when(productService.getProduct(PRODUCT_ID)) + .thenReturn(product); + when(brandService.getBrand(BRAND_ID)) + .thenReturn(brand); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(expectedRank); + when(productCacheService.applyLikeCountDelta(any(ProductInfo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isEqualTo(expectedRank); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class)); + verify(productService).getProduct(PRODUCT_ID); + verify(productCacheService).cacheProduct(eq(PRODUCT_ID), any(ProductInfo.class)); + } + + @Test + @DisplayName("캐시 미스 시 랭킹에 없는 상품은 null을 반환한다") + void getProduct_withCacheMiss_noRanking_returnsNull() { + // arrange + Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID); + setId(product, PRODUCT_ID); + + // Product.likeCount 설정 (리플렉션 사용) + try { + Field likeCountField = Product.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(product, LIKES_COUNT); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product likeCount", e); + } + + Brand brand = Brand.of(BRAND_NAME); + setId(brand, BRAND_ID); + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(null); + when(productService.getProduct(PRODUCT_ID)) + .thenReturn(product); + when(brandService.getBrand(BRAND_ID)) + .thenReturn(brand); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(null); + when(productCacheService.applyLikeCountDelta(any(ProductInfo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isNull(); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java new file mode 100644 index 000000000..cc7867966 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java @@ -0,0 +1,301 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("CouponEventHandler 쿠폰 적용 검증") +@RecordApplicationEvents +class CouponEventHandlerTest { + + @Autowired + private CouponEventHandler couponEventHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private ApplicationEvents applicationEvents; + + // ✅ OrderEventListener를 Mocking하여 CouponEventHandlerTest에서 주문 관련 로직이 실행되지 않도록 함 + // CouponEventHandlerTest는 쿠폰 도메인의 책임만 테스트해야 하므로 주문 관련 로직은 제외 + @MockitoBean + private com.loopers.interfaces.event.order.OrderEventListener orderEventListener; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + + @Test + @DisplayName("쿠폰 코드가 없으면 처리하지 않는다") + void handleOrderCreated_skips_whenNoCouponCode() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // 쿠폰 코드 없음 + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + couponEventHandler.handleOrderCreated(event); + + // assert + // 예외 없이 처리되어야 함 + } + + @Test + @DisplayName("정액 쿠폰을 정상적으로 적용할 수 있다") + void handleOrderCreated_appliesFixedAmountCoupon_success() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("FIXED5000", CouponType.FIXED_AMOUNT, 5_000); + createAndSaveUserCoupon(user.getId(), coupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "FIXED5000", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + couponEventHandler.handleOrderCreated(event); + + // assert + // 쿠폰 적용 성공 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.orderId()).isEqualTo(1L); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo("FIXED5000"); + assertThat(appliedEvent.discountAmount()).isEqualTo(5_000); + + // 쿠폰 적용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).isEmpty(); + } + + @Test + @DisplayName("정률 쿠폰을 정상적으로 적용할 수 있다") + void handleOrderCreated_appliesPercentageCoupon_success() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("PERCENT20", CouponType.PERCENTAGE, 20); + createAndSaveUserCoupon(user.getId(), coupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "PERCENT20", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + couponEventHandler.handleOrderCreated(event); + + // assert + // 쿠폰 적용 성공 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.orderId()).isEqualTo(1L); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo("PERCENT20"); + assertThat(appliedEvent.discountAmount()).isEqualTo(2_000); // 10,000 * 20% = 2,000 + + // 쿠폰 적용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).isEmpty(); + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 코드로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponNotFound() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "NON_EXISTENT", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("NON_EXISTENT"); + assertThat(failedEvent.failureReason()).contains("쿠폰을 찾을 수 없습니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } + + @Test + @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponNotOwnedByUser() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + // 사용자에게 쿠폰을 지급하지 않음 + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "COUPON001", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("COUPON001"); + assertThat(failedEvent.failureReason()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } + + @Test + @DisplayName("이미 사용된 쿠폰으로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponAlreadyUsed() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + userCoupon.use(); // 이미 사용 처리 + userCouponRepository.save(userCoupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "USED_COUPON", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("USED_COUPON"); + assertThat(failedEvent.failureReason()).contains("이미 사용된 쿠폰입니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java new file mode 100644 index 000000000..fc9afe984 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java @@ -0,0 +1,336 @@ +package com.loopers.application.heart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * LikeFacade 동시성 테스트 + *

+ * 여러 스레드에서 동시에 좋아요 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. + *

+ */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@DisplayName("LikeFacade 동시성 테스트") +class HeartFacadeConcurrencyTest { + + @Autowired + private HeartFacade heartFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + /** + * 상품들의 좋아요 수를 동기화합니다. + *

+ * 테스트에서 비동기 스케줄러를 기다리지 않고 직접 like count를 업데이트하기 위해 사용합니다. + *

+ * + * @param productIds 동기화할 상품 ID 목록 + */ + private void syncLikeCounts(List productIds) { + Map likeCountMap = likeRepository.countByProductIds(productIds); + for (Long productId : productIds) { + Long likeCount = likeCountMap.getOrDefault(productId, 0L); + productRepository.updateLikeCount(productId, likeCount); + } + } + + @Test + @DisplayName("동일한 상품에 대해 여러명이 좋아요를 요청해도, 상품의 좋아요 개수가 정상 반영되어야 한다") + void concurrencyTest_likeShouldBeProperlyCounted() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + int userCount = 10; + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(createAndSaveUser("user" + i, "user" + i + "@example.com", 0L)); + } + + ExecutorService executorService = Executors.newFixedThreadPool(userCount); + CountDownLatch latch = new CountDownLatch(userCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (User user : users) { + executorService.submit(() -> { + try { + heartFacade.addLike(user.getUserId(), productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(userCount); + assertThat(successCount.get()).isEqualTo(userCount); + assertThat(exceptions).isEmpty(); + } + + @Test + @DisplayName("동일한 사용자가 동시에 여러번 좋아요를 요청해도, 정상적으로 카운트되어야 한다") + void concurrencyTest_sameUserMultipleRequests_shouldBeCountedCorrectly() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + User user = createAndSaveUser("testuser", "test@example.com", 0L); + String userId = user.getUserId(); + + int concurrentRequestCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < concurrentRequestCount; i++) { + executorService.submit(() -> { + try { + heartFacade.addLike(userId, productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // UNIQUE 제약조건으로 인해 정확히 1개의 좋아요만 저장되어야 함 + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(1L); + // 애플리케이션 레벨 체크 또는 데이터베이스 UNIQUE 제약조건으로 인해 + // 모든 요청이 성공하거나 일부는 예외가 발생할 수 있지만, + // 최종적으로는 1개의 좋아요만 저장되어야 함 + assertThat(successCount.get() + exceptions.size()).isEqualTo(concurrentRequestCount); + } + + @Test + @DisplayName("@Transactional(readOnly = true)와 UNIQUE 제약조건은 서로 다른 목적을 가진다") + void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurposes() throws InterruptedException { + // 이 테스트는 @Transactional(readOnly = true)와 UNIQUE 제약조건의 차이를 보여줍니다. + // + // UNIQUE 제약조건: + // - 목적: 데이터 무결성 보장 (중복 데이터 방지) + // - 예시: LikeFacade.addLike()에서 동일 사용자가 동일 상품에 중복 좋아요 방지 + // - 작동: 데이터베이스 레벨에서 물리적으로 중복 삽입 방지 + // + // @Transactional(readOnly = true): + // - 목적: 여러 쿼리 간의 논리적 일관성 보장 + // - 예시: LikeFacade.getLikedProducts()에서 좋아요 목록과 집계 결과의 일관성 + // - 작동: 모든 쿼리가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // + // REPEATABLE READ 격리 수준에서: + // - 트랜잭션이 없으면: 각 쿼리가 독립적으로 실행되며, 각 쿼리는 자체 스냅샷을 봄 + // - 트랜잭션이 있으면: 모든 쿼리가 동일한 트랜잭션 시작 시점의 스냅샷을 봄 + // + // 실제 문제 시나리오: + // 1. 좋아요 목록 조회 (쿼리 1) - 시점 T1의 스냅샷 + // 2. 다른 트랜잭션이 좋아요 추가 (커밋) + // 3. 좋아요 수 집계 (쿼리 2) - 시점 T2의 스냅샷 (T1과 다를 수 있음) + // + // 트랜잭션이 없으면: + // - 쿼리 1과 쿼리 2가 서로 다른 시점의 스냅샷을 볼 수 있음 + // - 좋아요 목록에는 상품1이 1개로 보이지만, 집계 결과는 2개일 수 있음 + // + // 트랜잭션이 있으면: + // - 모든 쿼리가 동일한 시점의 스냅샷을 봄 + // - 좋아요 목록과 집계 결과가 일관됨 + // + // 왜 테스트가 통과하는가? + // - REPEATABLE READ에서는 각 쿼리가 자체적으로 일관된 스냅샷을 봄 + // - 쿼리 실행 시간이 매우 짧아서 다른 트랜잭션이 정확히 중간에 개입할 확률이 낮음 + // - 하지만 여러 쿼리 간의 논리적 일관성을 보장하려면 트랜잭션이 필요함 + + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 100, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 100, brand.getId()); + + User user1 = createAndSaveUser("user1", "user1@example.com", 0L); + User user2 = createAndSaveUser("user2", "user2@example.com", 0L); + String userId1 = user1.getUserId(); + String userId2 = user2.getUserId(); + + // user1이 상품1, 상품2에 좋아요를 이미 누른 상태 + heartFacade.addLike(userId1, product1.getId()); + heartFacade.addLike(userId1, product2.getId()); + + ExecutorService executorService = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(20); + List> allResults = new ArrayList<>(); + + // act + // 여러 스레드에서 동시에 조회를 수행 + for (int i = 0; i < 10; i++) { + executorService.submit(() -> { + try { + List result = heartFacade.getLikedProducts(userId1); + synchronized (allResults) { + allResults.add(result); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + // 다른 스레드들이 조회 중간에 좋아요를 추가/삭제 + for (int i = 0; i < 10; i++) { + final int index = i; + executorService.submit(() -> { + try { + // 조회가 시작된 후 실행되도록 약간의 지연 + Thread.sleep(1 + index); + if (index % 2 == 0) { + // user2가 상품1에 좋아요 추가 + try { + heartFacade.addLike(userId2, product1.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } else { + // user2가 상품2에 좋아요 추가 + try { + heartFacade.addLike(userId2, product2.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // 좋아요 수 동기화 (비동기 스케줄러를 기다리지 않고 직접 업데이트) + syncLikeCounts(List.of(product1.getId(), product2.getId())); + + // assert + // @Transactional(readOnly = true)가 있으면: + // - 모든 조회가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // - 각 조회 결과 내에서 좋아요 목록과 집계 결과가 일관됨 + + // @Transactional(readOnly = true)가 없으면: + // - 각 쿼리가 독립적으로 실행되어 서로 다른 시점의 데이터를 볼 수 있음 + // - 하지만 REPEATABLE READ에서는 각 쿼리가 자체 스냅샷을 보므로 + // 실제로는 문제가 드물 수 있음 + + // 검증: 모든 조회 결과가 정상적으로 반환되었는지 확인 + assertThat(allResults).hasSize(10); + + // 각 조회 결과가 올바른 형식인지 확인 + // 참고: allResults는 동기화 이전에 조회된 결과이므로 likesCount가 0일 수 있습니다. + // 이 테스트는 @Transactional(readOnly = true)의 일관성 보장을 검증하는 것이 목적이므로, + // 동시성 테스트 중 조회된 결과의 상품 ID 일관성만 확인합니다. + for (List result : allResults) { + // user1의 좋아요 목록에는 상품1, 상품2가 포함되어야 함 + List resultProductIds = result.stream() + .map(HeartFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(resultProductIds).contains(product1.getId(), product2.getId()); + } + + // 최종 상태 확인 (동기화 후) + List finalResult = heartFacade.getLikedProducts(userId1); + List finalProductIds = finalResult.stream() + .map(HeartFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(finalProductIds).containsExactlyInAnyOrder(product1.getId(), product2.getId()); + + // 동기화 후에는 정확한 좋아요 수가 반영되어야 함 + for (HeartFacade.LikedProduct likedProduct : finalResult) { + assertThat(likedProduct.likesCount()).isGreaterThan(0); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java new file mode 100644 index 000000000..9634d4c48 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java @@ -0,0 +1,266 @@ +package com.loopers.application.heart; + +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductService; +import com.loopers.application.user.UserService; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; +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.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; + +@DisplayName("HeartFacade 좋아요 등록/취소/중복 방지 흐름 검증") +class HeartFacadeTest { + + @Mock + private LikeService likeService; + + @Mock + private UserService userService; + + @Mock + private ProductService productService; // 조회용으로만 사용 + + @InjectMocks + private HeartFacade heartFacade; + + private static final String DEFAULT_USER_ID = "testuser"; + private static final Long DEFAULT_USER_INTERNAL_ID = 1L; + private static final Long DEFAULT_PRODUCT_ID = 1L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("좋아요를 등록할 수 있다") + void addLike_success() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.empty()); + + // act + heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert + // ✅ EDA 원칙: LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + verify(likeService).save(any(Like.class)); + // ProductService는 조회용으로만 사용되므로 검증하지 않음 + } + + @Test + @DisplayName("좋아요를 취소할 수 있다") + void removeLike_success() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + Like like = Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.of(like)); + + // act + heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert + // ✅ EDA 원칙: LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + verify(likeService).delete(like); + } + + @Test + @DisplayName("좋아요는 중복 등록되지 않는다.") + void addLike_isIdempotent() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.of(Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))); + + // act + heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert - save는 한 번만 호출되어야 함 (중복 방지) + verify(likeService, never()).save(any(Like.class)); + } + + @Test + @DisplayName("좋아요는 중복 취소되지 않는다.") + void removeLike_isIdempotent() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.empty()); // 좋아요 없음 + + // act - 좋아요가 없는 상태에서 취소 시도 + heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert - 예외가 발생하지 않아야 함 (멱등성 보장) + verify(likeService).getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + verify(likeService, never()).delete(any(Like.class)); + } + + @Test + @DisplayName("사용자를 찾을 수 없으면 예외를 던진다") + void addLike_userNotFound() { + // arrange + String unknownUserId = "unknown"; + when(userService.getUser(unknownUserId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + // act & assert + assertThatThrownBy(() -> heartFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("좋아요 등록 시 상품 존재 여부 검증은 제거됨 (이벤트 핸들러에서 처리)") + void addLike_productValidationRemoved() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + Long productId = 999L; + // ✅ EDA 원칙: Product 존재 여부 검증은 제거됨 + // 이벤트 핸들러에서 처리하거나 외래키 제약조건으로 보장 + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, productId)) + .thenReturn(Optional.empty()); + + // act + heartFacade.addLike(DEFAULT_USER_ID, productId); + + // assert + // ProductService.getProduct()는 호출되지 않음 (검증 제거됨) + verify(productService, never()).getProduct(any()); + verify(likeService).save(any(Like.class)); + } + + @Test + @DisplayName("좋아요한 상품 목록을 조회할 수 있다") + void getLikedProducts_success() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + + Long productId1 = 1L; + Long productId2 = 2L; + + Like like1 = Like.of(DEFAULT_USER_INTERNAL_ID, productId1); + Like like2 = Like.of(DEFAULT_USER_INTERNAL_ID, productId2); + List likes = List.of(like1, like2); + + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); + Product product2 = createMockProduct(productId2, "상품2", 20000, 20, 1L, 3L); + + when(likeService.getLikesByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + + // act + List result = heartFacade.getLikedProducts(DEFAULT_USER_ID); + + // assert + assertThat(result).hasSize(2); + assertThat(result).extracting(HeartFacade.LikedProduct::productId) + .containsExactlyInAnyOrder(productId1, productId2); + assertThat(result).extracting(HeartFacade.LikedProduct::likesCount) + .containsExactlyInAnyOrder(5L, 3L); + } + + @Test + @DisplayName("좋아요한 상품이 없으면 빈 목록을 반환한다") + void getLikedProducts_emptyList() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + when(likeService.getLikesByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(List.of()); + + // act + List result = heartFacade.getLikedProducts(DEFAULT_USER_ID); + + // assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("좋아요한 상품 목록 조회 시 상품을 찾을 수 없으면 예외를 던진다") + void getLikedProducts_productNotFound() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + + Long productId1 = 1L; + Long nonExistentProductId = 999L; + + Like like1 = Like.of(DEFAULT_USER_INTERNAL_ID, productId1); + Like like2 = Like.of(DEFAULT_USER_INTERNAL_ID, nonExistentProductId); + List likes = List.of(like1, like2); + + Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); + + when(likeService.getLikesByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + // nonExistentProductId가 포함되지 않아서 예외가 발생해야 함 + when(productService.getProducts(List.of(productId1, nonExistentProductId))) + .thenReturn(List.of(product1)); // product1만 반환 (nonExistentProductId는 없음) + + // act & assert + assertThatThrownBy(() -> heartFacade.getLikedProducts(DEFAULT_USER_ID)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("좋아요한 상품 목록 조회 시 사용자를 찾을 수 없으면 예외를 던진다") + void getLikedProducts_userNotFound() { + // arrange + String unknownUserId = "unknown"; + when(userService.getUser(unknownUserId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + // act & assert + assertThatThrownBy(() -> heartFacade.getLikedProducts(unknownUserId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + // Helper methods for test setup + + private void setupMocks(String userId, Long userInternalId, Long productId) { + setupMockUser(userId, userInternalId); + // ✅ EDA 원칙: ProductService는 조회용으로만 사용되므로 mock 설정 불필요 + // Product 존재 여부 검증은 제거됨 + } + + private void setupMockUser(String userId, Long userInternalId) { + User mockUser = mock(User.class); + when(mockUser.getId()).thenReturn(userInternalId); + when(userService.getUser(userId)).thenReturn(mockUser); + } + + private Product createMockProduct(Long productId, String name, Integer price, Integer stock, Long brandId, Long likeCount) { + Product product = mock(Product.class); + when(product.getId()).thenReturn(productId); + when(product.getName()).thenReturn(name); + when(product.getPrice()).thenReturn(price); + when(product.getStock()).thenReturn(stock); + when(product.getBrandId()).thenReturn(brandId); + // ✅ Product.likeCount 필드 mock 설정 (비동기 집계된 값) + when(product.getLikeCount()).thenReturn(likeCount); + return product; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java new file mode 100644 index 000000000..ae9b15fb9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java @@ -0,0 +1,163 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +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.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxBridgeEventListener 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxBridgeEventListenerTest { + + @Mock + private OutboxEventService outboxEventService; + + @InjectMocks + private OutboxBridgeEventListener outboxBridgeEventListener; + + @DisplayName("LikeAdded 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeAdded() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeAdded(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeAdded", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("LikeRemoved 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeRemoved() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeRemoved(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeRemoved", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("OrderCreated 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleOrderCreated() { + // arrange + Long orderId = 1L; + Long userId = 100L; + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 3) + ); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleOrderCreated(event); + + // assert + verify(outboxEventService).saveEvent( + "OrderCreated", + orderId.toString(), + "Order", + event, + "order-events", + orderId.toString() + ); + } + + @DisplayName("ProductViewed 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleProductViewed() { + // arrange + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed( + productId, userId, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleProductViewed(event); + + // assert + verify(outboxEventService).saveEvent( + "ProductViewed", + productId.toString(), + "Product", + event, + "product-events", + productId.toString() + ); + } + + @DisplayName("Outbox 저장 실패 시에도 예외를 던지지 않는다 (에러 격리).") + @Test + void doesNotThrowException_whenOutboxSaveFails() { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + doThrow(new RuntimeException("Outbox 저장 실패")) + .when(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + + // act & assert - 예외가 발생하지 않아야 함 + outboxBridgeEventListener.handleLikeAdded(event); + + // verify + verify(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + } + + @DisplayName("여러 이벤트를 순차적으로 처리할 수 있다.") + @Test + void canHandleMultipleEvents() { + // arrange + LikeEvent.LikeAdded likeAdded = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + LikeEvent.LikeRemoved likeRemoved = new LikeEvent.LikeRemoved(100L, 1L, LocalDateTime.now()); + ProductEvent.ProductViewed productViewed = new ProductEvent.ProductViewed( + 1L, 100L, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleLikeAdded(likeAdded); + outboxBridgeEventListener.handleLikeRemoved(likeRemoved); + outboxBridgeEventListener.handleProductViewed(productViewed); + + // assert + verify(outboxEventService, times(3)).saveEvent( + anyString(), anyString(), anyString(), any(), anyString(), anyString() + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java new file mode 100644 index 000000000..e2ab86a03 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java @@ -0,0 +1,168 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +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.*; + +/** + * OutboxEventService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventServiceTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventService outboxEventService; + + @DisplayName("이벤트를 Outbox에 저장할 수 있다.") + @Test + void canSaveEvent() throws Exception { + // arrange + String eventType = "LikeAdded"; + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + String topic = "like-events"; + String partitionKey = "1"; + String payload = "{\"userId\":100,\"productId\":1}"; + + when(objectMapper.writeValueAsString(event)).thenReturn(payload); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent(eventType, aggregateId, aggregateType, event, topic, partitionKey); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getEventType()).isEqualTo(eventType); + assertThat(savedEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(savedEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(savedEvent.getPayload()).isEqualTo(payload); + assertThat(savedEvent.getTopic()).isEqualTo(topic); + assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(savedEvent.getVersion()).isEqualTo(1L); // 최신 버전(0) + 1 + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(savedEvent.getEventId()).isNotNull(); + assertThat(savedEvent.getCreatedAt()).isNotNull(); + } + + @DisplayName("이벤트 저장 시 UUID로 고유한 eventId가 생성된다.") + @Test + void generatesUniqueEventId() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(anyString(), anyString())) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1"); + outboxEventService.saveEvent("LikeAdded", "2", "Product", event, "like-events", "2"); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId()); + } + + @DisplayName("같은 집계 ID에 대해 버전이 순차적으로 증가한다.") + @Test + void incrementsVersionSequentially() throws Exception { + // arrange + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L) // 첫 번째 호출: 최신 버전 0 + .thenReturn(1L) // 두 번째 호출: 최신 버전 1 + .thenReturn(2L); // 세 번째 호출: 최신 버전 2 + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("LikeRemoved", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("ProductViewed", aggregateId, aggregateType, event, "product-events", aggregateId); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(3)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + OutboxEvent event3 = captor.getAllValues().get(2); + + assertThat(event1.getVersion()).isEqualTo(1L); + assertThat(event2.getVersion()).isEqualTo(2L); + assertThat(event3.getVersion()).isEqualTo(3L); + } + + @DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenJsonSerializationFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)) + .thenThrow(new RuntimeException("JSON 직렬화 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository, never()).save(any()); + } + + @DisplayName("Repository 저장 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenRepositorySaveFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId("1", "Product")) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenThrow(new RuntimeException("DB 저장 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository).save(any(OutboxEvent.class)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java new file mode 100644 index 000000000..db50500ff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java @@ -0,0 +1,115 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +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.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("ProductEventHandler 재고 차감 검증") +@RecordApplicationEvents +class ProductEventHandlerTest { + + @Autowired + private com.loopers.interfaces.event.product.ProductEventListener productEventListener; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private Brand createAndSaveBrand(String name) { + Brand brand = Brand.of(name); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String name, int price, int stock, Long brandId) { + Product product = Product.of(name, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") + void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + int initialStock = 100; + + int orderCount = 10; + int quantityPerOrder = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + final int orderId = i + 1; + executorService.submit(() -> { + try { + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + (long) orderId, + 1L, // userId + null, // couponCode + 10_000, // subtotal + 0L, // usedPointAmount + List.of(new OrderEvent.OrderCreated.OrderItemInfo(productId, quantityPerOrder)), + LocalDateTime.now() + ); + // ProductEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // 재고 차감은 BEFORE_COMMIT으로 동기 처리되므로 예외가 발생하면 롤백됨 + productEventListener.handleOrderCreated(event); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // 재고 차감은 동기적으로 처리되므로 즉시 반영됨 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + int expectedStock = initialStock - (successCount.get() * quantityPerOrder); + + assertThat(savedProduct.getStock()).isEqualTo(expectedStock); + assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java new file mode 100644 index 000000000..0503b9ad9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java @@ -0,0 +1,492 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.utils.DatabaseCleanUp; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +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.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; + +import java.util.Collections; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * PurchasingFacade 서킷 브레이커 테스트. + *

+ * 서킷 브레이커의 동작을 검증합니다. + * - CLOSED → OPEN 전환 (실패율 임계값 초과) + * - OPEN → HALF_OPEN 전환 (일정 시간 후) + * - HALF_OPEN → CLOSED 전환 (성공 시) + * - HALF_OPEN → OPEN 전환 (실패 시) + * - 서킷 브레이커 OPEN 상태에서 Fallback 동작 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PurchasingFacade 서킷 브레이커 테스트") +class PurchasingFacadeCircuitBreakerTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + // 서킷 브레이커 상태 초기화 + if (circuitBreakerRegistry != null) { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(cb -> cb.reset()); + } + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("PG 연속 실패 시 서킷 브레이커가 CLOSED에서 OPEN으로 전환된다") + void createOrder_consecutiveFailures_circuitBreakerOpens() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 연속 실패 시뮬레이션 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.ServiceUnavailable( + "Service unavailable", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // CircuitBreaker를 리셋하여 초기 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.reset(); + } + } + + // act + // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고, + // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출됨 + int numberOfCalls = 5; + for (int i = 0; i < numberOfCalls; i++) { + purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + } + + // assert + // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) + // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 + // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 + verify(paymentGatewayClient, atMost(3)) + .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // 서킷 브레이커 상태 확인 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 연속 실패 후에는 OPEN 상태여야 함 + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + } + } + + @Test + @DisplayName("서킷 브레이커가 OPEN 상태일 때 Fallback이 동작한다") + void createOrder_circuitBreakerOpen_fallbackExecuted() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 OPEN 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // act + purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 서킷 브레이커가 OPEN 상태일 때는 PG API가 호출되지 않아야 함 (Fallback 동작) + verify(paymentGatewayClient, never()) + .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + } + + @Test + @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 성공 시 CLOSED로 전환된다") + void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 HALF_OPEN 상태로 만듦 + // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 먼저 OPEN 상태로 전환 + circuitBreaker.transitionToOpenState(); + // 그 다음 HALF_OPEN 상태로 전환 + circuitBreaker.transitionToHalfOpenState(); + } + } + + // PG 성공 응답 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + + // act + purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 서킷 브레이커 상태가 CLOSED로 전환되었는지 확인 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 성공 시 CLOSED로 전환될 수 있음 + assertThat(circuitBreaker.getState()).isIn( + CircuitBreaker.State.CLOSED, + CircuitBreaker.State.HALF_OPEN + ); + } + } + } + + @Test + @DisplayName("서킷 브레이커가 OPEN 상태일 때도 내부 시스템은 정상적으로 응답한다") + void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 OPEN 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // act + // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지) + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 내부 시스템은 정상적으로 응답해야 함 (예외가 발생하지 않아야 함) + assertThat(orderInfo).isNotNull(); + assertThat(orderInfo.orderId()).isNotNull(); + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 재고는 정상적으로 차감되어야 함 + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(9); + + // 포인트는 사용하지 않았으므로 차감되지 않음 + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + } + + @Test + @DisplayName("Fallback 응답의 CIRCUIT_BREAKER_OPEN 에러 코드가 올바르게 처리되어 주문이 PENDING 상태로 유지된다") + void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 OPEN 상태로 만들어 Fallback이 호출되도록 함 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // Fallback이 CIRCUIT_BREAKER_OPEN 에러 코드를 반환하도록 Mock 설정 + // (실제로는 PaymentGatewayClientFallback이 호출되지만, 테스트를 위해 명시적으로 설정) + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되어 주문이 PENDING 상태로 유지되어야 함 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + + // 비즈니스 실패 처리(주문 취소)가 호출되지 않았는지 확인 + // 주문이 CANCELED 상태가 아니어야 함 + assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + } + + @Test + @DisplayName("Retry 실패 후 Fallback이 호출되고 CIRCUIT_BREAKER_OPEN 응답이 올바르게 처리된다") + void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // CircuitBreaker를 OPEN 상태로 만들어 Fallback이 호출되도록 함 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // Fallback이 CIRCUIT_BREAKER_OPEN 에러 코드를 반환하도록 설정 + // 실제로는 PaymentGatewayClientFallback이 호출되지만, 테스트를 위해 Mock으로 시뮬레이션 + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // act + // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지) + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 1. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 2. 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + + // 3. CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되므로 주문 취소가 발생하지 않아야 함 + assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + + // 4. 재고는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING) + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(9); + + // 포인트는 사용하지 않았으므로 차감되지 않음 + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + } + + @Test + @DisplayName("Fallback 응답 처리 로직: CIRCUIT_BREAKER_OPEN 에러 코드는 외부 시스템 장애로 간주되어 주문이 PENDING 상태로 유지된다") + void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPending() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // CircuitBreaker를 OPEN 상태로 만들어 Fallback이 호출되도록 함 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // Fallback 응답 시뮬레이션: CIRCUIT_BREAKER_OPEN 에러 코드 반환 + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", // Fallback이 반환하는 에러 코드 + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 1. Fallback 응답의 CIRCUIT_BREAKER_OPEN 에러 코드가 올바르게 처리되어야 함 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 2. 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + + // 3. CIRCUIT_BREAKER_OPEN은 비즈니스 실패가 아니므로 주문 취소가 발생하지 않아야 함 + // PurchasingFacade의 isBusinessFailure() 메서드는 CIRCUIT_BREAKER_OPEN을 false로 반환해야 함 + assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + + // 4. 외부 시스템 장애로 인한 실패이므로 주문은 PENDING 상태로 유지되어 나중에 복구 가능해야 함 + // (상태 확인 API나 콜백을 통해 나중에 상태를 업데이트할 수 있어야 함) + } + +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java new file mode 100644 index 000000000..22d2bbb1d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java @@ -0,0 +1,313 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * PurchasingFacade 결제 콜백 및 상태 확인 테스트. + *

+ * PG 결제 콜백 처리 및 상태 확인 API를 통한 복구 로직을 검증합니다. + * - 콜백 수신 시 주문 상태 업데이트 + * - 콜백 미수신 시 상태 확인 API로 복구 + * - 타임아웃 후 상태 확인 API로 복구 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PurchasingFacade 결제 콜백 및 상태 확인 테스트") +class PurchasingFacadePaymentCallbackTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @MockitoBean + private PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("PG 결제 성공 콜백을 수신하면 주문 상태가 COMPLETED로 변경된다") + void handlePaymentCallback_successCallback_orderStatusUpdatedToCompleted() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청 성공 (트랜잭션 키 반환) + PaymentGatewayDto.ApiResponse paymentResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(paymentResponse); + + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + Long orderId = orderInfo.orderId(); + + // 콜백 검증을 위한 PG 조회 API Mock (SUCCESS 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // act + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + "TXN123456", + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ); + purchasingFacade.handlePaymentCallback(orderId, callbackRequest); + + // assert + Order savedOrder = orderRepository.findById(orderId).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); + } + + @Test + @DisplayName("PG 결제 실패 콜백을 수신하면 주문 상태가 CANCELED로 변경된다") + void handlePaymentCallback_failureCallback_orderStatusUpdatedToCanceled() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청 성공 (트랜잭션 키 반환) + PaymentGatewayDto.ApiResponse paymentResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(paymentResponse); + + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + Long orderId = orderInfo.orderId(); + + // 콜백 검증을 위한 PG 조회 API Mock (FAILED 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // act + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + "TXN123456", + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ); + purchasingFacade.handlePaymentCallback(orderId, callbackRequest); + + // assert + Order savedOrder = orderRepository.findById(orderId).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); + + // 재고와 포인트가 원복되었는지 확인 + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(10); + + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + } + + @Test + @DisplayName("타임아웃 후 상태 확인 API로 주문 상태를 복구할 수 있다") + void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청 타임아웃 + String transactionKey = "TXN123456"; + doThrow(new RuntimeException(new java.net.SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + Long orderId = orderInfo.orderId(); + + // 상태 확인 API 응답 (결제 성공) - 주문 ID로 조회 + PaymentGatewayDto.ApiResponse orderResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.valueOf(orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId)))) + .thenReturn(orderResponse); + + // act + purchasingFacade.recoverOrderStatusByPaymentCheck(user.getUserId(), orderId); + + // assert + // ✅ EDA 원칙: 결제 타임아웃으로 인해 주문이 취소된 경우, + // 이후 PG 상태 확인에서 SUCCESS가 반환되더라도 이미 취소된 주문은 복구할 수 없음 + // OrderEventHandler.handlePaymentCompleted에서 취소된 주문을 무시하도록 처리됨 + Order savedOrder = orderRepository.findById(orderId).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); + } + +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java new file mode 100644 index 000000000..366a0b91a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java @@ -0,0 +1,285 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +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.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; +import org.springframework.test.context.ActiveProfiles; + +import java.net.SocketTimeoutException; +import java.util.Collections; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * PurchasingFacade PG 연동 테스트. + *

+ * PG 결제 게이트웨이와의 연동에서 발생할 수 있는 다양한 시나리오를 검증합니다. + * - PG 연동 실패 시 주문 처리 + * - 타임아웃 발생 시 주문 상태 + * - 서킷 브레이커 동작 + * - 재시도 정책 동작 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PurchasingFacade PG 연동 테스트") +class PurchasingFacadePaymentGatewayTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient); // Mock 초기화 + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("PG 결제 요청이 타임아웃되어도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청이 타임아웃 발생 + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 재고는 차감되었는지 확인 + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(9); + + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음 + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 결제 요청이 실패해도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayFailure_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청이 실패 (외부 시스템 장애 - 주문은 PENDING 상태로 유지) + PaymentGatewayDto.ApiResponse failureResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "INTERNAL_SERVER_ERROR", // 외부 시스템 장애로 분류되어 주문이 PENDING 상태로 유지됨 + "서버 오류가 발생했습니다" + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(failureResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 서버가 500 에러를 반환해도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayServerError_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 서버가 500 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 연결이 실패해도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayConnectionFailure_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 연결 실패 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.ServiceUnavailable( + "Service unavailable", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 결제 요청이 타임아웃되어도 내부 시스템은 정상적으로 응답한다") + void createOrder_paymentGatewayTimeout_internalSystemRespondsNormally() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청이 타임아웃 발생 + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + null, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + // 내부 시스템은 정상적으로 응답해야 함 (예외가 발생하지 않아야 함) + assertThat(orderInfo).isNotNull(); + assertThat(orderInfo.orderId()).isNotNull(); + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java new file mode 100644 index 000000000..69eb22cb6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -0,0 +1,539 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +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.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@DisplayName("PurchasingFacade 주문 스펙 검증") +class PurchasingFacadeTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @BeforeEach + void setUp() { + // 기본적으로 모든 테스트에서 결제 성공 응답을 반환하도록 설정 + // 개별 테스트에서 필요시 재설정 가능 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + /** + * 쿠폰을 생성하고 저장합니다. + * + * @param code 쿠폰 코드 + * @param type 쿠폰 타입 + * @param discountValue 할인 값 + * @return 저장된 쿠폰 + */ + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + /** + * 사용자 쿠폰을 생성하고 저장합니다. + *

+ * 쿠폰은 이미 저장된 상태여야 합니다. + *

+ * + * @param userId 사용자 ID + * @param coupon 저장된 쿠폰 + * @return 저장된 사용자 쿠폰 + */ + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + + @Test + @DisplayName("주문 생성 시 재고 차감, 포인트 차감, 주문 완료, 외부 전송을 수행한다") + void createOrder_successFlow() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 10, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 5_000, 5, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product1.getId(), 2), + OrderItemCommand.of(product2.getId(), 1) + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); + + // assert + // ✅ EDA 원칙: createOrder는 주문을 PENDING 상태로 생성하고 OrderEvent.OrderCreated 이벤트를 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // ✅ 이벤트 핸들러가 재고 차감 처리 (통합 테스트이므로 실제 이벤트 핸들러가 실행됨) + Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2.getId()).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2 + assertThat(savedProduct2.getStock()).isEqualTo(4); // 5 - 1 + + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음 + } + + @Test + @DisplayName("주문 아이템이 비어 있으면 예외를 던진다") + void createOrder_emptyItems_throwsException() { + // arrange + String userId = "user"; + List emptyCommands = List.of(); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, null, "SAMSUNG", "4111-1111-1111-1111")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("사용자를 찾을 수 없으면 예외를 던진다") + void createOrder_userNotFound() { + // arrange + String unknownUserId = "unknown"; + List commands = List.of( + OrderItemCommand.of(1L, 1) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("상품 재고가 부족하면 예외를 던지고 포인트는 차감되지 않는다") + void createOrder_stockNotEnough() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 1, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 2) + ); + + // act & assert + // ✅ 재고 부족 사전 검증: PurchasingFacade에서 재고를 확인하여 예외 발생 + // ✅ 재고 차감은 ProductEventHandler에서 처리 + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); + } + + @Test + @DisplayName("상품 재고가 0이면 예외를 던지고 포인트는 차감되지 않는다") + void createOrder_stockZero() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 0, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 1) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); + } + + @Test + @DisplayName("유저의 포인트 잔액이 부족하면 주문은 생성되지만 포인트 사용 실패 이벤트가 발행된다") + void createOrder_pointNotEnough() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 5_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 1) + ); + + // act + // ✅ EDA 원칙: PurchasingFacade는 포인트 사전 검증을 하지 않음 + // ✅ 포인트 검증 및 차감은 PointEventHandler에서 처리 + // ✅ 포인트 부족 시 PointEventHandler에서 PointEvent.PointUsedFailed 이벤트 발행 + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111"); + + // assert + // 주문은 생성됨 (포인트 검증은 이벤트 핸들러에서 처리) + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + assertThat(orderInfo.orderId()).isNotNull(); + + // ✅ 재고는 차감됨 (ProductEventHandler가 동기적으로 처리) + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock - 1); + + // ✅ 포인트는 차감되지 않음 (포인트 부족으로 실패) + // 주의: 포인트 사용 실패 이벤트 발행 검증은 PointEventHandlerTest에서 수행 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); + } + + @Test + @DisplayName("동일 상품을 중복 주문하면 예외를 던진다") + void createOrder_duplicateProducts_throwsException() { + // arrange + User user = createAndSaveUser("testuser3", "test3@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드3"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + final Long productId = product.getId(); + + List commands = List.of( + OrderItemCommand.of(productId, 1), + OrderItemCommand.of(productId, 2) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 주문이 저장되지 않았는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).isEmpty(); + } + + @Test + @DisplayName("사용자의 주문 목록을 조회한다") + void getOrders_returnsUserOrders() { + // arrange + User user = createAndSaveUser("testuser4", "test4@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드4"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); + + // act + List orders = purchasingFacade.getOrders(user.getUserId()); + + // assert + assertThat(orders).hasSize(1); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(orders.get(0).status()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("사용자의 단일 주문을 조회한다") + void getOrder_returnsSingleOrder() { + // arrange + User user = createAndSaveUser("testuser5", "test5@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드5"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); + + // act + OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId()); + + // assert + assertThat(found.orderId()).isEqualTo(createdOrder.orderId()); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(found.status()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("다른 사용자의 주문은 조회할 수 없다") + void getOrder_withDifferentUser_throwsException() { + // arrange + User user1 = createAndSaveUser("user1", "user1@example.com", 50_000L); + User user2 = createAndSaveUser("user2", "user2@example.com", 50_000L); + final String user1Id = user1.getUserId(); + final String user2Id = user2.getUserId(); + + Brand brand = createAndSaveBrand("브랜드6"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, null, "SAMSUNG", "4111-1111-1111-1111"); + final Long orderId = user1Order.orderId(); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.getOrder(user2Id, orderId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("주문 전체 흐름에 대해 원자성이 보장되어야 한다 - 실패 시 모든 작업이 롤백된다") + void createOrder_atomicityGuaranteed() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + final String userId = user.getUserId(); + final long initialPoint = user.getPoint().getValue(); + + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 5, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 3, brand.getId()); + final Long product1Id = product1.getId(); + final Long product2Id = product2.getId(); + final int initialStock1 = product1.getStock(); + final int initialStock2 = product2.getStock(); + + // product2의 재고가 부족한 상황 (3개 재고인데 5개 주문) + List commands = List.of( + OrderItemCommand.of(product1Id, 2), + OrderItemCommand.of(product2Id, 5) // 재고 부족 + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); + + // 롤백 확인: 모든 상품의 재고가 변경되지 않았는지 확인 + Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(initialStock1); + assertThat(savedProduct2.getStock()).isEqualTo(initialStock2); + + // 롤백 확인: 주문이 저장되지 않았는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).isEmpty(); + } + + @Test + @DisplayName("주문 성공 시, 모든 처리는 정상 반영되어야 한다 - 재고, 포인트, 주문 모두 반영") + void createOrder_success_allOperationsReflected() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + final String userId = user.getUserId(); + final long initialPoint = user.getPoint().getValue(); + + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 10, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 15_000, 5, brand.getId()); + final Long product1Id = product1.getId(); + final Long product2Id = product2.getId(); + final int initialStock1 = product1.getStock(); + final int initialStock2 = product2.getStock(); + + List commands = List.of( + OrderItemCommand.of(product1Id, 3), + OrderItemCommand.of(product2Id, 2) + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); + + // assert + // ✅ EDA 원칙: createOrder는 주문을 PENDING 상태로 생성하고 OrderEvent.OrderCreated 이벤트를 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + assertThat(orderInfo.items()).hasSize(2); + + // ✅ 이벤트 핸들러가 재고 차감 처리 (통합 테스트이므로 실제 이벤트 핸들러가 실행됨) + Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3); + assertThat(savedProduct2.getStock()).isEqualTo(initialStock2 - 2); + + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); // 포인트 차감 없음 + + // 주문이 저장되었는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).hasSize(1); + assertThat(orders.get(0).orderId()).isEqualTo(orderInfo.orderId()); + } + + @Test + @DisplayName("정액 쿠폰을 적용하여 주문할 수 있다") + void createOrder_withFixedAmountCoupon_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("FIXED5000", CouponType.FIXED_AMOUNT, 5_000); + createAndSaveUserCoupon(user.getId(), coupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "FIXED5000") + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); + + // assert + // ✅ EDA 원칙: PurchasingFacade는 주문을 생성하고 이벤트를 발행하는 책임만 가짐 + // ✅ 쿠폰 할인 적용은 CouponEventHandler와 OrderEventHandler의 책임 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + assertThat(orderInfo.orderId()).isNotNull(); + // 주의: 쿠폰 할인 적용 및 쿠폰 사용 여부 검증은 CouponEventHandler/OrderEventHandler 테스트에서 수행 + } + + @Test + @DisplayName("정률 쿠폰을 적용하여 주문할 수 있다") + void createOrder_withPercentageCoupon_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("PERCENT20", CouponType.PERCENTAGE, 20); + createAndSaveUserCoupon(user.getId(), coupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "PERCENT20") + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); + + // assert + // ✅ EDA 원칙: PurchasingFacade는 주문을 생성하고 이벤트를 발행하는 책임만 가짐 + // ✅ 쿠폰 할인 적용은 CouponEventHandler와 OrderEventHandler의 책임 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + assertThat(orderInfo.orderId()).isNotNull(); + // 주의: 쿠폰 할인 적용 및 쿠폰 사용 여부 검증은 CouponEventHandler/OrderEventHandler 테스트에서 수행 + } + + // 주의: 쿠폰 검증 테스트는 CouponEventHandler 테스트로 이동해야 함 + // 쿠폰 검증(존재 여부, 소유 여부, 사용 가능 여부)은 CouponEventHandler에서 비동기로 처리되므로, + // PurchasingFacade에서는 검증할 수 없음 (이벤트 핸들러의 책임) + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java new file mode 100644 index 000000000..5bd82f939 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java @@ -0,0 +1,607 @@ +package com.loopers.application.ranking; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.zset.RedisZSetTemplate; +import com.loopers.zset.ZSetEntry; +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.lang.reflect.Field; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * RankingService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RedisZSetTemplate zSetTemplate; + + @Mock + private RankingKeyGenerator keyGenerator; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private RankingSnapshotService rankingSnapshotService; + + @InjectMocks + private RankingService rankingService; + + /** + * Product에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Product product, Long id) { + try { + Field idField = product.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product ID", e); + } + } + + /** + * Brand에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Brand brand, Long id) { + try { + Field idField = brand.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brand, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Brand ID", e); + } + } + + @DisplayName("랭킹을 조회할 수 있다.") + @Test + void canGetRankings() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 2L; + Long brandId1 = 10L; + Long brandId2 = 20L; + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.5), + new ZSetEntry(String.valueOf(productId2), 90.3) + ); + + Product product1 = Product.of("상품1", 10000, 10, brandId1); + Product product2 = Product.of("상품2", 20000, 5, brandId2); + Brand brand1 = Brand.of("브랜드1"); + Brand brand2 = Brand.of("브랜드2"); + + // ID 설정 + setId(product1, productId1); + setId(product2, productId2); + setId(brand1, brandId1); + setId(brand2, brandId2); + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(50L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + when(brandService.getBrands(List.of(brandId1, brandId2))) + .thenReturn(List.of(brand1, brand2)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(2); + assertThat(result.page()).isEqualTo(page); + assertThat(result.size()).isEqualTo(size); + assertThat(result.hasNext()).isTrue(); + + RankingService.RankingItem item1 = result.items().get(0); + assertThat(item1.rank()).isEqualTo(1L); + assertThat(item1.score()).isEqualTo(100.5); + assertThat(item1.productDetail().getId()).isEqualTo(productId1); + assertThat(item1.productDetail().getName()).isEqualTo("상품1"); + + RankingService.RankingItem item2 = result.items().get(1); + assertThat(item2.rank()).isEqualTo(2L); + assertThat(item2.score()).isEqualTo(90.3); + assertThat(item2.productDetail().getId()).isEqualTo(productId2); + assertThat(item2.productDetail().getName()).isEqualTo("상품2"); + } + + @DisplayName("빈 랭킹을 조회할 수 있다.") + @Test + void canGetEmptyRankings() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(List.of()); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).isEmpty(); + assertThat(result.page()).isEqualTo(page); + assertThat(result.size()).isEqualTo(size); + assertThat(result.hasNext()).isFalse(); + verify(zSetTemplate, never()).getSize(anyString()); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void canGetRankingsWithPaging() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 2; + int size = 10; + String key = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + List entries = List.of( + new ZSetEntry(String.valueOf(productId), 100.0) + ); + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 20L, 29L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(31L); // 31 > 20 + 10이므로 다음 페이지 있음 + when(productService.getProducts(List.of(productId))).thenReturn(List.of(product)); + when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.page()).isEqualTo(page); + assertThat(result.size()).isEqualTo(size); + assertThat(result.hasNext()).isTrue(); // 31 > 20 + 10 + + RankingService.RankingItem item = result.items().get(0); + assertThat(item.rank()).isEqualTo(21L); // start(20) + i(0) + 1 + } + + @DisplayName("랭킹에 포함된 상품이 DB에 없으면 스킵한다.") + @Test + void skipsProduct_whenProductNotFound() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 999L; // 존재하지 않는 상품 + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.0), + new ZSetEntry(String.valueOf(productId2), 90.0) + ); + + Product product1 = Product.of("상품1", 10000, 10, 10L); + Brand brand1 = Brand.of("브랜드1"); + + // ID 설정 + setId(product1, productId1); + setId(brand1, 10L); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(2L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1)); // productId2는 없음 + when(brandService.getBrands(List.of(10L))).thenReturn(List.of(brand1)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); // productId2는 스킵됨 + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1); + } + + @DisplayName("상품의 브랜드가 없으면 스킵한다.") + @Test + void skipsProduct_whenBrandNotFound() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 2L; + Long brandId1 = 10L; + Long brandId2 = 999L; // 존재하지 않는 브랜드 + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.0), + new ZSetEntry(String.valueOf(productId2), 90.0) + ); + + Product product1 = Product.of("상품1", 10000, 10, brandId1); + Product product2 = Product.of("상품2", 20000, 5, brandId2); + Brand brand1 = Brand.of("브랜드1"); + + // ID 설정 + setId(product1, productId1); + setId(product2, productId2); + setId(brand1, brandId1); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(2L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + when(brandService.getBrands(List.of(brandId1, brandId2))) + .thenReturn(List.of(brand1)); // brandId2는 없음 + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); // productId2는 브랜드가 없어서 스킵됨 + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1); + } + + @DisplayName("다음 페이지가 없을 때 hasNext가 false이다.") + @Test + void hasNextIsFalse_whenNoMorePages() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + List entries = List.of( + new ZSetEntry(String.valueOf(productId), 100.0) + ); + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(1L); // 전체 크기가 1이므로 다음 페이지 없음 + when(productService.getProducts(List.of(productId))).thenReturn(List.of(product)); + when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.hasNext()).isFalse(); // 1 <= 0 + 20 + } + + @DisplayName("특정 상품의 순위를 조회할 수 있다.") + @Test + void canGetProductRank() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String key = "ranking:all:20241215"; + Long rank = 5L; // 0-based + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(rank); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isEqualTo(6L); // 1-based (5 + 1) + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).getRank(key, String.valueOf(productId)); + } + + @DisplayName("랭킹에 없는 상품의 순위는 null이다.") + @Test + void returnsNull_whenProductNotInRanking() { + // arrange + Long productId = 999L; + LocalDate date = LocalDate.of(2024, 12, 15); + String key = "ranking:all:20241215"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(null); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isNull(); + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).getRank(key, String.valueOf(productId)); + } + + @DisplayName("같은 브랜드의 여러 상품이 랭킹에 포함될 수 있다.") + @Test + void canHandleMultipleProductsFromSameBrand() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 2L; + Long brandId = 10L; // 같은 브랜드 + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.0), + new ZSetEntry(String.valueOf(productId2), 90.0) + ); + + Product product1 = Product.of("상품1", 10000, 10, brandId); + Product product2 = Product.of("상품2", 20000, 5, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product1, productId1); + setId(product2, productId2); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(2L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + when(brandService.getBrands(List.of(brandId))) // 중복 제거되어 한 번만 조회 + .thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).productDetail().getBrandId()).isEqualTo(brandId); + assertThat(result.items().get(1).productDetail().getBrandId()).isEqualTo(brandId); + // 브랜드는 한 번만 조회됨 (중복 제거) + verify(brandService).getBrands(List.of(brandId)); + } + + @DisplayName("Redis 장애 시 스냅샷으로 Fallback한다.") + @Test + void fallbackToSnapshot_whenRedisFails() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String todayKey = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + RankingService.RankingItem rankingItem = new RankingService.RankingItem( + 1L, 100.0, + ProductDetail.from(product, brand.getName(), product.getLikeCount()) + ); + RankingService.RankingsResponse snapshot = new RankingService.RankingsResponse( + List.of(rankingItem), page, size, false + ); + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getTopRankings(todayKey, 0L, 19L)) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 스냅샷 조회 성공 + when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.of(snapshot)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId); + verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L); + verify(rankingSnapshotService).getSnapshot(date); + verify(rankingSnapshotService, never()).getSnapshot(date.minusDays(1)); + } + + @DisplayName("Redis 장애 시 스냅샷이 없으면 전날 스냅샷으로 Fallback한다.") + @Test + void fallbackToYesterdaySnapshot_whenSnapshotNotAvailable() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + int page = 0; + int size = 20; + String todayKey = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + RankingService.RankingItem rankingItem = new RankingService.RankingItem( + 1L, 100.0, + ProductDetail.from(product, brand.getName(), product.getLikeCount()) + ); + RankingService.RankingsResponse yesterdaySnapshot = new RankingService.RankingsResponse( + List.of(rankingItem), page, size, false + ); + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getTopRankings(todayKey, 0L, 19L)) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 오늘 스냅샷 없음, 전날 스냅샷 있음 + when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty()); + when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.of(yesterdaySnapshot)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId); + verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L); + verify(rankingSnapshotService).getSnapshot(date); + verify(rankingSnapshotService).getSnapshot(yesterday); + } + + @DisplayName("Redis 장애 시 스냅샷도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.") + @Test + void fallbackToDefaultRanking_whenSnapshotNotAvailable() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + int page = 0; + int size = 20; + String todayKey = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getTopRankings(todayKey, 0L, 19L)) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 스냅샷도 없음 + when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty()); + when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.empty()); + + // 기본 랭킹(좋아요순) 조회 + when(productService.findAll(null, "likes_desc", page, size)).thenReturn(List.of(product)); + when(productService.countAll(null)).thenReturn(1L); + when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId); + assertThat(result.items().get(0).score()).isEqualTo(product.getLikeCount().doubleValue()); + verify(rankingSnapshotService).getSnapshot(date); + verify(rankingSnapshotService).getSnapshot(yesterday); + verify(productService).findAll(null, "likes_desc", page, size); + } + + @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹으로 Fallback한다.") + @Test + void fallbackToYesterdayRanking_whenGetProductRankFails() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + String todayKey = "ranking:all:20241215"; + String yesterdayKey = "ranking:all:20241214"; + Long rank = 5L; // 0-based + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getRank(todayKey, String.valueOf(productId))) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 전날 랭킹 조회 성공 + when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId))).thenReturn(rank); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isEqualTo(6L); // 1-based (5 + 1) + verify(zSetTemplate).getRank(todayKey, String.valueOf(productId)); + verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId)); + } + + @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹이 없으면 null을 반환한다.") + @Test + void returnsNull_whenRedisAndYesterdayRankingFail() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + String todayKey = "ranking:all:20241215"; + String yesterdayKey = "ranking:all:20241214"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getRank(todayKey, String.valueOf(productId))) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 전날 랭킹 조회도 예외 발생 + when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId))) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isNull(); + verify(zSetTemplate).getRank(todayKey, String.valueOf(productId)); + verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java new file mode 100644 index 000000000..409e767c3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java @@ -0,0 +1,175 @@ +package com.loopers.application.user; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +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.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("PointEventHandler 포인트 사용 검증") +@RecordApplicationEvents +class PointEventHandlerTest { + + @Autowired + private PointEventHandler pointEventHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private ApplicationEvents applicationEvents; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + @Test + @DisplayName("포인트를 정상적으로 사용할 수 있다") + void handleOrderCreated_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 10_000L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + pointEventHandler.handleOrderCreated(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 차감되었는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(40_000L); // 50,000 - 10,000 + } + + @Test + @DisplayName("포인트 잔액이 부족하면 포인트 사용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenInsufficientBalance() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 5_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 10_000L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + try { + pointEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 포인트 사용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).hasSize(1); + PointEvent.PointUsedFailed failedEvent = applicationEvents.stream(PointEvent.PointUsedFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.usedPointAmount()).isEqualTo(10_000L); + assertThat(failedEvent.failureReason()).contains("포인트가 부족합니다"); + + // 포인트가 차감되지 않았는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(5_000L); // 변경 없음 + } + + @Test + @DisplayName("포인트 잔액이 정확히 사용 요청 금액과 같으면 정상적으로 사용할 수 있다") + void handleOrderCreated_success_whenBalanceEqualsUsedAmount() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 10_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 10_000L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + pointEventHandler.handleOrderCreated(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 차감되었는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(0L); // 10,000 - 10,000 + } + + @Test + @DisplayName("포인트 사용량이 0이면 정상적으로 처리된다") + void handleOrderCreated_success_whenUsedAmountIsZero() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 0L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + pointEventHandler.handleOrderCreated(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 변경되지 않았는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(50_000L); // 변경 없음 + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..db7a1de10 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -0,0 +1,173 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +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.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@DisplayName("UserService 통합 테스트") +class UserServiceIntegrationTest { + @Autowired + private UserService userService; + + @MockitoSpyBean + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /** + * 테스트용 사용자를 생성합니다. + */ + private void createUser(String userId, String email, String birthDate, Gender gender) { + userService.create(userId, email, birthDate, gender, Point.of(0L)); + } + + @DisplayName("회원 가입에 관한 통합 테스트") + @Nested + class SignUp { + @DisplayName("회원가입시 User 저장이 수행된다.") + @ParameterizedTest + @EnumSource(Gender.class) + void createsUser_whenValidIdIsProvided(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + Mockito.reset(userJpaRepository); + + // act + User user = userService.create(userId, email, birthDate, gender, Point.of(0L)); + + // assert + assertAll( + () -> assertThat(user).isNotNull(), + () -> assertThat(user.getUserId()).isEqualTo(userId), + () -> verify(userJpaRepository, times(1)).save(any(User.class)) + ); + } + + @DisplayName("이미 가입된 ID로 회원가입 시도 시, 실패한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void fails_whenDuplicateUserIdExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, gender, Point.of(0L)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.create(userId, email, birthDate, gender, Point.of(0L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("포인트 조회에 관한 통합 테스트") + @Nested + class PointInfo { + @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsPoints_whenUserExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + createUser(userId, email, birthDate, gender); + + // act + UserService.PointsInfo pointsInfo = userService.getPoints(userId); + + // assert + assertAll( + () -> assertThat(pointsInfo).isNotNull(), + () -> assertThat(pointsInfo.userId()).isEqualTo(userId), + () -> assertThat(pointsInfo.balance()).isEqualTo(0L) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.") + @Test + void throwsException_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + + // act & assert + assertThatThrownBy(() -> userService.getPoints(userId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + @DisplayName("포인트 충전에 관한 통합 테스트") + @Nested + class PointCharge { + @DisplayName("포인트를 충전할 수 있다.") + @ParameterizedTest + @EnumSource(Gender.class) + void chargesPoints_success(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + createUser(userId, email, birthDate, gender); + Long chargeAmount = 10_000L; + + // act + UserService.PointsInfo pointsInfo = userService.chargePoint(userId, chargeAmount); + + // assert + assertAll( + () -> assertThat(pointsInfo).isNotNull(), + () -> assertThat(pointsInfo.userId()).isEqualTo(userId), + () -> assertThat(pointsInfo.balance()).isEqualTo(chargeAmount) + ); + } + + @DisplayName("사용자가 존재하지 않으면 포인트 충전 시 예외가 발생한다.") + @Test + void throwsException_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + Long chargeAmount = 10_000L; + + // act & assert + assertThatThrownBy(() -> userService.chargePoint(userId, chargeAmount)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java new file mode 100644 index 000000000..4f1230c27 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java @@ -0,0 +1,198 @@ +package com.loopers.domain.coupon; + +import com.loopers.application.coupon.ApplyCouponCommand; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.discount.CouponDiscountStrategy; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +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.orm.ObjectOptimisticLockingFailureException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +/** + * CouponService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CouponService") +public class CouponServiceTest { + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @Mock + private CouponDiscountStrategyFactory couponDiscountStrategyFactory; + + @Mock + private CouponDiscountStrategy couponDiscountStrategy; + + @InjectMocks + private CouponService couponService; + + @DisplayName("쿠폰 적용") + @Nested + class ApplyCoupon { + @DisplayName("쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리할 수 있다.") + @Test + void appliesCouponAndCalculatesDiscount() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + Integer expectedDiscount = 5_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + when(couponDiscountStrategyFactory.getStrategy(CouponType.FIXED_AMOUNT)) + .thenReturn(couponDiscountStrategy); + when(couponDiscountStrategy.calculateDiscountAmount(subtotal, 5_000)) + .thenReturn(expectedDiscount); + when(userCouponRepository.save(any(UserCoupon.class))).thenReturn(userCoupon); + + // act + Integer result = couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + + // assert + assertThat(result).isEqualTo(expectedDiscount); + assertThat(userCoupon.getIsUsed()).isTrue(); // 쿠폰이 사용되었는지 확인 + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(userCouponRepository, times(1)).save(userCoupon); + } + + @DisplayName("쿠폰을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenCouponNotFound() { + // arrange + Long userId = 1L; + String couponCode = "NON_EXISTENT"; + Integer subtotal = 10_000; + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("쿠폰을 찾을 수 없습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, never()).findByUserIdAndCouponCodeForUpdate(any(), any()); + } + + @DisplayName("사용자가 소유한 쿠폰을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserCouponNotFound() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + } + + @DisplayName("이미 사용된 쿠폰이면 예외가 발생한다.") + @Test + void throwsException_whenCouponAlreadyUsed() { + // arrange + Long userId = 1L; + String couponCode = "USED_COUPON"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + userCoupon.use(); // 이미 사용 처리 + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("이미 사용된 쿠폰입니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(userCouponRepository, never()).save(any(UserCoupon.class)); + } + + @DisplayName("낙관적 락 충돌 시 예외가 발생한다.") + @Test + void throwsException_whenOptimisticLockConflict() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + // Coupon.calculateDiscountAmount()가 호출될 때 getStrategy()가 호출되므로 stubbing 필요 + when(couponDiscountStrategyFactory.getStrategy(any(CouponType.class))) + .thenReturn(couponDiscountStrategy); + when(couponDiscountStrategy.calculateDiscountAmount(anyInt(), anyInt())) + .thenReturn(5_000); + when(userCouponRepository.save(any(UserCoupon.class))) + .thenThrow(new ObjectOptimisticLockingFailureException(UserCoupon.class, userCoupon)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + assertThat(result.getMessage()).contains("쿠폰이 이미 사용되었습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(couponDiscountStrategyFactory, times(1)).getStrategy(CouponType.FIXED_AMOUNT); + verify(couponDiscountStrategy, times(1)).calculateDiscountAmount(subtotal, 5_000); + verify(userCouponRepository, times(1)).save(userCoupon); + } + } +} + 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..beed8dc1d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,473 @@ +package com.loopers.domain.order; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * OrderService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("OrderService") +public class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderEventPublisher orderEventPublisher; + + @InjectMocks + private OrderService orderService; + + @DisplayName("주문 저장") + @Nested + class SaveOrder { + @DisplayName("주문을 저장할 수 있다.") + @Test + void savesOrder() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.save(order); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(order); + } + } + + @DisplayName("주문 조회") + @Nested + class FindOrder { + @DisplayName("주문 ID로 주문을 조회할 수 있다.") + @Test + void findsById() { + // arrange + Long orderId = 1L; + Order expectedOrder = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder)); + + // act + Order result = orderService.getById(orderId); + + // assert + assertThat(result).isEqualTo(expectedOrder); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("주문 ID로 주문을 조회할 수 있다 (Optional 반환).") + @Test + void findsByIdOptional() { + // arrange + Long orderId = 1L; + Order expectedOrder = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder)); + + // act + Optional result = orderService.getOrder(orderId); + + // assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedOrder); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenOrderNotFound() { + // arrange + Long orderId = 999L; + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.getById(orderId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("사용자 ID로 주문 목록을 조회할 수 있다.") + @Test + void findsAllByUserId() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List expectedOrders = List.of( + Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()) + ); + when(orderRepository.findAllByUserId(userId)).thenReturn(expectedOrders); + + // act + List result = orderService.getOrdersByUserId(userId); + + // assert + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expectedOrders); + verify(orderRepository, times(1)).findAllByUserId(userId); + } + + @DisplayName("주문 상태로 주문 목록을 조회할 수 있다.") + @Test + void findsAllByStatus() { + // arrange + OrderStatus status = OrderStatus.PENDING; + List expectedOrders = List.of( + Order.of(OrderTestFixture.ValidOrder.USER_ID, OrderTestFixture.ValidOrderItem.createMultipleItems()) + ); + when(orderRepository.findAllByStatus(status)).thenReturn(expectedOrders); + + // act + List result = orderService.getOrdersByStatus(status); + + // assert + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expectedOrders); + verify(orderRepository, times(1)).findAllByStatus(status); + } + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + @DisplayName("주문을 생성할 수 있다 (쿠폰 없음).") + @Test + void createsOrder() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + Order expectedOrder = Order.of(userId, items); + when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); + + // act + Order result = orderService.create(userId, items); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(any(Order.class)); + } + + @DisplayName("주문을 생성할 수 있다 (쿠폰 포함).") + @Test + void createsOrderWithCoupon() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + String couponCode = "COUPON123"; + Integer discountAmount = 1000; + Order expectedOrder = Order.of(userId, items, couponCode, discountAmount); + when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); + + // act + Order result = orderService.create(userId, items, couponCode, discountAmount); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(any(Order.class)); + } + } + + @DisplayName("주문 완료") + @Nested + class CompleteOrder { + @DisplayName("주문을 완료 상태로 변경할 수 있다.") + @Test + void completesOrder() { + // arrange + Long orderId = 1L; + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.completeOrder(orderId); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).findById(orderId); + verify(orderRepository, times(1)).save(order); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenOrderNotFound() { + // arrange + Long orderId = 999L; + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.completeOrder(orderId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(orderRepository, times(1)).findById(orderId); + verify(orderRepository, never()).save(any(Order.class)); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + @DisplayName("주문을 취소하고 재고를 원복하며 포인트를 환불할 수 있다.") + @Test + void cancelsOrderAndRecoversResources() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + User user = createUser(userId); + Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()); + + List items = order.getItems(); + Product product1 = createProduct(items.get(0).getProductId()); + Product product2 = createProduct(items.get(1).getProductId()); + List products = List.of(product1, product2); + Long refundPointAmount = 5000L; + + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.cancelOrder(order, products, user, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(product1, times(1)).increaseStock(items.get(0).getQuantity()); + verify(product2, times(1)).increaseStock(items.get(1).getQuantity()); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("주문이 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIsNull() { + // arrange + User user = createUser(OrderTestFixture.ValidOrder.USER_ID); + List products = List.of(); + Long refundPointAmount = 0L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.cancelOrder(null, products, user, refundPointAmount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("사용자가 null이면 예외가 발생한다.") + @Test + void throwsException_whenUserIsNull() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + List products = List.of(); + Long refundPointAmount = 0L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.cancelOrder(order, products, null, refundPointAmount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("포인트를 사용하지 않은 주문은 포인트 환불 없이 취소할 수 있다.") + @Test + void cancelsOrderWithoutPointRefund() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + User user = createUser(userId); + Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()); + + List items = order.getItems(); + Product product1 = createProduct(items.get(0).getProductId()); + Product product2 = createProduct(items.get(1).getProductId()); + List products = List.of(product1, product2); + Long refundPointAmount = 0L; + + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.cancelOrder(order, products, user, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(product1, times(1)).increaseStock(items.get(0).getQuantity()); + verify(product2, times(1)).increaseStock(items.get(1).getQuantity()); + } + } + + @DisplayName("결제 결과에 따른 주문 상태 업데이트") + @Nested + class UpdateStatusByPaymentResult { + @DisplayName("결제 성공 시 주문을 완료 상태로 변경할 수 있다.") + @Test + void completesOrder_whenPaymentSuccess() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS, null, null); + + // assert + verify(orderRepository, times(1)).save(order); + verify(orderEventPublisher, times(1)).publish(any(OrderEvent.OrderCompleted.class)); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제 실패 시 주문을 취소 상태로 변경할 수 있다.") + @Test + void cancelsOrder_whenPaymentFailed() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + String reason = "결제 실패"; + Long refundPointAmount = 0L; + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED, reason, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(orderEventPublisher, times(1)).publish(any(OrderEvent.OrderCanceled.class)); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제 대기 상태면 주문 상태를 유지한다.") + @Test + void maintainsOrderStatus_whenPaymentPending() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.PENDING, null, null); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCompleted.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCanceled.class)); + } + + @DisplayName("이미 완료된 주문은 처리하지 않는다.") + @Test + void skipsProcessing_whenOrderAlreadyCompleted() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + order.complete(); // 이미 완료 상태 + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS, null, null); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCompleted.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCanceled.class)); + } + + @DisplayName("이미 취소된 주문은 처리하지 않는다.") + @Test + void skipsProcessing_whenOrderAlreadyCanceled() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + order.cancel(); // 이미 취소 상태 + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED, "결제 실패", 0L); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCompleted.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCanceled.class)); + } + + @DisplayName("주문이 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.updateStatusByPaymentResult(null, PaymentStatus.SUCCESS, null, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + } + + private User createUser(Long userId) { + return User.of( + String.valueOf(userId), + "test@example.com", + "1990-01-01", + Gender.MALE, + Point.of(0L) + ); + } + + private Product createProduct(Long productId) { + // Mock을 사용하여 ID 설정 + Product mockedProduct = mock(Product.class); + when(mockedProduct.getId()).thenReturn(productId); + doNothing().when(mockedProduct).increaseStock(any(Integer.class)); + return mockedProduct; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..b3e8db17b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class OrderTest { + + @DisplayName("정상 주문 / 예외 주문 흐름을 모두 검증한다.") + @Nested + class OrderFlow { + @DisplayName("정상 주문 흐름: 주문이 정상적으로 생성되고 총액이 올바르게 계산된다.") + @Test + void normalOrderFlow() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + // 상품 1: 10000 * 1 = 10000 + // 상품 2: 5000 * 2 = 10000 + // 총액: 20000 + + // act + Order order = Order.of(userId, items); + + // assert + assertThat(order.getUserId()).isEqualTo(userId); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getTotalAmount()).isEqualTo(20000); + assertThat(order.getItems()).hasSize(2); + assertThat(order.getItems()).containsExactlyElementsOf(items); + } + + @DisplayName("예외 주문 흐름: 주문 아이템이 null이면 예외가 발생한다.") + @Test + void exceptionOrderFlow_nullItems() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.of(userId, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("주문 아이템은 필수이며 최소 1개 이상이어야 합니다"); + } + + @DisplayName("예외 주문 흐름: 주문 아이템이 비어있으면 예외가 발생한다.") + @Test + void exceptionOrderFlow_emptyItems() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List emptyItems = List.of(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.of(userId, emptyItems); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("주문 아이템은 필수이며 최소 1개 이상이어야 합니다"); + } + + @DisplayName("예외 주문 흐름: 사용자 ID가 null이면 예외가 발생한다.") + @Test + void exceptionOrderFlow_nullUserId() { + // arrange + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.of(null, items); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("사용자 ID는 필수입니다"); + } + } + +} + + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java new file mode 100644 index 000000000..2f11dedf1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java @@ -0,0 +1,39 @@ +package com.loopers.domain.order; + +import java.util.List; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Order 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class OrderTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidOrder { + public static final Long USER_ID = 1L; + public static final Integer TOTAL_AMOUNT = 20000; + } + + // 기본 유효한 주문 아이템 데이터 + public static final class ValidOrderItem { + public static final Long PRODUCT_ID_1 = 1L; + public static final String NAME_1 = "테스트 상품 1"; + public static final Integer PRICE_1 = 10000; + public static final Integer QUANTITY_1 = 1; + + public static final Long PRODUCT_ID_2 = 2L; + public static final String NAME_2 = "테스트 상품 2"; + public static final Integer PRICE_2 = 5000; + public static final Integer QUANTITY_2 = 2; + + public static List createMultipleItems() { + return List.of( + OrderItem.of(PRODUCT_ID_1, NAME_1, PRICE_1, QUANTITY_1), + OrderItem.of(PRODUCT_ID_2, NAME_2, PRICE_2, QUANTITY_2) + ); + } + } + +} + + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java new file mode 100644 index 000000000..190eae400 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java @@ -0,0 +1,124 @@ +package com.loopers.domain.outbox; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * OutboxEvent 도메인 테스트. + */ +class OutboxEventTest { + + @DisplayName("OutboxEvent는 필수 필드로 생성되며 초기 상태가 PENDING이다.") + @Test + void createsOutboxEventWithPendingStatus() { + // arrange + String eventId = "event-123"; + String eventType = "OrderCreated"; + String aggregateId = "1"; + String aggregateType = "Order"; + String payload = "{\"orderId\":1}"; + String topic = "order-events"; + String partitionKey = "1"; + + // act + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .build(); + + // assert + assertThat(outboxEvent.getEventId()).isEqualTo(eventId); + assertThat(outboxEvent.getEventType()).isEqualTo(eventType); + assertThat(outboxEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(outboxEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(outboxEvent.getPayload()).isEqualTo(payload); + assertThat(outboxEvent.getTopic()).isEqualTo(topic); + assertThat(outboxEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(outboxEvent.getCreatedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("이벤트를 발행 완료 상태로 변경할 수 있다.") + @Test + void canMarkAsPublished() throws InterruptedException { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + LocalDateTime beforePublish = outboxEvent.getCreatedAt(); + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + + // act + outboxEvent.markAsPublished(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(outboxEvent.getPublishedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isAfter(beforePublish); + } + + @DisplayName("이벤트를 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailed() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("발행 완료 후 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailedAfterPublished() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + outboxEvent.markAsPublished(); + LocalDateTime publishedAt = outboxEvent.getPublishedAt(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + // markAsFailed는 publishedAt을 변경하지 않음 + assertThat(outboxEvent.getPublishedAt()).isEqualTo(publishedAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java new file mode 100644 index 000000000..4ab528833 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java @@ -0,0 +1,662 @@ +package com.loopers.domain.payment; + +import com.loopers.application.payment.PaymentService; +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * PaymentService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentService") +public class PaymentServiceTest { + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private PaymentGateway paymentGateway; + + @Mock + private PaymentEventPublisher paymentEventPublisher; + + @InjectMocks + private PaymentService paymentService; + + @BeforeEach + void setUp() { + // @Value 어노테이션 필드 설정 + ReflectionTestUtils.setField(paymentService, "callbackBaseUrl", "http://localhost:8080"); + } + + @DisplayName("결제 생성") + @Nested + class CreatePayment { + @DisplayName("카드 결제를 생성할 수 있다.") + @Test + void createsCardPayment() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + CardType cardType = PaymentTestFixture.ValidPayment.CARD_TYPE; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT; + + Payment expectedPayment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt); + when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment); + + // act + Payment result = paymentService.create(orderId, userId, cardType, cardNo, amount, requestedAt); + + // assert + assertThat(result).isNotNull(); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("포인트 결제를 생성할 수 있다.") + @Test + void createsPointPayment() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = PaymentTestFixture.ValidPayment.FULL_POINT; // 포인트로 전액 결제 + LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT; + + Payment expectedPayment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt); + when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment); + + // act + Payment result = paymentService.create(orderId, userId, totalAmount, usedPoint, requestedAt); + + // assert + assertThat(result).isNotNull(); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + } + + @DisplayName("결제 상태 변경") + @Nested + class UpdatePaymentStatus { + @DisplayName("결제를 SUCCESS 상태로 전이할 수 있다.") + @Test + void transitionsToSuccess() { + // arrange + Long paymentId = 1L; + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.toSuccess(paymentId, completedAt, null); + + // assert + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, times(1)).save(payment); + // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제를 FAILED 상태로 전이할 수 있다.") + @Test + void transitionsToFailed() { + // arrange + Long paymentId = 1L; + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + String failureReason = "카드 한도 초과"; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.toFailed(paymentId, failureReason, completedAt, null); + + // assert + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, times(1)).save(payment); + // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenPaymentNotFound() { + // arrange + Long paymentId = 999L; + LocalDateTime completedAt = LocalDateTime.now(); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.toSuccess(paymentId, completedAt, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } + + @DisplayName("결제 조회") + @Nested + class FindPayment { + @DisplayName("결제 ID로 결제를 조회할 수 있다.") + @Test + void findsById() { + // arrange + Long paymentId = 1L; + Payment expectedPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(expectedPayment)); + + // act + Payment result = paymentService.getPayment(paymentId); + + // assert + assertThat(result).isEqualTo(expectedPayment); + verify(paymentRepository, times(1)).findById(paymentId); + } + + @DisplayName("주문 ID로 결제를 조회할 수 있다.") + @Test + void findsByOrderId() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Payment expectedPayment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(expectedPayment)); + + // act + Optional result = paymentService.getPaymentByOrderId(orderId); + + // assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedPayment); + verify(paymentRepository, times(1)).findByOrderId(orderId); + } + + @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenPaymentNotFound() { + // arrange + Long paymentId = 999L; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.getPayment(paymentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(paymentRepository, times(1)).findById(paymentId); + } + } + + @DisplayName("PG 결제 요청") + @Nested + class RequestPayment { + @DisplayName("PG 결제 요청을 성공적으로 처리할 수 있다.") + @Test + void requestsPaymentSuccessfully() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Success successResult = new PaymentRequestResult.Success("TXN123456"); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequest.class))).thenReturn(successResult); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Success.class); + assertThat(((PaymentRequestResult.Success) result).transactionKey()).isEqualTo("TXN123456"); + verify(paymentRepository, times(1)).save(any(Payment.class)); + verify(paymentGateway, times(1)).requestPayment(any(PaymentRequest.class)); + } + + @DisplayName("비즈니스 실패 시 결제 상태를 FAILED로 변경한다.") + @Test + void updatesPaymentToFailed_whenBusinessFailure() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure( + "LIMIT_EXCEEDED", + "카드 한도 초과", + false, + false + ); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequest.class))).thenReturn(failureResult); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class); + verify(paymentRepository, times(2)).save(any(Payment.class)); // 생성 + 실패 상태 변경 + } + + @DisplayName("외부 시스템 장애 시 결제 상태를 PENDING으로 유지한다.") + @Test + void maintainsPendingStatus_whenExternalSystemFailure() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure( + "CIRCUIT_BREAKER_OPEN", + "결제 대기 상태", + false, + false + ); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequest.class))).thenReturn(failureResult); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class); + verify(paymentRepository, times(1)).save(any(Payment.class)); // 생성만 + verify(paymentRepository, never()).findById(anyLong()); // 상태 변경 없음 + } + + @DisplayName("잘못된 카드 번호로 인해 예외가 발생한다.") + @Test + void throwsException_whenInvalidCardNo() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String invalidCardNo = "1234"; // 잘못된 카드 번호 + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.requestPayment(orderId, userId, userEntityId, cardType, invalidCardNo, amount); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(paymentRepository, never()).save(any(Payment.class)); + verify(paymentGateway, never()).requestPayment(any(PaymentRequest.class)); + } + + @DisplayName("잘못된 카드 타입으로 인해 예외가 발생한다.") + @Test + void throwsException_whenInvalidCardType() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String invalidCardType = "INVALID"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.requestPayment(orderId, userId, userEntityId, invalidCardType, cardNo, amount); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(paymentRepository, never()).save(any(Payment.class)); + verify(paymentGateway, never()).requestPayment(any(PaymentRequest.class)); + } + } + + @DisplayName("결제 상태 조회") + @Nested + class GetPaymentStatus { + @DisplayName("결제 상태를 조회할 수 있다.") + @Test + void getsPaymentStatus() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus expectedStatus = PaymentStatus.SUCCESS; + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(expectedStatus); + + // act + PaymentStatus result = paymentService.getPaymentStatus(userId, orderId); + + // assert + assertThat(result).isEqualTo(expectedStatus); + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + @DisplayName("SUCCESS 콜백을 처리할 수 있다.") + @Test + void handlesSuccessCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.SUCCESS; + String reason = null; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("FAILED 콜백을 처리할 수 있다.") + @Test + void handlesFailedCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.FAILED; + String reason = "카드 한도 초과"; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("PENDING 콜백은 상태를 유지한다.") + @Test + void maintainsStatus_whenPendingCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.PENDING; + String reason = null; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + + @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.") + @Test + void logsWarning_whenPaymentNotFound() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.SUCCESS; + String reason = null; + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } + + @DisplayName("타임아웃 복구") + @Nested + class RecoverAfterTimeout { + @DisplayName("SUCCESS 상태로 복구할 수 있다.") + @Test + void recoversToSuccess() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.SUCCESS; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("FAILED 상태로 복구할 수 있다.") + @Test + void recoversToFailed() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.FAILED; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("PENDING 상태는 유지한다.") + @Test + void maintainsPendingStatus() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.PENDING; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + + @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.") + @Test + void logsWarning_whenPaymentNotFound() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.SUCCESS; + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java new file mode 100644 index 000000000..cc49a6467 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java @@ -0,0 +1,306 @@ +package com.loopers.domain.payment; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class PaymentTest { + + @DisplayName("필수 입력값 검증") + @Nested + class InputValidation { + @DisplayName("결제 생성 시 주문 ID가 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIdIsNull() { + // arrange + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Payment.of(null, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, amount, PaymentTestFixture.ValidPayment.REQUESTED_AT); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("결제 생성 시 결제 금액이 0 이하이면 예외가 발생한다.") + @Test + void throwsException_whenAmountIsNotPositive() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long invalidAmount = PaymentTestFixture.InvalidPayment.INVALID_AMOUNT; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Payment.of(orderId, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, invalidAmount, PaymentTestFixture.ValidPayment.REQUESTED_AT); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 검증") + @Nested + class StatusValidation { + @DisplayName("포인트로 전액 결제하면 SUCCESS 상태로 생성된다.") + @Test + void hasSuccessStatus_whenPointCoversTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = totalAmount; // 포인트로 전액 결제 + + // act + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, PaymentTestFixture.ValidPayment.REQUESTED_AT); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getUsedPoint()).isEqualTo(usedPoint); + assertThat(payment.getPaidAmount()).isEqualTo(0L); + } + + @DisplayName("포인트로 결제하지 않으면 PENDING 상태로 생성된다.") + @Test + void hasPendingStatus_whenPointDoesNotCoverTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act + Payment payment = Payment.of( + orderId, + userId, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + amount, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getUsedPoint()).isEqualTo(0L); + assertThat(payment.getPaidAmount()).isEqualTo(amount); + } + + @DisplayName("포인트로 부분 결제하면 PENDING 상태로 생성된다.") + @Test + void hasPendingStatus_whenPointPartiallyCoversTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = PaymentTestFixture.ValidPayment.PARTIAL_POINT; // 포인트로 절반 결제 + + // act + Payment payment = Payment.of( + orderId, + userId, + totalAmount, + usedPoint, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getUsedPoint()).isEqualTo(usedPoint); + assertThat(payment.getPaidAmount()).isEqualTo(totalAmount - usedPoint); + } + + @DisplayName("결제는 PENDING 상태에서 SUCCESS 상태로 전이할 수 있다.") + @Test + void canTransitionToSuccess_whenPending() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + + // act + payment.toSuccess(completedAt); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt); + } + + @DisplayName("결제는 PENDING 상태에서 FAILED 상태로 전이할 수 있다.") + @Test + void canTransitionToFailed_whenPending() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + String failureReason = "카드 한도 초과"; + + // act + payment.toFailed(failureReason, completedAt); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo(failureReason); + assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt); + } + + @DisplayName("FAILED 상태에서 SUCCESS로 전이할 수 없다.") + @Test + void throwsException_whenTransitioningToSuccessFromFailed() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + payment.toFailed("실패 사유", LocalDateTime.now()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + payment.toSuccess(LocalDateTime.now()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("SUCCESS 상태에서 FAILED로 전이할 수 없다.") + @Test + void throwsException_whenTransitioningToFailedFromSuccess() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + payment.toSuccess(LocalDateTime.now()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + payment.toFailed("실패 사유", LocalDateTime.now()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("완료된 결제는 isCompleted가 true를 반환한다.") + @Test + void returnsTrue_whenPaymentIsCompleted() { + // arrange + Payment successPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + successPayment.toSuccess(LocalDateTime.now()); + + Payment failedPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + failedPayment.toFailed("ERROR", LocalDateTime.now()); + + Payment pendingPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(successPayment.isCompleted()).isTrue(); + assertThat(failedPayment.isCompleted()).isTrue(); + assertThat(pendingPayment.isCompleted()).isFalse(); + } + + @DisplayName("이미 SUCCESS 상태인 결제를 다시 SUCCESS로 전이해도 예외가 발생하지 않는다.") + @Test + void doesNotThrowException_whenTransitioningToSuccessFromSuccess() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0); + payment.toSuccess(firstCompletedAt); + + // act + payment.toSuccess(secondCompletedAt); // 멱등성: 이미 SUCCESS 상태면 아무 작업도 하지 않음 + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지 + } + + @DisplayName("이미 FAILED 상태인 결제를 다시 FAILED로 전이해도 예외가 발생하지 않는다.") + @Test + void doesNotThrowException_whenTransitioningToFailedFromFailed() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0); + String firstReason = "첫 번째 실패 사유"; + String secondReason = "두 번째 실패 사유"; + payment.toFailed(firstReason, firstCompletedAt); + + // act + payment.toFailed(secondReason, secondCompletedAt); // 멱등성: 이미 FAILED 상태면 아무 작업도 하지 않음 + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo(firstReason); // 첫 번째 사유 유지 + assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지 + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java new file mode 100644 index 000000000..5e781bceb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java @@ -0,0 +1,30 @@ +package com.loopers.domain.payment; + +import java.time.LocalDateTime; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Payment 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class PaymentTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidPayment { + public static final Long ORDER_ID = 1L; + public static final Long USER_ID = 100L; + public static final Long AMOUNT = 50000L; + public static final CardType CARD_TYPE = CardType.SAMSUNG; + public static final String CARD_NO = "4111-1111-1111-1111"; + public static final LocalDateTime REQUESTED_AT = LocalDateTime.of(2025, 12, 1, 10, 0, 0); + public static final String TRANSACTION_KEY = "tx-key-12345"; + public static final Long ZERO_POINT = 0L; + public static final Long FULL_POINT = AMOUNT; // 전액 포인트 + public static final Long PARTIAL_POINT = AMOUNT / 2; // 부분 포인트 + } + + // 유효하지 않은 테스트 데이터 + public static final class InvalidPayment { + public static final Long INVALID_AMOUNT = 0L; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java new file mode 100644 index 000000000..588a2ac09 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java @@ -0,0 +1,100 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductDetailServiceTest { + + private final ProductDetailService productDetailService = new ProductDetailService(); + + @DisplayName("상품 상세 조회 시 Product + Brand + 좋아요 수 정보 조합은 도메인 서비스에서 처리했다") + @Test + void combineProductAndBrand() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of( + ProductTestFixture.ValidProduct.NAME, + ProductTestFixture.ValidProduct.PRICE, + ProductTestFixture.ValidProduct.STOCK, + brand.getId() + ); + Long likesCount = 10L; + + // act + ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount); + + // assert + assertThat(productDetail.getId()).isEqualTo(product.getId()); + assertThat(productDetail.getName()).isEqualTo(product.getName()); + assertThat(productDetail.getPrice()).isEqualTo(product.getPrice()); + assertThat(productDetail.getStock()).isEqualTo(product.getStock()); + assertThat(productDetail.getBrandId()).isEqualTo(brand.getId()); + assertThat(productDetail.getBrandName()).isEqualTo(brand.getName()); + assertThat(productDetail.getLikesCount()).isEqualTo(likesCount); + } + + @DisplayName("상품이 null이면 예외가 발생한다.") + @Test + void throwsException_whenProductIsNull() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Long likesCount = 0L; + + // act + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> { + productDetailService.combineProductAndBrand(null, brand, likesCount); + }); + + // assert + assertThat(result.getMessage()).contains("상품은 null일 수 없습니다"); + } + + @DisplayName("브랜드가 null이면 예외가 발생한다.") + @Test + void throwsException_whenBrandIsNull() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of( + ProductTestFixture.ValidProduct.NAME, + ProductTestFixture.ValidProduct.PRICE, + ProductTestFixture.ValidProduct.STOCK, + brand.getId() + ); + Long likesCount = 0L; + + // act + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> { + productDetailService.combineProductAndBrand(product, null, likesCount); + }); + + // assert + assertThat(result.getMessage()).contains("브랜드 정보는 필수입니다"); + } + + @DisplayName("좋아요 수가 null이면 예외가 발생한다.") + @Test + void throwsException_whenLikesCountIsNull() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of( + ProductTestFixture.ValidProduct.NAME, + ProductTestFixture.ValidProduct.PRICE, + ProductTestFixture.ValidProduct.STOCK, + brand.getId() + ); + + // act + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> { + productDetailService.combineProductAndBrand(product, brand, null); + }); + + // assert + assertThat(result.getMessage()).contains("좋아요 수는 null일 수 없습니다"); + } + +} + 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..c87faa10f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.product; + +import com.loopers.application.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ProductService") +public class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private ProductService productService; + + @DisplayName("상품 조회 (비관적 락)") + @Nested + class FindProductForUpdate { + @DisplayName("상품 ID로 상품을 조회할 수 있다. (비관적 락)") + @Test + void findsProductByIdForUpdate() { + // arrange + Long productId = 1L; + Product expectedProduct = Product.of("상품", 10_000, 10, 1L); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(expectedProduct)); + + // act + Product result = productService.getProductForUpdate(productId); + + // assert + assertThat(result).isEqualTo(expectedProduct); + verify(productRepository, times(1)).findByIdForUpdate(productId); + } + + @DisplayName("상품을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenProductNotFound() { + // arrange + Long productId = 999L; + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProductForUpdate(productId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("상품을 찾을 수 없습니다"); + verify(productRepository, times(1)).findByIdForUpdate(productId); + } + } + + @DisplayName("상품 저장") + @Nested + class SaveProducts { + @DisplayName("상품 목록을 저장할 수 있다.") + @Test + void savesAllProducts() { + // arrange + Product product1 = Product.of("상품1", 10_000, 10, 1L); + Product product2 = Product.of("상품2", 20_000, 5, 1L); + List products = List.of(product1, product2); + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productService.saveAll(products); + + // assert + verify(productRepository, times(1)).save(product1); + verify(productRepository, times(1)).save(product2); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..9cfe0294e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductTest { + + @DisplayName("상품 정보 객체는 브랜드 정보를 포함한다.") + @Test + void productContainsBrandId() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Long brandId = brand.getId(); + + // act + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, ProductTestFixture.ValidProduct.STOCK, brandId); + + // assert + assertThat(product.getBrandId()).isNotNull(); + assertThat(product.getBrandId()).isEqualTo(brandId); + } + + @DisplayName("상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다.") + @Test + void canDecreaseStock_whenOrdering() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + Integer orderQuantity = 5; + Integer initialStock = product.getStock(); + + // act + product.decreaseStock(orderQuantity); + + // assert + assertThat(product.getStock()).isEqualTo(initialStock - orderQuantity); + } + + @DisplayName("재고 감소 시 음수 수량을 전달하면 예외가 발생한다.") + @Test + void throwsException_whenDecreasingStockWithNegativeQuantity() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 취소 시 재고를 증가시킬 수 있다.") + @Test + void canIncreaseStock_whenCancelingOrder() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + Integer increaseQuantity = 5; + Integer initialStock = product.getStock(); + + // act + product.increaseStock(increaseQuantity); + + // assert + assertThat(product.getStock()).isEqualTo(initialStock + increaseQuantity); + } + + @DisplayName("재고 증가 시 음수 수량을 전달하면 예외가 발생한다.") + @Test + void throwsException_whenIncreasingStockWithNegativeQuantity() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.increaseStock(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고 부족 예외 흐름은 도메인 레벨에서 처리되며, 재고가 음수가 되지 않도록 방지한다.") + @Test + void preventsNegativeStock_atDomainLevel() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 10, brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(11); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("재고가 부족합니다"); + assertThat(product.getStock()).isEqualTo(10); // 재고가 변경되지 않았음을 확인 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java new file mode 100644 index 000000000..43da5f290 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java @@ -0,0 +1,33 @@ +package com.loopers.domain.product; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Product 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class ProductTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidProduct { + public static final String NAME = "테스트 상품"; + public static final Integer PRICE = 10000; + public static final Integer STOCK = 100; + } + + // 유효하지 않은 테스트 데이터 + public static final class InvalidProduct { + public static final String NAME = ""; + public static final Integer PRICE = -1; + public static final Integer STOCK = -1; + } + + // 기본 유효한 브랜드 데이터 + public static final class ValidBrand { + public static final String NAME = "테스트 브랜드"; + } + + // 유효하지 않은 브랜드 데이터 + public static final class InvalidBrand { + public static final String NAME = ""; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 000000000..1bde8daa1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,165 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * UserService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService") +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @DisplayName("사용자 조회") + @Nested + class FindUser { + @DisplayName("사용자 ID로 사용자를 조회할 수 있다.") + @Test + void findsUserByUserId() { + // arrange + String userId = "testuser"; + User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findByUserId(userId)).thenReturn(expectedUser); + + // act + User result = userService.getUser(userId); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findByUserId(userId); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + String userId = "unknown"; + when(userRepository.findByUserId(userId)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUser(userId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findByUserId(userId); + } + } + + @DisplayName("사용자 조회 (비관적 락)") + @Nested + class FindUserForUpdate { + @DisplayName("사용자 ID로 사용자를 조회할 수 있다. (비관적 락)") + @Test + void findsUserByUserIdForUpdate() { + // arrange + String userId = "testuser"; + User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findByUserIdForUpdate(userId)).thenReturn(expectedUser); + + // act + User result = userService.getUserForUpdate(userId); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findByUserIdForUpdate(userId); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + String userId = "unknown"; + when(userRepository.findByUserIdForUpdate(userId)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserForUpdate(userId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findByUserIdForUpdate(userId); + } + } + + @DisplayName("사용자 조회 (ID)") + @Nested + class FindUserById { + @DisplayName("사용자 ID (PK)로 사용자를 조회할 수 있다.") + @Test + void findsUserById() { + // arrange + Long id = 1L; + User expectedUser = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findById(id)).thenReturn(expectedUser); + + // act + User result = userService.getUserById(id); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findById(id); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + Long id = 999L; + when(userRepository.findById(id)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserById(id); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findById(id); + } + } + + @DisplayName("사용자 저장") + @Nested + class SaveUser { + @DisplayName("사용자를 저장할 수 있다.") + @Test + void savesUser() { + // arrange + User user = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.save(any(User.class))).thenReturn(user); + + // act + User result = userService.save(user); + + // assert + assertThat(result).isEqualTo(user); + verify(userRepository, times(1)).save(user); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 000000000..b1490b09c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class UserTest { + @DisplayName("User 도메인의 생성에 관한 단위 테스트") + @Nested + class Create { + @DisplayName("ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void throwsBadRequestException_whenIdFormatIsInvalid(Gender gender) { + // arrange + String userId = UserTestFixture.InvalidUser.USER_ID; + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.of(userId, UserTestFixture.ValidUser.EMAIL, UserTestFixture.ValidUser.BIRTH_DATE, gender, UserTestFixture.ValidUser.POINT); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void throwsBadRequestException_whenEmailFormatIsInvalid(Gender gender) { + // arrange + String email = UserTestFixture.InvalidUser.EMAIL; + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.of(UserTestFixture.ValidUser.USER_ID, email, UserTestFixture.ValidUser.BIRTH_DATE, gender, UserTestFixture.ValidUser.POINT); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void throwsBadRequestException_whenBirthDateIsInvalid(Gender gender) { + // arrange + String birthDateStr = UserTestFixture.InvalidUser.BIRTH_DATE; + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.of(UserTestFixture.ValidUser.USER_ID, UserTestFixture.ValidUser.EMAIL, birthDateStr, gender, UserTestFixture.ValidUser.POINT); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java new file mode 100644 index 000000000..9babd2fb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java @@ -0,0 +1,24 @@ +package com.loopers.domain.user; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 User 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class UserTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidUser { + public static final String USER_ID = "testuser"; + public static final String EMAIL = "test@example.com"; + public static final String BIRTH_DATE = "1990-01-01"; + public static final Point POINT = Point.of(0L); + } + + // 유효하지 않은 테스트 데이터 + public static final class InvalidUser { + public static final String USER_ID = "한글"; + public static final String EMAIL = "test"; + public static final String BIRTH_DATE = "2024.1.1"; + public static final Point POINT = Point.of(0L); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java new file mode 100644 index 000000000..be6e6a9bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +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.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * OutboxEventPublisher 통합 테스트. + *

+ * 실제 Kafka를 사용하여 Outbox 패턴의 이벤트 발행 동작을 검증합니다. + *

+ *

+ * Kafka 컨테이너: + * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다. + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class OutboxEventPublisherIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + @DisplayName("통합 테스트: Outbox 패턴을 통한 Kafka 이벤트 발행이 정상적으로 동작한다.") + @Test + void integrationTest() { + // TODO: 실제 Kafka를 사용한 통합 테스트 구현 + // 예: OutboxEvent를 저장한 후 OutboxEventPublisher가 Kafka로 발행하는지 확인 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java new file mode 100644 index 000000000..e54550433 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java @@ -0,0 +1,299 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.Message; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxEventPublisher 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventPublisherTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private KafkaTemplate kafkaTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventPublisher outboxEventPublisher; + + @DisplayName("PENDING 상태의 이벤트를 Kafka로 발행할 수 있다.") + @Test + void canPublishPendingEvents() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + assertThat(savedEvents).allMatch(e -> + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + assertThat(savedEvents).allMatch(e -> + e.getPublishedAt() != null + ); + } + + @DisplayName("PENDING 이벤트가 없으면 아무것도 발행하지 않는다.") + @Test + void doesNothing_whenNoPendingEvents() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); + verify(outboxEventRepository, never()).save(any(OutboxEvent.class)); + } + + @DisplayName("개별 이벤트 발행 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(eq("order-events"), any(Message.class))) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(kafkaTemplate.send(eq("like-events"), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + // event1은 FAILED, event2는 PUBLISHED + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-1") && + e.getStatus() == OutboxEvent.OutboxStatus.FAILED + ); + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-2") && + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + } + + @DisplayName("Kafka 발행 성공 시 이벤트 상태를 PUBLISHED로 변경한다.") + @Test + void marksAsPublished_whenKafkaPublishSucceeds() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(savedEvent.getPublishedAt()).isNotNull(); + } + + @DisplayName("Kafka 발행 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenKafkaPublishFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(savedEvent.getPublishedAt()).isNull(); + } + + @DisplayName("JSON 역직렬화 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenJsonDeserializationFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenThrow(new RuntimeException("JSON 역직렬화 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); + } + + @DisplayName("배치 크기만큼 이벤트를 조회한다.") + @Test + void queriesEventsWithBatchSize() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(outboxEventRepository).findPendingEvents(100); + } + + @DisplayName("각 토픽에 적절한 파티션 키를 사용하여 Kafka로 발행한다.") + @Test + void usesCorrectPartitionKeyForEachTopic() throws Exception { + // arrange + OutboxEvent likeEvent = createPendingEvent("event-1", "like-events", "product-123"); + OutboxEvent orderEvent = createPendingEvent("event-2", "order-events", "order-456"); + OutboxEvent productEvent = createPendingEvent("event-3", "product-events", "product-789"); + List pendingEvents = List.of(likeEvent, orderEvent, productEvent); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("productId", 123)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증 + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + verify(kafkaTemplate, times(3)).send( + topicCaptor.capture(), + messageCaptor.capture() + ); + + List topics = topicCaptor.getAllValues(); + List messages = messageCaptor.getAllValues(); + + // like-events는 productId를 파티션 키로 사용 + int likeIndex = topics.indexOf("like-events"); + assertThat(likeIndex).isNotEqualTo(-1); + assertThat(messages.get(likeIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-123"); + + // order-events는 orderId를 파티션 키로 사용 + int orderIndex = topics.indexOf("order-events"); + assertThat(orderIndex).isNotEqualTo(-1); + assertThat(messages.get(orderIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("order-456"); + + // product-events는 productId를 파티션 키로 사용 + int productIndex = topics.indexOf("product-events"); + assertThat(productIndex).isNotEqualTo(-1); + assertThat(messages.get(productIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-789"); + } + + /** + * PENDING 상태의 OutboxEvent를 생성합니다. + */ + private OutboxEvent createPendingEvent(String eventId, String topic, String partitionKey) { + return OutboxEvent.builder() + .eventId(eventId) + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{\"orderId\":1}") + .topic(topic) + .partitionKey(partitionKey) + .build(); + } + + /** + * Kafka 발행 성공을 시뮬레이션하는 CompletableFuture를 생성합니다. + */ + @SuppressWarnings("unchecked") + private CompletableFuture> createSuccessFuture() { + return (CompletableFuture>) (CompletableFuture) + CompletableFuture.completedFuture(null); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java new file mode 100644 index 000000000..dbfac8411 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java @@ -0,0 +1,276 @@ +package com.loopers.infrastructure.payment; + +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.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; +import org.springframework.test.context.ActiveProfiles; + +import java.net.SocketTimeoutException; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * PaymentGatewayClient 타임아웃 및 실패 처리 테스트. + *

+ * 외부 PG 시스템과의 통신에서 발생할 수 있는 다양한 장애 시나리오를 검증합니다. + * - 타임아웃 처리 + * - 네트워크 오류 처리 + * - 서버 오류 처리 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PaymentGatewayClient 타임아웃 및 실패 처리 테스트") +class PaymentGatewayClientTest { + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient); + } + + @Test + @DisplayName("PG 결제 요청 시 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_timeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Request timeout"); + } + + @Test + @DisplayName("PG 결제 요청 시 연결 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_connectionTimeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 연결 실패 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Connection timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Connection timeout"); + } + + @Test + @DisplayName("PG 결제 요청 시 읽기 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_readTimeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 읽기 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Read timed out"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Read timed out"); + } + + @Test + @DisplayName("PG 결제 상태 확인 API 호출 시 타임아웃이 발생하면 적절한 예외가 발생한다") + void getTransaction_timeout_throwsException() { + // arrange + String userId = "testuser"; + String transactionKey = "TXN123456"; + + // Mock 서버에서 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).getTransaction(anyString(), anyString()); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.getTransaction(userId, transactionKey)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Request timeout"); + } + + @Test + @DisplayName("PG 서버가 500 에러를 반환하면 적절한 예외가 발생한다") + void requestPayment_serverError_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 500 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(FeignException.class) + .matches(e -> ((FeignException) e).status() == 500); + } + + @Test + @DisplayName("PG 서버가 400 에러를 반환하면 적절한 예외가 발생한다") + void requestPayment_badRequest_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "INVALID_CARD", // 잘못된 카드 번호 + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 400 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.BadRequest( + "Bad Request", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(FeignException.class) + .matches(e -> ((FeignException) e).status() == 400); + } + + @Test + @DisplayName("PG 결제 요청이 성공하면 정상적인 응답을 받는다") + void requestPayment_success_returnsResponse() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 성공 응답 반환 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + + // act + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.requestPayment(userId, request); + + // assert + assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS); + assertThat(response.data()).isNotNull(); + assertThat(response.data().transactionKey()).isNotNull(); + } + + @Test + @DisplayName("PG 결제 상태 확인 API가 성공하면 정상적인 응답을 받는다") + void getTransaction_success_returnsResponse() { + // arrange + String userId = "testuser"; + String transactionKey = "TXN123456"; + + // Mock 서버에서 성공 응답 반환 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionDetailResponse( + transactionKey, + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.getTransaction(anyString(), anyString())) + .thenReturn(successResponse); + + // act + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.getTransaction(userId, transactionKey); + + // assert + assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS); + assertThat(response.data()).isNotNull(); + assertThat(response.data().transactionKey()).isEqualTo(transactionKey); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java new file mode 100644 index 000000000..559a26477 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java @@ -0,0 +1,202 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.user.UserService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.interfaces.api.pointwallet.PointWalletV1Dto; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.MediaType; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class PointWalletV1ApiE2ETest { + + private static final String ENDPOINT_POINTS = "/api/v1/me/points"; + + private final TestRestTemplate testRestTemplate; + private final UserService userService; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public PointWalletV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserService userService, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userService = userService; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/me/points") + @Nested + class GetMyPoints { + @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsPoints_whenUserExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, gender, Point.of(0L)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + headers.add("X-USER-ID", userId); + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().balance()).isEqualTo(0L), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.") + @Test + void returns404_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + headers.add("X-USER-ID", userId); + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode().value()).isEqualTo(404), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.") + @Test + void returns400_whenHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode().value()).isEqualTo(400), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("POST /api/v1/me/points/charge") + @Nested + class ChargePoints { + private static final String ENDPOINT_CHARGE = "/api/v1/me/points/charge"; + + @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsChargedBalance_whenUserExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, gender, Point.of(0L)); + + Long chargeAmount = 1000L; + PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add("X-USER-ID", userId); + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().balance()).isEqualTo(chargeAmount), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.") + @Test + void returns404_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + Long chargeAmount = 1000L; + PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add("X-USER-ID", userId); + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode().value()).isEqualTo(404), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java new file mode 100644 index 000000000..49c28c163 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java @@ -0,0 +1,532 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.user.UserService; +import com.loopers.domain.user.Point; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.purchasing.PurchasingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import feign.FeignException; +import feign.Request; + +import java.net.SocketTimeoutException; +import java.util.Collections; +import org.springframework.core.ParameterizedTypeReference; +import static org.mockito.Mockito.doThrow; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * PurchasingV1Api E2E 테스트. + *

+ * PG 연동 관련 E2E 시나리오를 검증합니다. + * - PG 타임아웃 시나리오 + * - PG 실패 시나리오 + * - 서킷 브레이커 동작 + *

+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@DisplayName("PurchasingV1Api E2E 테스트") +public class PurchasingV1ApiE2ETest { + + private static final String ENDPOINT_ORDERS = "/api/v1/orders"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private UserService userService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @MockitoBean + private PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient, paymentGatewaySchedulerClient); + // 서킷 브레이커 상태 초기화 + if (circuitBreakerRegistry != null) { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(CircuitBreaker::reset); + } + } + + // 테스트 데이터 준비 헬퍼 메서드 + private HttpEntity createOrderRequest(Long productId) { + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + PurchasingV1Dto.CreateRequest requestBody = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add("X-USER-ID", userId); + return new HttpEntity<>(requestBody, headers); + } + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + @DisplayName("외부 시스템(PG) 장애 시에도 항상 200 응답을 반환한다") + @Nested + class ExternalSystemFailureIsolation { + @DisplayName("PG 타임아웃 시에도 200 응답을 반환한다") + @Test + void returns200_whenPaymentGatewayTimeout() { + // arrange + HttpEntity httpEntity = createOrderRequest(null); + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType); + + // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING) + ); + } + + @DisplayName("PG 서버 500 에러 시에도 200 응답을 반환한다") + @Test + void returns200_whenPaymentGatewayServerError() { + // arrange + HttpEntity httpEntity = createOrderRequest(null); + + // 서킷 브레이커를 리셋하여 CLOSED 상태로 시작 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.reset(); + } + } + + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType); + + // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING) + ); + } + + @DisplayName("PG 실패 응답 시에도 200 응답을 반환한다") + @Test + void returns200_whenPaymentGatewayFailure() { + // arrange + HttpEntity httpEntity = createOrderRequest(null); + + PaymentGatewayDto.ApiResponse failureResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "INTERNAL_SERVER_ERROR", + "PG 서버 내부 오류가 발생했습니다" + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(failureResponse); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType); + + // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING) + ); + } + } + + } + + @DisplayName("POST /api/v1/orders/{orderId}/callback") + @Nested + class HandlePaymentCallback { + private static final String ENDPOINT_CALLBACK = "/api/v1/orders/{orderId}/callback"; + + @DisplayName("PG 결제 성공 콜백을 수신하면 주문 상태가 COMPLETED로 변경되고 200 응답을 반환한다") + @Test + void returns200_whenPaymentCallbackSuccess() { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + // 주문 생성 (PENDING 상태) + PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.add("X-USER-ID", userId); + HttpEntity createHttpEntity = new HttpEntity<>(createRequest, createHeaders); + + // PG 결제 요청 성공 Mock + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + )); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> createResponse = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType); + + Long orderId = createResponse.getBody().data().orderId(); + String transactionKey = "TXN123456"; + + // 콜백 검증을 위한 PG 조회 API Mock (SUCCESS 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // 콜백 요청 생성 + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + transactionKey, + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ); + + HttpHeaders callbackHeaders = new HttpHeaders(); + callbackHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity callbackHttpEntity = new HttpEntity<>(callbackRequest, callbackHeaders); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CALLBACK, + HttpMethod.POST, + callbackHttpEntity, + new ParameterizedTypeReference>() {}, + orderId + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("PG 결제 실패 콜백을 수신하면 주문 상태가 CANCELED로 변경되고 200 응답을 반환한다") + @Test + void returns200_whenPaymentCallbackFailure() { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + // 주문 생성 (PENDING 상태) + PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.add("X-USER-ID", userId); + HttpEntity createHttpEntity = new HttpEntity<>(createRequest, createHeaders); + + // PG 결제 요청 성공 Mock + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + )); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> createResponse = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType); + + Long orderId = createResponse.getBody().data().orderId(); + String transactionKey = "TXN123456"; + + // 콜백 검증을 위한 PG 조회 API Mock (FAILED 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // 콜백 요청 생성 (FAILED 상태) + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + transactionKey, + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ); + + HttpHeaders callbackHeaders = new HttpHeaders(); + callbackHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity callbackHttpEntity = new HttpEntity<>(callbackRequest, callbackHeaders); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CALLBACK, + HttpMethod.POST, + callbackHttpEntity, + new ParameterizedTypeReference>() {}, + orderId + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + } + + @DisplayName("POST /api/v1/orders/{orderId}/recover") + @Nested + class RecoverOrderStatus { + private static final String ENDPOINT_RECOVER = "/api/v1/orders/{orderId}/recover"; + + @DisplayName("수동으로 주문 상태를 복구할 수 있다") + @Test + void returns200_whenOrderStatusRecovered() { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + // 주문 생성 (타임아웃으로 인해 PENDING 상태 유지) + PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.add("X-USER-ID", userId); + HttpEntity createHttpEntity = new HttpEntity<>(createRequest, createHeaders); + + // PG 결제 요청 타임아웃 Mock (주문은 PENDING 상태로 유지) + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> createResponse = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType); + + Long orderId = createResponse.getBody().data().orderId(); + String transactionKey = "TXN123456"; + + // 상태 복구를 위한 PG 조회 API Mock (SUCCESS 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + HttpHeaders recoverHeaders = new HttpHeaders(); + recoverHeaders.add("X-USER-ID", userId); + HttpEntity recoverHttpEntity = new HttpEntity<>(recoverHeaders); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_RECOVER, + HttpMethod.POST, + recoverHttpEntity, + new ParameterizedTypeReference>() {}, + orderId + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java new file mode 100644 index 000000000..900e94328 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java @@ -0,0 +1,111 @@ +package com.loopers.interfaces.api; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.domain.user.Gender; +import com.loopers.interfaces.api.signup.SignUpV1Dto; +import com.loopers.domain.user.UserTestFixture; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.MediaType; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class SignUpV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/signup"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public SignUpV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/signup") + @Nested + class SignUp { + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsUserInfo_whenSignUpSucceeds(Gender gender) { + // arrange + SignUpV1Dto.SignUpRequest requestBody = new SignUpV1Dto.SignUpRequest( + UserTestFixture.ValidUser.USER_ID, + UserTestFixture.ValidUser.EMAIL, + UserTestFixture.ValidUser.BIRTH_DATE, + gender.name() + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(userJpaRepository.count()).isEqualTo(1L) + ); + } + + @DisplayName("회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다.") + @Test + void returns400_whenSignUpWithNoGender() { + // arrange + SignUpV1Dto.SignUpRequest requestBody = new SignUpV1Dto.SignUpRequest( + UserTestFixture.ValidUser.USER_ID, + UserTestFixture.ValidUser.EMAIL, + UserTestFixture.ValidUser.BIRTH_DATE, + null // gender missing + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode().value()).isEqualTo(400), + () -> assertThat(userJpaRepository.count()).isEqualTo(0L) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java new file mode 100644 index 000000000..6cea33855 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java @@ -0,0 +1,136 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.user.UserService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.interfaces.api.userinfo.UserInfoV1Dto; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.MediaType; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class UserInfoV1ApiE2ETest { + + private static final String ENDPOINT_ME = "/api/v1/me"; + + private final TestRestTemplate testRestTemplate; + private final UserService userService; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserInfoV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserService userService, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userService = userService; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/me") + @Nested + class GetUserInfo { + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsUserInfo_whenUserExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + userService.create(userId, email, birthDate, gender, Point.of(0L)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + headers.add("X-USER-ID", userId); + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().email()).isEqualTo(email), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(birthDate), + () -> assertThat(response.getBody().data().gender()).isEqualTo(gender.name()) + ); + } + + @DisplayName("존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.") + @Test + void returns404_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + headers.add("X-USER-ID", userId); + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode().value()).isEqualTo(404), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.") + @Test + void returns400_whenHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode().value()).isEqualTo(400), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java b/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java new file mode 100644 index 000000000..96ce26e96 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java @@ -0,0 +1,152 @@ +package com.loopers.testutil; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Circuit Breaker 테스트 유틸리티. + *

+ * Circuit Breaker를 특정 상태로 만들거나, 실패를 유발하여 Circuit Breaker를 열리게 하는 유틸리티 메서드를 제공합니다. + *

+ */ +@Component +public class CircuitBreakerTestUtil { + + private static final Logger log = LoggerFactory.getLogger(CircuitBreakerTestUtil.class); + private final CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + public CircuitBreakerTestUtil(CircuitBreakerRegistry circuitBreakerRegistry) { + this.circuitBreakerRegistry = circuitBreakerRegistry; + } + + /** + * Circuit Breaker를 OPEN 상태로 전환합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 (예: "paymentGatewayClient") + */ + public void openCircuitBreaker(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + log.info("Circuit Breaker '{}'를 OPEN 상태로 전환했습니다.", circuitBreakerName); + } else { + log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName); + } + } + + /** + * Circuit Breaker를 HALF_OPEN 상태로 전환합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + */ + public void halfOpenCircuitBreaker(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + circuitBreaker.transitionToHalfOpenState(); + log.info("Circuit Breaker '{}'를 HALF_OPEN 상태로 전환했습니다.", circuitBreakerName); + } else { + log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName); + } + } + + /** + * Circuit Breaker를 CLOSED 상태로 리셋합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + */ + public void resetCircuitBreaker(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + circuitBreaker.reset(); + log.info("Circuit Breaker '{}'를 리셋했습니다.", circuitBreakerName); + } else { + log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName); + } + } + + /** + * Circuit Breaker의 현재 상태를 반환합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return Circuit Breaker 상태 (CLOSED, OPEN, HALF_OPEN) + */ + public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + return circuitBreaker.getState(); + } + return null; + } + + /** + * 실패를 유발하여 Circuit Breaker를 OPEN 상태로 만듭니다. + *

+ * 이 메서드는 실패 임계값을 초과하도록 여러 번 실패를 유발합니다. + *

+ * + * @param circuitBreakerName Circuit Breaker 이름 + * @param failureFunction 실패를 유발하는 함수 (예: PG API 호출) + * @param minFailures 최소 실패 횟수 (실패율 임계값을 초과하기 위해 필요한 실패 횟수) + */ + public void triggerCircuitBreakerOpen(String circuitBreakerName, Runnable failureFunction, int minFailures) { + log.info("Circuit Breaker '{}'를 OPEN 상태로 만들기 위해 {}번의 실패를 유발합니다.", circuitBreakerName, minFailures); + + // Circuit Breaker 리셋 + resetCircuitBreaker(circuitBreakerName); + + // 실패 유발 + AtomicInteger failureCount = new AtomicInteger(0); + for (int i = 0; i < minFailures; i++) { + try { + failureFunction.run(); + } catch (Exception e) { + failureCount.incrementAndGet(); + log.debug("실패 {}번 발생: {}", failureCount.get(), e.getMessage()); + } + } + + log.info("총 {}번의 실패를 유발했습니다. Circuit Breaker 상태: {}", + failureCount.get(), getCircuitBreakerState(circuitBreakerName)); + } + + /** + * Circuit Breaker가 OPEN 상태인지 확인합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return OPEN 상태이면 true + */ + public boolean isCircuitBreakerOpen(String circuitBreakerName) { + CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName); + return state == CircuitBreaker.State.OPEN; + } + + /** + * Circuit Breaker가 HALF_OPEN 상태인지 확인합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return HALF_OPEN 상태이면 true + */ + public boolean isCircuitBreakerHalfOpen(String circuitBreakerName) { + CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName); + return state == CircuitBreaker.State.HALF_OPEN; + } + + /** + * Circuit Breaker가 CLOSED 상태인지 확인합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return CLOSED 상태이면 true + */ + public boolean isCircuitBreakerClosed(String circuitBreakerName) { + CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName); + return state == CircuitBreaker.State.CLOSED; + } +} + diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..1d691a669 --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,22 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl (필요시) + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java new file mode 100644 index 000000000..76619b777 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java @@ -0,0 +1,34 @@ +package com.loopers; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Spring Batch 애플리케이션 메인 클래스. + *

+ * 대량 데이터 집계 및 배치 처리를 위한 독립 실행형 애플리케이션입니다. + *

+ *

+ * 실행 방법: + *

+ * java -jar commerce-batch.jar \
+ *   --spring.batch.job.names=productMetricsAggregationJob \
+ *   targetDate=20241215
+ * 
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@SpringBootApplication(scanBasePackages = "com.loopers") +@EnableJpaRepositories(basePackages = "com.loopers.infrastructure") +@EntityScan(basePackages = "com.loopers.domain") +public class BatchApplication { + + public static void main(String[] args) { + System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args))); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..953aae115 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,134 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

+ * Spring Batch에서 집계 및 조회를 위한 메트릭 엔티티입니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
  • + *
  • Spring Batch를 통한 대량 데이터 처리
  • + *
+ *

+ *

+ * 모듈별 독립성: + *

    + *
  • commerce-batch 전용 엔티티 (독립적 진화 가능)
  • + *
  • commerce-streamer와는 별도로 관리되어 모듈별 커스터마이징 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

+ * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다. + * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다. + *

+ * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..4df1e6311 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,94 @@ +package com.loopers.domain.metrics; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

+ * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리
  • + *
+ *

+ *

+ * 배치 전용 메서드: + *

    + *
  • Spring Batch에서 날짜 기반 조회를 위한 메서드 포함
  • + *
  • 대량 데이터 처리를 위한 페이징 조회 지원
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductMetricsRepository { + + /** + * 상품 메트릭을 저장합니다. + * + * @param productMetrics 저장할 상품 메트릭 + * @return 저장된 상품 메트릭 + */ + ProductMetrics save(ProductMetrics productMetrics); + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다. + *

+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *

+ *

+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *

    + *
  • startDateTime: 2024-12-15 00:00:00
  • + *
  • endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
  • + *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다. + *

+ * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive) + * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + Page findByUpdatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable + ); + + /** + * Spring Batch의 RepositoryItemReader에서 사용하기 위한 JPA Repository를 반환합니다. + *

+ * RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로, + * 기술적 제약으로 인해 JPA Repository에 대한 접근을 제공합니다. + *

+ *

+ * 주의: 이 메서드는 Spring Batch의 기술적 요구사항으로 인해 제공됩니다. + * 일반적인 비즈니스 로직에서는 이 메서드를 사용하지 않고, + * 위의 도메인 메서드들을 사용해야 합니다. + *

+ * + * @return PagingAndSortingRepository를 구현한 JPA Repository + */ + org.springframework.data.repository.PagingAndSortingRepository getJpaRepository(); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java new file mode 100644 index 000000000..42a261c97 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -0,0 +1,167 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 상품 랭킹 Materialized View 엔티티. + *

+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *

+ *

+ * Materialized View 설계: + *

    + *
  • 테이블: `mv_product_rank` (단일 테이블)
  • + *
  • 주간 랭킹: period_type = WEEKLY
  • + *
  • 월간 랭킹: period_type = MONTHLY
  • + *
  • TOP 100만 저장하여 조회 성능 최적화
  • + *
+ *

+ *

+ * 인덱스 전략: + *

    + *
  • 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
  • + *
  • 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "mv_product_rank", + indexes = { + @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"), + @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간) + */ + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false, length = 20) + private PeriodType periodType; + + /** + * 기간 시작일 + *
    + *
  • 주간: 해당 주의 월요일 (ISO 8601 기준)
  • + *
  • 월간: 해당 월의 1일
  • + *
+ */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 랭킹 (1-100) + */ + @Column(name = "rank", nullable = false) + private Integer rank; + + /** + * 좋아요 수 + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductRank 인스턴스를 생성합니다. + * + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @param rank 랭킹 (1-100) + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + */ + public ProductRank( + PeriodType periodType, + LocalDate periodStartDate, + Long productId, + Integer rank, + Long likeCount, + Long salesCount, + Long viewCount + ) { + this.periodType = periodType; + this.periodStartDate = periodStartDate; + this.productId = productId; + this.rank = rank; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 랭킹 정보를 업데이트합니다. + * + * @param rank 새로운 랭킹 + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + */ + public void updateRank(Integer rank, Long likeCount, Long salesCount, Long viewCount) { + this.rank = rank; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + WEEKLY, // 주간 + MONTHLY // 월간 + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java new file mode 100644 index 000000000..f30679126 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank 도메인 Repository 인터페이스. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

+ */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 저장합니다. + *

+ * 기존 데이터가 있으면 삭제 후 새로 저장합니다 (UPSERT 방식). + *

+ * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param ranks 저장할 랭킹 리스트 (TOP 100) + */ + void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks); + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit); + + /** + * 특정 기간의 특정 상품 랭킹을 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 Optional.empty()) + */ + Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ); + + /** + * 특정 기간의 기존 랭킹 데이터를 삭제합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + */ + void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java new file mode 100644 index 000000000..97653efd6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java @@ -0,0 +1,141 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 랭킹 점수 집계 임시 엔티티. + *

+ * Step 1 (집계 로직 계산)에서 사용하는 임시 테이블입니다. + * product_id별로 점수를 집계하여 저장하며, 랭킹 번호는 저장하지 않습니다. + *

+ *

+ * 사용 목적: + *

    + *
  • Step 1에서 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
  • + *
  • Step 2에서 전체 데이터를 읽어서 TOP 100 선정 및 랭킹 번호 부여
  • + *
+ *

+ *

+ * 인덱스 전략: + *

    + *
  • product_id에 유니크 인덱스: 같은 product_id는 하나의 레코드만 존재 (UPSERT 방식)
  • + *
  • score에 인덱스: Step 2에서 정렬 시 성능 최적화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "tmp_product_rank_score", + indexes = { + @Index(name = "idx_product_id", columnList = "product_id", unique = true), + @Index(name = "idx_score", columnList = "score") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankScore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + /** + * 좋아요 수 (집계된 값) + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 (집계된 값) + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 (집계된 값) + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 종합 점수 + *

+ * 가중치: + *

    + *
  • 좋아요: 0.3
  • + *
  • 판매량: 0.5
  • + *
  • 조회수: 0.2
  • + *
+ *

+ */ + @Column(name = "score", nullable = false) + private Double score; + + /** + * 메트릭 값을 설정합니다. + *

+ * Repository에서만 사용하는 내부 메서드입니다. + *

+ */ + public void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) { + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.score = score; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductRankScore 인스턴스를 생성합니다. + * + * @param productId 상품 ID + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @param score 종합 점수 + */ + public ProductRankScore( + Long productId, + Long likeCount, + Long salesCount, + Long viewCount, + Double score + ) { + this.productId = productId; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.score = score; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java new file mode 100644 index 000000000..efb09527d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java @@ -0,0 +1,80 @@ +package com.loopers.domain.rank; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * ProductRankScore 도메인 Repository 인터페이스. + *

+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *

+ */ +public interface ProductRankScoreRepository { + + /** + * ProductRankScore를 저장합니다. + *

+ * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식). + *

+ * + * @param score 저장할 ProductRankScore + */ + void save(ProductRankScore score); + + /** + * 여러 ProductRankScore를 저장합니다. + *

+ * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식). + *

+ * + * @param scores 저장할 ProductRankScore 리스트 + */ + void saveAll(List scores); + + /** + * product_id로 ProductRankScore를 조회합니다. + * + * @param productId 상품 ID + * @return ProductRankScore (없으면 Optional.empty()) + */ + Optional findByProductId(Long productId); + + /** + * 여러 product_id로 ProductRankScore를 일괄 조회합니다. + *

+ * N+1 쿼리 문제를 방지하기 위해 사용합니다. + *

+ * + * @param productIds 상품 ID 집합 + * @return ProductRankScore 리스트 + */ + List findAllByProductIdIn(Set productIds); + + /** + * 모든 ProductRankScore를 점수 내림차순으로 조회합니다. + *

+ * Step 2에서 TOP 100 선정을 위해 사용합니다. + *

+ * + * @param limit 조회할 최대 개수 (기본: 전체) + * @return ProductRankScore 리스트 (점수 내림차순) + */ + List findAllOrderByScoreDesc(int limit); + + /** + * 모든 ProductRankScore를 조회합니다. + * + * @return ProductRankScore 리스트 + */ + List findAll(); + + /** + * 모든 ProductRankScore를 삭제합니다. + *

+ * Step 2 완료 후 임시 테이블을 정리하기 위해 사용합니다. + *

+ */ + void deleteAll(); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java new file mode 100644 index 000000000..7d23b370a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +/** + * ProductMetrics를 처리하는 Spring Batch ItemProcessor. + *

+ * 현재는 데이터를 그대로 전달하지만, 향후 집계 로직을 추가할 수 있습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Reader와 Writer 사이의 변환/필터링 로직을 위한 확장 포인트 제공
  • + *
  • 향후 주간/월간 집계를 위한 데이터 변환 로직 추가 가능
  • + *
  • 비즈니스 로직 검증 및 필터링 수행 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductMetricsItemProcessor implements ItemProcessor { + + /** + * ProductMetrics를 처리합니다. + *

+ * 현재는 데이터를 그대로 전달하지만, 필요시 변환/필터링 로직을 추가할 수 있습니다. + *

+ * + * @param item 처리할 ProductMetrics + * @return 처리된 ProductMetrics (null 반환 시 해당 항목은 Writer로 전달되지 않음) + */ + @Override + public ProductMetrics process(ProductMetrics item) throws Exception { + // 현재는 데이터를 그대로 전달 + // 향후 집계 로직이나 데이터 변환이 필요하면 여기에 추가 + return item; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java new file mode 100644 index 000000000..b7f420b87 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java @@ -0,0 +1,111 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.HashMap; +import java.util.Map; + +/** + * ProductMetrics를 읽기 위한 Spring Batch ItemReader Factory. + *

+ * Chunk-Oriented Processing을 위해 JPA Repository 기반 Reader를 생성합니다. + * 특정 날짜의 product_metrics 데이터를 페이징하여 읽습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
  • + *
  • 날짜 파라미터를 받아 해당 날짜의 데이터만 조회
  • + *
  • product_id 기준 정렬로 일관된 읽기 순서 보장
  • + *
+ *

+ *

+ * DIP 준수: + *

    + *
  • 도메인 레이어의 ProductMetricsRepository 인터페이스를 사용
  • + *
  • Spring Batch의 기술적 제약으로 인해 getJpaRepository()를 통해 JPA Repository 접근
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsItemReader { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * ProductMetrics를 읽는 ItemReader를 생성합니다. + *

+ * Job 파라미터에서 날짜를 받아 해당 날짜의 데이터만 조회합니다. + *

+ * + * @param targetDate 조회할 날짜 (yyyyMMdd 형식) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createReader(String targetDate) { + // 날짜 파라미터 파싱 + LocalDate date = parseDate(targetDate); + LocalDateTime startDateTime = date.atStartOfDay(); + LocalDateTime endDateTime = date.atTime(LocalTime.MAX); + + log.info("ProductMetrics Reader 초기화: targetDate={}, startDateTime={}, endDateTime={}", + date, startDateTime, endDateTime); + + // 정렬 기준 설정 (product_id 기준 오름차순) + Map sorts = new HashMap<>(); + sorts.put("productId", Sort.Direction.ASC); + + // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로 + // 기술적 제약으로 인해 getJpaRepository()를 통해 접근 + PagingAndSortingRepository jpaRepository = + productMetricsRepository.getJpaRepository(); + + return new RepositoryItemReaderBuilder() + .name("productMetricsReader") + .repository(jpaRepository) + .methodName("findByUpdatedAtBetween") + .arguments(startDateTime, endDateTime) + .pageSize(100) // Chunk 크기와 동일하게 설정 + .sorts(sorts) + .build(); + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + *

+ * yyyyMMdd 형식의 문자열을 파싱하며, 파싱 실패 시 오늘 날짜를 반환합니다. + *

+ * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다."); + return LocalDate.now(); + } + + try { + return LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e); + return LocalDate.now(); + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java new file mode 100644 index 000000000..89364f52e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * ProductMetrics를 처리하는 Spring Batch ItemWriter. + *

+ * 현재는 로깅만 수행하지만, 향후 Materialized View에 저장하는 로직을 추가할 수 있습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk 단위로 데이터를 처리하여 대량 데이터 처리 성능 최적화
  • + *
  • 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정
  • + *
  • 트랜잭션 단위는 Chunk 단위로 관리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductMetricsItemWriter implements ItemWriter { + + /** + * ProductMetrics Chunk를 처리합니다. + *

+ * 현재는 로깅만 수행하며, 향후 Materialized View에 저장하는 로직을 추가할 예정입니다. + *

+ * + * @param chunk 처리할 ProductMetrics Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + log.info("ProductMetrics Chunk 처리 시작: itemCount={}", items.size()); + + // 현재는 로깅만 수행 + // 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정 + for (ProductMetrics item : items) { + log.debug("ProductMetrics 처리: productId={}, likeCount={}, salesCount={}, viewCount={}, updatedAt={}", + item.getProductId(), item.getLikeCount(), item.getSalesCount(), + item.getViewCount(), item.getUpdatedAt()); + } + + log.info("ProductMetrics Chunk 처리 완료: itemCount={}", items.size()); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java new file mode 100644 index 000000000..1c874b3b7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java @@ -0,0 +1,148 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * ProductMetrics 집계를 위한 Spring Batch Job Configuration. + *

+ * Chunk-Oriented Processing 방식을 사용하여 대량의 product_metrics 데이터를 처리합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
  • + *
  • Job 파라미터 기반 실행: 날짜를 파라미터로 받아 특정 날짜의 데이터만 처리
  • + *
  • 확장성: 향후 주간/월간 집계를 위한 구조 준비
  • + *
  • 재시작 가능: 실패 시 이전 Chunk부터 재시작 가능
  • + *
+ *

+ *

+ * Chunk 크기 선택 근거: + *

    + *
  • 100개: 메모리 사용량과 성능의 균형
  • + *
  • 너무 작으면: 트랜잭션 오버헤드 증가
  • + *
  • 너무 크면: 메모리 사용량 증가 및 롤백 범위 확대
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductMetricsJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsItemReader productMetricsItemReader; + private final ProductMetricsItemProcessor productMetricsItemProcessor; + private final ProductMetricsItemWriter productMetricsItemWriter; + + /** + * ProductMetrics 집계 Job을 생성합니다. + *

+ * Job 파라미터: + *

    + *
  • targetDate: 처리할 날짜 (yyyyMMdd 형식, 예: "20241215")
  • + *
+ *

+ *

+ * 실행 예시: + *

+     * java -jar commerce-batch.jar --spring.batch.job.names=productMetricsAggregationJob targetDate=20241215
+     * 
+ *

+ * + * @return ProductMetrics 집계 Job + */ + @Bean + public Job productMetricsAggregationJob(Step productMetricsAggregationStep) { + return new JobBuilder("productMetricsAggregationJob", jobRepository) + .start(productMetricsAggregationStep) + .build(); + } + + /** + * ProductMetrics 집계 Step을 생성합니다. + *

+ * Chunk-Oriented Processing을 사용하여: + *

    + *
  1. Reader: 특정 날짜의 product_metrics를 페이징하여 읽기
  2. + *
  3. Processor: 데이터 변환/필터링 (현재는 pass-through)
  4. + *
  5. Writer: 집계 결과 처리 (현재는 로깅, 향후 MV 저장)
  6. + *
+ *

+ * + * @param productMetricsReader ProductMetrics Reader (StepScope Bean) + * @param productMetricsProcessor ProductMetrics Processor + * @param productMetricsWriter ProductMetrics Writer + * @return ProductMetrics 집계 Step + */ + @Bean + public Step productMetricsAggregationStep( + ItemReader productMetricsReader, + ItemProcessor productMetricsProcessor, + ItemWriter productMetricsWriter + ) { + return new StepBuilder("productMetricsAggregationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productMetricsReader) // StepScope Bean은 Step 실행 시점에 자동 주입됨 + .processor(productMetricsProcessor) + .writer(productMetricsWriter) + .build(); + } + + /** + * ProductMetrics Reader를 생성합니다. + *

+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *

+ * + * @param targetDate 조회할 날짜 (Job 파라미터에서 주입) + * @return ProductMetrics Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader productMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + return productMetricsItemReader.createReader(targetDate); + } + + /** + * ProductMetrics Processor를 주입받습니다. + * + * @return ProductMetrics Processor + */ + @Bean + public ItemProcessor productMetricsProcessor() { + return productMetricsItemProcessor; + } + + /** + * ProductMetrics Writer를 주입받습니다. + * + * @return ProductMetrics Writer + */ + @Bean + public ItemWriter productMetricsWriter() { + return productMetricsItemWriter; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java new file mode 100644 index 000000000..2cf591cef --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRank; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * ProductRank 집계를 위한 Processor. + *

+ * 기간 정보를 관리하고 Writer에서 사용할 수 있도록 제공합니다. + * 실제 집계는 Writer에서 Chunk 단위로 수행됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductRankAggregationProcessor { + + private ProductRank.PeriodType periodType; + private LocalDate periodStartDate; + + /** + * 기간 정보를 설정합니다. + *

+ * Job 파라미터에서 주입받아 설정합니다. + *

+ * + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param targetDate 기준 날짜 + */ + public void setPeriod(ProductRank.PeriodType periodType, LocalDate targetDate) { + this.periodType = periodType; + + if (periodType == ProductRank.PeriodType.WEEKLY) { + // 주간 시작일: 해당 주의 월요일 + this.periodStartDate = targetDate.with(java.time.DayOfWeek.MONDAY); + } else if (periodType == ProductRank.PeriodType.MONTHLY) { + // 월간 시작일: 해당 월의 1일 + this.periodStartDate = targetDate.with(TemporalAdjusters.firstDayOfMonth()); + } + } + + /** + * 기간 타입을 반환합니다. + * + * @return 기간 타입 + */ + public ProductRank.PeriodType getPeriodType() { + return periodType; + } + + /** + * 기간 시작일을 반환합니다. + * + * @return 기간 시작일 + */ + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java new file mode 100644 index 000000000..3f58bc891 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java @@ -0,0 +1,172 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.HashMap; +import java.util.Map; + +/** + * ProductRank 집계를 위한 Spring Batch ItemReader Factory. + *

+ * 주간/월간 집계를 위해 특정 기간의 모든 ProductMetrics를 읽습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • 주간 집계: 해당 주의 월요일부터 일요일까지의 데이터 조회
  • + *
  • 월간 집계: 해당 월의 1일부터 마지막 일까지의 데이터 조회
  • + *
  • 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankAggregationReader { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 주간 집계를 위한 Reader를 생성합니다. + *

+ * 해당 주의 월요일부터 일요일까지의 ProductMetrics를 조회합니다. + *

+ * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createWeeklyReader(LocalDate targetDate) { + DateRange weekRange = calculateWeeklyRange(targetDate); + + log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", + targetDate, weekRange.startDate(), weekRange.endDate()); + + return createReader(weekRange.startDateTime(), weekRange.endDateTime(), "weeklyReader"); + } + + /** + * 주간 범위를 계산합니다. + *

+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다. + *

+ * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return 주간 범위 (시작일, 종료일) + */ + DateRange calculateWeeklyRange(LocalDate targetDate) { + // 주간 시작일 계산 (월요일) + LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY); + LocalDateTime startDateTime = weekStart.atStartOfDay(); + + // 주간 종료일 계산 (다음 주 월요일 00:00:00) + LocalDate weekEnd = weekStart.plusWeeks(1); + LocalDateTime endDateTime = weekEnd.atStartOfDay(); + + return new DateRange(weekStart, weekEnd, startDateTime, endDateTime); + } + + /** + * 월간 집계를 위한 Reader를 생성합니다. + *

+ * 해당 월의 1일부터 마지막 일까지의 ProductMetrics를 조회합니다. + *

+ * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createMonthlyReader(LocalDate targetDate) { + DateRange monthRange = calculateMonthlyRange(targetDate); + + log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", + targetDate, monthRange.startDate(), monthRange.endDate()); + + return createReader(monthRange.startDateTime(), monthRange.endDateTime(), "monthlyReader"); + } + + /** + * 월간 범위를 계산합니다. + *

+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다. + *

+ * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return 월간 범위 (시작일, 종료일) + */ + DateRange calculateMonthlyRange(LocalDate targetDate) { + // 월간 시작일 계산 (1일) + LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth()); + LocalDateTime startDateTime = monthStart.atStartOfDay(); + + // 월간 종료일 계산 (다음 달 1일 00:00:00) + LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth()); + LocalDateTime endDateTime = monthEnd.atStartOfDay(); + + return new DateRange(monthStart, monthEnd, startDateTime, endDateTime); + } + + /** + * ProductMetrics를 읽는 ItemReader를 생성합니다. + * + * @param startDateTime 조회 시작 시각 + * @param endDateTime 조회 종료 시각 + * @param readerName Reader 이름 + * @return RepositoryItemReader 인스턴스 + */ + private RepositoryItemReader createReader( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + String readerName + ) { + // 정렬 기준 설정 (product_id 기준 오름차순) + Map sorts = new HashMap<>(); + sorts.put("productId", Sort.Direction.ASC); + + // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로 + // 기술적 제약으로 인해 getJpaRepository()를 통해 접근 + PagingAndSortingRepository jpaRepository = + productMetricsRepository.getJpaRepository(); + + return new RepositoryItemReaderBuilder() + .name(readerName) + .repository(jpaRepository) + .methodName("findByUpdatedAtBetween") + .arguments(startDateTime, endDateTime) + .pageSize(100) // Chunk 크기와 동일하게 설정 + .sorts(sorts) + .build(); + } + + /** + * 날짜 범위를 담는 레코드. + *

+ * 테스트 가능성을 위해 내부 클래스로 정의했습니다. + *

+ * + * @param startDate 시작일 + * @param endDate 종료일 (exclusive) + * @param startDateTime 시작 시각 + * @param endDateTime 종료 시각 (exclusive) + */ + record DateRange( + LocalDate startDate, + LocalDate endDate, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ) { + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java new file mode 100644 index 000000000..159138dae --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java @@ -0,0 +1,83 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +/** + * ProductRankScore를 ProductRank로 변환하는 Processor. + *

+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * ProductRankScore를 읽어서 랭킹 번호를 부여하고 ProductRank로 변환합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • ProductRankScore에 랭킹 번호 부여 (1부터 시작)
  • + *
  • TOP 100만 선정 (나머지는 null 반환하여 필터링)
  • + *
  • ProductRank로 변환
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class ProductRankCalculationProcessor implements ItemProcessor { + + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private int currentRank = 0; + private static final int TOP_RANK_LIMIT = 100; + + /** + * ProductRankScore를 ProductRank로 변환합니다. + *

+ * 랭킹 번호를 부여하고, TOP 100에 포함되는 경우에만 ProductRank를 반환합니다. + *

+ * + * @param score ProductRankScore + * @return ProductRank (TOP 100에 포함되는 경우), null (그 외) + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public ProductRank process(ProductRankScore score) throws Exception { + int rank = ++currentRank; + + // TOP 100에 포함되지 않으면 null 반환 (필터링) + if (rank > TOP_RANK_LIMIT) { + return null; + } + + // 기간 정보 가져오기 + ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType(); + LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate(); + + if (periodType == null || periodStartDate == null) { + log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다."); + return null; + } + + // ProductRank 생성 (랭킹 번호 부여) + ProductRank productRank = new ProductRank( + periodType, + periodStartDate, + score.getProductId(), + rank, // 랭킹 번호 (1부터 시작) + score.getLikeCount(), + score.getSalesCount(), + score.getViewCount() + ); + + return productRank; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java new file mode 100644 index 000000000..679d1d823 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.NonTransientResourceException; +import org.springframework.batch.item.ParseException; +import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.stereotype.Component; + +import java.util.Iterator; +import java.util.List; + +/** + * ProductRankScore를 읽는 Reader. + *

+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * ProductRankScore 테이블에서 점수 내림차순으로 모든 데이터를 읽습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Step 1에서 집계된 모든 ProductRankScore를 읽기
  • + *
  • 점수 내림차순으로 정렬된 데이터를 제공
  • + *
  • TOP 100 선정을 위해 전체 데이터를 읽어야 함
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class ProductRankCalculationReader implements ItemReader { + + private final ProductRankScoreRepository productRankScoreRepository; + private Iterator scoreIterator; + private boolean initialized = false; + + /** + * ProductRankScore를 읽습니다. + *

+ * 첫 호출 시 모든 데이터를 조회하고, 이후 Iterator를 통해 하나씩 반환합니다. + *

+ * + * @return ProductRankScore (더 이상 없으면 null) + * @throws UnexpectedInputException 예상치 못한 입력 오류 + * @throws ParseException 파싱 오류 + * @throws NonTransientResourceException 일시적이지 않은 리소스 오류 + */ + @Override + public ProductRankScore read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException { + if (!initialized) { + // 첫 호출 시 모든 데이터를 점수 내림차순으로 조회 + List scores = productRankScoreRepository.findAllOrderByScoreDesc(0); + this.scoreIterator = scores.iterator(); + this.initialized = true; + + log.info("ProductRankScore 조회 완료: totalCount={}", scores.size()); + } + + if (scoreIterator.hasNext()) { + return scoreIterator.next(); + } + + return null; // 더 이상 읽을 데이터가 없음 + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java new file mode 100644 index 000000000..40530a10a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java @@ -0,0 +1,84 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * ProductRank를 Materialized View에 저장하는 Writer. + *

+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * 랭킹 번호가 부여된 ProductRank를 Materialized View에 저장합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk 단위로 받은 ProductRank를 수집하고 저장
  • + *
  • 각 Chunk마다 전체 ProductRank를 저장 (saveRanks가 delete + insert를 수행)
  • + *
  • 기존 데이터 삭제 후 새 데이터 저장 (delete + insert 방식)
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class ProductRankCalculationWriter implements ItemWriter { + + private final ProductRankRepository productRankRepository; + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final List allRanks = new java.util.ArrayList<>(); + + /** + * ProductRank Chunk를 수집하고 저장합니다. + *

+ * 모든 Chunk를 메모리에 모아두고, 각 Chunk마다 전체를 저장합니다. + * saveRanks가 delete + insert를 수행하므로, 각 Chunk마다 전체를 저장해도 문제없습니다. + *

+ * + * @param chunk 처리할 ProductRank Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems() + .stream() + .filter(item -> item != null) // null 필터링 (TOP 100에 포함되지 않은 항목) + .collect(Collectors.toList()); + + if (items.isEmpty()) { + return; + } + + // 기간 정보 가져오기 + ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType(); + LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate(); + + if (periodType == null || periodStartDate == null) { + log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다."); + return; + } + + // 모든 Chunk를 수집 + allRanks.addAll(items); + log.debug("ProductRank Chunk 수집: count={}, total={}", items.size(), allRanks.size()); + + // 각 Chunk마다 전체를 저장 (saveRanks가 delete + insert를 수행하므로 문제없음) + log.info("ProductRank 저장: periodType={}, periodStartDate={}, total={}", + periodType, periodStartDate, allRanks.size()); + productRankRepository.saveRanks(periodType, periodStartDate, allRanks); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java new file mode 100644 index 000000000..875bd3519 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java @@ -0,0 +1,261 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * ProductRank 집계를 위한 Spring Batch Job Configuration. + *

+ * 주간/월간 TOP 100 랭킹을 Materialized View에 저장합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Step 1 (집계 로직 계산): 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
  • + *
  • Step 2 (랭킹 로직 실행): 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여
  • + *
  • Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
  • + *
  • Materialized View 저장: 조회 성능 최적화를 위한 TOP 100 랭킹 저장
  • + *
+ *

+ *

+ * Job 파라미터: + *

    + *
  • periodType: 기간 타입 (WEEKLY 또는 MONTHLY)
  • + *
  • targetDate: 기준 날짜 (yyyyMMdd 형식, 예: "20241215")
  • + *
+ *

+ *

+ * 실행 예시: + *

+ * // 주간 집계
+ * java -jar commerce-batch.jar \
+ *   --spring.batch.job.names=productRankAggregationJob \
+ *   periodType=WEEKLY targetDate=20241215
+ *
+ * // 월간 집계
+ * java -jar commerce-batch.jar \
+ *   --spring.batch.job.names=productRankAggregationJob \
+ *   periodType=MONTHLY targetDate=20241215
+ * 
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductRankJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductRankAggregationReader productRankAggregationReader; + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final ProductRankScoreAggregationWriter productRankScoreAggregationWriter; + private final ProductRankCalculationReader productRankCalculationReader; + private final ProductRankCalculationProcessor productRankCalculationProcessor; + private final ProductRankCalculationWriter productRankCalculationWriter; + + /** + * ProductRank 집계 Job을 생성합니다. + *

+ * 2-Step 구조: + *

    + *
  1. Step 1: 집계 로직 계산 (점수 집계)
  2. + *
  3. Step 2: 랭킹 로직 실행 (TOP 100 선정 및 랭킹 번호 부여)
  4. + *
+ *

+ * + * @param scoreAggregationStep Step 1: 집계 로직 계산 Step + * @param rankingCalculationStep Step 2: 랭킹 로직 실행 Step + * @return ProductRank 집계 Job + */ + @Bean + public Job productRankAggregationJob( + Step scoreAggregationStep, + Step rankingCalculationStep + ) { + return new JobBuilder("productRankAggregationJob", jobRepository) + .start(scoreAggregationStep) // Step 1 먼저 실행 + .next(rankingCalculationStep) // Step 1 완료 후 Step 2 실행 + .build(); + } + + /** + * Step 1: 집계 로직 계산 Step을 생성합니다. + *

+ * 모든 ProductMetrics를 읽어서 product_id별로 점수 집계하여 임시 테이블에 저장합니다. + *

+ *

+ * Chunk-Oriented Processing을 사용하여: + *

    + *
  1. Reader: 특정 기간의 product_metrics를 페이징하여 읽기
  2. + *
  3. Processor: Pass-through (필터링 필요 시 추가 가능)
  4. + *
  5. Writer: product_id별로 점수 집계하여 ProductRankScore 테이블에 저장
  6. + *
+ *

+ * + * @param productRankReader ProductRank Reader (StepScope Bean) + * @param productRankScoreWriter ProductRankScore Writer + * @return 집계 로직 계산 Step + */ + @Bean + public Step scoreAggregationStep( + ItemReader productRankReader, + ItemWriter productRankScoreWriter + ) { + return new StepBuilder("scoreAggregationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productRankReader) + .processor(item -> item) // Pass-through + .writer(productRankScoreWriter) + .build(); + } + + /** + * Step 2: 랭킹 로직 실행 Step을 생성합니다. + *

+ * 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여하여 Materialized View에 저장합니다. + *

+ *

+ * Chunk-Oriented Processing을 사용하여: + *

    + *
  1. Reader: ProductRankScore 테이블에서 모든 데이터를 점수 내림차순으로 읽기
  2. + *
  3. Processor: TOP 100 선정 및 랭킹 번호 부여
  4. + *
  5. Writer: ProductRank를 수집하고 저장
  6. + *
+ *

+ * + * @param productRankScoreReader ProductRankScore Reader + * @param productRankCalculationProcessor ProductRank 계산 Processor + * @param productRankCalculationWriter ProductRank 계산 Writer + * @return 랭킹 로직 실행 Step + */ + @Bean + public Step rankingCalculationStep( + ItemReader productRankScoreReader, + ItemProcessor productRankCalculationProcessor, + ItemWriter productRankCalculationWriter + ) { + return new StepBuilder("rankingCalculationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productRankScoreReader) + .processor(productRankCalculationProcessor) + .writer(productRankCalculationWriter) + .build(); + } + + /** + * ProductRank Reader를 생성합니다. + *

+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *

+ * + * @param periodType 기간 타입 (Job 파라미터에서 주입) + * @param targetDate 기준 날짜 (Job 파라미터에서 주입) + * @return ProductRank Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader productRankReader( + @Value("#{jobParameters['periodType']}") String periodType, + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + if (periodType == null || periodType.isEmpty()) { + throw new IllegalArgumentException("periodType 파라미터는 필수입니다. (WEEKLY 또는 MONTHLY)"); + } + + LocalDate date = parseDate(targetDate); + ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase()); + + // Processor에 기간 정보 설정 + productRankAggregationProcessor.setPeriod(period, date); + + if (period == ProductRank.PeriodType.WEEKLY) { + return productRankAggregationReader.createWeeklyReader(date); + } else { + return productRankAggregationReader.createMonthlyReader(date); + } + } + + /** + * Step 1용 ProductRankScore Writer를 주입받습니다. + * + * @return ProductRankScore Writer + */ + @Bean + public ItemWriter productRankScoreWriter() { + return productRankScoreAggregationWriter; + } + + /** + * Step 2용 ProductRankScore Reader를 주입받습니다. + * + * @return ProductRankScore Reader + */ + @Bean + public ItemReader productRankScoreReader() { + return productRankCalculationReader; + } + + /** + * Step 2용 ProductRank 계산 Processor를 주입받습니다. + * + * @return ProductRank 계산 Processor + */ + @Bean + public ItemProcessor productRankCalculationProcessor() { + return productRankCalculationProcessor; + } + + /** + * Step 2용 ProductRank 계산 Writer를 주입받습니다. + * + * @return ProductRank 계산 Writer + */ + @Bean + public ItemWriter productRankCalculationWriter() { + return productRankCalculationWriter; + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다."); + return LocalDate.now(); + } + + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e); + return LocalDate.now(); + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java new file mode 100644 index 000000000..59a8da624 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java @@ -0,0 +1,178 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * ProductRankScore 집계를 위한 Writer. + *

+ * Step 1 (집계 로직 계산 Step)에서 사용합니다. + * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고, + * ProductRankScore 임시 테이블에 저장합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk 단위로 받은 ProductMetrics를 product_id별로 집계
  • + *
  • 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)
  • + *
  • ProductRankScore 테이블에 저장 (랭킹 번호 없이)
  • + *
  • 같은 product_id가 여러 Chunk에 걸쳐 있을 경우 UPSERT 방식으로 누적
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankScoreAggregationWriter implements ItemWriter { + + private final ProductRankScoreRepository productRankScoreRepository; + + /** + * ProductMetrics Chunk를 집계하여 ProductRankScore 테이블에 저장합니다. + *

+ * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고 저장합니다. + * 같은 product_id가 여러 Chunk에 걸쳐 있을 경우, 기존 데이터를 조회하여 누적한 후 저장합니다. + *

+ * + * @param chunk 처리할 ProductMetrics Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + if (items.isEmpty()) { + log.warn("ProductMetrics Chunk가 비어있습니다."); + return; + } + + log.debug("ProductRankScore Chunk 처리 시작: itemCount={}", items.size()); + + // 같은 product_id를 가진 메트릭을 합산 (Chunk 내에서) + Map chunkAggregatedMap = items.stream() + .collect(Collectors.groupingBy( + ProductMetrics::getProductId, + Collectors.reducing( + new AggregatedMetrics(0L, 0L, 0L), + metrics -> new AggregatedMetrics( + metrics.getLikeCount(), + metrics.getSalesCount(), + metrics.getViewCount() + ), + (a, b) -> new AggregatedMetrics( + a.getLikeCount() + b.getLikeCount(), + a.getSalesCount() + b.getSalesCount(), + a.getViewCount() + b.getViewCount() + ) + ) + )); + + // Chunk 내 모든 productId를 한 번에 조회 + Set productIds = chunkAggregatedMap.keySet(); + Map existingScores = productRankScoreRepository + .findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(ProductRankScore::getProductId, Function.identity())); + + // 기존 데이터와 누적하여 ProductRankScore 생성 + List scores = chunkAggregatedMap.entrySet().stream() + .map(entry -> { + Long productId = entry.getKey(); + AggregatedMetrics chunkAggregated = entry.getValue(); + + // 기존 데이터 조회 (일괄 조회 결과에서) + ProductRankScore existing = existingScores.get(productId); + + // 기존 데이터와 누적 + Long totalLikeCount = chunkAggregated.getLikeCount(); + Long totalSalesCount = chunkAggregated.getSalesCount(); + Long totalViewCount = chunkAggregated.getViewCount(); + + if (existing != null) { + totalLikeCount += existing.getLikeCount(); + totalSalesCount += existing.getSalesCount(); + totalViewCount += existing.getViewCount(); + } + + // 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2) + double score = calculateScore(totalLikeCount, totalSalesCount, totalViewCount); + + return new ProductRankScore( + productId, + totalLikeCount, + totalSalesCount, + totalViewCount, + score + ); + }) + .collect(Collectors.toList()); + + // 저장 (기존 데이터가 있으면 덮어쓰기) + productRankScoreRepository.saveAll(scores); + + log.debug("ProductRankScore 저장 완료: count={}", scores.size()); + } + + /** + * 종합 점수를 계산합니다. + *

+ * 가중치: + *

    + *
  • 좋아요: 0.3
  • + *
  • 판매량: 0.5
  • + *
  • 조회수: 0.2
  • + *
+ *

+ * + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @return 종합 점수 + */ + private double calculateScore(Long likeCount, Long salesCount, Long viewCount) { + return likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2; + } + + /** + * 집계된 메트릭을 담는 내부 클래스. + */ + private static class AggregatedMetrics { + private final Long likeCount; + private final Long salesCount; + private final Long viewCount; + + public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) { + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + } + + public Long getLikeCount() { + return likeCount; + } + + public Long getSalesCount() { + return salesCount; + } + + public Long getViewCount() { + return viewCount; + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..ff10e63c2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +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; + +/** + * ProductMetrics JPA Repository. + *

+ * 상품 메트릭 집계 데이터를 관리합니다. + * commerce-batch 전용 Repository입니다. + *

+ *

+ * 모듈별 독립성: + *

    + *
  • commerce-batch의 필요에 맞게 커스터마이징된 Repository
  • + *
  • Spring Batch에서 날짜 기반 조회에 최적화
  • + *
+ *

+ */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다. + *

+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *

+ *

+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *

    + *
  • startDateTime: 2024-12-15 00:00:00
  • + *
  • endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
  • + *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다. + *

+ * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive) + * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + @Query("SELECT pm FROM ProductMetrics pm " + + "WHERE pm.updatedAt >= :startDateTime AND pm.updatedAt < :endDateTime " + + "ORDER BY pm.productId") + Page findByUpdatedAtBetween( + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable + ); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..51d974de5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +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; + +/** + * ProductMetricsRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *

+ *

+ * 배치 전용 구현: + *

    + *
  • Spring Batch에서 날짜 기반 조회에 최적화
  • + *
  • 대량 데이터 처리를 위한 페이징 조회 지원
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Page findByUpdatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable + ) { + return productMetricsJpaRepository.findByUpdatedAtBetween(startDateTime, endDateTime, pageable); + } + + /** + * {@inheritDoc} + */ + @Override + public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() { + return productMetricsJpaRepository; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java new file mode 100644 index 000000000..d50aa8991 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -0,0 +1,95 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank Repository 구현체. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 관리합니다. + *

+ */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks) { + // 기존 데이터 삭제 + deleteByPeriod(periodType, periodStartDate); + + // 새 데이터 저장 + for (ProductRank rank : ranks) { + entityManager.persist(rank); + } + + log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, count={}", + periodType, periodStartDate, ranks.size()); + } + + @Override + public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " + + "ORDER BY pr.rank ASC"; + + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setMaxResults(limit) + .getResultList(); + } + + @Override + public Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType " + + "AND pr.periodStartDate = :periodStartDate " + + "AND pr.productId = :productId"; + + try { + ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(rank); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } + + @Override + @Transactional + public void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate) { + String jpql = "DELETE FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate"; + + int deletedCount = entityManager.createQuery(jpql) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .executeUpdate(); + + log.debug("ProductRank 삭제 완료: periodType={}, periodStartDate={}, deletedCount={}", + periodType, periodStartDate, deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java new file mode 100644 index 000000000..3037e8f99 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java @@ -0,0 +1,113 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * ProductRankScore Repository 구현체. + *

+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *

+ */ +@Slf4j +@Repository +public class ProductRankScoreRepositoryImpl implements ProductRankScoreRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void save(ProductRankScore score) { + Optional existing = findByProductId(score.getProductId()); + + if (existing.isPresent()) { + // 기존 레코드가 있으면 덮어쓰기 (Writer에서 이미 누적된 값을 전달받음) + ProductRankScore existingScore = existing.get(); + existingScore.setMetrics( + score.getLikeCount(), + score.getSalesCount(), + score.getViewCount(), + score.getScore() + ); + entityManager.merge(existingScore); + log.debug("ProductRankScore 업데이트: productId={}", score.getProductId()); + } else { + // 없으면 새로 생성 + entityManager.persist(score); + log.debug("ProductRankScore 생성: productId={}", score.getProductId()); + } + } + + @Override + @Transactional + public void saveAll(List scores) { + for (ProductRankScore score : scores) { + save(score); + } + log.info("ProductRankScore 일괄 저장 완료: count={}", scores.size()); + } + + @Override + public Optional findByProductId(Long productId) { + String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId = :productId"; + + try { + ProductRankScore score = entityManager.createQuery(jpql, ProductRankScore.class) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(score); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } + + @Override + public List findAllByProductIdIn(Set productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } + + String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId IN :productIds"; + return entityManager.createQuery(jpql, ProductRankScore.class) + .setParameter("productIds", productIds) + .getResultList(); + } + + @Override + public List findAllOrderByScoreDesc(int limit) { + String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC"; + + jakarta.persistence.TypedQuery query = + entityManager.createQuery(jpql, ProductRankScore.class); + if (limit > 0) { + query.setMaxResults(limit); + } + + return query.getResultList(); + } + + @Override + public List findAll() { + String jpql = "SELECT prs FROM ProductRankScore prs"; + return entityManager.createQuery(jpql, ProductRankScore.class).getResultList(); + } + + @Override + @Transactional + public void deleteAll() { + String jpql = "DELETE FROM ProductRankScore"; + int deletedCount = entityManager.createQuery(jpql).executeUpdate(); + log.info("ProductRankScore 전체 삭제 완료: deletedCount={}", deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..8c66d71dc --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,43 @@ +spring: + main: + web-application-type: none # 배치 전용이므로 웹 서버 불필요 + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + jdbc: + initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 + job: + enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화 + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..133932ae4 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,217 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetrics 도메인 엔티티 테스트. + *

+ * commerce-batch 모듈의 ProductMetrics 엔티티에 대한 단위 테스트입니다. + *

+ */ +class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장)") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } + + @DisplayName("이벤트 버전이 메트릭 버전보다 크면 업데이트해야 한다고 판단한다") + @Test + void shouldUpdate_whenEventVersionIsGreater() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + Long eventVersion = 2L; + + // act + boolean result = metrics.shouldUpdate(eventVersion); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("이벤트 버전이 메트릭 버전보다 작거나 같으면 업데이트하지 않아야 한다고 판단한다") + @Test + void shouldNotUpdate_whenEventVersionIsLessOrEqual() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + metrics.incrementLikeCount(); // version = 2 + + // act & assert + assertThat(metrics.shouldUpdate(1L)).isFalse(); // 이벤트 버전이 더 작음 + assertThat(metrics.shouldUpdate(2L)).isFalse(); // 이벤트 버전이 같음 + } + + @DisplayName("이벤트 버전이 null이면 업데이트해야 한다고 판단한다 (하위 호환성)") + @Test + void shouldUpdate_whenEventVersionIsNull() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + + // act + boolean result = metrics.shouldUpdate(null); + + // assert + assertThat(result).isTrue(); // 하위 호환성을 위해 null이면 업데이트 + } + + @DisplayName("초기 버전(0)인 메트릭은 모든 이벤트 버전에 대해 업데이트해야 한다고 판단한다") + @Test + void shouldUpdate_whenMetricsVersionIsZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getVersion()).isEqualTo(0L); + + // act & assert + assertThat(metrics.shouldUpdate(0L)).isFalse(); // 같으면 업데이트 안 함 + assertThat(metrics.shouldUpdate(1L)).isTrue(); // 더 크면 업데이트 + assertThat(metrics.shouldUpdate(100L)).isTrue(); // 더 크면 업데이트 + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java new file mode 100644 index 000000000..72d0c592f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java @@ -0,0 +1,235 @@ +package com.loopers.domain.rank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductRank 도메인 엔티티 테스트. + *

+ * commerce-batch 모듈의 ProductRank 엔티티에 대한 단위 테스트입니다. + *

+ */ +class ProductRankTest { + + @DisplayName("ProductRank는 모든 필수 정보로 생성된다") + @Test + void createsProductRankWithAllFields() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); // 월요일 + Long productId = 1L; + Integer rank = 1; + Long likeCount = 100L; + Long salesCount = 500L; + Long viewCount = 1000L; + + // act + ProductRank productRank = new ProductRank( + periodType, + periodStartDate, + productId, + rank, + likeCount, + salesCount, + viewCount + ); + + // assert + assertThat(productRank.getPeriodType()).isEqualTo(periodType); + assertThat(productRank.getPeriodStartDate()).isEqualTo(periodStartDate); + assertThat(productRank.getProductId()).isEqualTo(productId); + assertThat(productRank.getRank()).isEqualTo(rank); + assertThat(productRank.getLikeCount()).isEqualTo(likeCount); + assertThat(productRank.getSalesCount()).isEqualTo(salesCount); + assertThat(productRank.getViewCount()).isEqualTo(viewCount); + assertThat(productRank.getCreatedAt()).isNotNull(); + assertThat(productRank.getUpdatedAt()).isNotNull(); + } + + @DisplayName("ProductRank 생성 시 createdAt과 updatedAt이 현재 시간으로 설정된다") + @Test + void setsCreatedAtAndUpdatedAtOnCreation() throws InterruptedException { + // arrange + LocalDateTime beforeCreation = LocalDateTime.now(); + Thread.sleep(1); + + // act + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + + Thread.sleep(1); + LocalDateTime afterCreation = LocalDateTime.now(); + + // assert + assertThat(productRank.getCreatedAt()) + .isAfter(beforeCreation) + .isBefore(afterCreation); + assertThat(productRank.getUpdatedAt()) + .isAfter(beforeCreation) + .isBefore(afterCreation); + } + + @DisplayName("주간 랭킹을 생성할 수 있다") + @Test + void createsWeeklyRank() { + // arrange + LocalDate weekStart = LocalDate.of(2024, 12, 9); // 월요일 + + // act + ProductRank weeklyRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + weekStart, + 1L, + 1, + 100L, + 500L, + 1000L + ); + + // assert + assertThat(weeklyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(weeklyRank.getPeriodStartDate()).isEqualTo(weekStart); + } + + @DisplayName("월간 랭킹을 생성할 수 있다") + @Test + void createsMonthlyRank() { + // arrange + LocalDate monthStart = LocalDate.of(2024, 12, 1); // 월의 1일 + + // act + ProductRank monthlyRank = new ProductRank( + ProductRank.PeriodType.MONTHLY, + monthStart, + 1L, + 1, + 100L, + 500L, + 1000L + ); + + // assert + assertThat(monthlyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(monthlyRank.getPeriodStartDate()).isEqualTo(monthStart); + } + + @DisplayName("랭킹 정보를 업데이트할 수 있다") + @Test + void canUpdateRank() throws InterruptedException { + // arrange + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + Integer newRank = 2; + Long newLikeCount = 200L; + Long newSalesCount = 600L; + Long newViewCount = 1100L; + LocalDateTime initialUpdatedAt = productRank.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + productRank.updateRank(newRank, newLikeCount, newSalesCount, newViewCount); + + // assert + assertThat(productRank.getRank()).isEqualTo(newRank); + assertThat(productRank.getLikeCount()).isEqualTo(newLikeCount); + assertThat(productRank.getSalesCount()).isEqualTo(newSalesCount); + assertThat(productRank.getViewCount()).isEqualTo(newViewCount); + assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("랭킹 업데이트 시 updatedAt이 갱신된다") + @Test + void updatesUpdatedAtWhenRankIsUpdated() throws InterruptedException { + // arrange + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + LocalDateTime initialUpdatedAt = productRank.getUpdatedAt(); + + // act + Thread.sleep(1); + productRank.updateRank(2, 200L, 600L, 1100L); + + // assert + assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("PeriodType enum이 올바르게 정의되어 있다") + @Test + void periodTypeEnumIsCorrectlyDefined() { + // assert + assertThat(ProductRank.PeriodType.WEEKLY).isNotNull(); + assertThat(ProductRank.PeriodType.MONTHLY).isNotNull(); + assertThat(ProductRank.PeriodType.values()).hasSize(2); + } + + @DisplayName("TOP 100 랭킹을 생성할 수 있다") + @Test + void createsTop100Rank() { + // arrange + Integer topRank = 100; + + // act + ProductRank top100Rank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 100L, + topRank, + 1L, + 1L, + 1L + ); + + // assert + assertThat(top100Rank.getRank()).isEqualTo(topRank); + assertThat(top100Rank.getRank()).isLessThanOrEqualTo(100); + } + + @DisplayName("랭킹 1위를 생성할 수 있다") + @Test + void createsFirstRank() { + // arrange + Integer firstRank = 1; + + // act + ProductRank firstPlaceRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + firstRank, + 1000L, + 5000L, + 10000L + ); + + // assert + assertThat(firstPlaceRank.getRank()).isEqualTo(firstRank); + assertThat(firstPlaceRank.getRank()).isGreaterThanOrEqualTo(1); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java new file mode 100644 index 000000000..23869009a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsItemProcessor 테스트. + */ +class ProductMetricsItemProcessorTest { + + private final ProductMetricsItemProcessor processor = new ProductMetricsItemProcessor(); + + @DisplayName("ProductMetrics를 그대로 전달한다 (pass-through)") + @Test + void processesItem_andReturnsSameItem() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + item.incrementLikeCount(); + item.incrementSalesCount(10); + item.incrementViewCount(); + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isSameAs(item); // 동일한 객체 반환 + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getLikeCount()).isEqualTo(1L); + assertThat(result.getSalesCount()).isEqualTo(10L); + assertThat(result.getViewCount()).isEqualTo(1L); + } + + @DisplayName("null이 아닌 모든 ProductMetrics를 처리한다") + @Test + void processesNonNullItem() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(100L); + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isNotNull(); + assertThat(result).isSameAs(item); + } + + @DisplayName("여러 번 처리해도 동일한 결과를 반환한다") + @Test + void processesItemMultipleTimes_returnsSameResult() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + item.incrementLikeCount(); + + // act + ProductMetrics result1 = processor.process(item); + ProductMetrics result2 = processor.process(item); + ProductMetrics result3 = processor.process(item); + + // assert + assertThat(result1).isSameAs(item); + assertThat(result2).isSameAs(item); + assertThat(result3).isSameAs(item); + } + + @DisplayName("초기값을 가진 ProductMetrics도 처리한다") + @Test + void processesItemWithInitialValues() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + // 초기값: 모든 카운트가 0 + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isSameAs(item); + assertThat(result.getLikeCount()).isEqualTo(0L); + assertThat(result.getSalesCount()).isEqualTo(0L); + assertThat(result.getViewCount()).isEqualTo(0L); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java new file mode 100644 index 000000000..4a3a75f93 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java @@ -0,0 +1,134 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductMetricsItemReader 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsItemReaderTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private PagingAndSortingRepository jpaRepository; + + @DisplayName("올바른 날짜 형식으로 Reader를 생성할 수 있다") + @Test + void createsReader_withValidDate() { + // arrange + String targetDate = "20241215"; + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("productMetricsReader"); + } + + @DisplayName("날짜 파라미터가 null이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withNullDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(null); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("날짜 파라미터가 빈 문자열이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withEmptyDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(""); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("잘못된 날짜 형식이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withInvalidDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader("invalid-date"); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("날짜 파라미터를 올바르게 파싱하여 날짜 범위를 설정한다") + @Test + void parsesDateCorrectly_andSetsDateTimeRange() { + // arrange + String targetDate = "20241215"; + LocalDate expectedDate = LocalDate.of(2024, 12, 15); + LocalDateTime expectedStart = expectedDate.atStartOfDay(); + LocalDateTime expectedEnd = expectedDate.atTime(LocalTime.MAX); + + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 날짜 파싱이 올바르게 되었는지 확인 (Reader 내부에서 사용되므로 간접적으로 검증) + // 실제 날짜 범위는 Repository 호출 시 사용되므로, Reader가 정상 생성되었으면 성공 + } + + @DisplayName("JPA Repository를 통해 Reader를 생성한다") + @Test + void createsReader_usingJpaRepository() { + // arrange + String targetDate = "20241215"; + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // getJpaRepository()가 호출되었는지 확인 + // (실제로는 RepositoryItemReader 내부에서 사용되므로 간접적으로 검증) + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java new file mode 100644 index 000000000..d0613096e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java @@ -0,0 +1,118 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.item.Chunk; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * ProductMetricsItemWriter 테스트. + */ +class ProductMetricsItemWriterTest { + + private final ProductMetricsItemWriter writer = new ProductMetricsItemWriter(); + + @DisplayName("Chunk를 정상적으로 처리할 수 있다") + @Test + void writesChunk_successfully() throws Exception { + // arrange + List items = createProductMetricsList(3); + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("빈 Chunk도 처리할 수 있다") + @Test + void writesEmptyChunk_successfully() throws Exception { + // arrange + Chunk chunk = new Chunk<>(new ArrayList<>()); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("큰 Chunk도 처리할 수 있다") + @Test + void writesLargeChunk_successfully() throws Exception { + // arrange + List items = createProductMetricsList(100); // Chunk 크기와 동일 + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("다양한 메트릭 값을 가진 Chunk를 처리할 수 있다") + @Test + void writesChunk_withVariousMetrics() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics1 = new ProductMetrics(1L); + metrics1.incrementLikeCount(); + items.add(metrics1); + + ProductMetrics metrics2 = new ProductMetrics(2L); + metrics2.incrementSalesCount(100); + items.add(metrics2); + + ProductMetrics metrics3 = new ProductMetrics(3L); + metrics3.incrementViewCount(); + metrics3.incrementViewCount(); + items.add(metrics3); + + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("Chunk의 모든 항목을 처리한다") + @Test + void writesChunk_processesAllItems() throws Exception { + // arrange + int itemCount = 10; + List items = createProductMetricsList(itemCount); + Chunk chunk = new Chunk<>(items); + + // act + writer.write(chunk); + + // assert + // 현재는 로깅만 수행하므로 예외가 발생하지 않으면 성공 + // 향후 Materialized View 저장 로직 추가 시 추가 검증 필요 + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + /** + * 테스트용 ProductMetrics 리스트를 생성합니다. + * + * @param count 생성할 항목 수 + * @return ProductMetrics 리스트 + */ + private List createProductMetricsList(int count) { + List items = new ArrayList<>(); + for (long i = 1; i <= count; i++) { + ProductMetrics metrics = new ProductMetrics(i); + metrics.incrementLikeCount(); + metrics.incrementSalesCount((int) i); + metrics.incrementViewCount(); + items.add(metrics); + } + return items; + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java new file mode 100644 index 000000000..a87ec4585 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java @@ -0,0 +1,121 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductRankAggregationProcessor 테스트. + */ +class ProductRankAggregationProcessorTest { + + private final ProductRankAggregationProcessor processor = new ProductRankAggregationProcessor(); + + @DisplayName("주간 기간 정보를 설정할 수 있다") + @Test + void setsWeeklyPeriod() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + + // act + processor.setPeriod(periodType, targetDate); + + // assert + assertThat(processor.getPeriodType()).isEqualTo(periodType); + assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일 + } + + @DisplayName("월간 기간 정보를 설정할 수 있다") + @Test + void setsMonthlyPeriod() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + + // act + processor.setPeriod(periodType, targetDate); + + // assert + assertThat(processor.getPeriodType()).isEqualTo(periodType); + assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 월의 1일 + } + + @DisplayName("주간 기간 설정 시 해당 주의 월요일을 시작일로 계산한다") + @Test + void calculatesWeekStartAsMonday_whenSettingWeeklyPeriod() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + + // 월요일 + LocalDate monday = LocalDate.of(2024, 12, 9); + // 수요일 + LocalDate wednesday = LocalDate.of(2024, 12, 11); + // 일요일 + LocalDate sunday = LocalDate.of(2024, 12, 15); + + // act & assert + processor.setPeriod(periodType, monday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + + processor.setPeriod(periodType, wednesday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + + processor.setPeriod(periodType, sunday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + } + + @DisplayName("월간 기간 설정 시 해당 월의 1일을 시작일로 계산한다") + @Test + void calculatesMonthStartAsFirstDay_whenSettingMonthlyPeriod() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + LocalDate expectedStart = LocalDate.of(2024, 12, 1); + + // 1일 + LocalDate firstDay = LocalDate.of(2024, 12, 1); + // 15일 + LocalDate midDay = LocalDate.of(2024, 12, 15); + // 마지막 일 + LocalDate lastDay = LocalDate.of(2024, 12, 31); + + // act & assert + processor.setPeriod(periodType, firstDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + + processor.setPeriod(periodType, midDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + + processor.setPeriod(periodType, lastDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + } + + @DisplayName("기간 정보를 여러 번 설정할 수 있다") + @Test + void canSetPeriodMultipleTimes() { + // arrange + LocalDate firstDate = LocalDate.of(2024, 12, 15); + LocalDate secondDate = LocalDate.of(2024, 11, 20); + + // act + processor.setPeriod(ProductRank.PeriodType.WEEKLY, firstDate); + ProductRank.PeriodType firstType = processor.getPeriodType(); + LocalDate firstStart = processor.getPeriodStartDate(); + + processor.setPeriod(ProductRank.PeriodType.MONTHLY, secondDate); + ProductRank.PeriodType secondType = processor.getPeriodType(); + LocalDate secondStart = processor.getPeriodStartDate(); + + // assert + assertThat(firstType).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(firstStart).isEqualTo(LocalDate.of(2024, 12, 9)); + + assertThat(secondType).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(secondStart).isEqualTo(LocalDate.of(2024, 11, 1)); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java new file mode 100644 index 000000000..286108683 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java @@ -0,0 +1,168 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductRankAggregationReader 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankAggregationReaderTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private PagingAndSortingRepository jpaRepository; + + @DisplayName("주간 Reader를 생성할 수 있다") + @Test + void createsWeeklyReader() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("weeklyReader"); + } + + @DisplayName("주간 Reader는 해당 주의 월요일부터 다음 주 월요일까지의 데이터를 조회한다") + @Test + void weeklyReaderQueriesFromMondayToNextMonday() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + ProductRankAggregationReader.DateRange range = reader.calculateWeeklyRange(targetDate); + + // assert + // 2024-12-15(일) -> 2024-12-09(월)이 시작일 + assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일 + assertThat(range.endDate()).isEqualTo(LocalDate.of(2024, 12, 16)); // 다음 주 월요일 + assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 9).atStartOfDay()); + assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2024, 12, 16).atStartOfDay()); + } + + @DisplayName("월간 Reader를 생성할 수 있다") + @Test + void createsMonthlyReader() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("monthlyReader"); + } + + @DisplayName("월간 Reader는 해당 월의 1일부터 다음 달 1일까지의 데이터를 조회한다") + @Test + void monthlyReaderQueriesFromFirstDayToNextMonth() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + ProductRankAggregationReader.DateRange range = reader.calculateMonthlyRange(targetDate); + + // assert + // 2024-12-15 -> 2024-12-01이 시작일 + assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 1일 + assertThat(range.endDate()).isEqualTo(LocalDate.of(2025, 1, 1)); // 다음 달 1일 + assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 1).atStartOfDay()); + assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2025, 1, 1).atStartOfDay()); + } + + @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다") + @Test + void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { + // arrange + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // 월요일 + LocalDate monday = LocalDate.of(2024, 12, 9); + // 수요일 + LocalDate wednesday = LocalDate.of(2024, 12, 11); + // 일요일 + LocalDate sunday = LocalDate.of(2024, 12, 15); + + // act + ProductRankAggregationReader.DateRange mondayRange = reader.calculateWeeklyRange(monday); + ProductRankAggregationReader.DateRange wednesdayRange = reader.calculateWeeklyRange(wednesday); + ProductRankAggregationReader.DateRange sundayRange = reader.calculateWeeklyRange(sunday); + + // assert + // 모두 같은 주의 월요일부터 시작해야 함 + LocalDate expectedStart = LocalDate.of(2024, 12, 9); // 월요일 + LocalDate expectedEnd = LocalDate.of(2024, 12, 16); // 다음 주 월요일 + + assertThat(mondayRange.startDate()).isEqualTo(expectedStart); + assertThat(mondayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(wednesdayRange.startDate()).isEqualTo(expectedStart); + assertThat(wednesdayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(sundayRange.startDate()).isEqualTo(expectedStart); + assertThat(sundayRange.endDate()).isEqualTo(expectedEnd); + } + + @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다") + @Test + void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { + // arrange + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // 1일 + LocalDate firstDay = LocalDate.of(2024, 12, 1); + // 15일 + LocalDate midDay = LocalDate.of(2024, 12, 15); + // 마지막 일 + LocalDate lastDay = LocalDate.of(2024, 12, 31); + + // act + ProductRankAggregationReader.DateRange firstDayRange = reader.calculateMonthlyRange(firstDay); + ProductRankAggregationReader.DateRange midDayRange = reader.calculateMonthlyRange(midDay); + ProductRankAggregationReader.DateRange lastDayRange = reader.calculateMonthlyRange(lastDay); + + // assert + // 모두 같은 월의 1일부터 시작해야 함 + LocalDate expectedStart = LocalDate.of(2024, 12, 1); // 1일 + LocalDate expectedEnd = LocalDate.of(2025, 1, 1); // 다음 달 1일 + + assertThat(firstDayRange.startDate()).isEqualTo(expectedStart); + assertThat(firstDayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(midDayRange.startDate()).isEqualTo(expectedStart); + assertThat(midDayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(lastDayRange.startDate()).isEqualTo(expectedStart); + assertThat(lastDayRange.endDate()).isEqualTo(expectedEnd); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java new file mode 100644 index 000000000..2bd675457 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java @@ -0,0 +1,250 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductRankCalculationProcessor 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankCalculationProcessorTest { + + @Mock + private ProductRankAggregationProcessor productRankAggregationProcessor; + + private ProductRankCalculationProcessor processor; + + @BeforeEach + void setUp() { + processor = new ProductRankCalculationProcessor(productRankAggregationProcessor); + } + + @DisplayName("랭킹 번호를 1부터 순차적으로 부여한다") + @Test + void assignsRankSequentially() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score1 = createProductRankScore(1L, 10L, 20L, 5L); + ProductRankScore score2 = createProductRankScore(2L, 15L, 25L, 8L); + ProductRankScore score3 = createProductRankScore(3L, 8L, 15L, 3L); + + // act + ProductRank rank1 = processor.process(score1); + ProductRank rank2 = processor.process(score2); + ProductRank rank3 = processor.process(score3); + + // assert + assertThat(rank1).isNotNull(); + assertThat(rank1.getRank()).isEqualTo(1); + assertThat(rank1.getProductId()).isEqualTo(1L); + + assertThat(rank2).isNotNull(); + assertThat(rank2.getRank()).isEqualTo(2); + assertThat(rank2.getProductId()).isEqualTo(2L); + + assertThat(rank3).isNotNull(); + assertThat(rank3.getRank()).isEqualTo(3); + assertThat(rank3.getProductId()).isEqualTo(3L); + } + + @DisplayName("TOP 100에 포함되는 경우 ProductRank를 반환한다") + @Test + void returnsProductRankForTop100() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(1); + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getPeriodType()).isEqualTo(periodType); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + assertThat(result.getLikeCount()).isEqualTo(10L); + assertThat(result.getSalesCount()).isEqualTo(20L); + assertThat(result.getViewCount()).isEqualTo(5L); + } + + @DisplayName("101번째 이후는 null을 반환한다 (TOP 100 초과)") + @Test + void returnsNullAfter100th() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + // 100개까지 처리 + for (int i = 1; i <= 100; i++) { + ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); + ProductRank result = processor.process(score); + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(i); + } + + // 101번째 처리 (rank > TOP_RANK_LIMIT이므로 null 반환) + ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L); + ProductRank result = processor.process(score101); + + // assert + assertThat(result).isNull(); // TOP 100 초과이므로 null 반환 + } + + @DisplayName("정확히 100번째는 ProductRank를 반환한다") + @Test + void returnsProductRankFor100th() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + // 99개까지 처리 + for (int i = 1; i <= 99; i++) { + ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); + processor.process(score); + } + + // 100번째 처리 + ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score100); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(100); + assertThat(result.getProductId()).isEqualTo(100L); + } + + @DisplayName("기간 정보가 설정되지 않으면 null을 반환한다") + @Test + void returnsNullWhenPeriodNotSet() throws Exception { + // arrange + when(productRankAggregationProcessor.getPeriodType()).thenReturn(null); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("기간 시작일이 설정되지 않으면 null을 반환한다") + @Test + void returnsNullWhenPeriodStartDateNotSet() throws Exception { + // arrange + when(productRankAggregationProcessor.getPeriodType()).thenReturn(ProductRank.PeriodType.WEEKLY); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("주간 기간 정보로 ProductRank를 생성한다") + @Test + void createsProductRankWithWeeklyPeriod() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + } + + @DisplayName("월간 기간 정보로 ProductRank를 생성한다") + @Test + void createsProductRankWithMonthlyPeriod() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 1); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + } + + @DisplayName("ProductRankScore의 메트릭 값을 ProductRank에 전달한다") + @Test + void transfersMetricsFromScoreToRank() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 100L, 200L, 50L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLikeCount()).isEqualTo(100L); + assertThat(result.getSalesCount()).isEqualTo(200L); + assertThat(result.getViewCount()).isEqualTo(50L); + } + + /** + * 테스트용 ProductRankScore를 생성합니다. + */ + private ProductRankScore createProductRankScore(Long productId, Long likeCount, Long salesCount, Long viewCount) { + double score = likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2; + return new ProductRankScore(productId, likeCount, salesCount, viewCount, score); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java new file mode 100644 index 000000000..626d3ee2f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java @@ -0,0 +1,251 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.Chunk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.*; + +/** + * ProductRankScoreAggregationWriter 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankScoreAggregationWriterTest { + + @Mock + private ProductRankScoreRepository productRankScoreRepository; + + @InjectMocks + private ProductRankScoreAggregationWriter writer; + + @DisplayName("Chunk 내에서 같은 product_id를 가진 메트릭을 집계한다") + @Test + void aggregatesMetricsByProductId() throws Exception { + // arrange + List items = new ArrayList<>(); + + // 같은 product_id를 가진 메트릭 2개 + ProductMetrics metrics1 = new ProductMetrics(1L); + metrics1.incrementLikeCount(); + metrics1.incrementSalesCount(10); + metrics1.incrementViewCount(); + items.add(metrics1); + + ProductMetrics metrics2 = new ProductMetrics(1L); + metrics2.incrementLikeCount(); + metrics2.incrementSalesCount(20); + metrics2.incrementViewCount(); + items.add(metrics2); + + // 다른 product_id + ProductMetrics metrics3 = new ProductMetrics(2L); + metrics3.incrementLikeCount(); + items.add(metrics3); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + List savedScores = captor.getValue(); + assertThat(savedScores).hasSize(2); + + // product_id=1: 좋아요 2, 판매량 30, 조회수 2 + ProductRankScore score1 = savedScores.stream() + .filter(s -> s.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + assertThat(score1.getLikeCount()).isEqualTo(2L); + assertThat(score1.getSalesCount()).isEqualTo(30L); + assertThat(score1.getViewCount()).isEqualTo(2L); + + // product_id=2: 좋아요 1, 판매량 0, 조회수 0 + ProductRankScore score2 = savedScores.stream() + .filter(s -> s.getProductId().equals(2L)) + .findFirst() + .orElseThrow(); + assertThat(score2.getLikeCount()).isEqualTo(1L); + assertThat(score2.getSalesCount()).isEqualTo(0L); + assertThat(score2.getViewCount()).isEqualTo(0L); + } + + @DisplayName("점수를 올바른 가중치로 계산한다") + @Test + void calculatesScoreWithCorrectWeights() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 1 + metrics.incrementSalesCount(10); // 10 + metrics.incrementViewCount(); // 1 + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + // 점수 = 1 * 0.3 + 10 * 0.5 + 1 * 0.2 = 0.3 + 5.0 + 0.2 = 5.5 + assertThat(savedScore.getScore()).isEqualTo(5.5); + } + + @DisplayName("기존 데이터가 있으면 누적하여 저장한다") + @Test + void accumulatesWithExistingData() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + // 기존 데이터: 좋아요 5, 판매량 20, 조회수 3 + ProductRankScore existingScore = new ProductRankScore(1L, 5L, 20L, 3L, 12.1); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of(existingScore)); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + // 누적: 좋아요 5+1=6, 판매량 20+10=30, 조회수 3+1=4 + assertThat(savedScore.getLikeCount()).isEqualTo(6L); + assertThat(savedScore.getSalesCount()).isEqualTo(30L); + assertThat(savedScore.getViewCount()).isEqualTo(4L); + // 점수 = 6 * 0.3 + 30 * 0.5 + 4 * 0.2 = 1.8 + 15.0 + 0.8 = 17.6 + assertThat(savedScore.getScore()).isEqualTo(17.6); + } + + @DisplayName("빈 Chunk는 처리하지 않는다") + @Test + void skipsEmptyChunk() throws Exception { + // arrange + Chunk chunk = new Chunk<>(new ArrayList<>()); + + // act + writer.write(chunk); + + // assert + verify(productRankScoreRepository, never()).findAllByProductIdIn(anySet()); + verify(productRankScoreRepository, never()).saveAll(anyList()); + } + + @DisplayName("여러 product_id를 가진 Chunk를 처리한다") + @Test + void processesMultipleProductIds() throws Exception { + // arrange + List items = new ArrayList<>(); + + for (long i = 1; i <= 5; i++) { + ProductMetrics metrics = new ProductMetrics(i); + metrics.incrementLikeCount(); + metrics.incrementSalesCount((int) i); + metrics.incrementViewCount(); + items.add(metrics); + } + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + List savedScores = captor.getValue(); + assertThat(savedScores).hasSize(5); + + // 각 product_id별로 저장되었는지 확인 + for (long i = 1; i <= 5; i++) { + long productId = i; + ProductRankScore score = savedScores.stream() + .filter(s -> s.getProductId().equals(productId)) + .findFirst() + .orElseThrow(); + assertThat(score.getProductId()).isEqualTo(productId); + assertThat(score.getLikeCount()).isEqualTo(1L); + assertThat(score.getSalesCount()).isEqualTo(productId); + assertThat(score.getViewCount()).isEqualTo(1L); + } + } + + @DisplayName("기존 데이터가 없으면 새로 생성한다") + @Test + void createsNewScoreWhenNoExistingData() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + assertThat(savedScore.getProductId()).isEqualTo(1L); + assertThat(savedScore.getLikeCount()).isEqualTo(1L); + assertThat(savedScore.getSalesCount()).isEqualTo(10L); + assertThat(savedScore.getViewCount()).isEqualTo(1L); + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index ea4b4d15a..eb986acdd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceStreamerApplication { @PostConstruct public void started() { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java new file mode 100644 index 000000000..bbb016ee2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java @@ -0,0 +1,64 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 이벤트 처리 기록 서비스. + *

+ * Kafka Consumer에서 이벤트의 멱등성을 보장하기 위한 서비스입니다. + * 이벤트 처리 전 `eventId`가 이미 처리되었는지 확인하고, + * 처리되지 않은 경우에만 처리 기록을 저장합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EventHandledService { + + private final EventHandledRepository eventHandledRepository; + + /** + * 이벤트가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + @Transactional(readOnly = true) + public boolean isAlreadyHandled(String eventId) { + return eventHandledRepository.existsByEventId(eventId); + } + + /** + * 이벤트 처리 기록을 저장합니다. + *

+ * UNIQUE 제약조건 위반 시 예외를 발생시킵니다. + * 이는 동시성 상황에서 중복 처리를 방지하기 위한 것입니다. + *

+ * + * @param eventId 이벤트 ID + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + * @throws org.springframework.dao.DataIntegrityViolationException 이미 처리된 이벤트인 경우 + */ + @Transactional + public void markAsHandled(String eventId, String eventType, String topic) { + try { + EventHandled eventHandled = new EventHandled(eventId, eventType, topic); + eventHandledRepository.save(eventHandled); + log.debug("이벤트 처리 기록 저장: eventId={}, eventType={}, topic={}", + eventId, eventType, topic); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 처리됨 (멱등성 보장) + log.warn("이벤트가 이미 처리되었습니다: eventId={}", eventId); + throw e; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java new file mode 100644 index 000000000..98227105d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java @@ -0,0 +1,165 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 상품 메트릭 집계 서비스. + *

+ * Kafka Consumer에서 이벤트를 수취하여 상품 메트릭을 집계합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 upsert합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
  • + *
  • Kafka Consumer를 통한 이벤트 기반 집계
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 좋아요 수를 증가시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

+ * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementLikeCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementLikeCount(); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 증가: productId={}, likeCount={}", productId, metrics.getLikeCount()); + } + + /** + * 좋아요 수를 감소시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

+ * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void decrementLikeCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.decrementLikeCount(); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 감소: productId={}, likeCount={}", productId, metrics.getLikeCount()); + } + + /** + * 판매량을 증가시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

+ * + * @param productId 상품 ID + * @param quantity 판매 수량 + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementSalesCount(Long productId, Integer quantity, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementSalesCount(quantity); + productMetricsRepository.save(metrics); + log.debug("판매량 증가: productId={}, quantity={}, salesCount={}", + productId, quantity, metrics.getSalesCount()); + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

+ * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementViewCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementViewCount(); + productMetricsRepository.save(metrics); + log.debug("조회 수 증가: productId={}, viewCount={}", productId, metrics.getViewCount()); + } + + /** + * 상품 메트릭을 조회하거나 없으면 생성합니다. + *

+ * 비관적 락을 사용하여 동시성 제어를 보장합니다. + * 신규 생성 시 동시 삽입으로 인한 unique constraint violation을 처리합니다. + *

+ * + * @param productId 상품 ID + * @return ProductMetrics 인스턴스 + */ + private ProductMetrics findOrCreate(Long productId) { + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseGet(() -> { + try { + ProductMetrics newMetrics = new ProductMetrics(productId); + return productMetricsRepository.save(newMetrics); + } catch (DataIntegrityViolationException e) { + // 동시 삽입 시 재조회 + log.debug("동시 삽입 감지, 재조회: productId={}", productId); + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseThrow(() -> new IllegalStateException( + "ProductMetrics 생성 실패: productId=" + productId)); + } + }); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java new file mode 100644 index 000000000..49c078580 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java @@ -0,0 +1,118 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 랭킹 이벤트 핸들러. + *

+ * 좋아요 추가/취소, 주문 생성, 상품 조회 이벤트를 받아 랭킹 점수를 집계하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: RankingService는 랭킹 점수 계산/적재, RankingEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 랭킹은 파생 View로 취급하며, 도메인 이벤트를 구독하여 집계
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingEventHandler { + + private final RankingService rankingService; + + /** + * 좋아요 추가 이벤트를 처리하여 랭킹 점수를 추가합니다. + * + * @param event 좋아요 추가 이벤트 + */ + public void handleLikeAdded(LikeEvent.LikeAdded event) { + log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + rankingService.addLikeScore(event.productId(), date, true); + + log.debug("좋아요 점수 추가 완료: productId={}", event.productId()); + } + + /** + * 좋아요 취소 이벤트를 처리하여 랭킹 점수를 차감합니다. + * + * @param event 좋아요 취소 이벤트 + */ + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + rankingService.addLikeScore(event.productId(), date, false); + + log.debug("좋아요 점수 차감 완료: productId={}", event.productId()); + } + + /** + * 주문 생성 이벤트를 처리하여 랭킹 점수를 추가합니다. + *

+ * 주문 금액 계산: + *

    + *
  • OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없음
  • + *
  • subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
  • + *
  • 향후 개선: 주문 이벤트에 개별 상품 가격 정보 추가
  • + *
+ *

+ * + * @param event 주문 생성 이벤트 + */ + public void handleOrderCreated(OrderEvent.OrderCreated event) { + log.debug("주문 생성 이벤트 처리: orderId={}", event.orderId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + + // 주문 아이템별로 점수 집계 + // 주의: OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없으므로 + // subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함 + int totalQuantity = event.orderItems().stream() + .mapToInt(OrderEvent.OrderCreated.OrderItemInfo::quantity) + .sum(); + + if (totalQuantity > 0 && event.subtotal() != null) { + double averagePrice = (double) event.subtotal() / totalQuantity; + + for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { + double orderAmount = averagePrice * item.quantity(); + rankingService.addOrderScore(item.productId(), date, orderAmount); + } + } + + log.debug("주문 점수 추가 완료: orderId={}", event.orderId()); + } + + /** + * 상품 조회 이벤트를 처리하여 랭킹 점수를 추가합니다. + * + * @param event 상품 조회 이벤트 + */ + public void handleProductViewed(ProductEvent.ProductViewed event) { + log.debug("상품 조회 이벤트 처리: productId={}", event.productId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + rankingService.addViewScore(event.productId(), date); + + log.debug("조회 점수 추가 완료: productId={}", event.productId()); + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java new file mode 100644 index 000000000..583b8b7d7 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java @@ -0,0 +1,35 @@ +package com.loopers.application.ranking; + +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 랭킹 키 생성 유틸리티. + *

+ * Redis ZSET 랭킹 키를 생성합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class RankingKeyGenerator { + private static final String DAILY_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 일간 랭킹 키를 생성합니다. + *

+ * 예: ranking:all:20241215 + *

+ * + * @param date 날짜 + * @return 일간 랭킹 키 + */ + public String generateDailyKey(LocalDate date) { + String dateStr = date.format(DATE_FORMATTER); + return DAILY_KEY_PREFIX + dateStr; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java new file mode 100644 index 000000000..f88096f5e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java @@ -0,0 +1,165 @@ +package com.loopers.application.ranking; + +import com.loopers.zset.RedisZSetTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Map; + +/** + * 랭킹 점수 계산 및 ZSET 적재 서비스. + *

+ * Kafka Consumer에서 이벤트를 수취하여 Redis ZSET에 랭킹 점수를 적재합니다. + *

+ *

+ * 설계 원칙: + *

    + *
  • Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
  • + *
  • Eventually Consistent: 일시적인 지연/중복 허용
  • + *
  • CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
  • + *
  • 단순성: ZSetTemplate을 직접 사용하여 불필요한 추상화 제거
  • + *
+ *

+ *

+ * 점수 계산 공식: + *

    + *
  • 조회: Weight = 0.1, Score = 1
  • + *
  • 좋아요: Weight = 0.2, Score = 1
  • + *
  • 주문: Weight = 0.6, Score = price * amount (정규화: log(1 + amount))
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double ORDER_WEIGHT = 0.6; + private static final Duration TTL = Duration.ofDays(2); + + private final RedisZSetTemplate zSetTemplate; + private final RankingKeyGenerator keyGenerator; + + /** + * 조회 이벤트 점수를 ZSET에 추가합니다. + * + * @param productId 상품 ID + * @param date 날짜 + */ + public void addViewScore(Long productId, LocalDate date) { + String key = keyGenerator.generateDailyKey(date); + double score = VIEW_WEIGHT; + incrementScore(key, productId, score); + log.debug("조회 점수 추가: productId={}, date={}, score={}", productId, date, score); + } + + /** + * 좋아요 이벤트 점수를 ZSET에 추가/차감합니다. + * + * @param productId 상품 ID + * @param date 날짜 + * @param isAdded 좋아요 추가 여부 (true: 추가, false: 취소) + */ + public void addLikeScore(Long productId, LocalDate date, boolean isAdded) { + String key = keyGenerator.generateDailyKey(date); + double score = isAdded ? LIKE_WEIGHT : -LIKE_WEIGHT; + incrementScore(key, productId, score); + log.debug("좋아요 점수 {}: productId={}, date={}, score={}", + isAdded ? "추가" : "차감", productId, date, score); + } + + /** + * 주문 이벤트 점수를 ZSET에 추가합니다. + *

+ * 주문 금액을 기반으로 점수를 계산합니다. + * 정규화를 위해 log(1 + orderAmount)를 사용합니다. + *

+ * + * @param productId 상품 ID + * @param date 날짜 + * @param orderAmount 주문 금액 (price * quantity) + */ + public void addOrderScore(Long productId, LocalDate date, double orderAmount) { + String key = keyGenerator.generateDailyKey(date); + // 정규화: log(1 + orderAmount) 사용하여 큰 금액 차이를 완화 + double score = Math.log1p(orderAmount) * ORDER_WEIGHT; + incrementScore(key, productId, score); + log.debug("주문 점수 추가: productId={}, date={}, orderAmount={}, score={}", + productId, date, orderAmount, score); + } + + /** + * 배치로 점수를 적재합니다. + *

+ * 같은 배치 내에서 같은 상품의 여러 이벤트를 메모리에서 집계한 후 한 번에 적재합니다. + *

+ * + * @param scoreMap 상품 ID별 점수 맵 + * @param date 날짜 + */ + public void addScoresBatch(Map scoreMap, LocalDate date) { + if (scoreMap.isEmpty()) { + return; + } + + String key = keyGenerator.generateDailyKey(date); + for (Map.Entry entry : scoreMap.entrySet()) { + zSetTemplate.incrementScore(key, String.valueOf(entry.getKey()), entry.getValue()); + } + + // TTL 설정 (최초 1회만) + zSetTemplate.setTtlIfNotExists(key, TTL); + + log.debug("배치 점수 적재 완료: date={}, count={}", date, scoreMap.size()); + } + + /** + * Score Carry-Over: 오늘의 랭킹을 내일 랭킹에 일부 반영합니다. + *

+ * 콜드 스타트 문제를 완화하기 위해 오늘의 랭킹을 가중치를 적용하여 내일 랭킹에 반영합니다. + * 예: 오늘 랭킹의 10%를 내일 랭킹에 반영 + *

+ * + * @param today 오늘 날짜 + * @param tomorrow 내일 날짜 + * @param carryOverWeight Carry-Over 가중치 (예: 0.1 = 10%) + * @return 반영된 멤버 수 + */ + public Long carryOverScore(LocalDate today, LocalDate tomorrow, double carryOverWeight) { + String todayKey = keyGenerator.generateDailyKey(today); + String tomorrowKey = keyGenerator.generateDailyKey(tomorrow); + + // 오늘 랭킹을 가중치를 적용하여 내일 랭킹에 합산 + Long result = zSetTemplate.unionStoreWithWeight(tomorrowKey, todayKey, carryOverWeight); + + // TTL 설정 + zSetTemplate.setTtlIfNotExists(tomorrowKey, TTL); + + log.info("Score Carry-Over 완료: today={}, tomorrow={}, weight={}, memberCount={}", + today, tomorrow, carryOverWeight, result); + return result; + } + + /** + * ZSET에 점수를 증가시킵니다. + *

+ * 점수 계산 후 ZSetTemplate을 통해 Redis에 적재합니다. + *

+ * + * @param key ZSET 키 + * @param productId 상품 ID + * @param score 증가시킬 점수 + */ + private void incrementScore(String key, Long productId, double score) { + zSetTemplate.incrementScore(key, String.valueOf(productId), score); + // TTL 설정 (최초 1회만) + zSetTemplate.setTtlIfNotExists(key, TTL); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java new file mode 100644 index 000000000..f806ffea1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java @@ -0,0 +1,37 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 좋아요 이벤트 DTO. + *

+ * Kafka에서 수신한 좋아요 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 LikeEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class LikeEvent { + + /** + * 좋아요 추가 이벤트. + */ + public record LikeAdded( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + } + + /** + * 좋아요 취소 이벤트. + */ + public record LikeRemoved( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java new file mode 100644 index 000000000..eacbbc19a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java @@ -0,0 +1,40 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 이벤트 DTO. + *

+ * Kafka에서 수신한 주문 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 OrderEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + Long usedPointAmount, + List orderItems, + LocalDateTime createdAt + ) { + /** + * 주문 아이템 정보. + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java new file mode 100644 index 000000000..4bd7f3587 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java @@ -0,0 +1,27 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 상품 이벤트 DTO. + *

+ * Kafka에서 수신한 상품 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 ProductEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java new file mode 100644 index 000000000..b280fb891 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -0,0 +1,62 @@ +package com.loopers.domain.eventhandled; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이벤트 처리 기록 엔티티. + *

+ * Kafka Consumer에서 처리한 이벤트의 멱등성을 보장하기 위한 엔티티입니다. + * `eventId`를 Primary Key로 사용하여 중복 처리를 방지합니다. + *

+ *

+ * 멱등성 보장: + *

    + *
  • 동일한 `eventId`를 가진 이벤트는 한 번만 처리됩니다
  • + *
  • UNIQUE 제약조건으로 데이터베이스 레벨에서 중복 방지
  • + *
  • 이벤트 처리 전 `eventId` 존재 여부를 확인하여 중복 처리 방지
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "event_handled", indexes = { + @Index(name = "idx_handled_at", columnList = "handled_at") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class EventHandled { + + @Id + @Column(name = "event_id", nullable = false, length = 255) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "topic", nullable = false, length = 255) + private String topic; + + @Column(name = "handled_at", nullable = false) + private LocalDateTime handledAt; + + /** + * EventHandled 인스턴스를 생성합니다. + * + * @param eventId 이벤트 ID (UUID) + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + */ + public EventHandled(String eventId, String eventType, String topic) { + this.eventId = eventId; + this.eventType = eventType; + this.topic = topic; + this.handledAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java new file mode 100644 index 000000000..536ddbd63 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -0,0 +1,40 @@ +package com.loopers.domain.eventhandled; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 저장소 인터페이스. + *

+ * 이벤트 처리 기록의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledRepository { + + /** + * 이벤트 처리 기록을 저장합니다. + * + * @param eventHandled 저장할 이벤트 처리 기록 + * @return 저장된 이벤트 처리 기록 + */ + EventHandled save(EventHandled eventHandled); + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..f552b355c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,127 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

+ * Kafka Consumer에서 이벤트를 수취하여 집계한 메트릭을 저장합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 관리합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
  • + *
  • Kafka Consumer를 통한 이벤트 기반 집계
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

+ * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다. + * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다. + *

+ * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..4ffe5938e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,58 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

+ * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductMetricsRepository { + + /** + * 상품 메트릭을 저장합니다. + * + * @param productMetrics 저장할 상품 메트릭 + * @return 저장된 상품 메트릭 + */ + ProductMetrics save(ProductMetrics productMetrics); + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 상품 ID로 메트릭을 조회합니다. (비관적 락) + *

+ * Upsert 시 동시성 제어를 위해 사용합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: PK(productId) 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 메트릭 집계 시 Lost Update 방지
  • + *
+ *

+ * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductIdForUpdate(Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java new file mode 100644 index 000000000..f3aefc464 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 JPA Repository. + * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledJpaRepository extends JpaRepository { + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..95dfc6b06 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * EventHandledRepository의 구현체. + *

+ * JPA를 사용하여 EventHandled 엔티티의 영속성을 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository jpaRepository; + + @Override + public EventHandled save(EventHandled eventHandled) { + return jpaRepository.save(eventHandled); + } + + @Override + public Optional findByEventId(String eventId) { + return jpaRepository.findByEventId(eventId); + } + + @Override + public boolean existsByEventId(String eventId) { + return jpaRepository.existsByEventId(eventId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..e54cb6aef --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + *

+ * 상품 메트릭 집계 데이터를 관리합니다. + *

+ */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 상품 ID로 메트릭을 조회합니다. (비관적 락) + *

+ * Upsert 시 동시성 제어를 위해 사용합니다. + *

+ * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT pm FROM ProductMetrics pm WHERE pm.productId = :productId") + Optional findByProductIdForUpdate(@Param("productId") Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..253da5917 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * ProductMetricsRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductIdForUpdate(Long productId) { + return productMetricsJpaRepository.findByProductIdForUpdate(productId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java new file mode 100644 index 000000000..c23a29d4c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 랭킹 Score Carry-Over 스케줄러. + *

+ * 매일 자정에 전날 랭킹을 오늘 랭킹에 일부 반영하여 콜드 스타트 문제를 완화합니다. + *

+ *

+ * 설계 원칙: + *

    + *
  • 콜드 스타트 완화: 매일 자정에 랭킹이 0점에서 시작하는 문제를 완화
  • + *
  • 가중치 적용: 전날 랭킹의 일부(예: 10%)만 반영하여 신선도 유지
  • + *
  • 에러 처리: Carry-Over 실패 시에도 다음 스케줄에서 재시도
  • + *
+ *

+ *

+ * 실행 시점: + *

    + *
  • 매일 자정(00:00:00)에 실행
  • + *
  • 전날(어제) 랭킹을 오늘 랭킹에 반영
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingCarryOverScheduler { + + private static final double DEFAULT_CARRY_OVER_WEIGHT = 0.1; // 10% + + private final RankingService rankingService; + + /** + * 전날 랭킹을 오늘 랭킹에 일부 반영합니다. + *

+ * 매일 자정에 실행되어 어제 랭킹의 일부를 오늘 랭킹에 반영합니다. + *

+ */ + @Scheduled(cron = "0 0 0 * * ?") // 매일 자정 (00:00:00) + public void carryOverScore() { + LocalDate today = LocalDate.now(ZoneId.of("UTC")); + LocalDate yesterday = today.minusDays(1); + + try { + Long memberCount = rankingService.carryOverScore(yesterday, today, DEFAULT_CARRY_OVER_WEIGHT); + + log.info("랭킹 Score Carry-Over 완료: yesterday={}, today={}, weight={}, memberCount={}", + yesterday, today, DEFAULT_CARRY_OVER_WEIGHT, memberCount); + } catch (org.springframework.dao.DataAccessException e) { + log.warn("Redis 장애로 인한 랭킹 Score Carry-Over 실패: yesterday={}, today={}, error={}", + yesterday, today, e.getMessage()); + // Redis 장애 시 Carry-Over 스킵 (다음 스케줄에서 재시도) + } catch (Exception e) { + log.warn("랭킹 Score Carry-Over 실패: yesterday={}, today={}", yesterday, today, e); + // Carry-Over 실패는 다음 스케줄에서 재시도 + } + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java new file mode 100644 index 000000000..2811056d9 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -0,0 +1,430 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 상품 메트릭 집계 Kafka Consumer. + *

+ * Kafka에서 이벤트를 수취하여 상품 메트릭을 집계합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert합니다. + *

+ *

+ * 처리 이벤트: + *

    + *
  • like-events: LikeAdded, LikeRemoved (좋아요 수 집계)
  • + *
  • order-events: OrderCreated (판매량 집계)
  • + *
  • product-events: ProductViewed (조회 수 집계)
  • + *
+ *

+ *

+ * Manual Ack: + *

    + *
  • 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
  • + *
  • 에러 발생 시 커밋하지 않아 재처리 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsConsumer { + + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + private final ObjectMapper objectMapper; + + private static final String EVENT_ID_HEADER = "eventId"; + private static final String EVENT_TYPE_HEADER = "eventType"; + private static final String VERSION_HEADER = "version"; + + /** + * like-events 토픽을 구독하여 좋아요 수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "like-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeLikeEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + String eventType; + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + // Spring Kafka가 자동으로 역직렬화한 경우 + if (value instanceof LikeEvent.LikeAdded) { + LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeAdded"; + } else if (value instanceof LikeEvent.LikeRemoved) { + LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeRemoved"; + } else { + // JSON 문자열인 경우 이벤트 타입 헤더로 구분 + String eventTypeHeader = extractEventType(record); + if ("LikeRemoved".equals(eventTypeHeader)) { + LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value); + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeRemoved"; + } else { + // 기본값은 LikeAdded + LikeEvent.LikeAdded event = parseLikeEvent(value); + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeAdded"; + } + } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, eventType, "like-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("좋아요 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("좋아요 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * order-events 토픽을 구독하여 판매량을 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrderEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + // 주문 아이템별로 판매량 집계 + for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { + productMetricsService.incrementSalesCount( + item.productId(), + item.quantity(), + eventVersion + ); + } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "OrderCreated", "order-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("주문 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("주문 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("주문 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeAdded 이벤트 + */ + private LikeEvent.LikeAdded parseLikeEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeAdded.class); + } catch (Exception e) { + throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeRemoved 이벤트 + */ + private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeRemoved.class); + } catch (Exception e) { + throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e); + } + } + + /** + * product-events 토픽을 구독하여 조회 수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "product-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeProductEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + ProductEvent.ProductViewed event = parseProductViewedEvent(value); + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + productMetricsService.incrementViewCount( + event.productId(), + eventVersion + ); + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("상품 조회 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 OrderCreated 이벤트 + */ + private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { + try { + if (value instanceof OrderEvent.OrderCreated) { + return (OrderEvent.OrderCreated) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, OrderEvent.OrderCreated.class); + } catch (Exception e) { + throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 ProductViewed 이벤트 + */ + private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { + try { + if (value instanceof ProductEvent.ProductViewed) { + return (ProductEvent.ProductViewed) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, ProductEvent.ProductViewed.class); + } catch (Exception e) { + throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 헤더에서 eventId를 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventId (없으면 null) + */ + private String extractEventId(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_ID_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 eventType을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventType (없으면 null) + */ + private String extractEventType(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_TYPE_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 version을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return version (없으면 null) + */ + private Long extractVersion(ConsumerRecord record) { + Header header = record.headers().lastHeader(VERSION_HEADER); + if (header != null && header.value() != null) { + try { + String versionStr = new String(header.value(), StandardCharsets.UTF_8); + return Long.parseLong(versionStr); + } catch (NumberFormatException e) { + log.warn("버전 헤더 파싱 실패: offset={}, partition={}", + record.offset(), record.partition()); + return null; + } + } + return null; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java new file mode 100644 index 000000000..8c19d687d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java @@ -0,0 +1,389 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 랭킹 집계 Kafka Consumer. + *

+ * Kafka에서 이벤트를 수취하여 Spring ApplicationEvent로 발행합니다. + * 조회, 좋아요, 주문 이벤트를 기반으로 실시간 랭킹을 구축합니다. + *

+ *

+ * 처리 이벤트: + *

    + *
  • like-events: LikeAdded, LikeRemoved (좋아요 점수 집계)
  • + *
  • order-events: OrderCreated (주문 점수 집계)
  • + *
  • product-events: ProductViewed (조회 점수 집계)
  • + *
+ *

+ *

+ * Manual Ack: + *

    + *
  • 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
  • + *
  • 에러 발생 시 커밋하지 않아 재처리 가능
  • + *
+ *

+ *

+ * 설계 원칙: + *

    + *
  • 관심사 분리: Consumer는 Kafka 메시지 수신/파싱만 담당, 비즈니스 로직은 EventHandler에서 처리
  • + *
  • 이벤트 핸들러 패턴: Kafka Event → Spring ApplicationEvent → RankingEventListener → RankingEventHandler
  • + *
  • Eventually Consistent: 일시적인 지연/중복 허용
  • + *
  • CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
  • + *
+ *

+ * + * @author Loopers + * @version 2.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingConsumer { + + private final ApplicationEventPublisher applicationEventPublisher; + private final EventHandledService eventHandledService; + private final ObjectMapper objectMapper; + + private static final String EVENT_ID_HEADER = "eventId"; + private static final String EVENT_TYPE_HEADER = "eventType"; + private static final String VERSION_HEADER = "version"; + + /** + * 개별 레코드 처리 로직을 정의하는 함수형 인터페이스. + */ + @FunctionalInterface + private interface RecordProcessor { + /** + * 개별 레코드를 처리합니다. + * + * @param record Kafka 메시지 레코드 + * @param eventId 이벤트 ID + * @return 처리된 이벤트 타입과 토픽 이름을 담은 EventProcessResult + * @throws Exception 처리 중 발생한 예외 + */ + EventProcessResult process(ConsumerRecord record, String eventId) throws Exception; + } + + /** + * 이벤트 처리 결과를 담는 레코드. + */ + private record EventProcessResult(String eventType, String topicName) { + } + + /** + * 공통 배치 처리 로직을 실행합니다. + *

+ * 멱등성 체크, 에러 처리, 배치 커밋 등의 공통 로직을 처리합니다. + *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + * @param topicName 토픽 이름 (로깅 및 이벤트 기록용) + * @param processor 개별 레코드 처리 로직 + */ + private void processBatch( + List> records, + Acknowledgment acknowledgment, + String topicName, + RecordProcessor processor + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + // 개별 레코드 처리 + EventProcessResult result = processor.process(record, eventId); + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, result.eventType(), result.topicName()); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("이벤트 처리 실패: topic={}, offset={}, partition={}", + topicName, record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("이벤트 처리 완료: topic={}, count={}", topicName, records.size()); + } catch (Exception e) { + log.error("배치 처리 실패: topic={}, count={}", topicName, records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * like-events 토픽을 구독하여 좋아요 점수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "like-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeLikeEvents( + List> records, + Acknowledgment acknowledgment + ) { + processBatch(records, acknowledgment, "like-events", (record, eventId) -> { + Object value = record.value(); + String eventType; + + // Spring Kafka가 자동으로 역직렬화한 경우 + if (value instanceof LikeEvent.LikeAdded) { + LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; + applicationEventPublisher.publishEvent(event); + eventType = "LikeAdded"; + } else if (value instanceof LikeEvent.LikeRemoved) { + LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; + applicationEventPublisher.publishEvent(event); + eventType = "LikeRemoved"; + } else { + // JSON 문자열인 경우 이벤트 타입 헤더로 구분 + String eventTypeHeader = extractEventType(record); + if ("LikeRemoved".equals(eventTypeHeader)) { + LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value); + applicationEventPublisher.publishEvent(event); + eventType = "LikeRemoved"; + } else { + // 기본값은 LikeAdded + LikeEvent.LikeAdded event = parseLikeEvent(value); + applicationEventPublisher.publishEvent(event); + eventType = "LikeAdded"; + } + } + + return new EventProcessResult(eventType, "like-events"); + }); + } + + /** + * order-events 토픽을 구독하여 주문 점수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrderEvents( + List> records, + Acknowledgment acknowledgment + ) { + processBatch(records, acknowledgment, "order-events", (record, eventId) -> { + Object value = record.value(); + OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + + // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트) + applicationEventPublisher.publishEvent(event); + + return new EventProcessResult("OrderCreated", "order-events"); + }); + } + + /** + * product-events 토픽을 구독하여 조회 점수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "product-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeProductEvents( + List> records, + Acknowledgment acknowledgment + ) { + processBatch(records, acknowledgment, "product-events", (record, eventId) -> { + Object value = record.value(); + ProductEvent.ProductViewed event = parseProductViewedEvent(value); + + // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트) + applicationEventPublisher.publishEvent(event); + + return new EventProcessResult("ProductViewed", "product-events"); + }); + } + + /** + * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeAdded 이벤트 + */ + private LikeEvent.LikeAdded parseLikeEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeAdded.class); + } catch (Exception e) { + throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeRemoved 이벤트 + */ + private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeRemoved.class); + } catch (Exception e) { + throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 OrderCreated 이벤트 + */ + private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { + try { + if (value instanceof OrderEvent.OrderCreated) { + return (OrderEvent.OrderCreated) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, OrderEvent.OrderCreated.class); + } catch (Exception e) { + throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 ProductViewed 이벤트 + */ + private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { + try { + if (value instanceof ProductEvent.ProductViewed) { + return (ProductEvent.ProductViewed) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, ProductEvent.ProductViewed.class); + } catch (Exception e) { + throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 헤더에서 eventId를 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventId (없으면 null) + */ + private String extractEventId(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_ID_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 eventType을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventType (없으면 null) + */ + private String extractEventType(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_TYPE_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 version을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return version (없으면 null) + */ + private Long extractVersion(ConsumerRecord record) { + Header header = record.headers().lastHeader(VERSION_HEADER); + if (header != null && header.value() != null) { + try { + String versionStr = new String(header.value(), StandardCharsets.UTF_8); + return Long.parseLong(versionStr); + } catch (NumberFormatException e) { + log.warn("버전 헤더 파싱 실패: offset={}, partition={}", + record.offset(), record.partition()); + return null; + } + } + return null; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java new file mode 100644 index 000000000..b72cc4a48 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java @@ -0,0 +1,121 @@ +package com.loopers.interfaces.event.ranking; + +import com.loopers.application.ranking.RankingEventHandler; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 랭킹 이벤트 리스너. + *

+ * 좋아요 추가/취소, 주문 생성, 상품 조회 이벤트를 받아서 랭킹 점수를 집계하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ *

+ * EDA 원칙: + *

    + *
  • 비동기 처리: @Async로 집계 처리를 비동기로 실행하여 Kafka Consumer의 성능에 영향 없음
  • + *
  • 이벤트 기반: 좋아요, 주문, 조회 이벤트를 구독하여 랭킹 점수 집계
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingEventListener { + + private final RankingEventHandler rankingEventHandler; + + /** + * 좋아요 추가 이벤트를 처리합니다. + *

+ * 비동기로 실행되어 랭킹 점수를 집계합니다. + *

+ * + * @param event 좋아요 추가 이벤트 + */ + @Async + @EventListener + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + rankingEventHandler.handleLikeAdded(event); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 좋아요 취소 이벤트를 처리합니다. + *

+ * 비동기로 실행되어 랭킹 점수를 차감합니다. + *

+ * + * @param event 좋아요 취소 이벤트 + */ + @Async + @EventListener + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + rankingEventHandler.handleLikeRemoved(event); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 생성 이벤트를 처리합니다. + *

+ * 비동기로 실행되어 랭킹 점수를 집계합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Async + @EventListener + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + rankingEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생: orderId={}", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 상품 조회 이벤트를 처리합니다. + *

+ * 비동기로 실행되어 랭킹 점수를 집계합니다. + *

+ * + * @param event 상품 조회 이벤트 + */ + @Async + @EventListener + public void handleProductViewed(ProductEvent.ProductViewed event) { + try { + rankingEventHandler.handleProductViewed(event); + } catch (Exception e) { + log.error("상품 조회 이벤트 처리 중 오류 발생: productId={}", event.productId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java new file mode 100644 index 000000000..77d7efcd9 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java @@ -0,0 +1,96 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +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 org.springframework.dao.DataIntegrityViolationException; + +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.*; + +/** + * EventHandledService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class EventHandledServiceTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @InjectMocks + private EventHandledService eventHandledService; + + @DisplayName("처리되지 않은 이벤트는 false를 반환한다.") + @Test + void isAlreadyHandled_returnsFalse_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(false); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isFalse(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("이미 처리된 이벤트는 true를 반환한다.") + @Test + void isAlreadyHandled_returnsTrue_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(true); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isTrue(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("처리되지 않은 이벤트는 정상적으로 저장된다.") + @Test + void markAsHandled_savesSuccessfully_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + EventHandled savedEventHandled = new EventHandled(eventId, eventType, topic); + when(eventHandledRepository.save(any(EventHandled.class))).thenReturn(savedEventHandled); + + // act + eventHandledService.markAsHandled(eventId, eventType, topic); + + // assert + verify(eventHandledRepository).save(any(EventHandled.class)); + } + + @DisplayName("이미 처리된 이벤트는 DataIntegrityViolationException을 발생시킨다.") + @Test + void markAsHandled_throwsException_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + when(eventHandledRepository.save(any(EventHandled.class))) + .thenThrow(new DataIntegrityViolationException("UNIQUE constraint violation")); + + // act & assert + assertThatThrownBy(() -> + eventHandledService.markAsHandled(eventId, eventType, topic) + ).isInstanceOf(DataIntegrityViolationException.class); + + verify(eventHandledRepository).save(any(EventHandled.class)); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java new file mode 100644 index 000000000..e8064e333 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java @@ -0,0 +1,217 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsServiceTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @InjectMocks + private ProductMetricsService productMetricsService; + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.decrementLikeCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(0L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + Long productId = 1L; + Integer quantity = 5; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, quantity, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getSalesCount()).isEqualTo(5L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementViewCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getViewCount()).isEqualTo(1L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("메트릭이 없으면 새로 생성한다.") + @Test + void createsNewMetrics_whenNotExists() { + // arrange + Long productId = 1L; + Long eventVersion = 1L; // 새로 생성된 메트릭의 버전(0)보다 큰 버전 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.empty()); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, eventVersion); + + // assert + verify(productMetricsRepository).findByProductIdForUpdate(productId); + // findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨 + verify(productMetricsRepository, atLeast(1)).save(any(ProductMetrics.class)); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + Long initialSalesCount = existingMetrics.getSalesCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, null, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, 0, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, -1, existingMetrics.getVersion() + 1L); + + // assert + // 유효하지 않은 수량은 무시되므로 값이 변경되지 않음 + assertThat(existingMetrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + // save()는 호출되지만 메트릭 값은 변경되지 않음 + verify(productMetricsRepository, times(3)).findByProductIdForUpdate(productId); + verify(productMetricsRepository, times(3)).save(existingMetrics); + } + + @DisplayName("오래된 이벤트는 스킵하여 메트릭을 업데이트하지 않는다.") + @Test + void skipsOldEvent_whenEventIsOlderThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long oldEventVersion = existingMetrics.getVersion() - 1L; // 이전 버전 이벤트 + + Long initialLikeCount = existingMetrics.getLikeCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + + // act + productMetricsService.incrementLikeCount(productId, oldEventVersion); + + // assert + // 오래된 이벤트는 스킵되므로 값이 변경되지 않음 + assertThat(existingMetrics.getLikeCount()).isEqualTo(initialLikeCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository, never()).save(any(ProductMetrics.class)); + } + + @DisplayName("최신 이벤트는 메트릭을 업데이트한다.") + @Test + void updatesMetrics_whenEventIsNewerThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long newEventVersion = existingMetrics.getVersion() + 1L; // 최신 버전 이벤트 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, newEventVersion); + + // assert + // 최신 이벤트는 반영됨 + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java new file mode 100644 index 000000000..a32182b98 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java @@ -0,0 +1,158 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +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.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RankingEventHandler 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingEventHandlerTest { + + @Mock + private RankingService rankingService; + + @InjectMocks + private RankingEventHandler rankingEventHandler; + + @DisplayName("좋아요 추가 이벤트를 처리할 수 있다.") + @Test + void canHandleLikeAdded() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // act + rankingEventHandler.handleLikeAdded(event); + + // assert + verify(rankingService).addLikeScore(eq(productId), any(LocalDate.class), eq(true)); + } + + @DisplayName("좋아요 취소 이벤트를 처리할 수 있다.") + @Test + void canHandleLikeRemoved() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + // act + rankingEventHandler.handleLikeRemoved(event); + + // assert + verify(rankingService).addLikeScore(eq(productId), any(LocalDate.class), eq(false)); + } + + @DisplayName("주문 생성 이벤트를 처리할 수 있다.") + @Test + void canHandleOrderCreated() { + // arrange + Long orderId = 1L; + Long userId = 100L; + OrderEvent.OrderCreated.OrderItemInfo item1 = + new OrderEvent.OrderCreated.OrderItemInfo(1L, 2); + OrderEvent.OrderCreated.OrderItemInfo item2 = + new OrderEvent.OrderCreated.OrderItemInfo(2L, 3); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, + userId, + null, // couponCode + 10000, // subtotal + null, // usedPointAmount + List.of(item1, item2), + LocalDateTime.now() + ); + + // act + rankingEventHandler.handleOrderCreated(event); + + // assert + // totalQuantity = 2 + 3 = 5 + // averagePrice = 10000 / 5 = 2000 + // item1: 2000 * 2 = 4000 + // item2: 2000 * 3 = 6000 + verify(rankingService).addOrderScore(eq(1L), any(LocalDate.class), eq(4000.0)); + verify(rankingService).addOrderScore(eq(2L), any(LocalDate.class), eq(6000.0)); + } + + @DisplayName("주문 아이템이 없으면 점수를 추가하지 않는다.") + @Test + void doesNothing_whenOrderItemsIsEmpty() { + // arrange + Long orderId = 1L; + Long userId = 100L; + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, + userId, + null, // couponCode + 10000, // subtotal + null, // usedPointAmount + List.of(), + LocalDateTime.now() + ); + + // act + rankingEventHandler.handleOrderCreated(event); + + // assert + verify(rankingService, never()).addOrderScore(any(), any(), anyDouble()); + } + + @DisplayName("주문 subtotal이 null이면 점수를 추가하지 않는다.") + @Test + void doesNothing_whenSubtotalIsNull() { + // arrange + Long orderId = 1L; + Long userId = 100L; + OrderEvent.OrderCreated.OrderItemInfo item = + new OrderEvent.OrderCreated.OrderItemInfo(1L, 2); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, + userId, + null, // couponCode + null, // subtotal + null, // usedPointAmount + List.of(item), + LocalDateTime.now() + ); + + // act + rankingEventHandler.handleOrderCreated(event); + + // assert + verify(rankingService, never()).addOrderScore(any(), any(), anyDouble()); + } + + @DisplayName("상품 조회 이벤트를 처리할 수 있다.") + @Test + void canHandleProductViewed() { + // arrange + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now()); + + // act + rankingEventHandler.handleProductViewed(event); + + // assert + verify(rankingService).addViewScore(eq(productId), any(LocalDate.class)); + } +} + diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java new file mode 100644 index 000000000..ed3e67e23 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java @@ -0,0 +1,296 @@ +package com.loopers.application.ranking; + +import com.loopers.zset.RedisZSetTemplate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RankingService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RedisZSetTemplate zSetTemplate; + + @Mock + private RankingKeyGenerator keyGenerator; + + @InjectMocks + private RankingService rankingService; + + @DisplayName("조회 점수를 ZSET에 추가할 수 있다.") + @Test + void canAddViewScore() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double expectedScore = 0.1; // VIEW_WEIGHT + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addViewScore(productId, date); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("좋아요 추가 시 점수를 ZSET에 추가할 수 있다.") + @Test + void canAddLikeScore_whenAdded() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double expectedScore = 0.2; // LIKE_WEIGHT + boolean isAdded = true; + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addLikeScore(productId, date, isAdded); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("좋아요 취소 시 점수를 ZSET에서 차감할 수 있다.") + @Test + void canSubtractLikeScore_whenRemoved() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double expectedScore = -0.2; // -LIKE_WEIGHT + boolean isAdded = false; + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addLikeScore(productId, date, isAdded); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("주문 점수를 ZSET에 추가할 수 있다.") + @Test + void canAddOrderScore() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double orderAmount = 10000.0; + // 정규화: log(1 + orderAmount) * ORDER_WEIGHT + // log(1 + 10000) ≈ 9.2103, 9.2103 * 0.6 ≈ 5.526 + double expectedScore = Math.log1p(orderAmount) * 0.6; // ORDER_WEIGHT = 0.6 + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addOrderScore(productId, date, orderAmount); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("주문 금액이 0일 때도 정상적으로 처리된다.") + @Test + void canAddOrderScore_whenOrderAmountIsZero() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double orderAmount = 0.0; + double expectedScore = Math.log1p(orderAmount) * 0.6; // log(1) * 0.6 = 0 + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addOrderScore(productId, date, orderAmount); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("배치로 여러 상품의 점수를 한 번에 적재할 수 있다.") + @Test + void canAddScoresBatch() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + + Map scoreMap = new HashMap<>(); + scoreMap.put(1L, 10.5); + scoreMap.put(2L, 20.3); + scoreMap.put(3L, 15.7); + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addScoresBatch(scoreMap, date); + + // assert + verify(keyGenerator).generateDailyKey(date); + + // 각 상품에 대해 incrementScore 호출 확인 + verify(zSetTemplate).incrementScore(eq(expectedKey), eq("1"), eq(10.5)); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq("2"), eq(20.3)); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq("3"), eq(15.7)); + + // TTL 설정은 한 번만 호출 + verify(zSetTemplate, times(1)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("빈 맵을 배치로 적재할 때는 아무 작업도 수행하지 않는다.") + @Test + void doesNothing_whenBatchIsEmpty() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + Map emptyScoreMap = new HashMap<>(); + + // act + rankingService.addScoresBatch(emptyScoreMap, date); + + // assert + verify(keyGenerator, never()).generateDailyKey(any()); + verify(zSetTemplate, never()).incrementScore(anyString(), anyString(), anyDouble()); + verify(zSetTemplate, never()).setTtlIfNotExists(anyString(), any(Duration.class)); + } + + @DisplayName("여러 날짜에 대해 독립적으로 점수를 추가할 수 있다.") + @Test + void canAddScoresForDifferentDates() { + // arrange + Long productId = 1L; + LocalDate date1 = LocalDate.of(2024, 12, 15); + LocalDate date2 = LocalDate.of(2024, 12, 16); + String key1 = "ranking:all:20241215"; + String key2 = "ranking:all:20241216"; + + when(keyGenerator.generateDailyKey(date1)).thenReturn(key1); + when(keyGenerator.generateDailyKey(date2)).thenReturn(key2); + + // act + rankingService.addViewScore(productId, date1); + rankingService.addViewScore(productId, date2); + + // assert + verify(keyGenerator).generateDailyKey(date1); + verify(keyGenerator).generateDailyKey(date2); + verify(zSetTemplate).incrementScore(eq(key1), eq(String.valueOf(productId)), eq(0.1)); + verify(zSetTemplate).incrementScore(eq(key2), eq(String.valueOf(productId)), eq(0.1)); + verify(zSetTemplate).setTtlIfNotExists(eq(key1), eq(Duration.ofDays(2))); + verify(zSetTemplate).setTtlIfNotExists(eq(key2), eq(Duration.ofDays(2))); + } + + @DisplayName("같은 상품에 여러 이벤트를 추가하면 점수가 누적된다.") + @Test + void accumulatesScoresForSameProduct() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addViewScore(productId, date); // +0.1 + rankingService.addLikeScore(productId, date, true); // +0.2 + rankingService.addOrderScore(productId, date, 1000.0); // +log(1001) * 0.6 + + // assert + verify(keyGenerator, times(3)).generateDailyKey(date); + + // 각 이벤트별로 incrementScore 호출 확인 + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.1)); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.2)); + + ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(Double.class); + verify(zSetTemplate, times(3)).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), scoreCaptor.capture()); + + // 주문 점수 계산 확인 + double orderScore = scoreCaptor.getAllValues().get(2); + double expectedOrderScore = Math.log1p(1000.0) * 0.6; + assertThat(orderScore).isCloseTo(expectedOrderScore, org.assertj.core.data.Offset.offset(0.001)); + + // TTL 설정은 각 호출마다 수행됨 (incrementScore 내부에서 호출) + verify(zSetTemplate, times(3)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("Score Carry-Over로 오늘 랭킹을 내일 랭킹에 반영할 수 있다.") + @Test + void canCarryOverScore() { + // arrange + LocalDate today = LocalDate.of(2024, 12, 15); + LocalDate tomorrow = LocalDate.of(2024, 12, 16); + String todayKey = "ranking:all:20241215"; + String tomorrowKey = "ranking:all:20241216"; + double carryOverWeight = 0.1; // 10% + + when(keyGenerator.generateDailyKey(today)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(tomorrow)).thenReturn(tomorrowKey); + when(zSetTemplate.unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight))) + .thenReturn(50L); + + // act + Long result = rankingService.carryOverScore(today, tomorrow, carryOverWeight); + + // assert + assertThat(result).isEqualTo(50L); + verify(keyGenerator).generateDailyKey(today); + verify(keyGenerator).generateDailyKey(tomorrow); + verify(zSetTemplate).unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight)); + verify(zSetTemplate).setTtlIfNotExists(eq(tomorrowKey), eq(Duration.ofDays(2))); + } + + @DisplayName("Score Carry-Over 가중치가 0일 때도 정상적으로 처리된다.") + @Test + void canCarryOverScore_withZeroWeight() { + // arrange + LocalDate today = LocalDate.of(2024, 12, 15); + LocalDate tomorrow = LocalDate.of(2024, 12, 16); + String todayKey = "ranking:all:20241215"; + String tomorrowKey = "ranking:all:20241216"; + double carryOverWeight = 0.0; + + when(keyGenerator.generateDailyKey(today)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(tomorrow)).thenReturn(tomorrowKey); + when(zSetTemplate.unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight))) + .thenReturn(0L); + + // act + Long result = rankingService.carryOverScore(today, tomorrow, carryOverWeight); + + // assert + assertThat(result).isEqualTo(0L); + verify(zSetTemplate).unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight)); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..6fab02bfb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다.") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장).") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다.") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java new file mode 100644 index 000000000..c43519195 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java @@ -0,0 +1,116 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +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.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsConsumer 통합 테스트. + *

+ * 실제 Kafka를 사용하여 이벤트 처리 동작을 검증합니다. + *

+ *

+ * Kafka 컨테이너: + * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다. + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class ProductMetricsConsumerIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Autowired + private KafkaProperties kafkaProperties; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + /** + * offset.reset: latest 설정이 제대로 적용되는지 확인하는 테스트. + *

+ * 테스트 목적: + * kafka.yml에 설정된 `offset.reset: latest`가 실제로 동작하는지 검증합니다. + *

+ *

+ * 동작 원리: + * 1. 이전 메시지를 Kafka에 발행 (이 메시지는 나중에 읽히지 않아야 함) + * 2. Consumer Group을 삭제하여 offset 정보 제거 + * 3. 새로운 메시지를 Kafka에 발행 + * 4. 새로운 Consumer Group으로 Consumer를 시작 + * 5. offset.reset: latest 설정으로 인해 Consumer는 최신 메시지(새로운 메시지)부터 읽기 시작해야 함 + *

+ *

+ * 검증 내용: + * - Consumer의 현재 position이 최신 offset(endOffset)과 같거나 가까운지 확인 + * - 이는 Consumer가 이전 메시지를 건너뛰고 최신 메시지부터 읽기 시작했다는 의미 + *

+ */ + @DisplayName("offset.reset: latest 설정이 적용되어 새로운 Consumer Group은 최신 메시지만 읽는다.") + @Test + void offsetResetLatest_shouldOnlyReadLatestMessages() throws Exception { + // 이 메시지는 나중에 Consumer가 읽지 않아야 함 (offset.reset: latest 때문) + String topic = "like-events"; + String partitionKey = "product-1"; + LikeEvent.LikeAdded oldMessage = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, oldMessage).get(); + + // Consumer Group을 삭제하면 offset 정보가 사라짐 + // 다음에 같은 Consumer Group으로 시작할 때 offset.reset 설정이 적용됨 + String testGroupId = "test-offset-reset-" + System.currentTimeMillis(); + kafkaCleanUp.resetConsumerGroup(testGroupId); + + // 이 메시지는 Consumer가 읽어야 함 (최신 메시지이므로) + LikeEvent.LikeAdded newMessage = new LikeEvent.LikeAdded(200L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, newMessage).get(); + + // 프로젝트의 kafka.yml 설정을 사용하여 Consumer 생성 + // 이 설정에는 offset.reset: latest가 포함되어 있음 + Map consumerProps = kafkaProperties.buildConsumerProperties(); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, testGroupId); + + try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { + // 특정 파티션에 할당 (테스트용) + TopicPartition partition = new TopicPartition(topic, 0); + consumer.assign(Collections.singletonList(partition)); + + // endOffset: 토픽의 마지막 메시지 다음 offset (현재는 2개 메시지가 있으므로 2) + // currentPosition: Consumer가 현재 읽을 위치 (offset.reset: latest면 endOffset과 같아야 함) + Long endOffset = consumer.endOffsets(Collections.singletonList(partition)).get(partition); + long currentPosition = consumer.position(partition); + + // offset.reset: latest 설정이 적용되었다면: + // - currentPosition은 endOffset과 같거나 가까워야 함 + // - 이는 Consumer가 이전 메시지(oldMessage)를 건너뛰고 최신 메시지(newMessage)부터 읽기 시작했다는 의미 + // 예: endOffset=2, currentPosition=2 → 이전 메시지(offset 0)를 건너뛰고 최신 메시지(offset 1)부터 시작 + assertThat(currentPosition) + .isGreaterThanOrEqualTo(endOffset); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java new file mode 100644 index 000000000..bf5306797 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -0,0 +1,393 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.record.TimestampType; + +import java.util.Optional; +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 org.springframework.dao.DataIntegrityViolationException; +import org.springframework.kafka.support.Acknowledgment; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsConsumer 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsConsumerTest { + + @Mock + private ProductMetricsService productMetricsService; + + @Mock + private EventHandledService eventHandledService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private Acknowledgment acknowledgment; + + @InjectMocks + private ProductMetricsConsumer productMetricsConsumer; + + @DisplayName("LikeAdded 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeAddedEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "1".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(1L)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeRemovedEvent() { + // arrange + String eventId = "test-event-id-2"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "2".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(2L)); + verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("OrderCreated 이벤트를 처리할 수 있다.") + @Test + void canConsumeOrderCreatedEvent() { + // arrange + String eventId = "test-event-id-3"; + Long orderId = 1L; + Long userId = 100L; + Long productId1 = 1L; + Long productId2 = 2L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3), + new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "3".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementSalesCount(eq(productId1), eq(3), eq(3L)); + verify(productMetricsService).incrementSalesCount(eq(productId2), eq(2), eq(3L)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("배치로 여러 이벤트를 처리할 수 있다.") + @Test + void canConsumeMultipleEvents() { + // arrange + String eventId1 = "test-event-id-4"; + String eventId2 = "test-event-id-5"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + LikeEvent.LikeRemoved event2 = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "4".getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "5".getBytes(StandardCharsets.UTF_8))); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty()) + ); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(4L)); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(5L)); + verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); + verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events"); + verify(acknowledgment, times(1)).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() { + // arrange + String eventId1 = "test-event-id-6"; + String eventId2 = "test-event-id-7"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Object invalidEvent = "invalid-event"; + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "6".getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "7".getBytes(StandardCharsets.UTF_8))); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + doThrow(new RuntimeException("처리 실패")) + .when(productMetricsService).incrementLikeCount(any(), anyLong()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(productMetricsService, atLeastOnce()).incrementLikeCount(any(), anyLong()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 acknowledgment를 수행한다.") + @Test + void acknowledgesEvenWhenIndividualEventFails() { + // arrange + String eventId = "test-event-id-8"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "8".getBytes(StandardCharsets.UTF_8))); + + // 서비스 호출 시 예외 발생 + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new RuntimeException("서비스 처리 실패")) + .when(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + // 예외 발생 시 markAsHandled는 호출되지 않음 + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("이미 처리된 이벤트는 스킵한다.") + @Test + void skipsAlreadyHandledEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("eventId가 없는 메시지는 건너뛴다.") + @Test + void skipsEventWithoutEventId() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService, never()).isAlreadyHandled(any()); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.") + @Test + void handlesDataIntegrityViolationException() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "9".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("UNIQUE constraint violation")) + .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.") + @Test + void handlesDuplicateMessagesIdempotently() { + // arrange + String eventId = "duplicate-event-id"; + Long productId = 1L; + Long userId = 100L; + Long eventVersion = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", String.valueOf(eventVersion).getBytes(StandardCharsets.UTF_8))); + + // 동일한 eventId를 가진 메시지 3개 생성 + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true + when(eventHandledService.isAlreadyHandled(eventId)) + .thenReturn(false) // 첫 번째: 처리됨 + .thenReturn(true) // 두 번째: 이미 처리됨 (스킵) + .thenReturn(true); // 세 번째: 이미 처리됨 (스킵) + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // isAlreadyHandled는 3번 호출됨 (각 메시지마다) + verify(eventHandledService, times(3)).isAlreadyHandled(eventId); + + // incrementLikeCount는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(productMetricsService, times(1)).incrementLikeCount(eq(productId), eq(eventVersion)); + + // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events"); + + // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료) + verify(acknowledgment, times(1)).acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java new file mode 100644 index 000000000..67485df03 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java @@ -0,0 +1,450 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.record.TimestampType; +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 org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.kafka.support.Acknowledgment; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * RankingConsumer 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingConsumerTest { + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private EventHandledService eventHandledService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private Acknowledgment acknowledgment; + + @InjectMocks + private RankingConsumer rankingConsumer; + + @DisplayName("LikeAdded 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeAddedEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeRemovedEvent() { + // arrange + String eventId = "test-event-id-2"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("eventType", "LikeRemoved".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeRemoved.class)); + verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("OrderCreated 이벤트를 처리할 수 있다.") + @Test + void canConsumeOrderCreatedEvent() { + // arrange + String eventId = "test-event-id-3"; + Long orderId = 1L; + Long userId = 100L; + Long productId1 = 1L; + Long productId2 = 2L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3), + new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("ProductViewed 이벤트를 처리할 수 있다.") + @Test + void canConsumeProductViewedEvent() { + // arrange + String eventId = "test-event-id-4"; + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "product-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeProductEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(ProductEvent.ProductViewed.class)); + verify(eventHandledService).markAsHandled(eventId, "ProductViewed", "product-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("배치로 여러 이벤트를 처리할 수 있다.") + @Test + void canConsumeMultipleEvents() { + // arrange + String eventId1 = "test-event-id-5"; + String eventId2 = "test-event-id-6"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + ProductEvent.ProductViewed event2 = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now()); + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()), + new ConsumerRecord<>("product-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty()) + ); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + // act + rankingConsumer.consumeLikeEvents(List.of(records.get(0)), acknowledgment); + rankingConsumer.consumeProductEvents(List.of(records.get(1)), acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(applicationEventPublisher).publishEvent(any(ProductEvent.ProductViewed.class)); + verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); + verify(eventHandledService).markAsHandled(eventId2, "ProductViewed", "product-events"); + verify(acknowledgment, times(2)).acknowledge(); + } + + @DisplayName("이미 처리된 이벤트는 스킵한다.") + @Test + void skipsAlreadyHandledEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher, never()).publishEvent(any()); + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("eventId가 없는 메시지는 건너뛴다.") + @Test + void skipsEventWithoutEventId() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService, never()).isAlreadyHandled(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() { + // arrange + String eventId1 = "test-event-id-7"; + String eventId2 = "test-event-id-8"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Object invalidEvent = "invalid-event"; + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty()) + ); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + // 첫 번째 이벤트는 파싱 실패로 publishEvent가 호출되지 않음 + // 두 번째 이벤트는 정상적으로 publishEvent가 호출됨 + verify(applicationEventPublisher, times(1)).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.") + @Test + void handlesDataIntegrityViolationException() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("UNIQUE constraint violation")) + .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("주문 이벤트에서 totalQuantity가 0이면 점수를 추가하지 않는다.") + @Test + void doesNotAddScore_whenTotalQuantityIsZero() { + // arrange + String eventId = "test-event-id-9"; + Long orderId = 1L; + Long userId = 100L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 0) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 0, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("주문 이벤트에서 subtotal이 null이면 점수를 추가하지 않는다.") + @Test + void doesNotAddScore_whenSubtotalIsNull() { + // arrange + String eventId = "test-event-id-10"; + Long orderId = 1L; + Long userId = 100L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 3) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, null, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.") + @Test + void handlesDuplicateMessagesIdempotently() { + // arrange + String eventId = "duplicate-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + // 동일한 eventId를 가진 메시지 3개 생성 + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true + when(eventHandledService.isAlreadyHandled(eventId)) + .thenReturn(false) // 첫 번째: 처리됨 + .thenReturn(true) // 두 번째: 이미 처리됨 (스킵) + .thenReturn(true); // 세 번째: 이미 처리됨 (스킵) + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // isAlreadyHandled는 3번 호출됨 (각 메시지마다) + verify(eventHandledService, times(3)).isAlreadyHandled(eventId); + + // publishEvent는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(applicationEventPublisher, times(1)).publishEvent(any(LikeEvent.LikeAdded.class)); + + // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events"); + + // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료) + verify(acknowledgment, times(1)).acknowledge(); + } +} diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md new file mode 100644 index 000000000..118642638 --- /dev/null +++ b/apps/pg-simulator/README.md @@ -0,0 +1,42 @@ +## PG-Simulator (PaymentGateway) + +### Description +Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다. +`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다. +- server port : 8082 +- actuator port : 8083 + +### Getting Started +부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요. +```shell +./gradlew :apps:pg-simulator:bootRun +``` + +API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다. +- 결제 요청 API +- 결제 정보 확인 `by transactionKey` +- 결제 정보 목록 조회 `by orderId` + +```http request +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 + +``` \ No newline at end of file diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts new file mode 100644 index 000000000..653d549da --- /dev/null +++ b/apps/pg-simulator/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + val kotlinVersion = "2.0.20" + + id("org.jetbrains.kotlin.jvm") version(kotlinVersion) + id("org.jetbrains.kotlin.kapt") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion) +} + +kotlin { + compilerOptions { + jvmToolchain(21) + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // web + 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"]}") + + // querydsl + kapt("com.querydsl:querydsl-apt::jakarta") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt new file mode 100644 index 000000000..05595d135 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt @@ -0,0 +1,24 @@ +package com.loopers + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync +import java.util.TimeZone + +@ConfigurationPropertiesScan +@EnableAsync +@SpringBootApplication +class PaymentGatewayApplication { + + @PostConstruct + fun started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt new file mode 100644 index 000000000..7e04d1ce0 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt @@ -0,0 +1,14 @@ +package com.loopers.application.payment + +/** + * 결제 주문 정보 + * + * 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다. + * + * @property orderId 주문 정보 + * @property transactions 주문에 엮인 트랜잭션 목록 + */ +data class OrderInfo( + val orderId: String, + val transactions: List, +) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt new file mode 100644 index 000000000..9a5ebdc5d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt @@ -0,0 +1,88 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import com.loopers.domain.payment.PaymentRelay +import com.loopers.domain.payment.PaymentRepository +import com.loopers.domain.payment.TransactionKeyGenerator +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class PaymentApplicationService( + private val paymentRepository: PaymentRepository, + private val paymentEventPublisher: PaymentEventPublisher, + private val paymentRelay: PaymentRelay, + private val transactionKeyGenerator: TransactionKeyGenerator, +) { + companion object { + private val RATE_LIMIT_EXCEEDED = (1..20) + private val RATE_INVALID_CARD = (21..30) + } + + @Transactional + fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo { + command.validate() + + val transactionKey = transactionKeyGenerator.generate() + val payment = paymentRepository.save( + Payment( + transactionKey = transactionKey, + userId = command.userId, + orderId = command.orderId, + cardType = command.cardType, + cardNo = command.cardNo, + amount = command.amount, + callbackUrl = command.callbackUrl, + ), + ) + + paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment)) + + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo { + val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo { + val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId) + if (payments.isEmpty()) { + throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.") + } + + return OrderInfo( + orderId = orderId, + transactions = payments.map { TransactionInfo.from(it) }, + ) + } + + @Transactional + fun handle(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + + val rate = (1..100).random() + when (rate) { + in RATE_LIMIT_EXCEEDED -> payment.limitExceeded() + in RATE_INVALID_CARD -> payment.invalidCard() + else -> payment.approve() + } + paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment)) + } + + fun notifyTransactionResult(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment)) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt new file mode 100644 index 000000000..01d8ae440 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt @@ -0,0 +1,22 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentCommand { + data class CreateTransaction( + val userId: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + fun validate() { + if (amount <= 0L) { + throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.") + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt new file mode 100644 index 000000000..5c21e51af --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt @@ -0,0 +1,39 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.TransactionStatus + +/** + * 트랜잭션 정보 + * + * @property transactionKey 트랜잭션 KEY + * @property orderId 주문 ID + * @property cardType 카드 종류 + * @property cardNo 카드 번호 + * @property amount 금액 + * @property status 처리 상태 + * @property reason 처리 사유 + */ +data class TransactionInfo( + val transactionKey: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val status: TransactionStatus, + val reason: String?, +) { + companion object { + fun from(payment: Payment): TransactionInfo = + TransactionInfo( + transactionKey = payment.transactionKey, + orderId = payment.orderId, + cardType = payment.cardType, + cardNo = payment.cardNo, + amount = payment.amount, + status = payment.status, + reason = payment.reason, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt new file mode 100644 index 000000000..8aec9dc82 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt @@ -0,0 +1,13 @@ +package com.loopers.config.web + +import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(UserInfoArgumentResolver()) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt new file mode 100644 index 000000000..55008a95d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class CardType { + SAMSUNG, + KB, + HYUNDAI, +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt new file mode 100644 index 000000000..cfc2386c1 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt @@ -0,0 +1,87 @@ +package com.loopers.domain.payment + +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "payments", + indexes = [ + Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"), + Index(name = "idx_user_order", columnList = "user_id, order_id"), + Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true), + ] +) +class Payment( + @Id + @Column(name = "transaction_key", nullable = false, unique = true) + val transactionKey: String, + + @Column(name = "user_id", nullable = false) + val userId: String, + + @Column(name = "order_id", nullable = false) + val orderId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + val cardType: CardType, + + @Column(name = "card_no", nullable = false) + val cardNo: String, + + @Column(name = "amount", nullable = false) + val amount: Long, + + @Column(name = "callback_url", nullable = false) + val callbackUrl: String, +) { + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: TransactionStatus = TransactionStatus.PENDING + private set + + @Column(name = "reason", nullable = true) + var reason: String? = null + private set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + private set + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + private set + + fun approve() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.") + } + status = TransactionStatus.SUCCESS + reason = "정상 승인되었습니다." + } + + fun invalidCard() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "잘못된 카드입니다. 다른 카드를 선택해주세요." + } + + fun limitExceeded() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "한도초과입니다. 다른 카드를 선택해주세요." + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt new file mode 100644 index 000000000..8e495b2e3 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt @@ -0,0 +1,28 @@ +package com.loopers.domain.payment + +object PaymentEvent { + data class PaymentCreated( + val transactionKey: String, + ) { + companion object { + fun from(payment: Payment): PaymentCreated = PaymentCreated(transactionKey = payment.transactionKey) + } + } + + data class PaymentHandled( + val transactionKey: String, + val status: TransactionStatus, + val reason: String?, + val callbackUrl: String, + ) { + companion object { + fun from(payment: Payment): PaymentHandled = + PaymentHandled( + transactionKey = payment.transactionKey, + status = payment.status, + reason = payment.reason, + callbackUrl = payment.callbackUrl, + ) + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt new file mode 100644 index 000000000..251c68319 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt @@ -0,0 +1,6 @@ +package com.loopers.domain.payment + +interface PaymentEventPublisher { + fun publish(event: PaymentEvent.PaymentCreated) + fun publish(event: PaymentEvent.PaymentHandled) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt new file mode 100644 index 000000000..e622899b2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +import com.loopers.application.payment.TransactionInfo + +interface PaymentRelay { + fun notify(callbackUrl: String, transactionInfo: TransactionInfo) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt new file mode 100644 index 000000000..c1173c0aa --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.payment + +interface PaymentRepository { + fun save(payment: Payment): Payment + fun findByTransactionKey(transactionKey: String): Payment? + fun findByTransactionKey(userId: String, transactionKey: String): Payment? + fun findByOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt new file mode 100644 index 000000000..c8703a763 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt @@ -0,0 +1,20 @@ +package com.loopers.domain.payment + +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Component +class TransactionKeyGenerator { + companion object { + private const val KEY_TRANSACTION = "TR" + private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + } + + fun generate(): String { + val now = LocalDateTime.now() + val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6) + return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid" + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt new file mode 100644 index 000000000..0c94bcfb9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class TransactionStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt new file mode 100644 index 000000000..c51e660a9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.user + +/** + * user 정보 + * + * @param userId 유저 식별자 + */ +data class UserInfo(val userId: String) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt new file mode 100644 index 000000000..715516360 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class PaymentCoreEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher, +) : PaymentEventPublisher { + override fun publish(event: PaymentEvent.PaymentCreated) { + applicationEventPublisher.publishEvent(event) + } + + override fun publish(event: PaymentEvent.PaymentHandled) { + applicationEventPublisher.publishEvent(event) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt new file mode 100644 index 000000000..ffd643c0f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.payment + +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.PaymentRelay +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class PaymentCoreRelay : PaymentRelay { + companion object { + private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java) + private val restTemplate = RestTemplate() + } + + override fun notify(callbackUrl: String, transactionInfo: TransactionInfo) { + runCatching { + restTemplate.postForEntity(callbackUrl, transactionInfo, Any::class.java) + }.onFailure { e -> logger.error("콜백 호출을 실패했습니다. {}", e.message, e) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt new file mode 100644 index 000000000..cf521c47d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import kotlin.jvm.optionals.getOrNull + +@Component +class PaymentCoreRepository( + private val paymentJpaRepository: PaymentJpaRepository, +) : PaymentRepository { + @Transactional + override fun save(payment: Payment): Payment { + return paymentJpaRepository.save(payment) + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(transactionKey: String): Payment? { + return paymentJpaRepository.findById(transactionKey).getOrNull() + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(userId: String, transactionKey: String): Payment? { + return paymentJpaRepository.findByUserIdAndTransactionKey(userId, transactionKey) + } + + override fun findByOrderId(userId: String, orderId: String): List { + return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId) + .sortedByDescending { it.updatedAt } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt new file mode 100644 index 000000000..a5ea32822 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentJpaRepository : JpaRepository { + fun findByUserIdAndTransactionKey(userId: String, transactionKey: String): Payment? + fun findByUserIdAndOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt new file mode 100644 index 000000000..434a229e2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api + +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.server.ServerWebInputException +import org.springframework.web.servlet.resource.NoResourceFoundException +import kotlin.collections.joinToString +import kotlin.jvm.java +import kotlin.text.isNotEmpty +import kotlin.text.toRegex + +@RestControllerAdvice +class ApiControllerAdvice { + private val log = LoggerFactory.getLogger(ApiControllerAdvice::class.java) + + @ExceptionHandler + fun handle(e: CoreException): ResponseEntity> { + log.warn("CoreException : {}", e.customMessage ?: e.message, e) + return failureResponse(errorType = e.errorType, errorMessage = e.customMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: MethodArgumentTypeMismatchException): ResponseEntity> { + val name = e.name + val type = e.requiredType?.simpleName ?: "unknown" + val value = e.value ?: "null" + val message = "요청 파라미터 '$name' (타입: $type)의 값 '$value'이(가) 잘못되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: MissingServletRequestParameterException): ResponseEntity> { + val name = e.parameterName + val type = e.parameterType + val message = "필수 요청 파라미터 '$name' (타입: $type)가 누락되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: HttpMessageNotReadableException): ResponseEntity> { + val errorMessage = when (val rootCause = e.rootCause) { + is InvalidFormatException -> { + val fieldName = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + + val valueIndicationMessage = when { + rootCause.targetType.isEnum -> { + val enumClass = rootCause.targetType + val enumValues = enumClass.enumConstants.joinToString(", ") { it.toString() } + "사용 가능한 값 : [$enumValues]" + } + + else -> "" + } + + val expectedType = rootCause.targetType.simpleName + val value = rootCause.value + + "필드 '$fieldName'의 값 '$value'이(가) 예상 타입($expectedType)과 일치하지 않습니다. $valueIndicationMessage" + } + + is MismatchedInputException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필수 필드 '$fieldPath'이(가) 누락되었습니다." + } + + is JsonMappingException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필드 '$fieldPath'에서 JSON 매핑 오류가 발생했습니다: ${rootCause.originalMessage}" + } + + else -> "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요." + } + + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = errorMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: ServerWebInputException): ResponseEntity> { + fun extractMissingParameter(message: String): String { + val regex = "'(.+?)'".toRegex() + return regex.find(message)?.groupValues?.get(1) ?: "" + } + + val missingParams = extractMissingParameter(e.reason ?: "") + return if (missingParams.isNotEmpty()) { + failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "필수 요청 값 \'$missingParams\'가 누락되었습니다.") + } else { + failureResponse(errorType = ErrorType.BAD_REQUEST) + } + } + + @ExceptionHandler + fun handleNotFound(e: NoResourceFoundException): ResponseEntity> { + return failureResponse(errorType = ErrorType.NOT_FOUND) + } + + @ExceptionHandler + fun handle(e: Throwable): ResponseEntity> { + log.error("Exception : {}", e.message, e) + val errorType = ErrorType.INTERNAL_ERROR + return failureResponse(errorType = errorType) + } + + private fun failureResponse(errorType: ErrorType, errorMessage: String? = null): ResponseEntity> = + ResponseEntity( + ApiResponse.fail(errorCode = errorType.code, errorMessage = errorMessage ?: errorType.message), + errorType.status, + ) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt new file mode 100644 index 000000000..f5c38ab5e --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api + +data class ApiResponse( + val meta: Metadata, + val data: T?, +) { + data class Metadata( + val result: Result, + val errorCode: String?, + val message: String?, + ) { + enum class Result { SUCCESS, FAIL } + + companion object { + fun success() = Metadata(Result.SUCCESS, null, null) + + fun fail(errorCode: String, errorMessage: String) = Metadata(Result.FAIL, errorCode, errorMessage) + } + } + + companion object { + fun success(): ApiResponse = ApiResponse(Metadata.success(), null) + + fun success(data: T? = null) = ApiResponse(Metadata.success(), data) + + fun fail(errorCode: String, errorMessage: String): ApiResponse = + ApiResponse( + meta = Metadata.fail(errorCode = errorCode, errorMessage = errorMessage), + data = null, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt new file mode 100644 index 000000000..9ef6c25da --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.argumentresolver + +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class UserInfoArgumentResolver: HandlerMethodArgumentResolver { + companion object { + private const val KEY_USER_ID = "X-USER-ID" + } + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return UserInfo::class.java.isAssignableFrom(parameter.parameterType) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): UserInfo { + val userId = webRequest.getHeader(KEY_USER_ID) + ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.") + + return UserInfo(userId) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt new file mode 100644 index 000000000..22d5cbe38 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.interfaces.api.ApiResponse +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/payments") +class PaymentApi( + private val paymentApplicationService: PaymentApplicationService, +) { + @PostMapping + fun request( + userInfo: UserInfo, + @RequestBody request: PaymentDto.PaymentRequest, + ): ApiResponse { + request.validate() + + // 100ms ~ 500ms 지연 + Thread.sleep((100..500L).random()) + + // 40% 확률로 요청 실패 + if ((1..100).random() <= 40) { + throw CoreException(ErrorType.INTERNAL_ERROR, "현재 서버가 불안정합니다. 잠시 후 다시 시도해주세요.") + } + + return paymentApplicationService.createTransaction(request.toCommand(userInfo.userId)) + .let { PaymentDto.TransactionResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping("/{transactionKey}") + fun getTransaction( + userInfo: UserInfo, + @PathVariable("transactionKey") transactionKey: String, + ): ApiResponse { + return paymentApplicationService.getTransactionDetailInfo(userInfo, transactionKey) + .let { PaymentDto.TransactionDetailResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping + fun getTransactionsByOrder( + userInfo: UserInfo, + @RequestParam("orderId", required = false) orderId: String, + ): ApiResponse { + return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId) + .let { PaymentDto.OrderResponse.from(it) } + .let { ApiResponse.success(it) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt new file mode 100644 index 000000000..52a00b156 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt @@ -0,0 +1,136 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.OrderInfo +import com.loopers.application.payment.PaymentCommand +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.TransactionStatus +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentDto { + data class PaymentRequest( + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + companion object { + private val REGEX_CARD_NO = Regex("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$") + private const val PREFIX_CALLBACK_URL = "http://localhost:8080" + } + + fun validate() { + if (orderId.isBlank() || orderId.length < 6) { + throw CoreException(ErrorType.BAD_REQUEST, "주문 ID는 6자리 이상 문자열이어야 합니다.") + } + if (!REGEX_CARD_NO.matches(cardNo)) { + throw CoreException(ErrorType.BAD_REQUEST, "카드 번호는 xxxx-xxxx-xxxx-xxxx 형식이어야 합니다.") + } + if (amount <= 0) { + throw CoreException(ErrorType.BAD_REQUEST, "결제금액은 양의 정수여야 합니다.") + } + if (!callbackUrl.startsWith(PREFIX_CALLBACK_URL)) { + throw CoreException(ErrorType.BAD_REQUEST, "콜백 URL 은 $PREFIX_CALLBACK_URL 로 시작해야 합니다.") + } + } + + fun toCommand(userId: String): PaymentCommand.CreateTransaction = + PaymentCommand.CreateTransaction( + userId = userId, + orderId = orderId, + cardType = cardType.toCardType(), + cardNo = cardNo, + amount = amount, + callbackUrl = callbackUrl, + ) + } + + data class TransactionDetailResponse( + val transactionKey: String, + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionDetailResponse = + TransactionDetailResponse( + transactionKey = transactionInfo.transactionKey, + orderId = transactionInfo.orderId, + cardType = CardTypeDto.from(transactionInfo.cardType), + cardNo = transactionInfo.cardNo, + amount = transactionInfo.amount, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class TransactionResponse( + val transactionKey: String, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionResponse = + TransactionResponse( + transactionKey = transactionInfo.transactionKey, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class OrderResponse( + val orderId: String, + val transactions: List, + ) { + companion object { + fun from(orderInfo: OrderInfo): OrderResponse = + OrderResponse( + orderId = orderInfo.orderId, + transactions = orderInfo.transactions.map { TransactionResponse.from(it) }, + ) + } + } + + enum class CardTypeDto { + SAMSUNG, + KB, + HYUNDAI, + ; + + fun toCardType(): CardType = when (this) { + SAMSUNG -> CardType.SAMSUNG + KB -> CardType.KB + HYUNDAI -> CardType.HYUNDAI + } + + companion object { + fun from(cardType: CardType) = when (cardType) { + CardType.SAMSUNG -> SAMSUNG + CardType.KB -> KB + CardType.HYUNDAI -> HYUNDAI + } + } + } + + enum class TransactionStatusResponse { + PENDING, + SUCCESS, + FAILED, + ; + + companion object { + fun from(transactionStatus: TransactionStatus) = when (transactionStatus) { + TransactionStatus.PENDING -> PENDING + TransactionStatus.SUCCESS -> SUCCESS + TransactionStatus.FAILED -> FAILED + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt new file mode 100644 index 000000000..241322890 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt @@ -0,0 +1,28 @@ +package com.loopers.interfaces.event.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.domain.payment.PaymentEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class PaymentEventListener( + private val paymentApplicationService: PaymentApplicationService, +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentCreated) { + val thresholdMillis = (1000L..5000L).random() + Thread.sleep(thresholdMillis) + + paymentApplicationService.handle(event.transactionKey) + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentHandled) { + paymentApplicationService.notifyTransactionResult(transactionKey = event.transactionKey) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt new file mode 100644 index 000000000..120f7fc5f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt @@ -0,0 +1,6 @@ +package com.loopers.support.error + +class CoreException( + val errorType: ErrorType, + val customMessage: String? = null, +) : RuntimeException(customMessage ?: errorType.message) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt new file mode 100644 index 000000000..e0799a5ea --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt @@ -0,0 +1,11 @@ +package com.loopers.support.error + +import org.springframework.http.HttpStatus + +enum class ErrorType(val status: HttpStatus, val code: String, val message: String) { + /** 범용 에러 */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase, "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.reasonPhrase, "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.reasonPhrase, "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.reasonPhrase, "이미 존재하는 리소스입니다."), +} diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml new file mode 100644 index 000000000..addf0e29c --- /dev/null +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -0,0 +1,77 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +datasource: + mysql-jpa: + main: + jdbc-url: jdbc:mysql://localhost:3306/paymentgateway + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: dev + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/http/pg-simulator/payments.http b/http/pg-simulator/payments.http new file mode 100644 index 000000000..096dd2f0a --- /dev/null +++ b/http/pg-simulator/payments.http @@ -0,0 +1,20 @@ +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java index 85c43ace9..8648e9c8f 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java @@ -33,7 +33,28 @@ public void truncateAllTables() { entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); for (String table : tableNames) { - entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate(); + String tableName = table; + // 테이블 이름에 이미 백틱이 있으면 생략 + if (!tableName.startsWith("`") && !tableName.endsWith("`")) { + tableName = "`" + tableName + "`"; + } + + // 테이블이 존재하는지 확인 후 TRUNCATE 수행 + try { + // 테이블 존재 여부 확인 + String checkTableSql = "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = ?"; + Long count = ((Number) entityManager.createNativeQuery(checkTableSql) + .setParameter(1, table.replace("`", "")) + .getSingleResult()).longValue(); + + if (count > 0) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + } + } catch (Exception e) { + // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행 + // 로그는 남기지 않음 (테스트 환경에서 정상적인 상황일 수 있음) + } } entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..33222efb1 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -1,6 +1,7 @@ package com.loopers.confg.kafka; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -8,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.TopicBuilder; import org.springframework.kafka.core.*; import org.springframework.kafka.listener.ContainerProperties; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; @@ -30,19 +32,19 @@ public class KafkaConfig { public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m @Bean - public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { + public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); return new DefaultKafkaProducerFactory<>(props); } @Bean - public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildConsumerProperties()); return new DefaultKafkaConsumerFactory<>(props); } @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { return new KafkaTemplate<>(producerFactory); } @@ -52,7 +54,7 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap } @Bean(name = BATCH_LISTENER) - public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( + public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, ByteArrayJsonMessageConverter converter ) { @@ -64,7 +66,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, HEARTBEAT_INTERVAL_MS); consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS); - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); // 수동 커밋 factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter)); @@ -72,4 +74,94 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setBatchListener(true); return factory; } + + /** + * Like 도메인 이벤트 토픽. + *

+ * 파티션 키: productId (상품별 좋아요 수 집계를 위해) + *

+ */ + @Bean + public NewTopic likeEventsTopic() { + return TopicBuilder.name("like-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Product 도메인 이벤트 토픽. + *

+ * 파티션 키: productId (상품별 재고 관리를 위해) + *

+ */ + @Bean + public NewTopic productEventsTopic() { + return TopicBuilder.name("product-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Order 도메인 이벤트 토픽. + *

+ * 파티션 키: orderId (주문별 이벤트 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name("order-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Payment 도메인 이벤트 토픽. + *

+ * 파티션 키: orderId (주문별 결제 처리 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic paymentEventsTopic() { + return TopicBuilder.name("payment-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Coupon 도메인 이벤트 토픽. + *

+ * 파티션 키: orderId (주문별 쿠폰 할인 적용 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic couponEventsTopic() { + return TopicBuilder.name("coupon-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * User 도메인 이벤트 토픽. + *

+ * 파티션 키: userId (사용자별 포인트 처리 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic userEventsTopic() { + return TopicBuilder.name("user-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } } diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..a2a73417b 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,6 +15,10 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + properties: + acks: all # 모든 리플리카에 쓰기 확인 (At Least Once 보장) + enable.idempotence: true # 중복 방지 (At Least Once 보장) + max.in.flight.requests.per.connection: 5 # idempotence=true일 때 필수 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer @@ -29,11 +33,13 @@ spring.config.activate.on-profile: local, test spring: kafka: - bootstrap-servers: localhost:19092 + # Testcontainers를 사용하는 경우 BOOTSTRAP_SERVERS가 자동으로 설정됨 + # 로컬 개발 환경에서는 localhost:19092 사용 + bootstrap-servers: ${BOOTSTRAP_SERVERS:localhost:19092} admin: properties: - bootstrap.servers: kafka:9092 - + bootstrap.servers: ${BOOTSTRAP_SERVERS:localhost:19092} + auto-create: true --- spring.config.activate.on-profile: dev diff --git a/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java new file mode 100644 index 000000000..4500d3b0b --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java @@ -0,0 +1,35 @@ +package com.loopers.testcontainers; + +import org.springframework.context.annotation.Configuration; +import org.testcontainers.kafka.ConfluentKafkaContainer; + +/** + * Kafka Testcontainers 설정. + *

+ * 테스트 실행 시 자동으로 Kafka 컨테이너를 시작하고, + * Spring Boot의 Kafka 설정에 동적으로 포트를 주입합니다. + *

+ *

+ * 동작 방식: + * 1. Kafka 컨테이너를 시작 + * 2. 동적으로 할당된 포트를 System Property로 설정 + * 3. kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용 + *

+ */ +@Configuration +public class KafkaTestContainersConfig { + + private static final ConfluentKafkaContainer kafkaContainer; + + static { + // Kafka 컨테이너 생성 및 시작 + // ConfluentKafkaContainer는 confluentinc/cp-kafka 이미지를 사용 + kafkaContainer = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.5.0"); + kafkaContainer.start(); + + // Spring Boot의 Kafka 설정에 동적으로 포트 주입 + // kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용 + String bootstrapServers = kafkaContainer.getBootstrapServers(); + System.setProperty("BOOTSTRAP_SERVERS", bootstrapServers); + } +} diff --git a/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java new file mode 100644 index 000000000..51207364a --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java @@ -0,0 +1,194 @@ +package com.loopers.utils; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.DeleteConsumerGroupsResult; +import org.apache.kafka.clients.admin.DeleteTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.KafkaAdmin; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Kafka 테스트 정리 유틸리티. + *

+ * 테스트 간 Kafka 메시지 격리를 위해 토픽을 삭제하고 재생성합니다. + *

+ *

+ * 사용 방법: + *

    + *
  • 통합 테스트에서 `@BeforeEach` 또는 `@AfterEach`에서 호출하여 테스트 간 격리 보장
  • + *
  • 단위 테스트는 Mock을 사용하므로 불필요
  • + *
+ *

+ *

+ * 주의: + * 프로덕션 환경에서는 사용하지 마세요. 테스트 환경에서만 사용해야 합니다. + *

+ */ +@Component +public class KafkaCleanUp { + + private static final List TEST_TOPICS = List.of( + "like-events", + "order-events", + "product-events", + "payment-events", + "coupon-events", + "user-events" + ); + + private final KafkaAdmin kafkaAdmin; + + public KafkaCleanUp(KafkaAdmin kafkaAdmin) { + this.kafkaAdmin = kafkaAdmin; + } + + /** + * 테스트용 토픽의 모든 메시지를 삭제합니다. + *

+ * 토픽을 삭제하고 재생성하여 모든 메시지를 제거합니다. + *

+ *

+ * 주의: 프로덕션 환경에서는 사용하지 마세요. + *

+ */ + public void deleteAllTestTopics() { + try (AdminClient adminClient = createAdminClient()) { + // 존재하는 토픽만 삭제 + Set existingTopics = adminClient.listTopics() + .names() + .get(5, TimeUnit.SECONDS); + + List topicsToDelete = TEST_TOPICS.stream() + .filter(existingTopics::contains) + .toList(); + + if (topicsToDelete.isEmpty()) { + return; + } + + // 토픽 삭제 (모든 메시지 제거) + DeleteTopicsResult deleteResult = adminClient.deleteTopics(topicsToDelete); + deleteResult.all().get(10, TimeUnit.SECONDS); + + // 토픽 삭제 후 재생성 대기 (Kafka가 토픽 삭제를 완료할 때까지) + Thread.sleep(1000); + } catch (Exception e) { + // 토픽이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 토픽이 없을 수 있음 + } + } + + /** + * 테스트용 토픽을 재생성합니다. + *

+ * 삭제된 토픽을 원래 설정으로 재생성합니다. + *

+ */ + public void recreateTestTopics() { + try (AdminClient adminClient = createAdminClient()) { + for (String topicName : TEST_TOPICS) { + try { + // 토픽이 이미 존재하는지 확인 + adminClient.describeTopics(Collections.singletonList(topicName)) + .allTopicNames() + .get(2, TimeUnit.SECONDS); + // 이미 존재하면 스킵 + continue; + } catch (Exception e) { + // 토픽이 없으면 생성 + } + + // 토픽 생성 + NewTopic newTopic = TopicBuilder.name(topicName) + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + + adminClient.createTopics(Collections.singletonList(newTopic)) + .all() + .get(5, TimeUnit.SECONDS); + } + } catch (Exception e) { + // 토픽 생성 실패는 무시 (이미 존재할 수 있음) + } + } + + /** + * 테스트용 토픽을 삭제하고 재생성합니다. + *

+ * 모든 메시지를 제거하고 깨끗한 상태로 시작합니다. + *

+ */ + public void resetAllTestTopics() { + deleteAllTestTopics(); + recreateTestTopics(); + } + + /** + * 모든 Consumer Group을 삭제하여 offset을 리셋합니다. + *

+ * 테스트 간 격리를 위해 사용합니다. + *

+ *

+ * 주의: 모든 Consumer Group을 삭제하므로 프로덕션 환경에서는 사용하지 마세요. + *

+ */ + public void resetAllConsumerGroups() { + try (AdminClient adminClient = createAdminClient()) { + // 모든 Consumer Group 목록 조회 + Set consumerGroups = adminClient.listConsumerGroups() + .all() + .get(5, TimeUnit.SECONDS) + .stream() + .map(group -> group.groupId()) + .collect(java.util.stream.Collectors.toSet()); + + if (consumerGroups.isEmpty()) { + return; + } + + // Consumer Group 삭제 (offset 리셋) + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(consumerGroups); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 Consumer Group이 없을 수 있음 + } + } + + /** + * 특정 Consumer Group을 삭제합니다. + * + * @param groupId 삭제할 Consumer Group ID + */ + public void resetConsumerGroup(String groupId) { + try (AdminClient adminClient = createAdminClient()) { + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups( + Collections.singletonList(groupId) + ); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + } + } + + /** + * AdminClient를 생성합니다. + */ + private AdminClient createAdminClient() { + Properties props = new Properties(); + Object bootstrapServers = kafkaAdmin.getConfigurationProperties() + .getOrDefault(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:19092"); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + return AdminClient.create(props); + } +} diff --git a/modules/redis/build.gradle.kts b/modules/redis/build.gradle.kts index 37ad4f6dd..86aa2a8a6 100644 --- a/modules/redis/build.gradle.kts +++ b/modules/redis/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api("org.springframework.boot:spring-boot-starter-data-redis") + api("com.fasterxml.jackson.core:jackson-databind") testFixturesImplementation("com.redis:testcontainers-redis") } diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKey.java b/modules/redis/src/main/java/com/loopers/cache/CacheKey.java new file mode 100644 index 000000000..2de210ea1 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKey.java @@ -0,0 +1,41 @@ +package com.loopers.cache; + +import java.time.Duration; + +/** + * 캐시 키 인터페이스. + *

+ * 캐시 키는 해당 인터페이스를 기반으로 구현되어야 합니다. + *

+ * + * @param 캐시 값의 타입 + * @author Loopers + * @version 1.0 + */ +public interface CacheKey { + + /** + * 캐시 키를 반환합니다. + * + * @return 캐시 키 문자열 + */ + String key(); + + /** + * 캐시 TTL (Time To Live)을 반환합니다. + * + * @return TTL + */ + Duration ttl(); + + /** + * 캐시 값의 타입을 반환합니다. + *

+ * 역직렬화 시 사용됩니다. + *

+ * + * @return 타입 + */ + Class type(); +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java b/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java new file mode 100644 index 000000000..db71b635e --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java @@ -0,0 +1,15 @@ +package com.loopers.cache; + +/** + * 캐시 직렬화/역직렬화 예외. + * + * @author Loopers + * @version 1.0 + */ +public class CacheSerializationException extends RuntimeException { + + public CacheSerializationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java b/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java new file mode 100644 index 000000000..2757dbe8e --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java @@ -0,0 +1,55 @@ +package com.loopers.cache; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * 캐시 템플릿 인터페이스. + *

+ * 캐시 조회, 저장, 삭제 등의 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CacheTemplate { + + /** + * 캐시에서 값을 조회합니다. + * + * @param cacheKey 캐시 키 + * @param 캐시 값의 타입 + * @return 캐시 값 (Optional) + */ + Optional get(CacheKey cacheKey); + + /** + * 캐시에 값을 저장합니다. + * + * @param cacheKey 캐시 키 + * @param value 저장할 값 + * @param 캐시 값의 타입 + */ + void put(CacheKey cacheKey, T value); + + /** + * 캐시를 무효화합니다. + * + * @param cacheKey 캐시 키 + */ + void evict(CacheKey cacheKey); + + /** + * 캐시에서 값을 조회하고, 없으면 로더를 실행하여 값을 가져온 후 캐시에 저장합니다. + *

+ * Cache-Aside 패턴을 구현합니다. + *

+ * + * @param cacheKey 캐시 키 + * @param loader 캐시에 값이 없을 때 실행할 로더 + * @param 캐시 값의 타입 + * @return 캐시 값 또는 로더로부터 가져온 값 + */ + T getOrLoad(CacheKey cacheKey, Supplier loader); +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java b/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java new file mode 100644 index 000000000..0cfeb2bee --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java @@ -0,0 +1,95 @@ +package com.loopers.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Redis 캐시 템플릿 구현체. + *

+ * Redis를 사용하여 캐시를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisCacheTemplate implements CacheTemplate { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional get(CacheKey cacheKey) { + try { + String json = redisTemplate.opsForValue().get(cacheKey.key()); + if (json == null) { + return Optional.empty(); + } + T value = deserialize(json, cacheKey.type()); + return Optional.ofNullable(value); + } catch (Exception e) { + log.warn("캐시 조회 실패. (key: {})", cacheKey.key(), e); + return Optional.empty(); + } + } + + @Override + public void put(CacheKey cacheKey, T value) { + try { + String json = serialize(value); + redisTemplate.opsForValue().set(cacheKey.key(), json, cacheKey.ttl()); + } catch (Exception e) { + log.warn("캐시 저장 실패. (key: {})", cacheKey.key(), e); + // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) + } + } + + @Override + public void evict(CacheKey cacheKey) { + try { + redisTemplate.delete(cacheKey.key()); + } catch (Exception e) { + log.warn("캐시 삭제 실패. (key: {})", cacheKey.key(), e); + } + } + + @Override + public T getOrLoad(CacheKey cacheKey, Supplier loader) { + Optional cached = get(cacheKey); + if (cached.isPresent()) { + return cached.get(); + } + + T value = loader.get(); + if (value != null) { + put(cacheKey, value); + } + return value; + } + + private String serialize(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new CacheSerializationException("캐시 직렬화 실패", e); + } + } + + private T deserialize(String json, Class type) { + try { + return objectMapper.readValue(json, type); + } catch (JsonProcessingException e) { + throw new CacheSerializationException("캐시 역직렬화 실패", e); + } + } +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java b/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java new file mode 100644 index 000000000..28e2c81d9 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java @@ -0,0 +1,34 @@ +package com.loopers.cache; + +import java.time.Duration; + +/** + * 간단한 캐시 키 구현체. + *

+ * 기본적인 캐시 키를 생성할 때 사용합니다. + *

+ * + * @param 캐시 값의 타입 + * @author Loopers + * @version 1.0 + */ +public record SimpleCacheKey( + String key, + Duration ttl, + Class type +) implements CacheKey { + + /** + * 캐시 키를 생성합니다. + * + * @param key 캐시 키 문자열 + * @param ttl TTL + * @param type 캐시 값의 타입 + * @param 캐시 값의 타입 + * @return 캐시 키 + */ + public static SimpleCacheKey of(String key, Duration ttl, Class type) { + return new SimpleCacheKey<>(key, ttl, type); + } +} + diff --git a/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java new file mode 100644 index 000000000..0b81e46a7 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java @@ -0,0 +1,215 @@ +package com.loopers.zset; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Redis ZSET 템플릿. + *

+ * Redis Sorted Set (ZSET) 조작 기능을 제공합니다. + * ZSET은 Redis 전용 데이터 구조이므로 인터페이스 분리 없이 클래스로 직접 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisZSetTemplate { + + private final RedisTemplate redisTemplate; + + /** + * ZSET에 점수를 증가시킵니다. + *

+ * ZINCRBY는 원자적 연산이므로 동시성 문제가 없습니다. + *

+ * + * @param key ZSET 키 + * @param member 멤버 (예: 상품 ID) + * @param score 증가시킬 점수 + */ + public void incrementScore(String key, String member, double score) { + try { + redisTemplate.opsForZSet().incrementScore(key, member, score); + } catch (Exception e) { + log.warn("ZSET 점수 증가 실패: key={}, member={}, score={}", key, member, score, e); + // Redis 연결 실패 시 로그만 기록하고 계속 진행 + } + } + + /** + * ZSET의 TTL을 설정합니다. + *

+ * 이미 TTL이 설정되어 있으면 설정하지 않습니다. + *

+ * + * @param key ZSET 키 + * @param ttl TTL (Duration) + */ + public void setTtlIfNotExists(String key, Duration ttl) { + try { + Long currentTtl = redisTemplate.getExpire(key); + if (currentTtl == null || currentTtl == -1) { + // TTL이 없거나 -1(만료 시간 없음)인 경우에만 설정 + redisTemplate.expire(key, ttl); + } + } catch (Exception e) { + log.warn("ZSET TTL 설정 실패: key={}", key, e); + } + } + + /** + * 특정 멤버의 순위를 조회합니다. + *

+ * 점수가 높은 순서대로 정렬된 순위를 반환합니다 (0부터 시작). + * 멤버가 없으면 null을 반환합니다. + *

+ * + * @param key ZSET 키 + * @param member 멤버 + * @return 순위 (0부터 시작, 없으면 null) + * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시 + */ + public Long getRank(String key, String member) { + return redisTemplate.opsForZSet().reverseRank(key, member); + } + + /** + * ZSET에서 상위 N개 멤버를 조회합니다. + *

+ * 점수가 높은 순서대로 정렬된 멤버와 점수를 반환합니다. + *

+ * + * @param key ZSET 키 + * @param start 시작 인덱스 (0부터 시작) + * @param end 종료 인덱스 (포함) + * @return 멤버와 점수 쌍의 리스트 + * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시 + */ + public List getTopRankings(String key, long start, long end) { + Set> tuples = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, start, end); + + if (tuples == null) { + return List.of(); + } + + List entries = new ArrayList<>(); + for (ZSetOperations.TypedTuple tuple : tuples) { + entries.add(new ZSetEntry(tuple.getValue(), tuple.getScore())); + } + return entries; + } + + /** + * ZSET의 크기를 조회합니다. + *

+ * ZSET에 포함된 멤버의 총 개수를 반환합니다. + *

+ * + * @param key ZSET 키 + * @return ZSET 크기 (없으면 0) + * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시 + */ + public Long getSize(String key) { + Long size = redisTemplate.opsForZSet().size(key); + return size != null ? size : 0L; + } + + /** + * 여러 ZSET을 합쳐서 새로운 ZSET을 생성합니다. + *

+ * ZUNIONSTORE 명령어를 사용하여 여러 소스 ZSET의 점수를 합산합니다. + * 같은 멤버가 여러 ZSET에 있으면 점수가 합산됩니다. + *

+ *

+ * 사용 사례: + *

    + *
  • 시간 단위 랭킹을 일간 랭킹으로 집계
  • + *
  • Score Carry-Over: 오늘 랭킹을 내일 랭킹에 일부 반영
  • + *
+ *

+ * + * @param destination 목적지 ZSET 키 + * @param sourceKeys 소스 ZSET 키 목록 + * @return 합쳐진 ZSET의 멤버 수 + */ + public Long unionStore(String destination, List sourceKeys) { + try { + if (sourceKeys.isEmpty()) { + log.warn("소스 키가 비어있습니다: destination={}", destination); + return 0L; + } + + Long result = redisTemplate.opsForZSet().unionAndStore( + sourceKeys.get(0), + sourceKeys.subList(1, sourceKeys.size()), + destination + ); + return result != null ? result : 0L; + } catch (Exception e) { + log.warn("ZSET 합치기 실패: destination={}, sourceKeys={}", destination, sourceKeys, e); + return 0L; + } + } + + /** + * 단일 ZSET을 가중치를 적용하여 목적지 ZSET에 합산합니다. + *

+ * 소스 ZSET의 점수에 가중치를 곱한 후 목적지 ZSET에 합산합니다. + * 목적지 ZSET이 이미 존재하면 기존 점수에 합산됩니다. + *

+ *

+ * 사용 사례: + *

    + *
  • Score Carry-Over: 오늘 랭킹을 0.1 배율로 내일 랭킹에 반영
  • + *
+ *

+ * + * @param destination 목적지 ZSET 키 + * @param sourceKey 소스 ZSET 키 + * @param weight 가중치 (예: 0.1 = 10%) + * @return 합쳐진 ZSET의 멤버 수 + */ + public Long unionStoreWithWeight(String destination, String sourceKey, double weight) { + try { + // ZUNIONSTORE를 사용하여 가중치 적용 + // destination과 sourceKey를 합치되, sourceKey에만 가중치 적용 + // 이를 위해 임시 키를 사용하거나 직접 구현 + + // 방법: sourceKey의 모든 멤버를 읽어서 가중치를 적용한 후 destination에 추가 + Set> sourceMembers = redisTemplate.opsForZSet() + .rangeWithScores(sourceKey, 0, -1); + + if (sourceMembers == null || sourceMembers.isEmpty()) { + return 0L; + } + + // 가중치를 적용하여 destination에 추가 + for (ZSetOperations.TypedTuple tuple : sourceMembers) { + String member = tuple.getValue(); + Double originalScore = tuple.getScore(); + if (member != null && originalScore != null) { + double weightedScore = originalScore * weight; + redisTemplate.opsForZSet().incrementScore(destination, member, weightedScore); + } + } + + return (long) sourceMembers.size(); + } catch (Exception e) { + log.warn("ZSET 가중치 합치기 실패: destination={}, sourceKey={}, weight={}", + destination, sourceKey, weight, e); + return 0L; + } + } +} diff --git a/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java new file mode 100644 index 000000000..0c9642503 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java @@ -0,0 +1,12 @@ +package com.loopers.zset; + +/** + * ZSET 엔트리 (멤버와 점수 쌍). + * + * @param member 멤버 + * @param score 점수 + * @author Loopers + * @version 1.0 + */ +public record ZSetEntry(String member, Double score) { +} diff --git a/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java b/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java new file mode 100644 index 000000000..77ca2f29a --- /dev/null +++ b/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java @@ -0,0 +1,37 @@ +package com.loopers.cache; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * 테스트용 No-Op 캐시 템플릿. + *

+ * 테스트에서 캐시를 사용하지 않을 때 사용합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class NoOpCacheTemplate implements CacheTemplate { + + @Override + public Optional get(CacheKey cacheKey) { + return Optional.empty(); + } + + @Override + public void put(CacheKey cacheKey, T value) { + // No-op + } + + @Override + public void evict(CacheKey cacheKey) { + // No-op + } + + @Override + public T getOrLoad(CacheKey cacheKey, Supplier loader) { + return loader.get(); + } +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index c99fb6360..eeb4fbb90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,8 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", + ":apps:commerce-batch", + ":apps:pg-simulator", ":apps:commerce-streamer", ":modules:jpa", ":modules:redis",