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로 실시간 업데이트)
+ *
+ *
+ * @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를 직접 참조하지 않고, 이벤트만 발행
+ * 주문의 기본 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에 저장 (관심사 분리)
+ * 결제의 생성, 조회, 상태 변경 및 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;
+
+/**
+ * 재고 차감 명령.
+ *
+ * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아 상품의 좋아요 수 및 재고를 업데이트하는 애플리케이션 로직을 처리합니다.
+ *
+ *
+ * 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;
+ }
+
+ /**
+ * 시간 단위 랭킹 키를 생성합니다.
+ *
+ *
+ * @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차 시도: 즉시 실행
+ *
2차 시도: 500ms 후 (500ms * 2^0)
+ *
3차 시도: 1000ms 후 (500ms * 2^1)
+ *
+ *
+ *
+ * 설계 근거:
+ *
+ *
유저 요청 경로: 긴 Retry는 스레드 점유 비용이 크므로 Retry 없이 빠르게 실패
+ *
스케줄러 경로: 비동기/배치 기반이므로 Retry가 안전하게 적용 가능 (Nice-to-Have 요구사항 충족)
+ *
Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
+ *
+ * @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를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다.
+ * 구현은 인프라 레이어에서 제공됩니다.
+ *
+ *
+ * @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 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) - 특정 상품 랭킹 조회 최적화