Skip to content

Commit aac425c

Browse files
authored
Merge pull request #328 from dd-jiny/volume-8
[Volume 8] 대기열 시스템 구현
2 parents 20aced9 + 7148cbe commit aac425c

File tree

253 files changed

+12868
-98
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

253 files changed

+12868
-98
lines changed

CLAUDE.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
66

77
**loopers-java-spring-template** — Multi-module Java 21 / Spring Boot 3.4.4 commerce backend (Gradle Kotlin DSL).
88

9-
Group: `com.loopers` | 감성 이커머스 MVP: 좋아요 → 장바구니 → 주문(결제 대기) 흐름.
9+
Group: `com.loopers` | 감성 이커머스 MVP: 좋아요 → 장바구니 → 주문 → 결제 흐름. 이벤트 기반 아키텍처(Transactional Outbox + Kafka).
1010

1111
## Build & Test Commands
1212

@@ -105,7 +105,7 @@ interfaces/ → application/ → domain/ ← infrastructure/
105105
| 구분 | 구조 | 해당 도메인 |
106106
|------|------|------------|
107107
| **단순 도메인** | Controller → **Service** 직접 호출 | User, Brand, Like, Stats |
108-
| **복잡한 도메인** | Controller → **Facade** → 여러 Service | Product, Cart, Order |
108+
| **복잡한 도메인** | Controller → **Facade** → 여러 Service | Product, Cart, Order, Coupon, Payment |
109109

110110
- **Facade**: 여러 도메인 서비스를 조합(orchestration)하고, 복잡한 비즈니스 플로우(재고 hold/release, 보상 트랜잭션 등)를 담당하는 application 레이어 클래스.
111111
- **단순 도메인은 AppService 없이 Controller에서 Service를 직접 호출**한다.
@@ -156,6 +156,9 @@ interfaces/ → application/ → domain/ ← infrastructure/
156156
| **Like** | 상품 좋아요 등록/취소 (멱등), 복합 PK, ProductService 참조 | `BaseStringIdEntity` | Service 직접 호출 |
157157
| **Cart** | 장바구니 CRUD, 주문 연계 복원, 복합 PK | `BaseStringIdEntity` | Facade |
158158
| **Order** | 주문 생성(DIRECT/CART), 취소, 만료 | `BaseStringIdEntity` | Facade |
159+
| **Coupon** | 쿠폰 발급 (Rush 선착순), 3-상태 머신 (AVAILABLE→RESERVED→USED) | `BaseStringIdEntity` | Facade |
160+
| **Payment** | PG 결제 연동, 분산락, Resilience4j | `BaseStringIdEntity` | Facade |
161+
| **Outbox** | Transactional Outbox 이벤트 발행 | - | Service |
159162
| **Stats** | 운영 통계 (주문 현황, 인기 상품) | - | Service 직접 호출 |
160163

161164
## Entity Base Classes
@@ -226,6 +229,41 @@ Both provide: `createdAt`, `updatedAt`, `guard()` override for entity validation
226229

227230
DIRECT 주문 취소/만료 시 `order_cart_restore` 테이블 `existsById` 확인으로 1회 복원 보장. CART 주문은 장바구니 유지.
228231

232+
### Transactional Outbox + Kafka 이벤트 파이프라인
233+
234+
비즈니스 트랜잭션과 이벤트 발행의 원자성을 보장하는 Outbox 패턴:
235+
236+
1. **Outbox 저장**: `OutboxEventService`가 비즈니스 TX 내에서 `OutboxEventModel` 저장 (`Propagation.MANDATORY`)
237+
2. **Relay 발행**: `OutboxEventRelay`가 5초 주기 폴링 → Kafka 발행 (PENDING → PUBLISHED, 실패 시 지수 백오프 10s→60s)
238+
3. **Consumer 처리**: `commerce-streamer`가 토픽별 Processor로 이벤트 소비 (멱등성 보장: `EventHandledModel`)
239+
4. **Cleanup**: `OutboxCleanupScheduler`가 7일 경과 PUBLISHED 이벤트 삭제
240+
241+
**Kafka 토픽**:
242+
- `catalog-events` (3 partitions, productId 파티셔닝): 좋아요/조회 이벤트
243+
- `order-events` (3 partitions, orderId 파티셔닝): 주문 생성/취소/만료 이벤트
244+
- `coupon-issue-requests` (1 partition, 순서 보장): 선착순 쿠폰 발급
245+
246+
**ApplicationEvent → Outbox 흐름**: `@TransactionalEventListener(AFTER_COMMIT)`로 도메인 이벤트 핸들링 후 Outbox 저장.
247+
248+
### 결제 분산락 + Resilience4j
249+
250+
**분산락**: `PaymentLock` 인터페이스 → Redis SETNX + Lua owner-verify unlock. 30초 TTL. Redis 장애 시 락 없이 진행 (가용성 우선).
251+
252+
**Resilience4j 데코레이터 체인** (`ResilientPgClient`):
253+
- Bulkhead (40 semaphore) → CircuitBreaker (80% failure-rate, 15s open) → Retry (3x, jitter 0.5-1.5s) → HTTP
254+
255+
**TX 분리 패턴**: Outbox 이벤트 TX-1 저장 → PG 호출 (TX 밖) → 결과 TX-2 저장. 커넥션 풀 점유 방지.
256+
257+
### 쿠폰 Rush 발급
258+
259+
선착순 쿠폰의 4-레이어 멱등 방어:
260+
1. `CouponDeduplicationCache`: Redis SETNX 중복 요청 차단
261+
2. `CouponRemainingCache`: Redis 잔여 수량 사전 차감
262+
3. CAS `issued_count` 업데이트로 오버셀 방지
263+
4. `CouponPendingActionRelay`: 3초 폴링으로 RESERVED → USED 확정 또는 복원
264+
265+
**DIP**: `CouponDeduplicationCache`, `CouponRemainingCache`, `CouponIssueMetrics`, `OutboxRelayMetrics` — 도메인 인터페이스로 추출, infrastructure에서 구현.
266+
229267
### 상품 변경 이력 (ProductRevision)
230268

231269
복합 PK (`product_id` + `revision_seq`). 상품 수정/삭제/복구 시 `before_snapshot`/`after_snapshot` JSON 저장.
@@ -242,6 +280,29 @@ DIRECT 주문 취소/만료 시 `order_cart_restore` 테이블 `existsById` 확
242280
| `UnavailableReason` | `DELETED`, `HIDDEN`, `BRAND_DELETED`, `BRAND_HIDDEN`, `STOPPED`, `TEMP_SOLD_OUT`, `OUT_OF_STOCK`, `INVALID_QUANTITY` | 장바구니 항목 주문 불가 사유 |
243281
| `RestoreReason` | `USER_CANCELLED`, `EXPIRED`, `PAYMENT_FAILED`, `PG_CANCELLED` | 복원 사유 |
244282
| `RestoreTriggerSource` | `CANCEL_API`, `PG_WEBHOOK`, `EXPIRE_JOB`, `MANUAL` | 복원 트리거 출처 |
283+
| `CouponActionStatus` | `AVAILABLE`, `RESERVED`, `USED` | 쿠폰 발급 3-상태 머신 |
284+
| `OutboxEventStatus` | `PENDING`, `PUBLISHED`, `CLEANUP` | Outbox 이벤트 상태 |
285+
286+
## Schedulers (commerce-api)
287+
288+
| 스케줄러 | 주기 | 역할 |
289+
|----------|------|------|
290+
| `OutboxEventRelay` | 5초 | Outbox → Kafka 발행 (지수 백오프) |
291+
| `OutboxCleanupScheduler` | - | 7일 경과 PUBLISHED 이벤트 삭제 |
292+
| `OrderExpiryScheduler` | 60초 | PENDING_PAYMENT → EXPIRED (결제 요청 중인 주문 제외) |
293+
| `PaymentPollingScheduler` | - | PG 결제 상태 폴링 (CB-aware fail-fast) |
294+
| `CouponActionRelay` | 3초 | RESERVED → USED 확정 또는 복원 |
295+
| `CartRestoreRetryScheduler` | 매일 05:30 | 실패한 장바구니 복원 재시도 |
296+
297+
별도 `ThreadPoolTaskScheduler` (poolSize=3)로 `@Scheduled` 태스크 실행.
298+
299+
## commerce-streamer (Kafka Consumer)
300+
301+
Kafka 이벤트를 소비하여 집계/처리하는 별도 애플리케이션 (Web 없음):
302+
- `CatalogEventProcessor`: 좋아요/조회 집계 → `ProductMetricsModel` upsert
303+
- `OrderEventProcessor`: 상품별 주문 수 추적 (멱등)
304+
- `CouponIssueProcessor`: 선착순 쿠폰 발급 4-레이어 멱등 처리
305+
- `EventHandledModel`: event_id PK로 중복 처리 방지, 30일 후 자동 삭제
245306

246307
## Key Conventions
247308

@@ -280,6 +341,8 @@ DIRECT 주문 취소/만료 시 `order_cart_restore` 테이블 `existsById` 확
280341
- `04-erd.md` — ERD (복합 PK, 인덱스 전략)
281342
- `05-architecture.md` — 종합 아키텍처 설계서
282343
- `07-facade-analysis.md` — Facade 필요성 분석
344+
- `07-index-cache-strategy.md` — 인덱스 + Redis 캐시 전략 (TTL, cache-aside, write-invalidate)
345+
- `08-order-payment-flow.md` — 주문-결제 상태 머신 + TX 분리 패턴 + PG 콜백 흐름
283346

284347
## 설계 원칙
285348

apps/commerce-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ dependencies {
22
// add-ons
33
implementation(project(":modules:jpa"))
44
implementation(project(":modules:redis"))
5+
implementation(project(":modules:kafka"))
56
implementation(project(":supports:jackson"))
67
implementation(project(":supports:logging"))
78
implementation(project(":supports:monitoring"))

apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.springframework.boot.SpringApplication;
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
7+
import org.springframework.scheduling.annotation.EnableAsync;
78
import org.springframework.scheduling.annotation.EnableScheduling;
89
import java.util.TimeZone;
910

@@ -12,6 +13,7 @@
1213
* REST API 서버를 기동하며, 타임존을 Asia/Seoul로 설정하고 스케줄링을 활성화한다.
1314
*/
1415
@ConfigurationPropertiesScan
16+
@EnableAsync
1517
@EnableScheduling
1618
@SpringBootApplication
1719
public class CommerceApiApplication {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.loopers.application.coupon;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.loopers.domain.coupon.CouponDeduplicationCache;
5+
import com.loopers.domain.coupon.CouponIssueMetrics;
6+
import com.loopers.domain.coupon.CouponIssueRequestMessage;
7+
import com.loopers.domain.coupon.CouponIssueResultModel;
8+
import com.loopers.domain.coupon.CouponIssueResultRepository;
9+
import com.loopers.domain.coupon.CouponRemainingCache;
10+
import com.loopers.support.error.CoreException;
11+
import com.loopers.support.error.ErrorType;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.kafka.core.KafkaTemplate;
15+
import org.springframework.stereotype.Service;
16+
17+
import java.time.Duration;
18+
import java.time.LocalDateTime;
19+
import java.util.UUID;
20+
import java.util.concurrent.TimeUnit;
21+
22+
/**
23+
* 선착순 쿠폰 발급 Facade — Thin Producer 전략.
24+
*
25+
* <p>Producer에서 DB TX를 사용하지 않는다.
26+
* Redis SETNX(중복 거절) + Redis DECR(수량 게이트키퍼) + Kafka send만 수행.
27+
* 모든 DB 작업은 Consumer(commerce-streamer)에서 단일 TX로 원자적 처리.</p>
28+
*/
29+
@Service
30+
@RequiredArgsConstructor
31+
@Slf4j
32+
public class CouponIssueFacade {
33+
34+
private final KafkaTemplate<Object, Object> kafkaTemplate;
35+
private final ObjectMapper objectMapper;
36+
private final CouponRemainingCache couponRemainingCache;
37+
private final CouponDeduplicationCache couponDeduplicationCache;
38+
private final CouponIssueResultRepository resultRepository;
39+
private final CouponIssueMetrics couponIssueMetrics;
40+
41+
public String requestRushIssue(Long userId, Long couponId) {
42+
String requestId = UUID.randomUUID().toString();
43+
44+
// Phase 1: Redis SETNX — 중복 요청 거절
45+
if (!tryDeduplication(userId, couponId)) {
46+
throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한 쿠폰입니다");
47+
}
48+
49+
// Phase 2: Redis DECR — 수량 소진 즉시 거절
50+
if (!tryDecrementRemaining(couponId)) {
51+
removeDeduplication(userId, couponId);
52+
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰이 모두 소진되었습니다.");
53+
}
54+
55+
// Phase 3: Kafka send — Consumer에게 처리 위임
56+
try {
57+
String jsonMessage = objectMapper.writeValueAsString(
58+
new CouponIssueRequestMessage(requestId, userId, couponId, LocalDateTime.now()));
59+
kafkaTemplate.send("coupon-issue-requests",
60+
couponId.toString(), jsonMessage).get(5, TimeUnit.SECONDS);
61+
} catch (Exception e) {
62+
restoreRemaining(couponId);
63+
removeDeduplication(userId, couponId);
64+
throw new CoreException(ErrorType.INTERNAL_ERROR, "발급 요청 실패. 다시 시도해주세요.");
65+
}
66+
67+
return requestId;
68+
}
69+
70+
public CouponIssueResultModel getIssueResult(String requestId) {
71+
return resultRepository.findById(requestId)
72+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급 처리 중입니다"));
73+
}
74+
75+
private boolean tryDeduplication(Long userId, Long couponId) {
76+
try {
77+
return couponDeduplicationCache.trySetIfAbsent(userId, couponId, Duration.ofHours(24));
78+
} catch (Exception e) {
79+
log.warn("[Redis장애] 중복 확인 실패, Consumer에서 방어", e);
80+
return true;
81+
}
82+
}
83+
84+
private void removeDeduplication(Long userId, Long couponId) {
85+
try {
86+
couponDeduplicationCache.delete(userId, couponId);
87+
} catch (Exception e) {
88+
log.warn("[Redis장애] dedup key 삭제 실패, TTL(24h) 후 자동 만료", e);
89+
}
90+
}
91+
92+
private boolean tryDecrementRemaining(Long couponId) {
93+
try {
94+
Long remaining = couponRemainingCache.decrementAndGet(couponId);
95+
if (remaining == null || remaining < 0) {
96+
restoreRemaining(couponId);
97+
return false;
98+
}
99+
return true;
100+
} catch (Exception e) {
101+
log.warn("[Redis장애] DECR 실패, DB CAS fallback", e);
102+
couponIssueMetrics.incrementRedisFallback();
103+
return true;
104+
}
105+
}
106+
107+
private void restoreRemaining(Long couponId) {
108+
try {
109+
couponRemainingCache.increment(couponId);
110+
} catch (Exception e) {
111+
log.warn("[Redis장애] INCR 복원 실패, 동기화 배치에서 보정", e);
112+
couponIssueMetrics.incrementIncrRestoreFail();
113+
}
114+
}
115+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.loopers.application.event;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.loopers.domain.like.event.ProductLikedEvent;
5+
import com.loopers.domain.like.event.ProductUnlikedEvent;
6+
import com.loopers.domain.product.ProductService;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.kafka.core.KafkaTemplate;
10+
import org.springframework.scheduling.annotation.Async;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.transaction.annotation.Propagation;
13+
import org.springframework.transaction.annotation.Transactional;
14+
import org.springframework.transaction.event.TransactionPhase;
15+
import org.springframework.transaction.event.TransactionalEventListener;
16+
17+
import java.time.LocalDateTime;
18+
import java.util.Map;
19+
20+
/**
21+
* 좋아요 이벤트 핸들러.
22+
*
23+
* <p>트랜잭션 커밋 후(AFTER_COMMIT) 비동기로 실행되며,
24+
* 상품의 좋아요 수를 증감시킨다. REQUIRES_NEW 트랜잭션으로 독립 실행된다.</p>
25+
*/
26+
@Slf4j
27+
@Component
28+
@RequiredArgsConstructor
29+
public class LikeEventHandler {
30+
31+
private final ProductService productService;
32+
private final KafkaTemplate<Object, Object> kafkaTemplate;
33+
private final ObjectMapper objectMapper;
34+
35+
/**
36+
* 좋아요 등록 이벤트를 처리한다 (좋아요 수 증가).
37+
*/
38+
@Async("eventTaskExecutor")
39+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
40+
@Transactional(propagation = Propagation.REQUIRES_NEW)
41+
public void handleProductLiked(ProductLikedEvent event) {
42+
try {
43+
productService.incrementLikeCount(event.productId());
44+
log.info("[ProductLiked] userId={}, productId={}", event.userId(), event.productId());
45+
46+
// Kafka direct send: 카탈로그 이벤트 (Outbox 불필요 — 비핵심 지표)
47+
sendCatalogEvent("PRODUCT_LIKED", event.productId(), event.userId());
48+
} catch (Exception e) {
49+
log.error("[ProductLiked] Failed to increment like count. userId={}, productId={}",
50+
event.userId(), event.productId(), e);
51+
}
52+
}
53+
54+
/**
55+
* 좋아요 취소 이벤트를 처리한다 (좋아요 수 감소).
56+
*/
57+
@Async("eventTaskExecutor")
58+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
59+
@Transactional(propagation = Propagation.REQUIRES_NEW)
60+
public void handleProductUnliked(ProductUnlikedEvent event) {
61+
try {
62+
productService.decrementLikeCount(event.productId());
63+
log.info("[ProductUnliked] userId={}, productId={}", event.userId(), event.productId());
64+
65+
// Kafka direct send: 카탈로그 이벤트 (Outbox 불필요 — 비핵심 지표)
66+
sendCatalogEvent("PRODUCT_UNLIKED", event.productId(), event.userId());
67+
} catch (Exception e) {
68+
log.error("[ProductUnliked] Failed to decrement like count. userId={}, productId={}",
69+
event.userId(), event.productId(), e);
70+
}
71+
}
72+
73+
private void sendCatalogEvent(String eventType, Long productId, Long userId) {
74+
try {
75+
Map<String, Object> message = Map.of(
76+
"eventType", eventType,
77+
"productId", productId,
78+
"userId", userId,
79+
"occurredAt", LocalDateTime.now().toString()
80+
);
81+
String jsonMessage = objectMapper.writeValueAsString(message);
82+
kafkaTemplate.send("catalog-events", String.valueOf(productId), jsonMessage)
83+
.whenComplete((result, ex) -> {
84+
if (ex != null) {
85+
log.error("[CatalogEvent] Kafka 발행 실패 — eventType={}, productId={}",
86+
eventType, productId, ex);
87+
}
88+
});
89+
} catch (Exception e) {
90+
log.warn("[CatalogEvent] Kafka 전송 실패 — eventType={}, productId={}", eventType, productId, e);
91+
}
92+
}
93+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.loopers.application.event;
2+
3+
import com.loopers.domain.order.event.OrderCancelledEvent;
4+
import com.loopers.domain.order.event.OrderCreatedEvent;
5+
import com.loopers.domain.order.event.OrderExpiredEvent;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.scheduling.annotation.Async;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.event.TransactionPhase;
11+
import org.springframework.transaction.event.TransactionalEventListener;
12+
13+
/**
14+
* 주문 이벤트 핸들러.
15+
*
16+
* <p>트랜잭션 커밋 후(AFTER_COMMIT) 비동기로 실행되며,
17+
* 주문 생성/취소/만료 이벤트를 처리한다.</p>
18+
*/
19+
@Slf4j
20+
@Component
21+
@RequiredArgsConstructor
22+
public class OrderEventHandler {
23+
24+
/**
25+
* 주문 생성 이벤트를 처리한다 (로깅).
26+
*/
27+
@Async("eventTaskExecutor")
28+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
29+
public void handleOrderCreated(OrderCreatedEvent event) {
30+
log.info("[OrderCreated] orderId={}, userId={}, orderType={}, totalAmount={}",
31+
event.orderId(), event.userId(), event.orderType(), event.totalAmount());
32+
}
33+
34+
/**
35+
* 주문 취소 이벤트를 처리한다 (로깅).
36+
*/
37+
@Async("eventTaskExecutor")
38+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
39+
public void handleOrderCancelled(OrderCancelledEvent event) {
40+
log.info("[OrderCancelled] orderId={}, userId={}",
41+
event.orderId(), event.userId());
42+
}
43+
44+
/**
45+
* 주문 만료 이벤트를 처리한다 (로깅).
46+
*/
47+
@Async("eventTaskExecutor")
48+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
49+
public void handleOrderExpired(OrderExpiredEvent event) {
50+
log.info("[OrderExpired] orderId={}, userId={}",
51+
event.orderId(), event.userId());
52+
}
53+
}

0 commit comments

Comments
 (0)