diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 6e904ea4e..91be3457a 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,7 +1,39 @@
{
"permissions": {
"allow": [
- "Bash(git add:*)"
+ "Bash(git add:*)",
+ "Bash(./gradlew :apps:commerce-api:test:*)",
+ "Bash(./gradlew:*)",
+ "Bash(find /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java -name \"CacheConfig.java\" -o -name \"*CacheConfig*\" 2>/dev/null | head -20)",
+ "Bash(JAVA_HOME=\"/mnt/c/Users/kdj10/.jdks/ms-21.0.10\" ./gradlew :apps:commerce-api:compileJava 2>&1 | tail -30)",
+ "Bash(ls \"/mnt/c/Users/kdj10/.jdks/ms-21.0.10/bin/\" | head -5; \"/mnt/c/Users/kdj10/.jdks/ms-21.0.10/bin/java\" -version 2>&1 | head -3)",
+ "Read(//home/ubuntu/projects/loop-pack-be-l2-vol3-java/**)",
+ "Bash(sg docker:*)",
+ "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.brand.BrandServiceTest'\" 2>&1 | grep -A5 \"FAILED\")",
+ "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.*'\" 2>&1 | grep \"FAILED\\\\|tests completed\" | tail -5)",
+ "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.coupon.CouponModelTest'\" 2>&1 | grep -A3 \"FAILED\")",
+ "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.domain.order.OrderCartRestoreIdempotencyTest'\" 2>&1 | grep -A3 \"FAILED\")",
+ "Bash(cd ~/projects/loop-pack-be-l2-vol3-java && sg docker -c \"./gradlew :apps:commerce-api:test --tests 'com.loopers.infrastructure.*'\" 2>&1 | grep \"FAILED\\\\|tests completed\" | tail -5)",
+ "Bash(chmod +x /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/seed.sh)",
+ "Bash(docker compose:*)",
+ "Bash(docker ps:*)",
+ "Bash(mysql -h 127.0.0.1 -P 3306 -u application -papplication loopers -e \"\nSELECT 'users' AS tbl, COUNT\\(*\\) AS cnt FROM users\nUNION ALL\nSELECT 'brands', COUNT\\(*\\) FROM brands\nUNION ALL\nSELECT 'products', COUNT\\(*\\) FROM products\nUNION ALL\nSELECT 'product_stocks', COUNT\\(*\\) FROM product_stocks;\n\" 2>/dev/null)",
+ "Bash(docker exec:*)",
+ "Bash(pip3 install:*)",
+ "Bash(python3 docs/DDL/seed.py 2>&1)",
+ "Bash(curl -s -X POST http://localhost:8080/api/v1/users \\\\\n -H \"Content-Type: application/json; charset=UTF-8\" \\\\\n -d '{\"loginId\":\"testuser\",\"password\":\"Test1234!\",\"userName\":\"testuser\",\"birthday\":\"19900101\",\"email\":\"test@loopers.com\",\"address\":\"Seoul\"}' 2>/dev/null)",
+ "Bash(curl -v -X POST http://localhost:8080/api/v1/users \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"loginId\":\"testuser\",\"password\":\"Test1234!\",\"userName\":\"testuser\",\"birthday\":\"19900101\",\"email\":\"test@loopers.com\",\"address\":\"Seoul\"}' 2>&1 | tail -20)",
+ "Bash(curl -s http://localhost:8080/api/v1/products/1 \\\\\n -H \"X-Loopers-LoginId: testuser\" \\\\\n -H \"X-Loopers-LoginPw: Test1234!\" | python3 -m json.tool 2>/dev/null | head -20)",
+ "Bash(sleep 40 && curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/actuator/health 2>/dev/null || echo \"not ready\")",
+ "Bash(kill 88108 2>/dev/null; sleep 15 && curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/actuator/health)",
+ "Bash(sleep 20 && curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/actuator/health)",
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\" -H \"X-Loopers-LoginId: testuser\" -H \"X-Loopers-LoginPw: Test1234!\" \"http://localhost:8080/api/v1/products/1\")",
+ "Bash(docker run:*)",
+ "Bash(find /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache -type f -name \"*.java\" 2>/dev/null | sort)",
+ "Bash(find:*)",
+ "Bash(git checkout:*)",
+ "Bash(BASE=~/projects/loop-pack-be-l2-vol3-java/apps/commerce-api/src)",
+ "Read(//mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/$BASE/main/java/com/loopers/infrastructure/cache/**)"
]
},
"hooks": {
diff --git a/.claude/skills/anaylize-query/SKILL.md b/.claude/skills/anaylize-query/SKILL.md
new file mode 100644
index 000000000..d160c04a8
--- /dev/null
+++ b/.claude/skills/anaylize-query/SKILL.md
@@ -0,0 +1,97 @@
+---
+name: analyze-query
+description:
+ 대상이 되는 코드 범위를 탐색하고, Spring @Transactional, JPA, QueryDSL 기반의 코드에 대해 트랜잭션 범위, 영속성 컨텍스트, 쿼리 실행 시점 관점에서 분석한다.
+
+ 특히 다음을 중점적으로 점검한다.
+ - 트랜잭션이 불필요하게 크게 잡혀 있지는 않은지
+ - 조회/쓰기 로직이 하나의 트랜잭션에 혼합되어 있지는 않은지
+ - JPA의 지연 로딩, flush 타이밍, 변경 감지로 인해
+ 의도치 않은 쿼리 또는 락이 발생할 가능성은 없는지
+
+ 단순한 정답 제시가 아니라, 현재 구조의 의도와 trade-off를 드러내고 개선 가능 지점을 선택적으로 판단할 수 있도록 돕는다.
+---
+
+### 📌 Analysis Scope
+이 스킬은 아래 대상에 대해 분석한다.
+- @Transactional 이 선언된 클래스 / 메서드
+- Service / Facade / Application Layer 코드
+- JPA Entity, Repository, QueryDSL 사용 코드
+- 하나의 유즈케이스(요청 흐름) 단위
+> 컨트롤러 → 서비스 → 레포지토리 전체 흐름을 기준으로 분석하며 특정 메서드만 떼어내어 판단하지 않는다.
+
+### 🔍 Analysis Checklist
+#### 1. Transaction Boundary 분석
+다음을 순서대로 확인한다.
+- 트랜잭션 시작 지점은 어디인가?
+ - Service / Facade / 그 외 계층?
+- 트랜잭션이 실제로 필요한 작업은 무엇인가?
+ - 상태 변경 (쓰기)
+ - 단순 조회
+- 트랜잭션 내부에서 수행되는 작업 나열
+ - 외부 API 호출
+ - 복잡한 조회(QueryDSL)
+ - 반복문 기반 처리
+
+**출력 예시**
+```markdown
+- 현재 트랜잭션 범위:
+OrderFacade.placeOrder()
+ ├─ 유저 검증
+ ├─ 상품 조회
+ ├─ 주문 생성
+ ├─ 결제 요청
+ └─ 재고 차감
+
+- 트랜잭션이 필요한 핵심 작업:
+- 주문 생성
+- 재고 차감
+```
+
+#### 2. 불필요하게 큰 트랜잭션 식별
+아래 패턴이 존재하는지 점검한다.
+- Controller 에서 Transactional 이 사용되고 있음
+- 읽기 전용 로직이 쓰기 트랜잭션에 포함됨
+- 외부 시스템 호출이 트랜잭션 내부에 포함됨
+- 트랜잭션 내부에서 대량 조회 / 복잡한 QueryDSL 실행
+- 상태 변경 이후에도 트랜잭션이 길게 유지됨
+
+**문제 후보 예시**
+- 결제 API 호출이 트랜잭션 내부에 포함되어 있음
+- 주문 생성 이후 추천 상품 조회 로직까지 동일 트랜잭션에 포함됨
+
+#### 3. JPA / 영속성 컨텍스트 관점 분석
+다음을 중심으로 분석한다.
+- Entity 변경이 언제 flush 되는지
+- 조회용 Entity가 변경 감지 대상이 되는지
+- 지연 로딩으로 인해 트랜잭션 후반에 쿼리가 발생할 가능성
+- @Transactional(readOnly = true) 미적용 여부
+
+**체크리스트 예시**
+```markdown
+- 단순 조회인데 Entity 반환 후 변경 가능성 존재?
+- DTO Projection 대신 Entity 조회 사용 여부
+- QueryDSL 조회 결과가 영속성 컨텍스트에 포함되는지
+```
+
+#### 4. Improvement Proposal (선택적 제안)
+개선안은 강제하지 않고 선택지로 제시한다.
+- 트랜잭션 분리
+ - 조회 → 쓰기 분리
+ - Facade에서 orchestration, Service는 최소 트랜잭션
+- `@Transactional(readOnly = true)` 적용
+- DTO Projection (읽기 전용 모델) 도입
+- 외부 호출 / 이벤트 발행을 트랜잭션 외부로 이동
+- Application Service / Domain Service 책임 재조정
+
+**개선안 예시**
+```markdown
+[개선안 1]
+- 주문 생성과 결제 요청을 분리
+- 주문 생성까지만 트랜잭션 유지
+- 결제 요청은 트랜잭션 종료 후 수행
+
+[고려 사항]
+- 결제 실패 시 주문 상태 관리 필요
+- 보상 트랜잭션 또는 상태 전이 설계 필요
+```
\ No newline at end of file
diff --git a/.claude/skills/anylize-external-integration/SKILL.md b/.claude/skills/anylize-external-integration/SKILL.md
new file mode 100644
index 000000000..3fdbb93d6
--- /dev/null
+++ b/.claude/skills/anylize-external-integration/SKILL.md
@@ -0,0 +1,72 @@
+---
+name: analyze-external-integration
+description:
+ 외부 시스템(결제, 재고 시스템, 메시징, 서드파티 API 등)과 연동되는 기능의 설계를 분석한다.
+ 트랜잭션 경계, 상태 일관성, 실패 시나리오, 재시도 및 중복 실행 가능성을 중심으로 리스크를 드러낸다.
+ 설계를 대신 생성하지 않으며, 이미 작성된 설계를 검증하고 개선 선택지를 제시하는 데 사용한다.
+ 외부 시스템 호출이 포함된 기능 구현 전/후 설계 리뷰 목적으로 사용한다.
+---
+
+외부 시스템 연동 설계를 분석할 때 반드시 다음 흐름을 따른다.
+
+### 1️⃣ 기능이 아니라 "불확실성" 관점으로 재해석한다
+- 단순 호출 순서를 요약하지 않는다.
+- 외부 시스템은 항상 다음을 만족한다고 가정한다:
+ - 지연될 수 있다
+ - 실패할 수 있다
+ - 중복 실행될 수 있다
+ - 성공했지만 응답이 유실될 수 있다
+- 현재 설계가 이러한 불확실성을 어떻게 다루는지 설명한다.
+
+---
+
+### 2️⃣ 트랜잭션 경계를 검증한다
+- 외부 호출이 트랜잭션 내부에 존재하는지 확인한다.
+- 외부 시스템과 내부 DB 상태가 하나의 트랜잭션처럼 다뤄지고 있는지 분석한다.
+- 다음 질문을 반드시 포함한다:
+ - 외부 호출 실패 시 내부 상태는 어떻게 되는가?
+ - 내부 커밋 이후 외부 호출 실패 시 복구 가능한가?
+ - 외부 성공 후 내부 실패 시 상태는 어떻게 정합성을 유지하는가?
+
+---
+
+### 3️⃣ 상태 기반으로 구조를 다시 본다
+- 호출 흐름이 아니라 상태 전이를 중심으로 설명한다.
+- 내부 도메인 상태와 외부 시스템 상태를 분리해서 정리한다.
+- 두 상태가 어긋날 수 있는 지점을 명시한다.
+
+---
+
+### 4️⃣ 중복 요청 및 재시도 가능성을 분석한다
+- 네트워크 재시도 상황을 가정한다.
+- 동일 요청이 두 번 실행될 경우 문제를 설명한다.
+- 멱등성(Idempotency) 고려 여부를 확인한다.
+
+---
+
+### 5️⃣ 장애 시나리오를 최소 3가지 이상 생성한다
+- 정상 흐름보다 실패 흐름을 우선한다.
+- 각 장애 상황에서:
+ - 데이터 정합성
+ - 상태 불일치
+ - 복구 가능성
+ 을 분석한다.
+
+---
+
+### 6️⃣ 해결책은 정답처럼 제시하지 않는다
+- 현재 구조의 장점과 리스크를 분리한다.
+- 대안 구조가 있다면 선택지 형태로 제시한다.
+ 예:
+ - 동기 호출 유지
+ - 상태 기반 단계 분리
+ - 비동기 이벤트 전환
+- 각 선택지의 복잡도와 운영 부담을 함께 설명한다.
+
+---
+
+### 7️⃣ 톤 & 스타일 가이드
+- 코드 레벨 수정안을 직접 제시하지 않는다.
+- 설계를 비판하지 말고 리스크를 드러내는 리뷰 톤을 유지한다.
+- 외부 시스템은 항상 신뢰할 수 없다는 전제를 유지한다.
+- 구현보다 책임, 경계, 상태 일관성을 중심으로 분석한다.
diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg
new file mode 100644
index 000000000..1e38fd845
--- /dev/null
+++ b/.venv/pyvenv.cfg
@@ -0,0 +1,5 @@
+home = /usr/bin
+include-system-site-packages = false
+version = 3.12.3
+executable = /usr/bin/python3.12
+command = /usr/bin/python3 -m venv /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/.venv
diff --git a/CLAUDE.md b/CLAUDE.md
index 3b9ee7594..25e33f2a3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,43 +64,46 @@ Only `apps/*` modules produce BootJar. All other modules produce plain Jar.
Layered Architecture with strict dependency direction (DIP):
```
+CustomerAuthInterceptor (고객 인증, userId를 request attribute에 저장)
+ ↓
interfaces/ → application/ → domain/ ← infrastructure/
-(Controller) (AppService/Facade) (Service, Model, Repository interface) (Repository impl)
+(Controller) (Facade only) (Service, Model, Repository interface) (Repository impl)
```
### Application Layer 적용 기준
| 구분 | 구조 | 해당 도메인 |
|------|------|------------|
-| **단순 도메인** | Controller → **AppService** → Service (returns Model) | Example, User, Brand, Like, Stats |
-| **복잡한 도메인** | Controller → **Facade** → 여러 Service (returns Model) | Product, Cart, Order |
+| **단순 도메인** | Controller → **Service** 직접 호출 | User, Brand, Like, Stats |
+| **복잡한 도메인** | Controller → **Facade** → 여러 Service | Product, Cart, Order |
-- **AppService**: 단일 도메인 서비스를 호출하고 Model → Info 변환을 담당하는 얇은 application 레이어 클래스.
-- **Facade**: 여러 도메인 서비스를 조합(orchestration)하고 Model → Info 변환을 담당하는 application 레이어 클래스.
-- **도메인 서비스는 Model을 반환**한다. Info DTO 변환은 항상 application 레이어(AppService/Facade)에서 수행한다.
+- **Facade**: 여러 도메인 서비스를 조합(orchestration)하고, 복잡한 비즈니스 플로우(재고 hold/release, 보상 트랜잭션 등)를 담당하는 application 레이어 클래스.
+- **단순 도메인은 AppService 없이 Controller에서 Service를 직접 호출**한다.
+- **인증은 `CustomerAuthInterceptor`에서 처리**하고, 인증된 사용자는 `@AuthUser` 어노테이션으로 Controller에 주입된다.
### Layer Responsibilities
-**interfaces/** — HTTP concerns only. Controllers receive requests, call AppService/Facade, return `ApiResponse`.
+**interfaces/** — HTTP concerns only. Controllers receive requests, call Service/Facade, return `ApiResponse`.
- DTOs are inner static classes in a wrapper class (e.g., `UserV1Dto.RegisterRequest`, `UserV1Dto.RegisterResponse`)
- Request DTOs use Bean Validation annotations (`@NotBlank`, `@Size`, etc.)
-- Response DTOs have `static from(Info)` factory methods
+- Response DTOs have `static from(Model)` factory methods — 도메인 모델에서 직접 변환
- All responses wrapped in `ApiResponse` — `record ApiResponse(Metadata meta, T data)`
+- `CustomerAuthInterceptor`: 고객 API 인증 처리, `@AuthUser`로 인증된 사용자 주입
+- `AdminAuthInterceptor`: 관리자 API 인증 처리
-**application/** — AppService (단순) / Facade (복잡). Orchestrates domain services, sets transaction boundaries, converts Domain Model → Info DTO.
-- AppService: `*AppService` (annotated `@Service`) — 단일 서비스 호출 + Model → Info 변환
-- Facade: `*Facade` (annotated `@Service`) — 여러 서비스 조합 + Model → Info 변환
-- `*Info` DTO는 여기에 위치 — application 레이어에서 정의, interfaces 레이어로 전달
+**application/** — Facade만 사용 (복잡한 도메인). 여러 도메인 서비스를 조합, 트랜잭션 경계 설정.
+- Facade: `*Facade` (annotated `@Service`) — 여러 서비스 조합 + 트랜잭션 관리
+- `*Info` DTO는 복잡한 도메인(Order, Cart, Product)에서 조합된 결과를 표현할 때만 사용
- `@Transactional(readOnly = true)` at class level, `@Transactional` on write methods
**domain/** — Business logic. Service + Model (JPA Entity) + Repository interface.
-- `*Service` contains business logic, returns `*Model` (NOT Info DTO)
+- `*Service` contains business logic, returns `*Model`
- `*Model` is the JPA entity with validation logic and factory methods
-- `*Repository` is a plain Java interface (no Spring Data extends). Repository 반환 타입도 도메인 레이어 타입만 사용 (application 레이어 DTO 금지)
-- 통계 등 집계 쿼리는 `*Projection` DTO를 도메인 레이어에 정의하여 Repository/Service가 반환하고, application 레이어에서 `*Info`로 변환한다 (예: `StatsProjection` → `StatsInfo`)
+- `*Repository` is a plain Java interface (no Spring Data extends)
+- 통계 등 집계 쿼리는 `*Projection` DTO를 도메인 레이어에 정의하여 Repository/Service가 반환하고, Controller에서 Response DTO로 변환한다
- Domain models do NOT depend on infrastructure (e.g., password encoding via `PasswordEncoder` interface defined in domain)
- 비즈니스 규칙은 도메인 객체/enum에 캡슐화한다 (예: `UnavailableReason.evaluate()` 정적 팩토리로 주문 불가 사유 판별)
-- **도메인 서비스 독립성**: `*Service`는 자신의 Repository만 의존한다. 다른 도메인 서비스를 직접 참조하지 않는다. 크로스 도메인 조합은 반드시 AppService/Facade에서 수행한다.
+- **도메인 서비스 크로스 참조 허용**: `*Service`는 비즈니스 로직 수행을 위해 다른 `*Service`를 참조할 수 있다. (예: `LikeService` → `ProductService`로 상품 존재 검증)
**infrastructure/** — Implements domain repository interfaces.
- `*RepositoryImpl` delegates to `*JpaRepository` (Spring Data JPA)
@@ -108,22 +111,22 @@ interfaces/ → application/ → domain/ ← infrastructure/
### DTO Conversion Chain
-**단순 도메인**: `V1Dto.Request` → AppService → Service (returns Model) → `Info.from(Model)` → `V1Dto.Response`
-**복잡한 도메인**: `V1Dto.Request` → Facade → 여러 Service (returns Model) → `Info.from(Model)` → `V1Dto.Response`
+**단순 도메인**: `V1Dto.Request` → Service (returns Model) → `V1Dto.Response.from(Model)`
+**복잡한 도메인**: `V1Dto.Request` → Facade → 여러 Service (returns Model) → `Info.from(Model)` → `V1Dto.Response.from(Info)`
## Domain Catalog
| 도메인 | 설명 | Base Entity | Application Layer |
|--------|------|-------------|-------------------|
-| **Example** | 예제 도메인 | `BaseEntity` (Long PK) | AppService |
-| **User** | 회원 (Like/Cart/Order가 참조) | `BaseStringIdEntity` (String PK) | AppService |
-| **Brand** | 브랜드 CRUD, 소프트 삭제, display_status | `BaseStringIdEntity` | AppService |
+| **Example** | 예제 도메인 | `BaseEntity` (Long PK) | - |
+| **User** | 회원 (Like/Cart/Order가 참조) | `BaseStringIdEntity` (Long PK) | Service 직접 호출 |
+| **Brand** | 브랜드 CRUD, 소프트 삭제, display_status | `BaseStringIdEntity` | Service 직접 호출 |
| **Product** | 상품 CRUD, revision 이력, sale_status | `BaseStringIdEntity` | Facade |
| **ProductStock** | 재고 관리 (on_hand, reserved), CAS hold/release | - | (Product Facade) |
-| **Like** | 상품 좋아요 등록/취소 (멱등), 복합 PK | `BaseStringIdEntity` | AppService |
+| **Like** | 상품 좋아요 등록/취소 (멱등), 복합 PK, ProductService 참조 | `BaseStringIdEntity` | Service 직접 호출 |
| **Cart** | 장바구니 CRUD, 주문 연계 복원, 복합 PK | `BaseStringIdEntity` | Facade |
| **Order** | 주문 생성(DIRECT/CART), 취소, 만료 | `BaseStringIdEntity` | Facade |
-| **Stats** | 운영 통계 (주문 현황, 인기 상품) | - | AppService |
+| **Stats** | 운영 통계 (주문 현황, 인기 상품) | - | Service 직접 호출 |
## Entity Base Classes
@@ -131,7 +134,7 @@ Two coexisting base entity patterns in `modules/jpa`:
| 항목 | `BaseEntity` (기존) | `BaseStringIdEntity` (신규) |
|------|--------------------|-----------------------------|
-| PK | `Long` (auto-increment) | 서브클래스에서 `@Id @UuidGenerator`로 정의 (String UUID 36자) |
+| PK | `Long` (auto-increment) | 서브클래스에서 `@Id @GeneratedValue(IDENTITY)`로 정의 (Long, bigint auto_increment) |
| PK 컬럼명 | `id` (고정) | 서브클래스에서 직접 정의 (user_id, brand_id, product_id, order_id) |
| 삭제 방식 | `deletedAt` 단일 | `del_yn` + `deletedAt` 이중 관리 |
| 삭제 메서드 | `delete()` / `restore()` | `softDelete()` / `restore()` (멱등) |
@@ -141,10 +144,17 @@ Both provide: `createdAt`, `updatedAt`, `guard()` override for entity validation
## API Authentication
-| 구분 | Prefix | 인증 방식 |
-|------|--------|-----------|
-| 고객 API | `/api/v1` | `X-Loopers-LoginId` + `X-Loopers-LoginPw` headers |
-| 관리자 API | `/api-admin/v1` | `X-Loopers-Ldap: loopers.admin` header |
+| 구분 | Prefix | 인증 방식 | 처리 |
+|------|--------|-----------|------|
+| 고객 API | `/api/v1` | `X-Loopers-LoginId` + `X-Loopers-LoginPw` headers | `CustomerAuthInterceptor` → `@AuthUser` |
+| 관리자 API | `/api-admin/v1` | `X-Loopers-Ldap: loopers.admin` header | `AdminAuthInterceptor` |
+
+### 고객 인증 흐름
+
+1. `CustomerAuthInterceptor`가 인증 헤더 검증 및 `UserService.authenticate()` 호출
+2. 인증된 `UserModel`을 request attribute에 저장
+3. `AuthUserArgumentResolver`가 `@AuthUser` 어노테이션 파라미터에 `UserModel` 주입
+4. Controller에서 `@AuthUser UserModel user`로 인증된 사용자 접근
## Error Handling
@@ -244,10 +254,11 @@ DIRECT 주문 취소/만료 시 `order_cart_restore` 테이블 `existsById` 확
## 설계 원칙
- 도메인 객체는 비즈니스 규칙을 캡슐화한다. 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높다.
-- 애플리케이션 서비스(AppService/Facade)는 서로 다른 도메인을 조립하여 기능을 제공한다.
-- API request/response DTO와 application 레이어의 Info DTO는 분리한다.
-- 도메인 모델(`*Model`)은 interfaces 레이어에 노출하지 않는다. application 레이어에서 `*Info` DTO로 변환하여 반환한다.
-- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행한다.
+- **단순 도메인은 Controller에서 Service를 직접 호출**한다. AppService 레이어는 제거되었다.
+- **복잡한 도메인(Product, Cart, Order)은 Facade를 통해 여러 서비스를 조합**한다.
+- **도메인 서비스는 필요시 다른 도메인 서비스를 참조**할 수 있다. (예: `LikeService` → `ProductService`)
+- **인증은 Interceptor에서 처리**하고, `@AuthUser`로 Controller에 주입한다.
+- Response DTO는 도메인 모델에서 직접 변환한다 (`V1Dto.Response.from(Model)`).
- 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징한다.
## Implementation Guide
diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index c6a363556..eaa0d69a4 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -14,6 +14,9 @@ dependencies {
// security (for password encoding)
implementation("org.springframework.security:spring-security-crypto")
+ // L1 cache (Caffeine)
+ implementation("com.github.ben-manes.caffeine:caffeine")
+
// querydsl
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAppService.java
deleted file mode 100644
index 14193d43a..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAppService.java
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.loopers.application.brand;
-
-import com.loopers.domain.brand.BrandService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * 브랜드 도메인 Application Service.
- * 도메인 서비스를 호출하고 Model → Info 변환을 담당한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class BrandAppService {
-
- private final BrandService brandService;
-
- /**
- * 새 브랜드를 등록한다.
- *
- * @param brandName 브랜드명
- * @param description 설명
- * @param address 주소
- * @return 생성된 브랜드 정보 DTO
- */
- @Transactional
- public BrandInfo createBrand(String brandName, String description, String address) {
- return BrandInfo.from(brandService.createBrand(brandName, description, address));
- }
-
- /**
- * 관리자용 전체 브랜드 목록을 조회한다 (삭제 포함).
- *
- * @return 전체 브랜드 정보 DTO 목록
- */
- public List findAllForAdmin() {
- return brandService.findAllForAdmin().stream()
- .map(BrandInfo::from)
- .toList();
- }
-
- /**
- * 고객에게 노출 가능한 브랜드를 ID로 조회한다.
- *
- * @param brandId 브랜드 ID
- * @return 브랜드 정보 DTO
- */
- public BrandInfo findVisibleById(String brandId) {
- return BrandInfo.from(brandService.findVisibleById(brandId));
- }
-
- /**
- * 고객에게 노출 가능한 브랜드 목록을 조회한다. 키워드가 있으면 검색한다.
- *
- * @param keyword 검색 키워드 (null이면 전체 조회)
- * @return 브랜드 정보 DTO 목록
- */
- public List findAllVisibleBrands(String keyword) {
- return brandService.findAllVisibleBrands(keyword).stream()
- .map(BrandInfo::from)
- .toList();
- }
-
- /**
- * 브랜드 정보를 수정한다.
- *
- * @param brandId 브랜드 ID
- * @param brandName 새 브랜드명
- * @param description 새 설명
- * @param address 새 주소
- * @return 수정된 브랜드 정보 DTO
- */
- @Transactional
- public BrandInfo updateBrand(String brandId, String brandName, String description, String address) {
- return BrandInfo.from(brandService.updateBrand(brandId, brandName, description, address));
- }
-
- /**
- * 브랜드를 소프트 삭제한다.
- *
- * @param brandId 삭제할 브랜드 ID
- */
- @Transactional
- public void deleteBrand(String brandId) {
- brandService.deleteBrand(brandId);
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
deleted file mode 100644
index 34633812f..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.loopers.application.brand;
-
-import com.loopers.domain.brand.BrandModel;
-import com.loopers.support.enums.DisplayStatus;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.time.ZonedDateTime;
-
-/**
- * 브랜드 정보 DTO.
- * 도메인 모델({@link BrandModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다.
- */
-@Getter
-@Builder
-public class BrandInfo {
- private final String brandId;
- private final String brandName;
- private final String description;
- private final String address;
- private final DisplayStatus displayStatus;
- private final String attachFile;
- private final String delYn;
- private final ZonedDateTime deletedAt;
- private final ZonedDateTime createdAt;
-
- /**
- * BrandModel을 BrandInfo DTO로 변환한다.
- *
- * @param model 변환할 브랜드 엔티티
- * @return 브랜드 정보 DTO
- */
- public static BrandInfo from(BrandModel model) {
- return BrandInfo.builder()
- .brandId(model.getBrandId())
- .brandName(model.getBrandName())
- .description(model.getDescription())
- .address(model.getAddress())
- .displayStatus(model.getDisplayStatus())
- .attachFile(model.getAttachFile())
- .delYn(model.getDelYn())
- .deletedAt(model.getDeletedAt())
- .createdAt(model.getCreatedAt())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartAppService.java
deleted file mode 100644
index 4a0c0558b..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartAppService.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.loopers.application.cart;
-
-import com.loopers.domain.cart.CartService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * 장바구니 도메인 Application Service.
- *
- *
단일 도메인 서비스(CartService)만 호출하는 얇은 메서드를 담당한다.
- * 인증 후 CartService에 위임한다.
*/
@Service
@RequiredArgsConstructor
@@ -44,7 +41,6 @@
public class CartFacade {
private final CartService cartService;
- private final UserService userService;
private final ProductService productService;
private final StockService stockService;
private final BrandService brandService;
@@ -55,13 +51,11 @@ public class CartFacade {
*
장바구니 항목에 상품, 브랜드, 재고 정보를 조합하여
* 주문 가능 여부와 불가 사유를 포함한 CartInfo 목록을 반환한다.
*
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
+ * @param userId 사용자 ID
* @return 해당 사용자의 장바구니 항목 목록 (상품/브랜드/재고 정보 포함)
*/
- public List getCart(String loginId, String loginPw) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return buildCartInfoList(cartService.getCartItems(user.getUserId()));
+ public List getCart(Long userId) {
+ return buildCartInfoList(cartService.getCartItems(userId));
}
/**
@@ -69,23 +63,18 @@ public List getCart(String loginId, String loginPw) {
*
*
상품 주문 가능 여부와 재고 초과 여부를 검증한 뒤 장바구니에 추가한다.
*
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
+ * @param userId 사용자 ID
* @param productId 추가할 상품 ID
* @param qty 수량
*/
@Transactional
- public void addItem(String loginId, String loginPw, String productId, int qty) {
- UserModel user = userService.authenticate(loginId, loginPw);
-
+ public void addItem(Long userId, Long productId, int qty) {
productService.findOrderableById(productId);
ProductStockModel stock = stockService.findByProductId(productId);
- if (stock.getAvailableQty() < qty) {
- throw new CoreException(ErrorType.CART_STOCK_EXCEEDED);
- }
+ stock.validateCanHold(qty);
- cartService.addItem(user.getUserId(), productId, qty);
+ cartService.addItem(userId, productId, qty);
}
/**
@@ -93,21 +82,27 @@ public void addItem(String loginId, String loginPw, String productId, int qty) {
*
*
재고 초과 여부를 검증한 뒤 수량을 변경한다.
*
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
+ * @param userId 사용자 ID
* @param productId 수량을 변경할 상품 ID
* @param newQty 변경할 새 수량
*/
@Transactional
- public void changeQuantity(String loginId, String loginPw, String productId, int newQty) {
- UserModel user = userService.authenticate(loginId, loginPw);
-
+ public void changeQuantity(Long userId, Long productId, int newQty) {
ProductStockModel stock = stockService.findByProductId(productId);
- if (stock.getAvailableQty() < newQty) {
- throw new CoreException(ErrorType.CART_STOCK_EXCEEDED);
- }
+ stock.validateCanHold(newQty);
- cartService.changeQuantity(user.getUserId(), productId, newQty);
+ cartService.changeQuantity(userId, productId, newQty);
+ }
+
+ /**
+ * 장바구니에서 상품을 삭제한다.
+ *
+ * @param userId 사용자 ID
+ * @param productId 삭제할 상품 ID
+ */
+ @Transactional
+ public void removeItem(Long userId, Long productId) {
+ cartService.removeItem(userId, productId);
}
/**
@@ -119,7 +114,7 @@ public void changeQuantity(String loginId, String loginPw, String productId, int
* @param userId 조회할 사용자 ID
* @return 해당 사용자의 장바구니 항목 목록 (상품/브랜드/재고 정보 포함)
*/
- public List getCartForAdmin(String userId) {
+ public List getCartForAdmin(Long userId) {
return buildCartInfoList(cartService.getCartItems(userId));
}
@@ -132,18 +127,18 @@ private List buildCartInfoList(List items) {
return List.of();
}
- List productIds = items.stream()
+ List productIds = items.stream()
.map(CartItemModel::getProductId).distinct().toList();
- Map productMap = productService.findAllByIds(productIds)
+ Map productMap = productService.findAllByIds(productIds)
.stream().collect(Collectors.toMap(ProductModel::getProductId, Function.identity()));
- Set brandIds = productMap.values().stream()
+ Set brandIds = productMap.values().stream()
.map(ProductModel::getBrandId).collect(Collectors.toSet());
- Map brandMap = brandService.findAllByIds(brandIds)
+ Map brandMap = brandService.findAllByIds(brandIds)
.stream().collect(Collectors.toMap(BrandModel::getBrandId, Function.identity()));
- Map stockMap = stockService.findAllByProductIds(productIds)
+ Map stockMap = stockService.findAllByProductIds(productIds)
.stream().collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity()));
List result = new ArrayList<>();
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java
index 22fe99ac7..4fd575108 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java
@@ -20,14 +20,14 @@
@Getter
@Builder
public class CartInfo {
- private final String userId;
- private final String productId;
+ private final Long userId;
+ private final Long productId;
private final int quantity;
private final boolean available;
private final UnavailableReason unavailableReason;
private final String productName;
private final BigDecimal price;
- private final String brandId;
+ private final Long brandId;
private final String brandName;
private final String imageUrl;
private final int availableStock;
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java
deleted file mode 100644
index e6844943d..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.loopers.application.like;
-
-import com.loopers.domain.like.LikeService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * 좋아요 도메인 Application Service.
- * 사용자 인증과 좋아요 도메인 서비스를 조합하고 Model → Info 변환을 담당한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class LikeAppService {
-
- private final UserService userService;
- private final LikeService likeService;
-
- /**
- * 상품에 좋아요를 등록한다. 이미 좋아요한 경우 무시한다 (멱등).
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @param productId 상품 ID
- */
- @Transactional
- public void addLike(String loginId, String loginPw, String productId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- likeService.addLike(user.getUserId(), productId);
- }
-
- /**
- * 상품 좋아요를 취소한다. 좋아요가 없으면 무시한다 (멱등).
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @param productId 상품 ID
- */
- @Transactional
- public void removeLike(String loginId, String loginPw, String productId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- likeService.removeLike(user.getUserId(), productId);
- }
-
- /**
- * 특정 사용자의 좋아요 목록을 조회한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @return 좋아요 정보 DTO 목록
- */
- public List getMyLikes(String loginId, String loginPw) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return likeService.getMyLikes(user.getUserId()).stream()
- .map(LikeInfo::from)
- .toList();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
deleted file mode 100644
index bfae8c31e..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.loopers.application.like;
-
-import com.loopers.domain.like.LikeModel;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.time.LocalDateTime;
-
-/**
- * 좋아요 정보 DTO.
- * 도메인 모델({@link LikeModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다.
- */
-@Getter
-@Builder
-public class LikeInfo {
- private final String userId;
- private final String productId;
- private final LocalDateTime createdAt;
-
- /**
- * LikeModel을 LikeInfo DTO로 변환한다.
- *
- * @param model 변환할 좋아요 엔티티
- * @return 좋아요 정보 DTO
- */
- public static LikeInfo from(LikeModel model) {
- return LikeInfo.builder()
- .userId(model.getUserId())
- .productId(model.getProductId())
- .createdAt(model.getCreatedAt())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java
deleted file mode 100644
index 52f0bf151..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.loopers.application.order;
-
-import com.loopers.domain.order.OrderItemModel;
-import com.loopers.domain.order.OrderModel;
-import com.loopers.domain.order.OrderService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 주문 도메인 Application Service.
- *
- *
단일 도메인 서비스(OrderService)만 호출하는 얇은 조회 메서드를 담당한다.
- * 고객용 메서드는 UserService 인증을 포함한다.
- * Model → Info 변환을 수행한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class OrderAppService {
-
- private final OrderService orderService;
- private final UserService userService;
-
- /**
- * 주문 상세 정보를 조회한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param orderId 조회할 주문 ID
- * @return 주문 상세 정보
- */
- public OrderInfo getOrderDetail(String loginId, String loginPw, String orderId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- OrderModel order = orderService.findByIdAndUserId(orderId, user.getUserId());
- List items = orderService.findOrderItems(order.getOrderId());
- return OrderInfo.from(order, items);
- }
-
- /**
- * 기간별 주문 목록을 조회한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param start 조회 시작 일시
- * @param end 조회 종료 일시
- * @return 조회 기간 내 주문 목록
- */
- public List getOrders(String loginId, String loginPw,
- LocalDateTime start, LocalDateTime end) {
- UserModel user = userService.authenticate(loginId, loginPw);
- List orders = orderService.findAllByUserId(user.getUserId(), start, end);
- return toOrderInfoList(orders);
- }
-
- /**
- * 주문 ID로 주문 상세를 조회한다 (관리자용).
- *
- * @param orderId 주문 ID
- * @return 주문 정보 DTO
- */
- public OrderInfo findOrderById(String orderId) {
- OrderModel order = orderService.findOrderById(orderId);
- List items = orderService.findOrderItems(order.getOrderId());
- return OrderInfo.from(order, items);
- }
-
- /**
- * 기간별 전체 주문 목록을 조회한다 (관리자용).
- *
- * @param start 조회 시작 일시
- * @param end 조회 종료 일시
- * @return 주문 정보 DTO 목록
- */
- public List findAllOrders(LocalDateTime start, LocalDateTime end) {
- List orders = orderService.findAllOrders(start, end);
- return toOrderInfoList(orders);
- }
-
- /**
- * 주문 목록을 배치 로딩으로 OrderInfo 목록으로 변환한다.
- * N+1 쿼리 대신 단일 IN 쿼리로 주문 항목을 일괄 조회한다.
- */
- private List toOrderInfoList(List orders) {
- if (orders.isEmpty()) {
- return List.of();
- }
- List orderIds = orders.stream().map(OrderModel::getOrderId).toList();
- Map> itemMap = orderService.findOrderItemsByOrderIds(orderIds);
- return orders.stream()
- .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of())))
- .toList();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index b165045ad..bfb60f321 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -3,6 +3,9 @@
import com.loopers.domain.brand.BrandModel;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.cart.CartService;
+import com.loopers.domain.coupon.CouponModel;
+import com.loopers.domain.coupon.CouponService;
+import com.loopers.domain.coupon.UserCouponModel;
import com.loopers.domain.order.OrderCartRestoreModel;
import com.loopers.domain.order.OrderItemCommand;
import com.loopers.domain.order.OrderItemModel;
@@ -12,8 +15,6 @@
import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.StockService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
import com.loopers.support.enums.OrderType;
import com.loopers.support.enums.RestoreReason;
import com.loopers.support.enums.RestoreTriggerSource;
@@ -23,24 +24,28 @@
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
/**
* 주문 Facade (퍼사드)
*
- *
*/
@Service
@RequiredArgsConstructor
@@ -48,62 +53,103 @@
public class OrderFacade {
private final OrderService orderService;
- private final UserService userService;
private final ProductService productService;
private final BrandService brandService;
private final StockService stockService;
private final CartService cartService;
+ private final CouponService couponService;
/**
* 직접 주문을 생성한다.
*
- *
상품 상세 페이지에서 바로 주문하는 DIRECT 주문 방식이다.
- * 상품 검증 → 브랜드 조회 → 재고 hold → 주문 저장 순으로 진행하며,
- * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param items 주문할 상품 항목 목록
+ * @param userId 사용자 ID
+ * @param items 주문할 상품 항목 목록
+ * @param userCouponId 적용할 발급 쿠폰 ID (없으면 null)
* @return 생성된 주문 정보
*/
@Transactional
- public OrderInfo createDirectOrder(String loginId, String loginPw, List items) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return processOrder(user.getUserId(), OrderType.DIRECT, items);
+ public OrderInfo createDirectOrder(Long userId, List items, Long userCouponId) {
+ return processOrder(userId, OrderType.DIRECT, items, userCouponId);
}
/**
* 장바구니 주문을 생성한다.
*
- *
장바구니에서 선택한 상품들을 주문하는 CART 주문 방식이다.
- * 상품 검증 → 브랜드 조회 → 재고 hold → 주문 저장 순으로 진행하며,
- * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param items 주문할 상품 항목 목록
+ * @param userId 사용자 ID
+ * @param items 주문할 상품 항목 목록
+ * @param userCouponId 적용할 발급 쿠폰 ID (없으면 null)
* @return 생성된 주문 정보
*/
@Transactional
- public OrderInfo createCartOrder(String loginId, String loginPw, List items) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return processOrder(user.getUserId(), OrderType.CART, items);
+ public OrderInfo createCartOrder(Long userId, List items, Long userCouponId) {
+ return processOrder(userId, OrderType.CART, items, userCouponId);
+ }
+
+ /**
+ * 주문 목록을 조회한다 (기간 필터).
+ */
+ public List getOrders(Long userId, LocalDateTime start, LocalDateTime end) {
+ List orders = orderService.findAllByUserId(userId, start, end);
+ Map> itemMap = orderService.batchLoadOrderItems(orders);
+ return orders.stream()
+ .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of())))
+ .toList();
+ }
+
+ /**
+ * 주문 상세 정보를 조회한다.
+ */
+ public OrderInfo getOrderDetail(Long userId, Long orderId) {
+ OrderModel order = orderService.findByIdAndUserId(orderId, userId);
+ List items = orderService.findOrderItems(order.getOrderId());
+ return OrderInfo.from(order, items);
+ }
+
+ /**
+ * 관리자용 전체 주문 목록을 조회한다 (기간 필터).
+ */
+ public List getOrdersForAdmin(LocalDateTime start, LocalDateTime end) {
+ List orders = orderService.findAllOrders(start, end);
+ Map> itemMap = orderService.batchLoadOrderItems(orders);
+ return orders.stream()
+ .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of())))
+ .toList();
+ }
+
+ /**
+ * 관리자용 주문 상세 정보를 조회한다.
+ */
+ public OrderInfo getOrderDetailForAdmin(Long orderId) {
+ OrderModel order = orderService.findOrderById(orderId);
+ List items = orderService.findOrderItems(order.getOrderId());
+ return OrderInfo.from(order, items);
}
/**
* 주문 생성 공통 로직.
*
- * 주문 항목 검증/병합 → 상품 조회 → 브랜드 조회 → 재고 hold → 스냅샷 생성 → 주문 저장.
+ * 쿠폰 검증 → 재고 hold → 할인 배분 → 주문 저장 → 쿠폰 사용 처리.
* 데드락 방지를 위해 productId 오름차순으로 재고 예약을 수행한다.
* 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
*
*/
- private OrderInfo processOrder(String userId, OrderType orderType, List items) {
+ private OrderInfo processOrder(Long userId, OrderType orderType, List items,
+ Long userCouponId) {
List merged = orderService.validateAndPrepare(userId, items);
- List snapshots = new ArrayList<>();
- List heldProductIds = new ArrayList<>();
- BigDecimal totalAmount = BigDecimal.ZERO;
+ // Step 1: 쿠폰 검증 (재고 hold 전에 수행 → 보상 복잡도 최소화)
+ UserCouponModel userCoupon = null;
+ CouponModel coupon = null;
+ if (userCouponId != null) {
+ userCoupon = couponService.validateAndGetUserCoupon(userId, userCouponId);
+ coupon = couponService.findByIdForAdmin(userCoupon.getCouponId());
+ }
+
+ // Step 2: 재고 hold + 스냅샷 생성 + 쿠폰 할인 계산
+ List rawSnapshots = new ArrayList<>();
+ List heldProductIds = new ArrayList<>();
+ BigDecimal totalOriginalAmount = BigDecimal.ZERO;
+ BigDecimal totalDiscount = BigDecimal.ZERO;
try {
for (OrderItemCommand item : merged) {
@@ -113,47 +159,87 @@ private OrderInfo processOrder(String userId, OrderType orderType, List finalSnapshots = applyDiscountProportionally(rawSnapshots, totalOriginalAmount,
+ totalDiscount);
+ BigDecimal totalFinalAmount = totalOriginalAmount.subtract(totalDiscount);
+
+ // Step 4: 주문 생성
+ OrderModel order = orderService.createOrder(userId, orderType, totalFinalAmount, finalSnapshots);
+
+ // Step 5: 쿠폰 사용 처리 (동일 트랜잭션)
+ if (userCoupon != null) {
+ couponService.markCouponAsUsed(userCoupon.getUserCouponId(), order.getOrderId());
+ }
+
List orderItems = orderService.findOrderItems(order.getOrderId());
return OrderInfo.from(order, orderItems);
}
+ /**
+ * 할인 금액을 주문 항목별 originalAmount 비율로 배분한다.
+ * 반올림 오차는 마지막 항목이 흡수한다.
+ */
+ private List applyDiscountProportionally(List rawSnapshots,
+ BigDecimal totalOriginalAmount,
+ BigDecimal totalDiscount) {
+ if (totalDiscount.compareTo(BigDecimal.ZERO) == 0) {
+ return rawSnapshots;
+ }
+
+ List result = new ArrayList<>();
+ BigDecimal allocatedDiscount = BigDecimal.ZERO;
+
+ for (int i = 0; i < rawSnapshots.size(); i++) {
+ OrderItemSnapshot raw = rawSnapshots.get(i);
+ boolean isLast = (i == rawSnapshots.size() - 1);
+ BigDecimal itemDiscount;
+ if (isLast) {
+ itemDiscount = totalDiscount.subtract(allocatedDiscount);
+ } else {
+ itemDiscount = totalDiscount
+ .multiply(raw.originalAmount())
+ .divide(totalOriginalAmount, 0, RoundingMode.FLOOR);
+ allocatedDiscount = allocatedDiscount.add(itemDiscount);
+ }
+
+ OrderItemSnapshot snapshotWithDiscount = new OrderItemSnapshot(
+ raw.productId(), raw.quantity(), raw.productName(),
+ raw.unitPrice(), raw.brandId(), raw.brandName(), raw.imageUrl(),
+ raw.originalAmount(), itemDiscount, raw.originalAmount().subtract(itemDiscount));
+ result.add(snapshotWithDiscount);
+ }
+ return result;
+ }
/**
* 주문을 취소한다.
- *
- *
CAS 상태 전이 후 재고를 해제하고, DIRECT 주문인 경우 장바구니를 복원한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param orderId 취소할 주문 ID
*/
@Transactional
- public void cancelOrder(String loginId, String loginPw, String orderId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- Optional order = orderService.cancelOrder(user.getUserId(), orderId);
+ public void cancelOrder(Long userId, Long orderId) {
+ Optional order = orderService.cancelOrder(userId, orderId);
order.ifPresent(o -> releaseStocksAndRestore(o, RestoreReason.USER_CANCELLED, RestoreTriggerSource.CANCEL_API));
}
/**
* 배치 스케줄러가 주문을 만료 처리한다.
- *
- *
CAS 상태 전이 후 재고를 해제하고, DIRECT 주문인 경우 장바구니를 복원한다.
- * 인증이 필요하지 않은 시스템 내부 호출용 메서드이다.
- *
- * @param orderId 주문 ID
*/
@Transactional
- public void expireOrder(String orderId) {
+ public void expireOrder(Long orderId) {
Optional order = orderService.expireOrder(orderId);
order.ifPresent(o -> releaseStocksAndRestore(o, RestoreReason.EXPIRED, RestoreTriggerSource.EXPIRE_JOB));
}
@@ -161,9 +247,9 @@ public void expireOrder(String orderId) {
/**
* 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
*/
- private void compensateHeldStocks(List heldProductIds, List merged) {
+ private void compensateHeldStocks(List heldProductIds, List merged) {
for (int i = heldProductIds.size() - 1; i >= 0; i--) {
- String heldProductId = heldProductIds.get(i);
+ Long heldProductId = heldProductIds.get(i);
int qty = merged.stream()
.filter(m -> m.productId().equals(heldProductId))
.findFirst().map(OrderItemCommand::quantity).orElse(0);
@@ -173,11 +259,6 @@ private void compensateHeldStocks(List heldProductIds, List
- * 주문 항목의 재고를 productId 오름차순으로 해제하고,
- * DIRECT 주문인 경우 장바구니 복원을 수행한다.
- * PK 충돌 시 이미 복원된 것으로 간주하여 skip한다 (멱등 보장).
- *
*/
private void releaseStocksAndRestore(OrderModel order, RestoreReason reason,
RestoreTriggerSource triggerSource) {
@@ -190,6 +271,9 @@ private void releaseStocksAndRestore(OrderModel order, RestoreReason reason,
stockService.release(item.getProductId(), item.getQuantity());
}
+ // 쿠폰 복원 (멱등)
+ couponService.restoreCoupon(order.getOrderId());
+
if (order.getOrderType() == OrderType.DIRECT) {
if (!orderService.existsCartRestore(order.getOrderId())) {
orderService.saveCartRestore(
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
index 029d03d2f..5e5f8a6b6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -22,8 +22,8 @@
@Getter
@Builder
public class OrderInfo {
- private final String orderId;
- private final String userId;
+ private final Long orderId;
+ private final Long userId;
private final OrderType orderType;
private final OrderStatus status;
private final BigDecimal totalAmount;
@@ -33,10 +33,6 @@ public class OrderInfo {
/**
* OrderModel과 OrderItemModel 목록을 조합하여 OrderInfo DTO로 변환한다.
- *
- * @param order 주문 엔티티
- * @param items 주문 항목 엔티티 목록 (null이면 빈 리스트)
- * @return 주문 정보 DTO (주문 항목 스냅샷 포함)
*/
public static OrderInfo from(OrderModel order, List items) {
return OrderInfo.builder()
@@ -55,29 +51,25 @@ public static OrderInfo from(OrderModel order, List items) {
/**
* 주문 항목 정보 DTO.
- *
- * 주문 시점의 상품 스냅샷 데이터(상품명, 단가, 브랜드, 이미지 등)를 포함하여
- * 주문 후 상품 정보가 변경되더라도 주문 당시 정보를 보존한다.
- *
*/
@Getter
@Builder
public static class OrderItemInfo {
- private final String orderId;
+ private final Long orderId;
private final int orderItemSeq;
- private final String productId;
+ private final Long productId;
private final int quantity;
private final String snapshotProductName;
private final BigDecimal snapshotUnitPrice;
private final String snapshotBrandId;
private final String snapshotBrandName;
private final String snapshotImageUrl;
+ private final BigDecimal originalAmount;
+ private final BigDecimal discountAmount;
+ private final BigDecimal finalAmount;
/**
* OrderItemModel을 OrderItemInfo DTO로 변환한다.
- *
- * @param item 주문 항목 엔티티
- * @return 주문 항목 정보 DTO (스냅샷 데이터 포함)
*/
public static OrderItemInfo from(OrderItemModel item) {
return OrderItemInfo.builder()
@@ -90,6 +82,9 @@ public static OrderItemInfo from(OrderItemModel item) {
.snapshotBrandId(item.getSnapshotBrandId())
.snapshotBrandName(item.getSnapshotBrandName())
.snapshotImageUrl(item.getSnapshotImageUrl())
+ .originalAmount(item.getOriginalAmount())
+ .discountAmount(item.getDiscountAmount())
+ .finalAmount(item.getFinalAmount())
.build();
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java
deleted file mode 100644
index af296ee8c..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.loopers.application.product;
-
-import com.loopers.domain.product.ProductService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * 상품 도메인 Application Service.
- *
- *
단일 도메인 서비스(ProductService)만 호출하는 얇은 메서드를 담당한다.
- * Model → Info 변환을 수행한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class ProductAppService {
-
- private final ProductService productService;
-
- /**
- * 상품을 소프트 삭제한다.
- *
- * @param productId 삭제할 상품 ID
- */
- @Transactional
- public void deleteProduct(String productId) {
- productService.deleteProduct(productId);
- }
-
- /**
- * 상품의 변경 이력(Revision) 목록을 조회한다.
- *
- * @param productId 이력을 조회할 상품 ID
- * @return 상품 변경 이력 Info 목록
- */
- public List getRevisions(String productId) {
- return productService.findRevisionsByProductId(productId).stream()
- .map(ProductRevisionInfo::from)
- .toList();
- }
-
- /**
- * 특정 상품의 개별 변경 이력 상세를 조회한다.
- *
- * @param productId 상품 ID
- * @param revisionSeq 변경 순번
- * @return 변경 이력 Info
- */
- public ProductRevisionInfo getRevisionDetail(String productId, Long revisionSeq) {
- return ProductRevisionInfo.from(productService.findRevisionById(productId, revisionSeq));
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java
index e8ac41a43..a7a6a3eb7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java
@@ -14,7 +14,7 @@
* @param description 상품 설명
* @param initialStock 초기 재고 수량
*/
-public record ProductCreateCommand(String productName, String brandId,
+public record ProductCreateCommand(String productName, Long brandId,
BigDecimal price, String description,
int initialStock) {
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
index 4cf8e5060..0d77caaea 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -2,21 +2,20 @@
import com.loopers.domain.brand.BrandModel;
import com.loopers.domain.brand.BrandService;
-import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.ProductStockModel;
import com.loopers.domain.product.StockService;
import com.loopers.interfaces.api.PageResponse;
import com.loopers.support.enums.ProductSortType;
+import com.loopers.support.page.PageQuery;
+import com.loopers.support.page.PagedResult;
import lombok.RequiredArgsConstructor;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Sort;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -36,6 +35,11 @@
*
관리자용 상품 목록 조회 (재고 포함)
*
변경 이력(Revision) 조회
*
+ *
+ *
캐시 위계 분리
+ *
상품 목록 캐시(productList)는 상품 ID 목록 + 페이징 메타만 저장하고,
+ * 개별 상품 정보는 productDetail 캐시(L1+L2)에서 조회한다.
+ * 이로써 상품 수정 시 목록 캐시를 invalidate할 필요 없이 productDetail만 evict하면 된다.
*/
@Service
@RequiredArgsConstructor
@@ -45,7 +49,6 @@ public class ProductFacade {
private final ProductService productService;
private final StockService stockService;
private final BrandService brandService;
- private final LikeService likeService;
/**
* 고객용 상품 목록을 정렬 + 페이징하여 조회한다.
@@ -60,24 +63,63 @@ public class ProductFacade {
* @param size 페이지 크기
* @return 페이징된 상품 정보 목록 (재고, 브랜드명, 좋아요 수 포함)
*/
- public PageResponse getProductsForCustomer(String keyword, String brandId,
+ public PageResponse getProductsForCustomer(String keyword, Long brandId,
ProductSortType sort, int page, int size) {
- if (sort == ProductSortType.LIKES_DESC) {
- return getProductsSortedByLikes(keyword, brandId, page, size);
+ if (keyword == null && page == 0 && size == 20) {
+ return getProductsFromCachedIds(brandId, sort, page, size);
}
+ return getProductsFromDb(keyword, brandId, sort, page, size);
+ }
- Sort dbSort = switch (sort) {
- case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt");
- case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price");
- default -> Sort.by(Sort.Direction.DESC, "createdAt");
- };
+ /**
+ * 캐시 위계 분리: productList 캐시에는 ID 목록 + 페이징 메타만 저장한다.
+ * 개별 상품 정보는 productDetail 캐시(L1+L2)에서 조회하여 조합한다.
+ */
+ private PageResponse getProductsFromCachedIds(Long brandId,
+ ProductSortType sort, int page, int size) {
+ ProductListIdCache idCache = getCachedProductListIds(brandId, sort, page, size);
+
+ List products = idCache.productIds().stream()
+ .map(productService::findById)
+ .toList();
+
+ List enriched = enrichProducts(products);
+
+ return new PageResponse<>(enriched, idCache.page(), idCache.size(),
+ idCache.totalElements(), idCache.totalPages());
+ }
+
+ @Cacheable(cacheNames = "productList",
+ key = "T(String).valueOf(#brandId) + ':' + #sort.name() + ':p' + #page + ':s' + #size")
+ public ProductListIdCache getCachedProductListIds(Long brandId,
+ ProductSortType sort, int page, int size) {
+ PageQuery query = buildPageQuery(sort, page, size);
+ PagedResult productPage = productService.findAllForCustomer(null, brandId, query);
+
+ List productIds = productPage.content().stream()
+ .map(ProductModel::getProductId)
+ .toList();
+
+ return new ProductListIdCache(productIds, productPage.page(), productPage.size(),
+ productPage.totalElements(), productPage.totalPages());
+ }
+
+ private PageResponse getProductsFromDb(String keyword, Long brandId,
+ ProductSortType sort, int page, int size) {
+ PageQuery query = buildPageQuery(sort, page, size);
+ PagedResult productPage = productService.findAllForCustomer(keyword, brandId, query);
+ List enriched = enrichProducts(productPage.content());
- Page productPage = productService.findAllForCustomer(keyword, brandId,
- PageRequest.of(page, size, dbSort));
- List enriched = enrichProducts(productPage.getContent());
+ return new PageResponse<>(enriched, productPage.page(), productPage.size(),
+ productPage.totalElements(), productPage.totalPages());
+ }
- return new PageResponse<>(enriched, productPage.getNumber(), productPage.getSize(),
- productPage.getTotalElements(), productPage.getTotalPages());
+ private PageQuery buildPageQuery(ProductSortType sort, int page, int size) {
+ return switch (sort) {
+ case LATEST -> new PageQuery(page, size, "createdAt", false);
+ case PRICE_ASC -> new PageQuery(page, size, "price", true);
+ case LIKES_DESC -> new PageQuery(page, size, "likeCount", false);
+ };
}
/**
@@ -88,12 +130,11 @@ public PageResponse getProductsForCustomer(String keyword, String b
* @param productId 조회할 상품 ID
* @return 상품 상세 정보 (재고, 브랜드명, 좋아요 수 포함)
*/
- public ProductInfo getProductDetailForCustomer(String productId) {
+ public ProductInfo getProductDetailForCustomer(Long productId) {
ProductModel product = productService.findById(productId);
ProductStockModel stock = stockService.findByProductId(productId);
BrandModel brand = brandService.findById(product.getBrandId());
- long likeCount = likeService.countByProductId(productId);
- return ProductInfo.from(product, stock, brand.getBrandName(), likeCount);
+ return ProductInfo.from(product, stock, brand.getBrandName(), product.getLikeCount());
}
/**
@@ -106,22 +147,24 @@ public ProductInfo getProductDetailForCustomer(String productId) {
*/
public List getProductsForAdmin(boolean includeDeleted) {
List products = productService.findAllForAdmin(includeDeleted);
+ List productIds = products.stream().map(ProductModel::getProductId).toList();
+ Map stockMap = stockService.findAllByProductIds(productIds).stream()
+ .collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity()));
return products.stream()
- .map(product -> {
- ProductStockModel stock = stockService.findByProductId(product.getProductId());
- return ProductInfo.from(product, stock);
- })
+ .map(product -> ProductInfo.from(product, stockMap.get(product.getProductId())))
.toList();
}
/**
* 상품을 신규 등록한다.
*
- *
브랜드 존재 여부를 검증한 뒤, 상품을 생성하고, 초기 재고를 설정한다.
+ *
브랜드 존재 여부를 검증한 뒤, 상품을 생성하고, 초기 재고를 설정한다.
+ * 상품 생성은 목록 구조를 변경하므로 productList 캐시를 전체 무효화한다.
*
* @param command 상품 생성 커맨드
* @return 생성된 상품 정보
*/
+ @CacheEvict(cacheNames = "productList", allEntries = true)
@Transactional
public ProductInfo createProduct(ProductCreateCommand command) {
brandService.findById(command.brandId());
@@ -134,7 +177,9 @@ public ProductInfo createProduct(ProductCreateCommand command) {
/**
* 상품 정보를 수정한다.
*
- *
상품을 수정한 뒤 재고 정보를 결합하여 반환한다.
+ *
상품을 수정한 뒤 재고 정보를 결합하여 반환한다.
+ * 캐시 위계 분리에 의해 productList는 ID 목록만 캐싱하므로,
+ * 상품 정보 수정 시 productList evict 불필요 (productDetail만 evict됨).
*
* @param command 상품 수정 커맨드
* @return 수정된 상품 정보
@@ -149,48 +194,28 @@ public ProductInfo updateProduct(ProductUpdateCommand command) {
}
/**
- * 좋아요 수 기준 내림차순 정렬 + 수동 페이징. like count가 별도 테이블이므로 DB-level 정렬 불가.
- */
- private PageResponse getProductsSortedByLikes(String keyword, String brandId,
- int page, int size) {
- List allProducts = productService.findAllForCustomer(keyword, brandId);
- List enriched = enrichProducts(allProducts);
-
- List sorted = enriched.stream()
- .sorted(Comparator.comparingLong(ProductInfo::getLikeCount).reversed())
- .toList();
-
- int totalElements = sorted.size();
- int totalPages = (totalElements + size - 1) / size;
- int fromIndex = Math.min(page * size, totalElements);
- int toIndex = Math.min(fromIndex + size, totalElements);
- List pageContent = sorted.subList(fromIndex, toIndex);
-
- return new PageResponse<>(pageContent, page, size, totalElements, totalPages);
- }
-
- /**
- * 상품 목록에 재고, 브랜드명, 좋아요 수를 배치 조회하여 결합한다 (N+1 방지).
+ * 상품 목록에 재고, 브랜드명을 배치 조회하여 결합한다 (N+1 방지).
+ * likeCount는 ProductModel에서 직접 읽는다 (비정규화).
*/
private List enrichProducts(List products) {
if (products.isEmpty()) {
return List.of();
}
- List productIds = products.stream().map(ProductModel::getProductId).toList();
- List brandIds = products.stream().map(ProductModel::getBrandId).distinct().toList();
+ List productIds = products.stream().map(ProductModel::getProductId).toList();
+ List brandIds = products.stream().map(ProductModel::getBrandId).distinct().toList();
- Map brandMap = brandService.findAllByIds(brandIds).stream()
+ Map stockMap = stockService.findAllByProductIds(productIds).stream()
+ .collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity()));
+ Map brandMap = brandService.findAllByIds(brandIds).stream()
.collect(Collectors.toMap(BrandModel::getBrandId, Function.identity()));
- Map likeCountMap = likeService.countByProductIds(productIds);
return products.stream()
.map(product -> {
- ProductStockModel stock = stockService.findByProductId(product.getProductId());
+ ProductStockModel stock = stockMap.get(product.getProductId());
BrandModel brand = brandMap.get(product.getBrandId());
String brandName = brand != null ? brand.getBrandName() : null;
- long likeCount = likeCountMap.getOrDefault(product.getProductId(), 0L);
- return ProductInfo.from(product, stock, brandName, likeCount);
+ return ProductInfo.from(product, stock, brandName, product.getLikeCount());
})
.toList();
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
index e50766ac2..c906abf04 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
@@ -20,8 +20,8 @@
@Getter
@Builder
public class ProductInfo {
- private final String productId;
- private final String brandId;
+ private final Long productId;
+ private final Long brandId;
private final String productName;
private final String description;
private final BigDecimal price;
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListIdCache.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListIdCache.java
new file mode 100644
index 000000000..370a5fc36
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListIdCache.java
@@ -0,0 +1,20 @@
+package com.loopers.application.product;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 상품 목록 캐시용 경량 DTO.
+ *
+ * 캐시 위계 분리를 위해 상품 ID 목록과 페이징 메타데이터만 저장한다.
+ * 개별 상품 정보는 productDetail 캐시에서 별도 조회한다.
+ *
+ */
+public record ProductListIdCache(
+ List productIds,
+ int page,
+ int size,
+ long totalElements,
+ int totalPages
+) implements Serializable {
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java
deleted file mode 100644
index 3abcb23fa..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.loopers.application.product;
-
-import com.loopers.domain.product.ProductRevisionModel;
-import com.loopers.support.enums.ProductRevisionAction;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.time.LocalDateTime;
-
-/**
- * 상품 변경 이력 정보 DTO.
- *
- * 도메인 모델({@link ProductRevisionModel})을 직접 노출하지 않고
- * interfaces 계층에 전달하기 위한 응답 객체이다.
- *
- */
-@Getter
-@Builder
-public class ProductRevisionInfo {
- private final String productId;
- private final Long revisionSeq;
- private final ProductRevisionAction action;
- private final String changedBy;
- private final String changeReason;
- private final String beforeSnapshot;
- private final String afterSnapshot;
- private final LocalDateTime createdAt;
-
- /**
- * ProductRevisionModel을 ProductRevisionInfo DTO로 변환한다.
- *
- * @param model 상품 변경 이력 엔티티
- * @return 변경 이력 정보 DTO
- */
- public static ProductRevisionInfo from(ProductRevisionModel model) {
- return ProductRevisionInfo.builder()
- .productId(model.getProductId())
- .revisionSeq(model.getRevisionSeq())
- .action(model.getAction())
- .changedBy(model.getChangedBy())
- .changeReason(model.getChangeReason())
- .beforeSnapshot(model.getBeforeSnapshot())
- .afterSnapshot(model.getAfterSnapshot())
- .createdAt(model.getCreatedAt())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java
index 6b94398b1..ab90a13be 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java
@@ -14,7 +14,7 @@
* @param description 변경할 상품 설명
* @param imageUrl 변경할 이미지 URL
*/
-public record ProductUpdateCommand(String productId, String productName,
+public record ProductUpdateCommand(Long productId, String productName,
BigDecimal price, String description,
String imageUrl) {
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java
deleted file mode 100644
index 4d0bdc982..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.loopers.application.stats;
-
-import com.loopers.domain.stats.StatsService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDate;
-import java.util.List;
-
-/**
- * 운영 통계 Application Service.
- * 도메인 서비스를 호출하고 StatsProjection → StatsInfo 변환을 수행한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class StatsAppService {
-
- private final StatsService statsService;
-
- public StatsInfo.Overview getOverview(LocalDate startAt, LocalDate endAt) {
- return StatsInfo.Overview.from(statsService.getOverview(startAt, endAt));
- }
-
- public List getDailyOrderStats(LocalDate startAt, LocalDate endAt) {
- return statsService.getDailyOrderStats(startAt, endAt).stream()
- .map(StatsInfo.DailyOrderStat::from)
- .toList();
- }
-
- public List getTopLikedProducts(int limit) {
- return statsService.getTopLikedProducts(limit).stream()
- .map(StatsInfo.ProductStat::from)
- .toList();
- }
-
- public List getTopOrderedProducts(int limit) {
- return statsService.getTopOrderedProducts(limit).stream()
- .map(StatsInfo.ProductStat::from)
- .toList();
- }
-
- public List getLowStockProducts(int threshold) {
- return statsService.getLowStockProducts(threshold).stream()
- .map(StatsInfo.LowStockProduct::from)
- .toList();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java
deleted file mode 100644
index 955835776..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.loopers.application.stats;
-
-import com.loopers.domain.stats.StatsProjection;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.math.BigDecimal;
-import java.time.LocalDate;
-
-/**
- * 운영 통계 정보 DTO 모음.
- * 주문 현황 개요, 일별 주문 통계, 인기 상품, 저재고 상품 정보를 포함한다.
- */
-public class StatsInfo {
-
- /**
- * 주문 현황 개요 DTO.
- * 결제 대기, 취소, 만료 건수를 포함한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class Overview {
- private final long pendingCount;
- private final long cancelledCount;
- private final long expiredCount;
-
- public static Overview from(StatsProjection.Overview projection) {
- return Overview.builder()
- .pendingCount(projection.getPendingCount())
- .cancelledCount(projection.getCancelledCount())
- .expiredCount(projection.getExpiredCount())
- .build();
- }
- }
-
- /**
- * 일별 주문 통계 DTO.
- * 특정 날짜의 주문 건수와 총 주문 금액을 포함한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class DailyOrderStat {
- private final LocalDate date;
- private final long orderCount;
- private final BigDecimal totalAmount;
-
- public static DailyOrderStat from(StatsProjection.DailyOrderStat projection) {
- return DailyOrderStat.builder()
- .date(projection.getDate())
- .orderCount(projection.getOrderCount())
- .totalAmount(projection.getTotalAmount())
- .build();
- }
- }
-
- /**
- * 상품 통계 DTO.
- * 인기 좋아요 상품, 인기 주문 상품 등 상품별 집계 결과를 표현한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class ProductStat {
- private final String productId;
- private final String productName;
- private final long count;
-
- public static ProductStat from(StatsProjection.ProductStat projection) {
- return ProductStat.builder()
- .productId(projection.getProductId())
- .productName(projection.getProductName())
- .count(projection.getCount())
- .build();
- }
- }
-
- /**
- * 저재고 상품 DTO.
- * 가용 재고가 임계값 이하인 상품의 재고 현황을 포함한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class LowStockProduct {
- private final String productId;
- private final String productName;
- private final int onHand;
- private final int reserved;
- private final int availableQty;
-
- public static LowStockProduct from(StatsProjection.LowStockProduct projection) {
- return LowStockProduct.builder()
- .productId(projection.getProductId())
- .productName(projection.getProductName())
- .onHand(projection.getOnHand())
- .reserved(projection.getReserved())
- .availableQty(projection.getAvailableQty())
- .build();
- }
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java
deleted file mode 100644
index 96444aa99..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.loopers.application.user;
-
-import com.loopers.domain.user.UserRegisterCommand;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * 사용자 도메인 Application Service.
- * 도메인 서비스를 호출하고 Model → Info 변환을 담당한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class UserAppService {
-
- private final UserService userService;
-
- /**
- * 회원가입을 수행한다.
- *
- * @param command 회원가입 커맨드
- * @return 생성된 사용자 정보
- */
- @Transactional
- public UserInfo register(UserRegisterCommand command) {
- return UserInfo.from(userService.register(command));
- }
-
- /**
- * 인증 후 본인 정보를 조회하여 UserInfo로 반환한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @return 사용자 정보 DTO (마스킹된 이름 포함)
- */
- public UserInfo getMyInfo(String loginId, String loginPw) {
- return UserInfo.from(userService.authenticate(loginId, loginPw));
- }
-
- /**
- * 인증 헤더로 인증한 뒤 비밀번호를 변경한다.
- *
- * @param loginId 인증 헤더의 로그인 ID
- * @param loginPw 인증 헤더의 비밀번호
- * @param currentPw 현재 비밀번호 (body)
- * @param newPw 새 비밀번호 (body)
- */
- @Transactional
- public void changePassword(String loginId, String loginPw,
- String currentPw, String newPw) {
- userService.authenticateAndChangePassword(loginId, loginPw, currentPw, newPw);
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
deleted file mode 100644
index 7b8c49a3a..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.loopers.application.user;
-
-import com.loopers.domain.user.UserModel;
-import lombok.Builder;
-import lombok.Getter;
-
-/**
- * 사용자 정보 DTO.
- * 도메인 모델({@link UserModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다.
- * 비밀번호를 제외하고 마스킹된 이름을 포함한다.
- */
-@Getter
-@Builder
-public class UserInfo {
- private final String userId;
- private final String loginId;
- private final String maskedName;
- private final String birthday;
- private final String email;
- private final String address;
-
- /**
- * UserModel을 UserInfo DTO로 변환한다. password를 제외하고 maskedName을 포함한다.
- *
- * @param user 변환할 사용자 엔티티
- * @return 사용자 정보 DTO
- */
- public static UserInfo from(UserModel user) {
- return UserInfo.builder()
- .userId(user.getUserId())
- .loginId(user.getLoginId())
- .maskedName(user.getMaskedName())
- .birthday(user.getBirthday())
- .email(user.getEmail())
- .address(user.getAddress())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java
index 2e36ad88c..f4303b59e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java
@@ -28,13 +28,13 @@ public class OrderExpiryScheduler {
*/
@Scheduled(fixedDelay = 60000)
public void expireOrders() {
- List expiredOrderIds = orderService.findExpiredPendingOrderIds();
+ List expiredOrderIds = orderService.findExpiredPendingOrderIds();
if (expiredOrderIds.isEmpty()) {
return;
}
log.info("만료 대상 주문 {}건 처리 시작", expiredOrderIds.size());
int successCount = 0;
- for (String orderId : expiredOrderIds) {
+ for (Long orderId : expiredOrderIds) {
try {
orderFacade.expireOrder(orderId);
successCount++;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java
index f6fe7cd95..39ce24388 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java
@@ -8,12 +8,13 @@
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
-import org.hibernate.annotations.UuidGenerator;
/**
* 브랜드 JPA 엔티티.
@@ -27,9 +28,9 @@
public class BrandModel extends BaseStringIdEntity {
@Id
- @UuidGenerator
- @Column(name = "brand_id", length = 36)
- private String brandId;
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "brand_id")
+ private Long brandId;
@Column(name = "brand_name", nullable = false)
private String brandName;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
index 76d279438..bc3b11ba2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
@@ -26,7 +26,7 @@ public interface BrandRepository {
* @param brandId 브랜드 ID
* @return 브랜드 (Optional)
*/
- Optional findById(String brandId);
+ Optional findById(Long brandId);
/**
* 전체 브랜드 목록을 조회한다 (관리자용, 삭제 포함).
@@ -58,5 +58,5 @@ public interface BrandRepository {
* @param brandIds 브랜드 ID 목록
* @return 해당 브랜드 목록
*/
- List findAllByIds(Collection brandIds);
+ List findAllByIds(Collection brandIds);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
index 10781f353..0370df605 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
@@ -1,9 +1,12 @@
package com.loopers.domain.brand;
+import com.loopers.domain.product.ProductService;
import com.loopers.support.enums.DisplayStatus;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +23,7 @@
public class BrandService {
private final BrandRepository brandRepository;
+ private final ProductService productService;
/**
* 새 브랜드를 등록한다.
@@ -57,11 +61,12 @@ public List findAllForAdmin() {
* @param brandIds 브랜드 ID 목록
* @return 브랜드 엔티티 목록
*/
- public List findAllByIds(Collection brandIds) {
+ public List findAllByIds(Collection brandIds) {
return brandRepository.findAllByIds(brandIds);
}
- public BrandModel findById(String brandId) {
+ @Cacheable(cacheNames = "brandDetail", key = "#brandId")
+ public BrandModel findById(Long brandId) {
return brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
}
@@ -73,7 +78,7 @@ public BrandModel findById(String brandId) {
* @return 브랜드 정보 DTO
* @throws CoreException 브랜드가 존재하지 않거나 비노출 상태일 때 (BRAND_NOT_FOUND)
*/
- public BrandModel findVisibleById(String brandId) {
+ public BrandModel findVisibleById(Long brandId) {
BrandModel brand = findById(brandId);
if (!brand.isVisibleForCustomer()) {
throw new CoreException(ErrorType.BRAND_NOT_FOUND);
@@ -104,23 +109,25 @@ public List findAllVisibleBrands(String keyword) {
* @return 수정된 브랜드 정보 DTO
* @throws CoreException 브랜드가 존재하지 않을 때 (BRAND_NOT_FOUND)
*/
+ @CacheEvict(cacheNames = "brandDetail", key = "#brandId")
@Transactional
- public BrandModel updateBrand(String brandId, String brandName, String description, String address) {
+ public BrandModel updateBrand(Long brandId, String brandName, String description, String address) {
BrandModel brand = findById(brandId);
brand.updateInfo(brandName, description, address);
return brand;
}
/**
- * 브랜드를 소프트 삭제한다.
+ * 브랜드를 소프트 삭제한다. 소속 상품도 연쇄 소프트 삭제된다.
*
* @param brandId 삭제할 브랜드 ID
* @throws CoreException 브랜드가 존재하지 않을 때 (BRAND_NOT_FOUND)
- * @수정요망 : 브랜드 삭제시 브랜드의 상품들도 소프트 딜리트
*/
+ @CacheEvict(cacheNames = "brandDetail", key = "#brandId")
@Transactional
- public void deleteBrand(String brandId) {
+ public void deleteBrand(Long brandId) {
BrandModel brand = findById(brandId);
brand.softDelete();
+ productService.softDeleteByBrandId(brandId);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java
index 94860f810..5850db315 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java
@@ -20,6 +20,6 @@
@AllArgsConstructor
@EqualsAndHashCode
public class CartItemId implements Serializable {
- private String userId;
- private String productId;
+ private Long userId;
+ private Long productId;
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java
index 7e545c338..b2c511330 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java
@@ -30,12 +30,12 @@
public class CartItemModel {
@Id
- @Column(name = "user_id", length = 36)
- private String userId;
+ @Column(name = "user_id")
+ private Long userId;
@Id
- @Column(name = "product_id", length = 36)
- private String productId;
+ @Column(name = "product_id")
+ private Long productId;
@Column(nullable = false)
private int quantity;
@@ -46,7 +46,7 @@ public class CartItemModel {
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
- private CartItemModel(String userId, String productId, int quantity) {
+ private CartItemModel(Long userId, Long productId, int quantity) {
validateQuantity(quantity);
this.userId = userId;
this.productId = productId;
@@ -63,7 +63,7 @@ private CartItemModel(String userId, String productId, int quantity) {
* @return 생성된 CartItemModel 인스턴스
* @throws CoreException quantity <= 0인 경우 (BAD_REQUEST)
*/
- public static CartItemModel create(String userId, String productId, int quantity) {
+ public static CartItemModel create(Long userId, Long productId, int quantity) {
return new CartItemModel(userId, productId, quantity);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java
index a7f10739f..85f19b1de 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java
@@ -41,5 +41,5 @@ public interface CartItemRepository {
* @param userId 사용자 ID
* @return 해당 사용자의 장바구니 항목 목록
*/
- List findAllByUserId(String userId);
+ List findAllByUserId(Long userId);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java
index e7cf9f76b..6e7a787a0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java
@@ -28,7 +28,7 @@ public class CartService {
* @param productId 상품 ID
* @param quantity 복원할 수량
*/
- public record RestoreItem(String productId, int quantity) {
+ public record RestoreItem(Long productId, int quantity) {
}
private final CartItemRepository cartItemRepository;
@@ -45,7 +45,7 @@ public record RestoreItem(String productId, int quantity) {
* @param qty 추가할 수량
*/
@Transactional
- public void addItem(String userId, String productId, int qty) {
+ public void addItem(Long userId, Long productId, int qty) {
CartItemId cartItemId = new CartItemId(userId, productId);
cartItemRepository.findById(cartItemId).ifPresentOrElse(
existingItem -> existingItem.mergeQuantity(qty),
@@ -62,7 +62,7 @@ public void addItem(String userId, String productId, int qty) {
* @throws CoreException 장바구니 항목이 존재하지 않을 때 (CART_ITEM_NOT_FOUND)
*/
@Transactional
- public void changeQuantity(String userId, String productId, int newQty) {
+ public void changeQuantity(Long userId, Long productId, int newQty) {
CartItemId cartItemId = new CartItemId(userId, productId);
CartItemModel item = cartItemRepository.findById(cartItemId)
.orElseThrow(() -> new CoreException(ErrorType.CART_ITEM_NOT_FOUND));
@@ -76,7 +76,7 @@ public void changeQuantity(String userId, String productId, int newQty) {
* @param productId 상품 ID
*/
@Transactional
- public void removeItem(String userId, String productId) {
+ public void removeItem(Long userId, Long productId) {
CartItemId cartItemId = new CartItemId(userId, productId);
cartItemRepository.findById(cartItemId).ifPresent(cartItemRepository::delete);
}
@@ -90,7 +90,7 @@ public void removeItem(String userId, String productId) {
* @param userId 사용자 ID
* @return 장바구니 항목 엔티티 목록
*/
- public List getCartItems(String userId) {
+ public List getCartItems(Long userId) {
return cartItemRepository.findAllByUserId(userId);
}
@@ -104,7 +104,7 @@ public List getCartItems(String userId) {
* @param items 복원할 항목 목록 (상품 ID + 수량)
*/
@Transactional
- public void restoreFromOrder(String userId, List items) {
+ public void restoreFromOrder(Long userId, List items) {
for (RestoreItem item : items) {
CartItemId cartItemId = new CartItemId(userId, item.productId());
cartItemRepository.findById(cartItemId).ifPresentOrElse(
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
new file mode 100644
index 000000000..3230f4b91
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
@@ -0,0 +1,130 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.domain.BaseStringIdEntity;
+import com.loopers.support.enums.DiscountType;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+
+/**
+ * 쿠폰 템플릿 JPA 엔티티.
+ *
+ * 고정 금액(FIXED) 또는 정률(RATE) 할인을 제공하는 쿠폰 템플릿이다.
+ * {@link BaseStringIdEntity}를 상속하여 소프트 삭제를 지원한다.
+ *