Skip to content

[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현#282

Open
dd-jiny wants to merge 3 commits intoLoopers-dev-lab:dd-jinyfrom
dd-jiny:volume-7
Open

[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현#282
dd-jiny wants to merge 3 commits intoLoopers-dev-lab:dd-jinyfrom
dd-jiny:volume-7

Conversation

@dd-jiny
Copy link
Copy Markdown

@dd-jiny dd-jiny commented Mar 27, 2026

📌 Summary

커머스 백엔드에 이벤트 드리븐 아키텍처를 도입하여, 주문–쿠폰–좋아요 간 동기 의존성을 이벤트 경계로 분리하고,
Transactional Outbox + Kafka 파이프라인으로 시스템 간 이벤트 전파를 구현했다.
선착순 쿠폰(100장 한정)은 Redis DECR 게이트키퍼 + DB CAS 이중 방어로 정확성을 보장한다.

  • 이벤트 분리: 주문 TX에서 쿠폰 확정·좋아요·조회수 등 부수 효과를 AFTER_COMMIT + @async로 격리 → 리스너 실패가 주문 롤백에 영향 없음
  • 3경로 발행 전략: Outbox(금전 이벤트) / 직접 Kafka(메트릭 이벤트) / 내부 Outbox(쿠폰 확정) — 이벤트 중요도별 경로 분리
  • 선착순 쿠폰: VU=500 (1000명 중 500명 동시) → 정확히 100장 발급, 초과 0건. issued_count=100, user_coupons=100, Consumer Lag=0
  • 데이터 정합성: Outbox PUBLISHED 5,565건(에러율 0.00%), 주문 6,318건 정상 처리. 부하 테스트 중 9건 버그 발견·수정

🧭 Context & Decision

문제 정의

문제 1 — 주문 TX에 쿠폰 확정이 동기로 결합 + 도메인 간 이벤트 전파 부재 (Step 1)

두 가지 문제가 동시에 존재한다:

A. 주문 TX 내 쿠폰 확정 동기 결합: 주문 생성 메서드가 재고 hold + 쿠폰 확정을 하나의 트랜잭션에서 처리하고 있어, 쿠폰 서비스 장애 시 주문까지 롤백되는 구조적 결함이 존재한다.

B. 도메인 간 이벤트 전파 부재: 좋아요 카운트, 조회수 갱신 등 메트릭 이벤트는 주문 TX와 무관하게 각 도메인(LikeService, ProductFacade)에서 독립적으로 발생하지만, 이를 다른 시스템(commerce-streamer)에 전파할 이벤트 기반 구조가 없다. 각 도메인이 직접 동기 호출로 처리하고 있어 확장이 어렵다.

[문제 A — 주문 TX 결합]
  OrderFacade.createDirectOrder() {
    stockService.hold()              ← 주문 본질
    couponService.confirmCouponUsed() ← 부수 효과가 같은 TX → 실패 시 주문 롤백
    COMMIT
  }

[문제 B — 이벤트 전파 부재]
  LikeService.toggleLike()     → 좋아요 카운트 갱신 (독립 도메인, 주문 TX와 무관)
  ProductFacade.getProduct()   → 조회수 갱신 (독립 도메인, 주문 TX와 무관)
  → 이 이벤트들을 commerce-streamer에 전파할 구조가 없음
리스크 설명
주문 롤백 전파 쿠폰 서비스 장애 시 주문까지 롤백 — 부수 효과가 본질(주문)을 위협
응답 지연 쿠폰 확정이 주문 응답에 직렬로 합산
도메인 결합 좋아요/조회수 등 메트릭 이벤트를 다른 시스템에 전파할 구조 없음 → 새 이벤트 추가 시 서비스 코드 직접 수정 필요
쿠폰 불일치 TX 분리 시 "주문 성공 + 쿠폰 미확정" 또는 "주문 롤백 + 쿠폰 USED" 상태 불일치 발생 가능
성공 기준 목표값
주문 성공률 리스너 실패 시에도 100%
주문 응답 시간 < 20ms (부수 효과 제외)
쿠폰 이중 사용 0건 (RESERVED CAS로 구조적 차단)
쿠폰 영구 잠금 0건 (좀비 RESERVED 배치 복구, 최대 1시간)

문제 2 — 시스템 간 이벤트 전파에 보장 메커니즘이 없다 (Step 2)

주문/좋아요/조회수 등의 이벤트를 다른 시스템(commerce-streamer)으로 전파할 구조가 없다. 이벤트를 단순 Kafka 발행하면 TX 커밋과 Kafka 발행의 원자성이 보장되지 않아 메시지 유실이나 순서 역전이 발생할 수 있다.

리스크 설명
이벤트 유실 TX 커밋 후 Kafka 발행 실패 시 이벤트 영구 유실 → 매출 통계 부정확
이중 발행 Kafka 발행 성공 후 DB 마킹 실패 시 동일 이벤트 재발행 → Consumer 이중 처리
순서 역전 Kafka rebalance, 배치 처리 시 이전 이벤트가 나중에 도착 → 구버전으로 덮어쓰기
포이즌 필 파싱 불가 메시지가 Consumer 재시도 루프에 걸려 정상 메시지 차단
장시간 장애 Kafka 브로커 장애 시 Outbox 재시도가 DB 부하 증가 유발
성공 기준 목표값
Outbox PUBLISHED 전환율 > 99%
Outbox PENDING 적체 < 10건 (5초 폴링 기준)
Kafka Consumer Lag < 100
product_metrics 정합성 like_count 불일치 0건 (배치 보정 후)
DLQ 적재 포이즌 필 즉시 격리, 정상 소비 흐름 차단 0회

문제 3 — 선착순 쿠폰의 동시성 제어와 리소스 경합 (Step 3)

선착순 쿠폰(100장 한정)에 1,000명이 동시에 요청하면, 동기 DB 접근으로 커넥션 풀이 고갈되어 상품 조회 등 다른 API까지 영향을 받는다. 또한 동일 리소스(하나의 couponId)에 트래픽이 집중되어 Redis 핫키와 Kafka 핫파티션이 발생한다.

리스크 설명
HikariCP 고갈 1,000명 x DB TX = 커넥션 풀 고갈 → 무관한 API까지 응답 불가
오버이슈 동시 요청에서 issued_count 경쟁 → 100장 초과 발급
중복 발급 사용자 중복 클릭 + Kafka at-least-once 재전달 → 같은 사용자에게 2장 발급
Redis 핫키 coupon:remaining 단일 키에 1,000+ ops/sec 집중 → Cluster 환경 단일 샤드 병목
Kafka 핫파티션 순서 보장을 위한 단일 파티션에 모든 요청 집중 → Consumer 처리량 병목
성공 기준 목표값
발급 정확성 정확히 N장 발급, 초과 0건
중복 발급 0건 (4-Layer 멱등성)
API 서버 DB 커넥션 점유 0개 (Thin Producer)
응답 시간 (1,000 VU) < 5ms (Redis only)
다른 API 영향 없음
전체 에러율 < 0.1%

핵심 결정 요약

# 결정 항목 최종 선택 핵심 진입점 핵심 근거
1 이벤트 실행 시점 AFTER_COMMIT + @Async OrderFacade.createDirectOrder()OrderEventHandler.handleOrderCreated() 리스너 실패가 주문 TX에 영향 없음, 응답 지연 제거
2 이벤트 발행 경로 3경로 분리 A: OutboxEventRelay → Kafka / B: LikeEventHandler → Kafka 직접 / C: CouponActionRelay → 내부 처리 readOnly TX 제약, 이벤트 중요도별 적정 보장 수준
3 쿠폰 상태 머신 AVAILABLE → RESERVED → USED CouponService.reserveCoupon()confirmCouponUsed()restoreCouponByAction() 주문 TX와 쿠폰 TX 분리 시 중간 상태로 이중 사용 방지
4 Outbox Relay 패턴 Relay + Processor 별도 빈 OutboxEventRelay.relay()OutboxEventProcessor.publishAndMark() (3쌍 동일 패턴) Spring AOP 자기호출 우회, TX 경계 구조적 보장
5 선착순 쿠폰 전략 Thin Producer + Consumer TX CouponIssueFacade.requestRushIssue() (Redis only) → CouponIssueProcessor.process() (DB TX) HikariCP 고갈 방지, Redis 게이트키퍼 + DB CAS 이중 방어
6 Outbox 실패 재시도 Exponential Backoff + DEAD OutboxEventProcessor.publishAndMark() — retryCount, nextRetryAt, DEAD 전이 일시 장애 자동 복구, 장시간 장애 격리
7 Consumer 예외 처리 NonRetryable → DLQ KafkaConsumerConfigNonRetryableEventException catch → topic + "-dlq" 발행 포이즌 필 즉시 격리, Head-of-Line Blocking 방지
8 순서 역전 방어 증분 집계 + ON DUPLICATE KEY UPDATE CatalogEventProcessor.process()ProductMetricsJpaRepository incrementLikeCount() 증분 방식으로 순서 역전 문제 자체를 회피
9 이벤트 유실 보정 일일 배치 SSOT LikeCountReconciliationScheduler (05:00) → MetricsReconciliationScheduler (06:00) likes 테이블 COUNT 기준 재계산, 최대 24시간 보정
10 핫키·핫파티션 MVP 단일 키/파티션 허용 CouponIssueFacade — Redis DECR 단일 키 / KafkaTopicConfig — partitions(1) MVP 규모 충분, 확장 시 서브키 분산 + couponId 파티셔닝

Step 1 — 이벤트 경계 분리

결정 1 — 이벤트 실행 시점: AFTER_COMMIT + @async

의도: 주문 생성 메서드가 쿠폰 확정, 좋아요 카운트, 조회수 갱신을 동기로 처리하고 있어 "쿠폰 서비스 장애 → 주문 롤백"이라는 구조적 결함이 존재한다. 본 결정의 원칙은 **"주문은 반드시 성공해야 하고, 부수 효과는 실패해도 주문에 영향을 주면 안 된다"**이며, 이를 코드 구조로 강제하는 방식을 채택한다. 단순 try-catch는 "실패를 무시"하는 것이지 "격리"가 아니다. TX 커밋 이후에만 부수 효과를 실행하면, 실패 여부와 관계없이 주문은 이미 확정된 상태이므로 구조적 격리가 보장된다.

쿠폰은 금전적 자산인데 왜 후속 조치(AFTER_COMMIT)로 분리했는가:

쿠폰 차감은 좋아요나 조회수와는 성격이 다르다. 쿠폰은 할인이라는 금전적 가치를 가지므로, 직관적으로는 주문과 같은 TX에서 동기로 처리하는 것이 안전해 보인다. 동기 유지 여부가 본 설계에서 가장 높은 판단 난이도를 가진다.

그러나 Session 7의 핵심 목표는 **"이벤트 드리븐으로 도메인 간 경계를 분리하는 것"**이다. 쿠폰만 동기로 남기면, 주문 TX 안에 쿠폰 서비스 의존성이 그대로 유지되어 이벤트 분리의 핵심 목적이 훼손된다. "금전적 가치가 있으니 동기가 맞다"는 판단은 모놀리식 구조에서는 유효하나, 이벤트 드리븐 전환의 맥락에서는 **"비동기로 분리하되, 금전적 가치에 걸맞는 보상 전략을 갖추는 것"**이 적합한 방향이다.

따라서 쿠폰 차감을 AFTER_COMMIT 후속 조치로 분리하되, 좋아요/조회수처럼 "유실 허용"으로 두지 않고 3중 안전장치를 설계하여 금전적 가치에 걸맞는 보장 수준을 확보한다:

좋아요/조회수 (유실 허용):   AFTER_COMMIT → @Async → 끝. 실패 시 일일 배치 보정
쿠폰 확정 (유실 불가):      AFTER_COMMIT → @Async → 내부 Outbox → Relay → CAS 확정
                            + 좀비 RESERVED 배치 정리 + 주문 취소/만료 시 restore
안전장치 보호 대상 동작
내부 Outbox (coupon_pending_actions) @async 실행 실패, JVM 크래시 DB에 확정 요청 기록 → Relay가 3초마다 폴링하여 재시도
좀비 RESERVED 배치 Outbox마저 실패한 극단적 케이스 1시간 주기로 오래된 RESERVED를 AVAILABLE로 복구
주문 취소/만료 시 restore 주문이 취소/만료되었는데 쿠폰이 RESERVED인 경우 CAS로 RESERVED → AVAILABLE 복구

이 3중 안전장치의 설계 근거는 결정 3에서 상세히 다룬다.

// OrderFacade — 주문 TX
@Transactional
public OrderInfo createDirectOrder(Long userId, ..., Long userCouponId) {
    couponService.reserveCoupon(userCouponId);          // CAS: AVAILABLE → RESERVED
    // ... 주문 생성 + 재고 hold ...
    applicationEventPublisher.publishEvent(new OrderCreatedEvent(order, userCouponId));
}

// OrderEventHandler — TX 커밋 후 별도 스레드
@TransactionalEventListener(phase = AFTER_COMMIT)
@Async("eventTaskExecutor")
public void handleOrderCreated(OrderCreatedEvent event) {
    couponPendingActionService.save(event.getUserCouponId(), CONFIRM);
}

AFTER_COMMIT만으로는 부족한 이유: @async 없이 AFTER_COMMIT만 사용하면 같은 스레드에서 리스너가 실행되어 응답이 지연된다. @async를 추가하면 별도 스레드에서 실행되어 응답은 빠르지만, JVM 크래시 시 큐에 있던 태스크가 유실될 수 있다.

유실 대응:

  • 금전적 가치 이벤트(쿠폰 확정): coupon_pending_actions 내부 Outbox로 보장
  • 메트릭 이벤트(좋아요/조회수): 유실 허용 + 일일 배치 보정
  • Kafka 전파 이벤트(주문): outbox_events Outbox로 보장
관련 클래스 파일 경로 메서드 역할
AsyncConfig commerce-api/.../infrastructure/async/ eventTaskExecutor() ThreadPoolTaskExecutor (core=4, max=8, queue=100, CallerRunsPolicy)
OrderEventHandler commerce-api/.../application/event/ handleOrderCreated(), handleOrderCancelled(), handleOrderExpired() @TransactionalEventListener(AFTER_COMMIT) + @Async("eventTaskExecutor")
LikeEventHandler commerce-api/.../application/event/ handleProductLiked(), handleProductUnliked() 동일 패턴, catalog-events 직접 Kafka 발행
ProductViewEventHandler commerce-api/.../application/event/ handleProductViewed() 동일 패턴, catalog-events 직접 Kafka 발행
대안 비교 및 선택 근거
대안 실행 시점 주문 격리 응답 시간 리스너 실패 시
A. 동기 직접 호출 같은 TX 안에서 즉시 격리 안 됨 — 쿠폰 실패 시 주문 롤백 95ms+ (직렬 합산) 주문 롤백
B. BEFORE_COMMIT TX 커밋 직전 격리 안 됨 — 리스너 예외 시 TX 롤백 95ms+ (동일) 주문 롤백
C. AFTER_COMMIT + @async (채택) TX 커밋 완료 후, 별도 스레드 구조적 격리 — 주문 이미 확정 15ms (주문만) 주문 무관, Outbox 재시도
대안 A (동기 직접 호출):
  OrderFacade.createDirectOrder() {
    orderService.create()              // 15ms
    couponService.confirmCouponUsed()  // 50ms ← 실패 시 전체 롤백
    productService.incrementLikeCount() // 30ms
    COMMIT → [응답 95ms+]
  }

대안 C (AFTER_COMMIT + @Async):
  OrderFacade.createDirectOrder() {
    couponService.reserveCoupon()      // CAS: AVAILABLE → RESERVED
    orderService.create()              // 15ms
    eventPublisher.publishEvent(new OrderCreatedEvent(...))
    COMMIT → [응답 15ms]
  }
  // 별도 스레드 (eventTaskExecutor)
  OrderEventHandler.handleOrderCreated() {
    couponPendingActionService.save(userCouponId, CONFIRM)  // 내부 Outbox 기록
  }

선택 이유: A안은 "쿠폰 서비스 장애 → 주문 롤백"이 치명적이다. B안(BEFORE_COMMIT)도 리스너 예외가 TX 롤백을 유발한다. C안은 TX 커밋 이후에만 부수 효과를 실행하므로, 리스너 실패와 관계없이 주문은 이미 확정된 상태이다. @async 추가로 응답 지연도 제거된다.

결정 2 — 3경로 이벤트 발행 전략

의도: 단일 파이프라인(모든 이벤트를 Kafka로 발행)이 운영 단순성 측면에서 유리하나, 모든 이벤트를 같은 경로로 보내는 것은 과도한 일관성에 해당한다. 좋아요 하나에 Outbox INSERT를 위해 write TX를 여는 것은 비용 대비 효과가 맞지 않으며, 쿠폰 확정을 같은 앱 안에서 처리하는데 Kafka를 경유하면 불필요한 네트워크 홉만 추가된다. **"이벤트의 중요도와 소비자 위치에 따라 경로가 달라야 한다"**는 원칙 하에, 유실 시 피해 크기를 기준으로 3경로를 분리한다.

3가지 제약이 단일 경로를 불가능하게 만든다:

  1. readOnly TX 제약: 좋아요/조회수는 @Transactional(readOnly=true) → Outbox INSERT 불가
  2. 불필요한 네트워크 홉: 쿠폰 확정은 같은 앱 내 처리인데 Kafka를 경유하면 지연만 증가
  3. 중요도 차이: 주문 이벤트 유실은 매출 통계 오류, 좋아요 유실은 미미

3경로 흐름도:

flowchart LR
    subgraph API ["commerce-api"]
        OF[OrderFacade]
        LEH[LikeEventHandler]
        PVEH[ProductViewEventHandler]
        OEH[OrderEventHandler]
        OB[outbox_events]
        CPB[coupon_pending_actions]
        OER[OutboxEventRelay]
        CAR[CouponActionRelay]
        CAP[CouponActionProcessor]
    end

    subgraph MQ ["Kafka"]
        OT[order-events]
        CT[catalog-events]
    end

    subgraph STR ["commerce-streamer"]
        OC[OrderEventConsumer]
        CC[CatalogEventConsumer]
    end

    OF -- INSERT same TX --> OB
    OB -- 5s polling --> OER
    OER -- publish --> OT
    OT --> OC

    LEH -- direct send --> CT
    PVEH -- direct send --> CT
    CT --> CC

    OEH -- INSERT --> CPB
    CPB -- 3s polling --> CAR
    CAR --> CAP
Loading
경로 대상 보장 수준 흐름
A. Outbox → Kafka 주문 생성/취소/만료 At Least Once OrderFacade → outbox_events INSERT → OutboxEventRelay 5초 폴링 → order-events
B. @async → Kafka 직접 좋아요, 조회수 Best Effort + 배치 보정 LikeEventHandler / ProductViewEventHandler → catalog-events 직접 발행
C. @async → 내부 Outbox 쿠폰 확정 (RESERVED → USED) At Least Once OrderEventHandler → coupon_pending_actions INSERT → CouponActionRelay 3초 폴링 → CAS 확정

경로 선택 기준:

flowchart TD
    Q1{Financial impact on loss?}
    Q2{Consumed by other app?}
    Q3{Same app processing?}

    Q1 -- Yes --> A["Route A: Outbox to Kafka"]
    Q1 -- No --> Q2
    Q2 -- Yes --> B["Route B: Direct Kafka"]
    Q2 -- No --> Q3
    Q3 -- Yes --> C["Route C: Internal Outbox"]
Loading

Outbox가 2개인 이유: outbox_events(Kafka 발행용, 7일 보관)와 coupon_pending_actions(내부 처리용, 3일 보관)는 생명주기, 처리 방식, 장애 격리가 다르다. 하나로 합치면 Kafka 발행 실패가 쿠폰 확정을 막거나 그 역이 발생할 수 있다.

관련 클래스 파일 경로 메서드 역할
OrderFacade commerce-api/.../application/order/ createDirectOrder() 경로 A — Outbox INSERT
OutboxEventRelay + OutboxEventProcessor commerce-api/.../batch/ relay() / publishAndMark() 경로 A — 5초 폴링 + Kafka 발행
LikeEventHandler commerce-api/.../application/event/ handleProductLiked(), handleProductUnliked() 경로 B — KafkaTemplate.send() 직접 발행
ProductViewEventHandler commerce-api/.../application/event/ handleProductViewed() 경로 B — KafkaTemplate.send() 직접 발행
OrderEventHandler commerce-api/.../application/event/ handleOrderCreated() 경로 C — CouponPendingActionService.saveConfirm()
CouponActionRelay + CouponActionProcessor commerce-api/.../batch/ relay() / process() 경로 C — 3초 폴링 + CAS 확정
대안 비교 및 선택 근거
전략 경로 수 장점 단점
A. 전부 Outbox → Kafka 1 운영 단순, 발행 보장 일관적 readOnly TX에서 Outbox INSERT 불가
B. 전부 직접 Kafka 1 Outbox 불필요, 구현 최소 주문 TX와 Kafka 발행 원자성 미보장
C. 전부 ApplicationEvent 1 인프라 의존 없음 commerce-streamer에 전파 불가
D. 3경로 분리 (채택) 3 이벤트 중요도에 맞는 보장 수준 운영 복잡도

선택 이유: A안은 기술적으로 불가능(readOnly TX), B안은 금전적 이벤트에 위험, C안은 시스템 간 전파 불가. D안은 유실 불가(Outbox) / 유실 허용+배치 보정(직접 Kafka) / 같은 앱 내 보장(내부 Outbox) — 각 경로가 자기 역할에 최적화된 구조다.

결정 3 — 쿠폰 CAS 상태 머신

의도: 결정 1에서 쿠폰 확정을 AFTER_COMMIT으로 분리하였으므로, **"분리로 인해 생기는 불일치를 어떻게 구조적으로 막을 것인가"**가 본 결정의 핵심 과제이다.

주문 TX와 쿠폰 확정 TX가 분리되면, 주문은 커밋됐는데 쿠폰 확정이 실패할 경우 "쿠폰이 사용됐는데 AVAILABLE 상태" 또는 **"주문이 롤백됐는데 쿠폰은 USED 상태"**라는 불일치가 발생할 수 있다. 이 문제를 중간 상태(RESERVED) 도입으로 해결한다. RESERVED는 "쿠폰을 잡아뒀지만 아직 확정은 아닌" 상태로, 주문 TX에서 RESERVED로 전이한 뒤 AFTER_COMMIT에서 USED로 확정한다. 주문 롤백 시에는 RESERVED가 그대로 남아 배치가 복구하며, 주문 성공 후에만 USED로 전이된다. Session 6의 결제 상태 머신(REQUESTED → SUCCESS/FAILED)과 동일하게, "불확실한 구간에는 중간 상태를 두는" 패턴을 적용한다.

                  주문 TX 내                     AFTER_COMMIT 후
UserCoupon:  AVAILABLE ──→ RESERVED ──────────────→ USED
                              │                      │
                              │  주문 취소/만료        │
                              └──→ AVAILABLE ←────────┘
                                   (restore)

쿠폰의 금전적 가치를 보호하기 위한 전략 — RESERVED + 3중 보상

쿠폰은 할인이라는 금전적 가치를 가진다. 동기 TX에서 분리한 대가로, 다음 5가지 실패 시나리오를 모두 닫아야 한다:

시나리오 1: 주문 성공 + 쿠폰 확정 성공           → 정상 (RESERVED → USED)
시나리오 2: 주문 성공 + 쿠폰 확정 @Async 실패     → 내부 Outbox가 재시도
시나리오 3: 주문 성공 + JVM 크래시 (Outbox 기록 전) → 좀비 RESERVED → 배치 복구
시나리오 4: 주문 롤백 (재고 부족 등)              → RESERVED가 남음 → 주문 취소 로직에서 restore
시나리오 5: 주문 취소/만료                        → CAS로 RESERVED → AVAILABLE 복구
# 보상 전략 보호 시나리오 동작 방식 보장 수준
1 내부 Outbox (coupon_pending_actions) 시나리오 2 주문 TX에서 확정 요청을 DB에 기록 → CouponActionRelay가 3초 폴링 → CAS 확정 At Least Once
2 좀비 RESERVED 배치 시나리오 3 1시간 주기로 created_at < now - 1hour인 RESERVED를 AVAILABLE로 복구 Eventual (최대 1시간)
3 취소/만료 시 restore 시나리오 4, 5 OrderFacade.cancelOrder() / OrderExpiryScheduler → CAS: RESERVED → AVAILABLE 즉시
[정상 경로]
  주문 TX: AVAILABLE ──CAS──→ RESERVED    ← 쿠폰 잠금 (같은 TX)
  AFTER_COMMIT: Outbox 기록 → Relay → Processor: RESERVED ──CAS──→ USED  ← 확정

[보상 경로 1 — @Async 실패]
  AFTER_COMMIT 리스너 실패 → 하지만 Outbox에는 이미 기록됨
  → CouponActionRelay가 3초 후 재시도 → RESERVED ──CAS──→ USED

[보상 경로 2 — JVM 크래시 (Outbox 기록 전)]
  RESERVED 상태로 방치 (좀비)
  → CouponActionCleanupScheduler (1시간 주기): 오래된 RESERVED → AVAILABLE 복구

[보상 경로 3 — 주문 취소/만료]
  사용자 취소 또는 배치 만료 → OrderFacade/Scheduler
  → CouponService.restoreCoupon(): RESERVED ──CAS──→ AVAILABLE (즉시 복구)

왜 이 구조가 동기 TX와 동등한 안전성을 가지는가: 동기 TX에서는 "주문+쿠폰이 함께 성공하거나 함께 롤백"된다. 비동기 구조에서는 "주문은 즉시 성공, 쿠폰은 RESERVED로 잠금 후 확정 시도"한다. 확정이 실패해도 쿠폰은 RESERVED 상태(다른 주문에서 사용 불가)이므로 이중 사용은 구조적으로 불가능하다. 최악의 경우(JVM 크래시 + Outbox 미기록)에도 1시간 후 배치가 RESERVED를 AVAILABLE로 복구하므로 쿠폰이 영구 잠금되지 않는다.

CAS 전이의 멱등성: UPDATE user_coupons SET status = 'USED' WHERE id = ? AND status = 'RESERVED' — 이미 USED이면 affected=0으로 무시. 재시도 안전.

관련 클래스 파일 경로 메서드 역할
CouponService commerce-api/.../domain/coupon/ reserveCoupon() CAS: AVAILABLE → RESERVED
CouponService confirmCouponUsed() CAS: RESERVED → USED
CouponService restoreCouponByAction() CAS: RESERVED → AVAILABLE or USED → AVAILABLE
CouponPendingActionService commerce-api/.../domain/coupon/ saveConfirm() / saveRestore() 내부 Outbox 저장
CouponActionRelay + CouponActionProcessor commerce-api/.../batch/ relay() / process() 3초 폴링 + CAS 확정
CouponActionCleanupScheduler commerce-api/.../batch/ cleanup() 1시간 주기 좀비 RESERVED 정리
대안 비교 및 선택 근거
후보 방식 이중 사용 방지 주문 롤백 시 복구 리스너 실패 시 복구
A. BEFORE_COMMIT에서 즉시 USED 커밋 전 확정 CAS로 차단 불가 — 쿠폰 USED 잔류 해당 없음 (동기)
B. RESERVED 중간 상태 (채택) 주문 TX에서 RESERVED → AFTER_COMMIT에서 USED CAS로 구조적 차단 restoreCoupon() 복구 Relay 재시도 + 좀비 배치
C. Saga 패턴 각 로컬 TX + 보상 TX 보상 TX로 복구 보상 TX로 복구 Orchestrator — MVP에 과도
D. SELECT FOR UPDATE 주문 TX에서 쿠폰 행 락 락 직렬화 TX 롤백 시 해제 불가 — AFTER_COMMIT 시 TX 종료

선택 이유: A안은 "주문 롤백 시 쿠폰 USED 잔류"가 치명적. D안은 AFTER_COMMIT 시점에 TX 종료로 락 무효. C안은 MVP에 과도. B안은 이중 사용을 구조적으로 불가능하게 하면서, 최악의 경우에도 배치가 복구하는 균형점이다.

결정 4 — Relay + Processor 분리 (AOP 자기호출 방지)

의도: Outbox Relay와 쿠폰 확정 Relay 구현 시, @Scheduled 메서드 안에서 @Transactional 메서드를 호출하는 구조가 자연스럽게 도출된다. 단일 클래스에서 "스케줄링 + 트랜잭션 처리"를 담당하면 코드가 간결하나, Spring AOP의 프록시 기반 동작 방식으로 인해 같은 클래스 내 메서드 호출은 프록시를 우회하여 @Transactional이 무시된다. 이는 런타임에서야 드러나는 버그이며 테스트에서 발견하기도 어렵다. **"TX 경계를 구조적으로 보장"**하기 위해 Relay(스케줄링 담당)와 Processor(TX 처리 담당)를 별도 빈으로 분리한다.

❌ 자기호출 문제:
  OutboxRelay.relay()  ← @Scheduled
    └→ this.publishAndMark()  ← @Transactional이지만 프록시 우회 → TX 없음!

✅ 별도 빈 분리:
  OutboxEventRelay.relay()  ← @Scheduled (TX 없음)
    └→ outboxEventProcessor.publishAndMark()  ← 별도 빈의 @Transactional → TX 적용!

이 패턴이 3쌍 반복된다:

Relay (스케줄링) Processor (TX 처리) 대상
OutboxEventRelay OutboxEventProcessor Kafka 발행
CouponActionRelay CouponActionProcessor 쿠폰 확정
CouponIssueConsumer CouponIssueProcessor 선착순 쿠폰 발급

트레이드오프: 클래스 수 증가(3쌍 = 6클래스). 대신 TX 경계가 명확하고 각 Processor를 독립 테스트 가능.

대안 비교 및 선택 근거
대안 방식 TX 보장 테스트 용이성 코드량
A. 단일 클래스 (자기호출) @Scheduled + @Transactional 같은 클래스 보장 안 됨 TX 누락 미발견 1클래스
B. self-injection @Lazy 자기 주입 보장됨 의존 관계 복잡 1클래스
C. TransactionTemplate 프로그래밍 방식 TX 보장됨 좋음 1클래스
D. Relay + Processor 분리 (채택) 별도 빈 분리 구조적 보장 최고 2클래스 x 3쌍

선택 이유: A안은 "돌아가는 것처럼 보이지만 TX가 걸리지 않는" 가장 위험한 버그를 만든다. D안은 코드량이 가장 많지만, **"TX가 걸려야 하는 곳에 TX가 걸린다"**를 구조가 보장한다.


Step 2 — Outbox + Kafka 파이프라인

결정 6 — Outbox Relay 장애 처리: Exponential Backoff + DEAD 격리

의도: Outbox Relay의 Kafka 발행 실패 시 두 가지 극단을 회피해야 한다. 무한 재시도는 Kafka 브로커 장시간 장애 시 DB 부하만 증가시키고, 즉시 포기는 일시적 네트워크 문제에도 불필요한 데이터 유실을 발생시킨다. Exponential Backoff(3초 → 9초 → 27초 → 81초 → 243초)로 일시적 장애의 자동 복구를 지원하되, 5회 실패 후에는 DEAD로 격리하여 정상 메시지의 처리를 차단하지 않도록 설계한다. DEAD 이벤트의 자동 복구는 의도적으로 구현하지 않았으며, "5회 연속 실패한 원인"을 운영자가 확인한 후 조치하는 것이 더 안전하다.

1회 실패: 3초 후 재시도     ← 일시 네트워크 문제 대부분 여기서 복구
2회 실패: 9초 후 재시도     ← Kafka 브로커 재시작 중이면 여기서 복구
3회 실패: 27초 후 재시도
4회 실패: 81초 후 재시도
5회 실패: → DEAD 상태로 격리 (30일 보관)  ← 6분간 복구 안 되면 사람이 봐야 함

DEAD 이벤트는 자동 복구하지 않는다. 운영자가 모니터링에서 outbox.events.dead 메트릭을 감지하여 수동 조치한다.

관련 클래스 파일 경로 메서드 역할
OutboxEventProcessor commerce-api/.../batch/ publishAndMark() 성공 시 PUBLISHED, 실패 시 retryCount+1 + nextRetryAt, 5회 초과 시 DEAD
OutboxEventModel commerce-api/.../domain/outbox/ retryCount, nextRetryAt, status (PENDING/PUBLISHED/DEAD)
OutboxEventRelay commerce-api/.../batch/ relay() WHERE status = 'PENDING' AND nextRetryAt <= now
대안 비교 및 선택 근거
전략 동작 일시 장애 복구 장시간 장애 대응 DB 부하
A. 무한 재시도 매 5초 재시도 자동 복구 무한 루프 높음
B. 즉시 포기 실패 즉시 DEAD 불가 빠른 격리 최소
C. 고정 간격 재시도 N회 동일 간격 자동 복구 N회 후 DEAD 중간
D. Exponential Backoff (채택) 3s→9s→27s→81s→243s, 5회 후 DEAD 자동 복구 DEAD 격리 최소

선택 이유: D안은 초반 빠른 재시도(3초, 9초)로 일시 장애를 잡고, 실패 반복 시 간격을 늘려 DB 부하를 줄인다. 5회 = 총 363초(~6분) 동안 복구되지 않으면 DEAD로 격리한다.

결정 7 — Consumer 예외 분류 + DLQ

의도: 모든 예외를 동일하게 처리하면, JSON 파싱 실패처럼 100번 재시도해도 실패하는 **"고칠 수 없는 메시지"**가 재시도 루프에 걸려 정상 메시지까지 처리가 차단된다(Head-of-Line Blocking). 반면 DB 커넥션 일시 장애는 수 초 후 재시도하면 성공할 수 있다. **"재시도해서 나아질 수 있는가?"**를 기준으로 예외를 두 계층으로 분리하고, 재시도 불가 메시지는 DLQ로 즉시 격리하여 정상 소비 흐름을 보호한다. Session 6에서 PG 예외를 분류한 패턴(SocketTimeout → 재시도 금지, ConnectException → 재시도 허용)과 동일한 판단 기준을 적용한다.

// 비재시도성: DLQ로 즉시 이동
public class NonRetryableEventException extends RuntimeException { }
// - JSON 파싱 실패, 필수 필드 누락, 알 수 없는 eventType

// 재시도성: Kafka 자체 재시도
public class RetryableEventException extends RuntimeException { }
// - DB 커넥션 실패, Redis 타임아웃, 일시적 락 충돌
catalog-events       → catalog-events-dlq
order-events         → order-events-dlq
coupon-issue-requests → coupon-issue-requests-dlq
관련 클래스 파일 경로 메서드 역할
NonRetryableEventException commerce-streamer/.../domain/event/ 재시도 불가 예외 (DLQ 대상)
RetryableEventException commerce-streamer/.../domain/event/ 재시도 가능 예외 (Kafka 재시도)
KafkaConsumerConfig commerce-streamer/.../infrastructure/kafka/ DLQ 라우팅: NonRetryable → topic + "-dlq"
KafkaTopicConfig commerce-api/.../infrastructure/kafka/ DLQ 토픽 자동 생성
대안 비교 및 선택 근거
전략 동작 포이즌 필 방어 정상 소비 보호 운영 가시성
A. 모든 예외 재시도 무조건 Kafka 재시도 불가 차단 없음
B. 모든 예외 무시 로그만 남기고 skip 방어됨 보호됨 유실 추적 어려움
C. DefaultErrorHandler N회 재시도 후 skip 부분 방어 부분 보호 중간
D. 예외 분류 + DLQ (채택) 비재시도성 → DLQ, 재시도성 → 재시도 즉시 격리 완전 보호 높음

선택 이유: D안은 **"이 예외가 재시도해서 나아질 수 있는가?"**로 1차 분류 후, 나아질 수 없는 메시지는 DLQ로 즉시 격리한다. DLQ에 격리된 메시지는 원인 분석 후 재처리할 수 있어 운영 가시성도 확보된다.

결정 8 — version 기반 순서 역전 방어

의도: Kafka는 같은 파티션 내에서 순서를 보장하지만, Consumer 배치 집계나 재처리(rebalance) 상황에서는 순서가 뒤집힐 수 있다. 좋아요 이벤트처럼 스냅샷 방식(like_count=5like_count=6)으로 upsert하는 경우, 이전 이벤트가 나중에 도착하면 최신 값을 구버전으로 덮어쓰는 문제가 발생한다. updated_at 타임스탬프 비교는 고부하 시 동일 마이크로초 충돌이나 서버 간 NTP 동기화 불완전으로 비교가 무의미해질 수 있다. **"시간은 거짓말할 수 있지만, 정수 시퀀스는 거짓말하지 않는다"**는 원칙에 따라 정수 version을 순서 판단 기준으로 채택한다.

[문제: updated_at만 사용]
  14:30:00.000001  이벤트A (eventId=2001, like_count=5)
  14:30:00.000001  이벤트B (eventId=2002, like_count=6)
  → WHERE updated_at 비교 시 동일 시각으로 판단 불가

[해결: version 정수]
  → WHERE like_version < 2002 → 2001 < 2002 → 정상 반영
이벤트 유형 집계 방식 순서 역전 방어
주문 (Outbox 경유) 증분 (order_count += 1) 순서 무관 — 합산 동일
좋아요 (직접 발행) 증분 (like_count += 1 / like_count -= 1) 순서 무관 — 증분이므로 역전 문제 자체 회피
조회수 (직접 발행) 증분 (view_count += 1) 순서 무관 — 합산 동일
관련 클래스 파일 경로 메서드 역할
CatalogEventProcessor commerce-streamer/.../interfaces/consumer/ process() 증분 집계: ON DUPLICATE KEY UPDATE like_count = like_count + 1
OrderEventProcessor commerce-streamer/.../interfaces/consumer/ process() 증분 방식, 순서 무관
ProductMetricsJpaRepository commerce-streamer/.../infrastructure/metrics/ incrementLikeCount(), decrementLikeCount() 실제 upsert 쿼리 실행
대안 비교 및 선택 근거
전략 비교 기준 동일 시각 충돌 NTP 불일치 적용 범위
A. updated_at 타임스탬프 취약 취약 전체
B. Kafka offset offset 정수 안전 무관 직접 발행만
C. 정수 version (채택) version 정수 안전 무관 전체
D. 벡터 클록 분산 인과관계 안전 안전 MVP에 과도

선택 이유: A안은 동일 마이크로초 충돌. B안은 Outbox 경유 시 적용 불가. C안은 AUTO_INCREMENT PK와 Consumer 단조 증가로 통일된 순서 판단이 가능하다.

결정 9 — 보정 배치 체인

의도: 결정 2에서 좋아요/조회수를 "유실 허용 + 배치 보정"으로 분류하였다. 이 판단이 성립하려면 **"보정 배치가 확실히 동작한다"**는 전제가 필요하다. "실시간 파이프라인은 best effort, 배치는 SSOT(Single Source of Truth) 보정"이라는 이중 구조를 적용한다. 좋아요 카운트의 SSOT는 likes 테이블의 실제 레코드 수이므로, 배치가 COUNT(likes)products.like_count를 비교하여 차이가 나면 보정한다. 본 구조에서는 배치 실행 순서가 중요하다. 05:00에 products.like_count를 정확하게 맞춘 후, 06:00에 product_metrics가 이를 참조해야 한다. 순서가 바뀌면 잘못된 값으로 보정되므로, 시간 간격을 1시간 두어 05:00 배치 완료 후 06:00 배치가 실행되도록 구성한다.

매시간  00:xx   CouponRemainingSync        Redis ↔ DB remaining 동기화
매일    02:00   event_log cleanup          90일 초과 삭제
매일    03:00   outbox_event cleanup       7일 초과 PUBLISHED 삭제
매일    03:30   coupon_pending_actions cleanup  3일 초과 삭제
매일    04:00   event_handled cleanup      30일 초과 삭제
매일    05:00   LikeCountReconciliation    products.like_count = COUNT(likes) 동기화
매일    06:00   MetricsReconciliation      product_metrics.like_count 보정 (05:00 의존)

05:00 → 06:00 의존성: LikeCountReconciliation이 products.like_count를 정확하게 맞춘 후, MetricsReconciliation이 product_metrics를 보정해야 한다. 순서가 바뀌면 잘못된 값으로 보정.

관련 클래스 파일 경로 메서드 역할
LikeCountReconciliationScheduler commerce-api/.../batch/ reconcileLikeCounts() @Scheduled(cron="0 0 5 * * *")
MetricsReconciliationScheduler commerce-streamer/.../batch/ reconcileMetrics() @Scheduled(cron="0 0 6 * * *")
CouponRemainingSync commerce-api/.../batch/ syncRemainingFromDb() @Scheduled(fixedDelay=3600000)
EventCleanupScheduler commerce-streamer/.../batch/ cleanupEventHandled(), cleanupEventLog() event_log, event_handled 정리
OutboxCleanupScheduler commerce-api/.../batch/ cleanupOutbox() outbox_event, coupon_pending_actions 정리
대안 비교 및 선택 근거
전략 동작 정합성 보장 보정 지연
A. 보정 안 함 유실 시 불일치 허용 보장 안 됨
B. 실시간 보정 유실 감지 즉시 재조회 실시간 즉시 — 부하 과다
C. 일일 배치 SSOT (채택) likes 테이블 COUNT 기준 재계산 최종 보장 최대 24시간
D. CDC DB binlog 자동 동기화 준실시간 MVP 범위 초과

선택 이유: 좋아요 카운트가 일시적으로 1~2 차이나는 것은 사용자 경험에 영향이 미미하고, 24시간 이내에 정확해지면 충분하다.


Step 3 — 선착순 쿠폰

결정 5 — Thin Producer 전략

의도: 선착순 쿠폰의 핵심 문제는 **"1,000명이 동시에 몰릴 때 정확히 100장만 발급"**하는 것이다. 정확성만 고려하면 DB 락 직렬화로 충분하나, 1,000명이 동시에 DB 커넥션을 점유하면 HikariCP가 고갈되어 상품 조회 등 다른 API까지 영향을 받는다. 본 설계의 원칙은 **"API 서버에서는 DB를 아예 사용하지 않고, 빠르게 수용/거절만 수행한다"**이다. Redis로 ~900건을 1ms 이내에 거절하고, 통과한 ~100건만 Kafka로 위임하면 API 서버의 DB 커넥션 점유는 0이다. 정확성은 Consumer가 단일 TX에서 DB CAS로 보장한다. Session 6에서 PG 호출을 TX 밖으로 분리한 패턴(TX-1 → NO TX → TX-2)의 연장선상에서, 한 단계 더 나아가 TX 자체를 제거하는 것이 Thin Producer의 핵심이다.

세 가지 관점을 동시에 만족시켜야 한다:

  • 사용자 경험: 1,000명이 동시에 몰려도 응답이 즉시 돌아와야 한다
  • 멱등성: 같은 사용자가 중복 클릭하거나 Kafka가 재전달해도 1장만 발급되어야 한다
  • 데이터 원자성: 정확히 100장만 발급되고 101장째는 구조적으로 불가능해야 한다

Redis와 DB의 역할 분리 — 성능 계층 vs 정확성 계층:

Redis DECR는 쿠폰 정합성을 보장하는 수단이 아니다. 정확성의 최종 보장은 DB CAS가 담당한다. Redis는 1,000건 중 ~900건을 1ms 이내에 거절하여 Kafka·Consumer·DB 부하를 줄이는 **게이트키퍼(성능 계층)**이다. Redis가 장애로 동작하지 않아도 DB CAS가 101장째를 차단하므로 오버이슈는 발생하지 않는다.

┌─ 성능 계층 (Redis) ─── 정확하지 않아도 됨, "대략 100건 이하"로 필터링 ─┐
│  Redis SETNX → 중복 클릭 거절                                         │
│  Redis DECR  → 수량 소진 즉시 거절 (~900건 차단, ~100건 통과)           │
│  → Kafka send → 202 Accepted                                         │
│  ※ Redis 장애 시: 필터링 없이 Kafka 진입 → DB CAS가 최종 방어          │
└───────────────────────────────────────────────────────────────────────┘
         │ ~100건만 Kafka 진입
┌─ 정확성 계층 (DB CAS) ─── 반드시 정확해야 함, "정확히 100건 이하" 보장 ─┐
│  Layer 2: event_handled PK INSERT (Kafka 재전달 멱등)                  │
│  Layer 3: coupon_issue_result 상태 조회 (이미 발급 시 스킵)             │
│  Layer 4: DB CAS — UPDATE issued_count += 1                           │
│           WHERE issued_count < max_quantity (최종 물리 방어)            │
│  → user_coupons INSERT + coupon_issue_result(ISSUED) INSERT           │
│  → 모두 1 TX 원자적                                                   │
└───────────────────────────────────────────────────────────────────────┘

4-Layer 멱등성 — 각 레이어가 없으면 어떻게 깨지는가:

Layer 계층 방어 대상 없으면 발생하는 문제
1. Redis SETNX 성능 같은 사용자 중복 클릭 같은 사용자가 Kafka에 10건 발행 → Consumer 부하
2. event_handled PK 정확성 Kafka 재전달 (at-least-once) 동일 이벤트 2번 처리 → 2장 발급
3. coupon_issue_result 정확성 비즈니스 중복 발급 다른 eventId로 같은 사용자+쿠폰 조합 재처리
4. DB CAS 정확성 수량 초과 (101장째) 동시 처리 시 issued_count 경쟁 → 오버이슈

Layer 1(Redis)은 성능 계층 — 불필요한 트래픽을 Kafka 진입 전에 차단한다. 정확성에는 관여하지 않는다.
Layer 2~4(DB)는 정확성 계층 — 정확히 N장만 발급되고 101장째는 구조적으로 불가능함을 보장한다.
Redis가 완전히 장애 나도, DB CAS(Layer 4)만으로 오버이슈는 발생하지 않는다. 처리량이 감소할 뿐이다.

Redis 장애 시 동작: Redis DECR 실패 → Kafka 발행 허용 (게이트키퍼 무력화) → 모든 요청이 Consumer에 도달 → DB CAS가 최종 방어. 매시간 CouponRemainingSync 배치가 Redis ↔ DB 정합성 보정.

왜 Outbox 패턴을 사용하지 않았는가 — Command와 Event의 차이

주문 생성(Step 2)에서는 Outbox를 사용하고, 선착순 쿠폰(Step 3)에서는 사용하지 않는다. 이 차이는 Kafka 메시지의 의미론적 성격 차이에서 온다.

Event (사실의 기록)         vs         Command (행위의 요청)
──────────────────                  ──────────────────
"주문이 생성되었다"                    "이 쿠폰을 발급해라"
OrderCreatedEvent                    CouponIssueRequest

이미 일어난 일 → 취소 불가              아직 안 일어난 일 → 거부 가능
Outbox에 "사실"을 기록                  Outbox에 "의도"를 기록?
발행 보장이 핵심                       빠른 수용/거절이 핵심

Outbox가 선착순 쿠폰에 적합하지 않은 이유:

  1. DB TX가 Thin Producer의 목적을 파괴한다 — Outbox INSERT는 DB TX가 필수이므로, 1,000명 x DB TX = Thin Producer가 아니다.
  2. Command는 "기록할 사실"이 없다 — 발급 요청 시점에는 아직 아무 사실도 발생하지 않았다.
  3. Command는 거절이 자연스럽고, 재요청으로 복구된다 — Event 유실은 시스템 간 불일치(심각). Command 유실은 사용자가 다시 요청(자기복구).
[Event — Outbox 필수]
  OrderFacade:
    BEGIN TX
      INSERT orders (사실 발생)
      INSERT outbox_event (사실 기록)
    COMMIT → Relay가 Kafka로 전파

[Command — Outbox 불필요]
  CouponIssueFacade:
    Redis SETNX (중복 거절)
    Redis DECR (수량 게이트키퍼)
    Kafka send (처리 위임)       ← DB TX 없음
    → 202 Accepted
    → Consumer가 실제 "사실"을 만든다
관련 클래스 파일 경로 메서드 역할
CouponIssueFacade commerce-api/.../application/coupon/ requestRushIssue() Thin Producer: Redis SETNX → DECR → Kafka send → 200 (DB TX 없음)
CouponIssueConsumer commerce-streamer/.../interfaces/consumer/ listen() Kafka 단일 레코드 리스너, manual ACK
CouponIssueProcessor commerce-streamer/.../interfaces/consumer/ process() @Transactional: event_handled + CAS + user_coupon + issue_result 원자적
CouponRemainingSync commerce-api/.../batch/ syncRemainingFromDb() 매시간 Redis remaining ↔ DB (max_quantity - issued_count) 동기화
대안 비교 및 선택 근거

사용자 경험 (1,000명 동시 응답)

전략 응답 시간 DB 커넥션 다른 API 영향
A. DB 동기 (SELECT FOR UPDATE) 수 초~수십 초 1,000개 전체 마비
B. Redis + DB 동기 ~100ms 1,000개 고갈 위험
C. Outbox 패턴 ~50ms 1,000개 TX 필요
D. Thin Producer (채택) ~1ms 0 없음

멱등성 레이어

전략 사용자 중복 Kafka 재전달 비즈니스 중복 레이어 수
A. DB 동기 해당 없음 UNIQUE 1
B. Redis + DB SETNX 해당 없음 UNIQUE 2
D. Thin Producer (채택) SETNX event_handled issue_result + CAS 4

전략 총합 비교

관점 A. DB 동기 B. Redis+DB D. Thin Producer
응답 속도 (1,000명) 수 초 ~100ms ~1ms
DB 커넥션 1,000개 1,000개 0개
멱등성 레이어 1 2 4
오버이슈 방지 락 직렬화 불일치 위험 이중 방어
구현 복잡도 낮음 중간 높음

최종 판단: 구현 복잡도는 가장 높지만, 선착순 쿠폰의 3가지 핵심 요구(즉시 응답 + 중복 방지 + 정확한 수량)를 모두 구조적으로 만족하는 유일한 대안이다.

결정 10 — 핫키 · 핫파티션 대응 전략

의도: 선착순 쿠폰은 동일 시점에 동일 리소스(하나의 couponId)로 트래픽이 집중되는 구조이다. 이로 인해 Redis 핫키(단일 키에 1,000+ ops/sec 집중)와 Kafka 핫파티션(단일 파티션에 모든 메시지 집중)이 발생한다. 두 문제는 성격이 다르며 대응 전략도 구분해야 한다.

문제 1 — Redis 핫키

선착순 쿠폰 요청 시 coupon:{couponId}:remaining 키에 1,000 VU가 동시에 DECR를 수행한다.

1,000 VU 동시 요청
  └→ Redis SETNX  coupon:issue:dedup:userId:couponId   ← 유저별 키 (분산됨)
  └→ Redis DECR   coupon:couponId:remaining            ← 쿠폰별 키 (집중!)
                   ^^^^^^^^^^^^^^^^^^^^^^^^
                   모든 요청이 같은 키에 집중 → 핫키
구분 현재 구현 리스크 대응 전략
MVP (100장, 1,000 VU) 단일 키 낮음 — Redis 단일 노드 10만+ ops/sec 별도 대응 불필요
확장 (10,000장, 10만 VU) 동일 높음 — Cluster 단일 샤드에 10만 ops/sec 집중 서브키 분산 전략

확장 시 서브키 분산 전략:

단일 키를 N개 서브키로 분할하여 Redis Cluster의 여러 샤드에 분산한다.

현재 (단일 키):
  coupon:42:remaining = 100
  → 모든 요청이 하나의 샤드에 집중

확장 (서브키 N=10):
  coupon:42:remaining:0 = 10     ← 샤드 A
  coupon:42:remaining:1 = 10     ← 샤드 B
  ...
  coupon:42:remaining:9 = 10     ← 샤드 J

  요청 → hash(userId) % 10 = subKey
  → 10개 샤드에 분산, 단일 샤드 부하 1/10
// 현재 구현 — 단일 키 (MVP 충분)
private boolean tryDecrementRemaining(Long couponId) {
    String key = "coupon:" + couponId + ":remaining";
    Long remaining = redisTemplate.opsForValue().decrement(key);
    if (remaining == null || remaining < 0) {
        restoreRemaining(couponId);
        return false;
    }
    return true;
}

// 확장 시 — 서브키 분산
private boolean tryDecrementRemaining(Long couponId, Long userId) {
    int subKey = Math.abs(userId.hashCode()) % SUB_KEY_COUNT;
    String key = "coupon:" + couponId + ":remaining:" + subKey;
    Long remaining = redisTemplate.opsForValue().decrement(key);
    if (remaining == null || remaining < 0) {
        redisTemplate.opsForValue().increment(key);
        return false;
    }
    return true;
}

트레이드오프: 서브키 분산 시 일부 서브키가 먼저 소진되면 해당 유저는 수량이 남아 있어도 거절될 수 있다. CouponRemainingSync가 N개 키를 합산하여 보정해야 한다.


문제 2 — Kafka 핫파티션

토픽 파티션 수 파티션 키 핫파티션 리스크 현재 대응
catalog-events 3 productId 중간 — 인기 상품 편중 hash 분산 + 배치 보정
order-events 3 orderId 낮음 — 자연 분산
coupon-issue-requests 1 couponId 의도적 — 순서 보장 단일 Consumer 순차 처리
// KafkaTopicConfig.java
@Bean public NewTopic catalogEventsTopic() {
    return TopicBuilder.name("catalog-events").partitions(3).replicas(1).build();
}
@Bean public NewTopic orderEventsTopic() {
    return TopicBuilder.name("order-events").partitions(3).replicas(1).build();
}
@Bean public NewTopic couponIssueRequestsTopic() {
    return TopicBuilder.name("coupon-issue-requests")
        .partitions(1)   // 선착순 수량 제한을 위해 단일 파티션 필수
        .replicas(1).build();
}

coupon-issue-requests의 단일 파티션은 의도적 선택이다. 100장 규모에서 단일 Consumer의 처리량(초당 수백 건)으로 충분하므로 병목이 아니다.

확장 시 파티셔닝 전략:

10,000장 규모로 확장 시 couponId 기반 파티셔닝으로 전환한다.

현재 (단일 파티션):
  coupon-issue-requests [P0] ← 모든 쿠폰의 모든 요청
  → Consumer 1대가 순차 처리

확장 (couponId 기반 N파티션):
  coupon-issue-requests [P0] ← couponId=1의 요청들
  coupon-issue-requests [P1] ← couponId=2의 요청들
  coupon-issue-requests [P2] ← couponId=3의 요청들
  → Consumer 3대가 병렬 처리
  → 같은 couponId 내 순서 보장 (Kafka 파티션 내 순서 보장)
// 현재 구현 — couponId를 파티션 키로 이미 사용
kafkaTemplate.send("coupon-issue-requests", couponId.toString(), jsonMessage);

// 확장 시 — KafkaTopicConfig에서 partitions(1) → partitions(N) 변경만 필요
// 코드 변경 없이 확장 가능

핵심: 현재 코드가 이미 couponId를 파티션 키로 사용하므로, 파티션 수만 늘리면 코드 변경 없이 확장 가능하다.

catalog-events 인기 상품 편중 대응:

  • 유실 허용 + 배치 보정(결정 9) 구조이므로 일시적 지연 허용
  • Consumer 배치(10건) 처리로 throughput 확보
  • 확장 필요 시 파티션 수 증가(3 → 6 → 12) + Consumer Group 인스턴스 추가
관련 클래스 파일 경로 설정 / 메서드 역할
KafkaTopicConfig commerce-api/.../infrastructure/kafka/ partitions(1) / partitions(3) 토픽별 파티션 수
CouponIssueFacade commerce-api/.../application/coupon/ requestRushIssue()kafkaTemplate.send(topic, couponId, ...) couponId를 파티션 키로 사용
LikeEventHandler commerce-api/.../application/event/ handleProductLiked()kafkaTemplate.send(topic, productId, ...) productId를 파티션 키로 사용
CouponRemainingSync commerce-api/.../batch/ syncRemainingFromDb() 핫키 분산 시 서브키 합산 보정
EventMetrics commerce-api/.../infrastructure/monitoring/ incrementRedisFallback()coupon.redis.decr.fallback Redis 핫키 장애 fallback 모니터링
대안 비교 및 선택 근거

Redis 핫키 대안

전략 처리량 정확성 구현 복잡도
A. 단일 키 (채택/MVP) 10만+ ops/sec 정확 최소
B. 서브키 분산 N x 10만+ ops/sec 정확 (합산 보정 필요) 중간
C. Local Counter 무제한 (로컬) 근사치 (동기화 지연) 높음

Kafka 핫파티션 대안 (coupon-issue-requests)

전략 순서 보장 처리량 확장성
A. 단일 파티션 (채택/MVP) 전체 순서 단일 Consumer 제한적
B. couponId 파티셔닝 (확장 시) couponId 내 보장 N x Consumer 높음
C. Random 파티셔닝 보장 안 됨 최고 최고

선택 이유: MVP(100장, 1,000 VU)에서 Redis 단일 키는 10만+ ops/sec로 충분하고, Kafka 단일 파티션도 Consumer 처리량으로 100건 처리에 충분하다. 현재 코드가 이미 couponId를 파티션 키로 사용하여, 확장 시 파티션 수 변경만으로 전환 가능하다.

k6 부하 테스트 결과

테스트 환경: WSL2 /mnt/c (I/O 병목). 성능 수치는 환경 한계로 절대값이 아닌 상대 경향으로 해석.
상세 분석: docs/task/session7/19. FINAL-K6-TEST-REPORT.md

테스트 방법

인프라: Docker Compose로 MySQL 8.0 + Redis Master/Replica + Kafka Single Broker를 로컬에 기동.
: commerce-api(8080) + commerce-streamer(8081)를 Fat JAR로 별도 JVM 실행.
시드: 유저 1,000명, 브랜드 5개, 상품 100개(재고 10,000), 선착순 쿠폰 1개(100장 한정)를 Admin API로 투입.
부하: k6(Docker, --network host)로 Step별 시나리오를 순차 실행. 각 테스트 후 DB 정합성 SQL + Redis 상태 검증.

Step 1 (이벤트 분리)     → 좋아요 toggle + 주문 생성/취소로 리스너 격리 확인
Step 2 (Kafka Pipeline) → 주문 Outbox 발행 → Relay → Consumer 처리 흐름 확인
Step 3 (선착순 쿠폰)     → VU=10→50→100→500 단계별 ramp-up으로 정확성 + 환경 한계 분리
검증                     → DB SQL(issued_count, user_coupons, outbox 상태) + Redis remaining + Consumer Lag

Step 1 — ApplicationEvent

테스트 VU 핵심 메트릭 결과 Pass/Fail
event-like-throughput 100 like_success + unlike_success 2,017건 Pass
event-order-isolation 30 주문 성공률 / http_req_failed 100% (1,488건) / 0.00% Pass
  • event-like-throughput: 100 VU가 3분간 좋아요 toggle(등록/취소) 반복. @Async + AFTER_COMMIT 이벤트 분리 후 like_count 갱신이 정상 작동하는지 확인.
  • event-order-isolation: 30 VU가 2분간 주문 생성/취소 반복. 리스너에서 예외가 발생해도 주문 TX에 영향 없이 **성공률 100%**를 유지하는지 확인.

Step 2 — Kafka Pipeline

테스트 VU 핵심 메트릭 결과 Pass/Fail
outbox-relay-steady 20 http_req_failed / 주문 건수 0.00% / 2,248건 Pass
kafka-consumer-lag 50 http_req_failed / 이벤트 생성 0.00% / 6,669건 Pass
  • outbox-relay-steady: 20 VU가 5분간 주문 생성+취소 반복. 주문 TX와 함께 저장된 Outbox 이벤트가 5초 간격 Relay에 의해 Kafka로 발행되는지 확인. Outbox PUBLISHED 5,565건 전환 확인.
  • kafka-consumer-lag: 50 VU가 5분간 좋아요+주문+상품조회를 혼합 실행. 카탈로그(직접 Kafka) + 주문(Outbox→Kafka) 이벤트가 Consumer까지 도달하는지 확인. 총 6,669건 이벤트 생성.

Step 3 — 선착순 쿠폰 (VU=500 최종)

VU=10→50→100으로 단계별 ramp-up하여 환경 한계와 로직 병목을 분리한 뒤, VU=500 원래 시나리오를 실행.
테스트 흐름: k6 VU가 각각 고유 유저로 POST /api/v1/coupons/1/rush-issue 호출 → Redis SETNX(중복 차단) → DECR(수량 게이트키퍼) → Kafka send → Consumer CAS 발급 → DB 검증.
각 테스트 전 쿠폰 데이터를 리셋(issued_count=0, Redis remaining=100, dedup key 삭제)하고, 테스트 후 90초 대기하여 Consumer 처리 완료 후 DB 정합성을 검증.

1000명 중 500명 동시 → 100장 정확 발급 검증:

메트릭 목표 실측 Pass/Fail
202 Accepted (Kafka 진입) == 100 100 Pass
coupons.issued_count == 100 100 Pass
user_coupons 건수 == 100 100 Pass
coupon_issue_result ISSUED == 100 100 Pass
초과 발급 0건 0건 Pass
Redis remaining 0 0 Pass
Consumer Lag 0 0 Pass

VU 단계별 Producer 검증:

VU Accepted Sold Out 에러 Redis remaining
10 10 0 0 90
50 50 0 0 50
100 100 0 137 (WSL 타임아웃) 0
500 100 196 204 (WSL 타임아웃) 0

핫파티션 분석

구간 핫파티션 여부 위험도 판단
Redis DECR (coupon:{id}:remaining) 핫키 500 VU 정확성 100%. 프로덕션 ~1만 RPS까지 충분
Kafka (partitionKey=couponId) 핫파티션 의도된 설계 — 순서 보장이 정확성에 필수. 100장 한정에서 병목 없음

기능 정합성 종합

항목 평가
이벤트 분리 — 리스너 실패 격리 Pass
Outbox Relay — PUBLISHED 전환 Pass
Kafka 이벤트 발행 Pass
Consumer 역직렬화 + DB 처리 Pass
선착순 쿠폰 — Redis 게이트키퍼 + Kafka 진입 Pass
선착순 쿠폰 — Consumer CAS 발급 (100장 정확) Pass

총평

부하 테스트는 성능 벤치마크로서는 환경 한계(WSL I/O 병목)로 절대 수치 도출 불가이지만,
통합 검증 도구로서 9건의 운영 버그를 발견·수정하여 Outbox Relay 정상화, Consumer 역직렬화 통일,
선착순 쿠폰 E2E 파이프라인 관통(issued_count=100, 초과 0건)을 달성했다.`

🏗️ Design Overview

변경 범위

영향 받는 기존 도메인:

  • OrderFacade — Outbox INSERT 추가 (주문 생성/취소/만료), ApplicationEvent 발행 추가
  • OrderExpiryScheduler — OrderExpiredEvent 발행 추가
  • LikeService — ProductLikedEvent / ProductUnlikedEvent 발행 추가
  • ProductFacade — ProductViewedEvent 발행 추가
  • CouponServicereserveCoupon(), confirmCouponUsed(), restoreCoupon() CAS 메서드 추가
  • UserCouponModelstatus 필드 추가 (AVAILABLE, RESERVED, USED)
  • CouponModelmaxQuantity, issuedCount, couponType 필드 추가

신규 — 공통 인프라 (Step 0):

  • EventHandledModel / EventLogModel — 멱등 처리 + 감사 로그 (commerce-api, commerce-streamer 양쪽)
  • OutboxEventModel — Transactional Outbox 엔티티 (PENDING → PUBLISHED → DEAD)
  • AsyncConfig@async 스레드풀 (core=4, max=8, queue=100, CallerRunsPolicy)
  • KafkaTopicConfig — 3 토픽 + 3 DLQ 자동 생성
  • KafkaConsumerConfig — 배치 리스너(10건) + 단일 레코드 리스너 + DLQ 라우팅
  • EventCleanupScheduler / OutboxCleanupScheduler — 정리 배치

신규 — Step 1 (ApplicationEvent 경계 분리):

  • 이벤트 6개: OrderCreatedEvent, OrderCancelledEvent, OrderExpiredEvent, ProductLikedEvent, ProductUnlikedEvent, ProductViewedEvent
  • 리스너 3개: OrderEventHandler, LikeEventHandler, ProductViewEventHandler
  • 쿠폰 내부 Outbox: CouponPendingActionModel, CouponPendingActionService, CouponActionRelay, CouponActionProcessor, CouponActionCleanupScheduler
  • Enum 3개: CouponPendingActionType, CouponPendingActionStatus, UserCouponStatus

신규 — Step 2 (Outbox + Kafka 파이프라인):

  • Producer: OutboxEventRelay + OutboxEventProcessor
  • Consumer (commerce-streamer): CatalogEventConsumer + CatalogEventProcessor, OrderEventConsumer + OrderEventProcessor
  • ProductMetricsModel — product_metrics 엔티티 (ON DUPLICATE KEY UPDATE 증분 집계)
  • 보정 배치: LikeCountReconciliationScheduler, MetricsReconciliationScheduler

신규 — Step 3 (선착순 쿠폰):

  • Producer: CouponIssueFacade (Thin Producer, DB TX 없음)
  • Consumer: CouponIssueConsumer + CouponIssueProcessor
  • CouponIssueResultModel — 발급 결과 추적 (ISSUED / REJECTED)
  • CouponIssueRequestMessage — Kafka 메시지 DTO
  • CouponRemainingSync — Redis ↔ DB 매시간 동기화
  • API: POST /api/v1/coupons/{couponId}/rush-issue (200 OK), GET /api/v1/coupons/issue-result/{requestId}

신규 — 운영 모니터링 (Step Ops):

  • EventMetrics — Outbox Counter/Gauge/Timer (commerce-api)
  • ConsumerMetrics — Consumer Lag/Duration (commerce-streamer)
  • Prometheus 설정 (docker/grafana/prometheus.yml) + datasource 프로비저닝
  • AdminOpsV1Controller — Outbox 상태 조회 API (/api-admin/v1/ops/outbox/status)

신규 — 예외 계층:

  • NonRetryableEventException — DLQ 전송 대상 (JSON 파싱 실패, 필드 누락)
  • RetryableEventException — Kafka 재시도 대상 (DB 장애, Redis 타임아웃)

신규 — DDL:

  • event_handled — 멱등 처리 (PK: event_id, 30일 보관)
  • event_log — 감사 로그 (90일 보관)
  • outbox_event — Transactional Outbox (7일 보관)
  • coupon_pending_actions — 내부 Outbox (3일 보관)
  • product_metrics — 집계 테이블 (like_count, sales_count, view_count, version)
  • coupon_issue_result — 선착순 쿠폰 발급 결과
  • ALTER coupons — max_quantity, issued_count, coupon_type 추가
  • ALTER user_coupons — status 컬럼 추가

주요 컴포넌트 & 핵심 메서드

컴포넌트 파일 경로 책임 핵심 메서드
OrderFacade commerce-api/.../application/order/ 주문 오케스트레이션 + 이벤트 발행 createDirectOrder() — 쿠폰 RESERVED + 재고 hold + Outbox INSERT + Event 발행
CouponService commerce-api/.../domain/coupon/ 쿠폰 CAS 상태 전이 reserveCoupon(), confirmCouponUsed(), restoreCouponByAction()
OrderEventHandler commerce-api/.../application/event/ 주문 이벤트 후처리 handleOrderCreated(), handleOrderCancelled(), handleOrderExpired() — AFTER_COMMIT + @async
LikeEventHandler commerce-api/.../application/event/ 좋아요 이벤트 Kafka 발행 handleProductLiked(), handleProductUnliked() — AFTER_COMMIT + @async
OutboxEventRelay commerce-api/.../batch/ Outbox → Kafka 폴링 relay()@scheduled(5초), PENDING 조회
OutboxEventProcessor commerce-api/.../batch/ 단건 발행 + 상태 전이 publishAndMark() — Kafka send + PUBLISHED, 5회 실패 시 DEAD
CouponActionRelay commerce-api/.../batch/ 쿠폰 확정 폴링 relay()@scheduled(3초)
CouponActionProcessor commerce-api/.../batch/ 쿠폰 CAS 확정 process() — confirmCouponUsed() CAS
CouponIssueFacade commerce-api/.../application/coupon/ Thin Producer (DB TX 없음) requestRushIssue() — Redis SETNX → DECR → Kafka send → 200
CouponIssueProcessor commerce-streamer/.../interfaces/consumer/ 선착순 쿠폰 원자적 처리 process() — event_handled + CAS + user_coupon + issue_result
CatalogEventProcessor commerce-streamer/.../interfaces/consumer/ 좋아요/조회수 집계 process() — ON DUPLICATE KEY UPDATE 증분 집계
OrderEventProcessor commerce-streamer/.../interfaces/consumer/ 판매량 집계 process() — sales_count 증분
CouponRemainingSync commerce-api/.../batch/ Redis ↔ DB 정합성 syncRemainingFromDb() — 매시간 동기화
EventMetrics commerce-api/.../infrastructure/monitoring/ Outbox 모니터링 counter(outbox.published), gauge(outbox.pending)

🔁 Flow Diagram

주문 생성 + 이벤트 분리 (Step 1)

sequenceDiagram
    participant C as Client
    participant F as OrderFacade
    participant CS as CouponService
    participant SS as StockService
    participant OS as OrderService
    participant DB
    participant EH as OrderEventHandler

    C->>F: POST /api/v1/orders

    Note over F,DB: Order TX
    F->>CS: reserveCoupon()
    CS->>DB: CAS AVAILABLE to RESERVED
    F->>SS: hold(productId, qty)
    SS->>DB: CAS reserved += qty
    F->>OS: create(PENDING_PAYMENT)
    OS->>DB: INSERT order + order_items
    F->>DB: INSERT outbox_event
    F-->>C: ApiResponse 200

    Note over EH,DB: AFTER_COMMIT + Async
    EH->>DB: INSERT coupon_pending_actions
Loading

Outbox to Kafka 파이프라인 (Step 2)

sequenceDiagram
    participant R as OutboxEventRelay
    participant P as OutboxEventProcessor
    participant DB
    participant K as Kafka
    participant CS as commerce-streamer
    participant M as product_metrics

    Note over R,K: Relay 5s polling
    R->>DB: SELECT PENDING events
    DB-->>R: OutboxEventModel list
    loop each event
        R->>P: publishAndMark(event)
        P->>K: send to order-events
        P->>DB: UPDATE status PUBLISHED
    end

    Note over CS,M: Consumer idempotent
    K->>CS: ORDER_CREATED event
    CS->>M: INSERT event_handled PK
    alt PK conflict
        M-->>CS: skip duplicate
    else new event
        CS->>M: UPSERT product_metrics
    end
    CS->>K: manual ACK
Loading

선착순 쿠폰 (Step 3)

sequenceDiagram
    participant C as Client
    participant F as CouponIssueFacade
    participant R as Redis
    participant K as Kafka
    participant P as CouponIssueProcessor
    participant DB

    C->>F: POST /coupons/rush-issue

    Note over F,K: Thin Producer - No DB TX
    F->>R: SETNX dedup key
    alt already exists
        R-->>F: false
        F-->>C: 409 Conflict
    end
    F->>R: DECR remaining
    alt sold out
        F->>R: INCR rollback
        F-->>C: 400 Sold Out
    end
    F->>K: send coupon-issue-requests
    F-->>C: 202 Accepted

    Note over P,DB: Consumer Single TX
    K->>P: processIssue()
    P->>DB: INSERT event_handled L2
    P->>DB: SELECT issue_result L3
    P->>DB: CAS issued_count L4
    alt CAS success
        P->>DB: INSERT user_coupons
        P->>DB: INSERT issue_result ISSUED
    else CAS fail
        P->>DB: INSERT issue_result REJECTED
    end
    P->>K: manual ACK

    C->>F: GET /issue-result
    F->>DB: SELECT coupon_issue_result
    F-->>C: ISSUED or REJECTED or 404
Loading

✅ Checklist

Step 0 — 공통 인프라

  • Kafka 의존성 추가 (commerce-api: modules:kafka)
  • Kafka 설정 보강 (acks=all, idempotence=true)
  • AsyncConfig — @async 스레드풀 (core=4, max=8, queue=100)
  • DDL — event_handled, event_log, outbox_event, coupon_pending_actions, product_metrics, coupon_issue_result
  • Kafka 토픽 3개 + DLQ 3개 자동 생성
  • KafkaConsumerConfig — 배치/단일 리스너 팩토리 + DLQ 라우팅
  • 멱등 처리 인프라 (EventHandledModel + Repository, 양쪽 앱)
  • Outbox 인프라 (OutboxEventModel + Repository)
  • 정리 배치 (EventCleanupScheduler, OutboxCleanupScheduler)

Step 1 — ApplicationEvent 경계 분리

  • UserCouponStatus RESERVED + CAS 쿼리
  • CouponPendingAction 엔티티 + 내부 Outbox
  • CouponService 확장 (reserve, confirm, restore)
  • CouponActionRelay + CouponActionProcessor (3초 폴링)
  • 이벤트 6개 정의 + 발행 코드 (OrderFacade, LikeService, ProductFacade)
  • 이벤트 리스너 3개 (OrderEventHandler, LikeEventHandler, ProductViewEventHandler)

Step 2 — Outbox + Kafka 파이프라인

  • OrderFacade Outbox INSERT (생성/취소/만료)
  • OutboxEventRelay + OutboxEventProcessor (5초 폴링, Exponential Backoff)
  • 리스너에 Kafka 직접 발행 추가 (LikeEventHandler, ProductViewEventHandler)
  • ProductMetrics 엔티티 + version 기반 ON DUPLICATE KEY UPDATE upsert
  • CatalogEventConsumer + Processor (배치: 좋아요/조회수 집계)
  • OrderEventConsumer + Processor (단일 배치: 판매량 집계)
  • 보정 배치 (LikeCount 05:00, Metrics 06:00)

Step 3 — 선착순 쿠폰

  • CouponModel 변경 (maxQuantity, issuedCount)
  • CouponIssueResult 엔티티 + Repository (양쪽 앱)
  • CouponIssueFacade — Thin Producer (Redis SETNX → DECR → Kafka send)
  • CouponIssueConsumer + Processor — 4-Layer 멱등 처리
  • CouponRemainingSync — Redis ↔ DB 매시간 동기화
  • API: rush-issue (202 200) + issue-result 조회

Step Ops — 운영 모니터링

  • EventMetrics + ConsumerMetrics (Micrometer 커스텀 메트릭)
  • Grafana 대시보드 Prometheus 설정 + PromQL (대시보드 JSON 미포함)
  • OperationalQueryService — Outbox/Consumer 상태 조회 API

검증

  • k6 부하 테스트 8종 (event, outbox, rush-coupon, full-mixed)
  • 데이터 정합성: issued_count=100, user_coupons=100, 초과 발급 0건, Outbox PUBLISHED 5,565건
  • 부하 테스트 중 발견된 버그 9건 수정 완료

🔍 리뷰 포인트

1. 4-Layer 멱등성 — 과도한 방어가 아닌지 판단을 구하고 싶습니다

해결하려는 비즈니스 문제: 선착순 쿠폰은 "정확히 100장, 101장째 불가"가 핵심입니다. 중복 발급 1건이라도 발생하면 쿠폰의 금전적 가치가 비정상 지출됩니다.

시도한 접근: Producer와 Consumer가 분리되어 멱등성 위협이 4가지 존재하므로, 각 위협에 대응하는 전용 레이어를 배치했습니다.

flowchart LR
    subgraph Producer ["Producer - commerce-api"]
        L1["L1: Redis SETNX<br/>중복 클릭 차단"]
    end

    subgraph MQ ["Kafka"]
        Q["coupon-issue-requests<br/>at-least-once"]
    end

    subgraph Consumer ["Consumer - commerce-streamer"]
        L2["L2: event_handled PK<br/>Kafka 재전달 차단"]
        L3["L3: coupon_issue_result<br/>비즈니스 중복 차단"]
        L4["L4: DB CAS<br/>수량 초과 차단"]
    end

    L1 --> Q --> L2 --> L3 --> L4
Loading

코드 위치:

이렇게 선택한 이유: 각 레이어를 빼면 깨지는 시나리오가 존재합니다.

Layer 빼면 발생하는 문제 계층
L1 Redis SETNX 같은 사용자가 Kafka에 10건 발행 → Consumer 부하 성능
L2 event_handled Kafka 재전달 시 동일 이벤트 2번 처리 → 2장 발급 정확성
L3 issue_result 다른 eventId로 같은 userId+couponId 재처리 정확성
L4 DB CAS 동시 처리 시 issued_count 경쟁 → 101장 발급 정확성

인지하고 있는 트레이드오프: 4-Layer로 인해 구현 복잡도가 증가합니다. MVP에서 L1(Redis) + L4(DB CAS) 2겹만으로도 대부분의 시나리오를 커버할 수 있으며, L2~L3은 Kafka at-least-once 재전달이나 Consumer 재처리 같은 인프라 레벨 장애에 대한 방어입니다.

멘토님께 여쭙고 싶은 점: MVP 단계에서 L1 + L4만으로 충분할지, 아니면 L2L3까지 갖추는 것이 적절한지 판단이 서지 않아 의견을 여쭙고 싶습니다. L2L3을 빼면 구현이 단순해지지만, Kafka 재전달 시나리오에서 중복 발급 리스크가 남습니다. 혹시 실무에서 이런 멱등성 레이어를 단계적으로 추가하는 기준이 있다면 조언을 부탁드리겠습니다.


2. Outbox Relay 5초 폴링 간격 — 적정한 수치인지 조언을 구하고 싶습니다

해결하려는 비즈니스 문제: 주문 생성 후 order-events가 commerce-streamer에 전파되기까지의 지연 시간이 사용자 경험과 운영 데이터 정합성에 영향을 줍니다. 폴링 간격이 짧으면 DB 부하가 증가하고, 길면 이벤트 전파가 지연됩니다.

시도한 접근: 5초 고정 간격 폴링 + Exponential Backoff 재시도를 적용했습니다.

sequenceDiagram
    participant R as OutboxEventRelay
    participant P as OutboxEventProcessor
    participant DB
    participant K as Kafka

    loop every 5 seconds
        R->>DB: SELECT WHERE status PENDING AND nextRetryAt lte now
        DB-->>R: events list
        loop each event
            R->>P: publishAndMark
            alt success
                P->>K: send
                P->>DB: status = PUBLISHED
            else fail
                P->>DB: retryCount++ nextRetryAt = 3^n sec
                Note over P,DB: 5 fails then DEAD
            end
        end
    end
Loading

코드 위치:

이렇게 선택한 이유:

간격 이벤트 전파 지연 DB SELECT 부하 (1시간) 적정성
1초 ~1초 3,600회 DB 부하 높음
5초 ~5초 720회 채택
30초 ~30초 120회 전파 지연 과다

인지하고 있는 트레이드오프: 5초는 "실시간"은 아니지만, 대부분의 커머스 시나리오에서 허용 가능한 지연이라고 판단했습니다. 다만 주문 직후 product_metrics 조회 시 최대 5초간 반영 지연이 존재합니다. 궁극적으로는 CDC(Change Data Capture)로 전환하면 polling 없이 준실시간 전파가 가능할 것으로 생각합니다.

멘토님께 여쭙고 싶은 점: 5초라는 간격을 "DB 부하와 전파 지연의 균형점"으로 선택했는데, 실무 운영 환경에서 이 간격이 적정한지 감이 잡히지 않아 조언을 구하고 싶습니다. 혹시 운영에서 더 짧거나 긴 간격이 필요했던 사례가 있는지, 또한 CDC 전환을 고려해야 하는 시점의 기준(트래픽 규모, 지연 허용치 등)이 있다면 경험을 나눠주시면 큰 도움이 되겠습니다.


dd-jiny added 2 commits March 27, 2026 05:04
파이프라인, 선착순 쿠폰

동기 결합된 부가 로직을 이벤트 기반으로
  분리하고,
  시스템 간 비동기 통신을 Kafka로 구축하며, 운영 안정성을 위한
  모니터링/보정 배치를 추가한다.

  ## Step 0: 공통 인프라
  - Kafka 모듈 의존성 추가 (commerce-api) 및 kafka.yml 보강
  (acks=all, idempotence)
  - @EnableAsync + eventTaskExecutor 스레드풀 설정
  - Kafka 토픽 자동 생성 (catalog-events, order-events,
  coupon-issue-requests)
  - DLQ 포함 배치/단건 Consumer Factory (commerce-streamer)
  - 멱등 처리 인프라 (event_handled, event_log) 엔티티 +
  Repository
  - Outbox 인프라 (outbox_event) 엔티티 + Service + Repository
  - 이벤트 테이블 정리 배치 (EventCleanup, OutboxCleanup)
  - DDL 스크립트 (session7-ddl.sql)

  ## Step 1: ApplicationEvent 경계 분리
  - 쿠폰 CAS 3단계 상태 머신 (AVAILABLE → RESERVED → USED) +
  coupon_pending_actions Outbox
  - CouponActionRelay/Processor — 3초 폴링으로 쿠폰 확정/복원
  - 이벤트 클래스 6개 (Order 3, Like 2, Product 1)
  - OrderFacade — CAS 쿠폰 선점 + CONFIRM Outbox + 이벤트 발행
  - LikeService — SELECT FOR UPDATE 제거 → AFTER_COMMIT 이벤트로
   집계 분리
  - ProductFacade — 상품 조회 이벤트 발행
  - 이벤트 리스너 3개 (OrderEventHandler, LikeEventHandler,
  ProductViewEventHandler)

  ## Step 2: Outbox + Kafka 이벤트 파이프라인
  - OrderFacade — Outbox 기록 (ORDER_CREATED/CANCELLED/EXPIRED)
  - OutboxEventRelay (5초 폴링) + OutboxEventProcessor (Kafka
  발행 + 상태 관리)
  - LikeEventHandler/ProductViewEventHandler — Kafka 직접 발행
  (catalog-events)
  - ProductMetrics 엔티티 + Repository (commerce-streamer,
  upsert 기반)
  - CatalogEventConsumer/Processor — 좋아요/조회수 집계
  - OrderEventConsumer/Processor — 판매량 집계 (멱등 처리)
  - LikeCountReconciliation 보정 배치

  ## Step 3: Kafka 기반 선착순 쿠폰 발급
  - CouponIssueFacade — Thin Producer (Redis SETNX + DECR +
  Kafka, DB TX 0)
  - CouponIssueConsumer/Processor — 4단계 멱등 방어 (Redis →
  event_handled → result → DB CAS)
  - CouponRemainingSync — Redis-DB 동기화 배치 (매시간)
  - /rush-issue, /issue-result API 추가

  ## 운영 모니터링
  - EventMetrics, OutboxMetrics (Micrometer Counter/Gauge/Timer)
  - ConsumerMetrics (catalog/order/coupon 처리 카운터)
  - MetricsReconciliationScheduler (product_metrics like_count
  보정)
  - AdminOpsV1Controller (/api-admin/v1/ops/outbox/status)

  ## Nice-To-Have
  - Consumer Group 분리 (metrics/coupon 장애 격리)
  - CatalogBatchAggregator — 메모리 집계 + 벌크 upsert 최적화
  - Outbox Relay exponential backoff (3^n초)
  - Kafka send failure callback (whenComplete)
  - CartRestoreRetryScheduler — 장바구니 복원 재시도 배치 (매일
  05:30)
end-to-end 수정

    - OutboxEventJpaRepository: NOW() →
    UTC_TIMESTAMP()로 변경하여
      NORMALIZE_UTC + LocalDateTime/ZonedDateTime
    타입 불일치 해결
    - OutboxEventRelay: 폴링 쿼리 실패 시 지수
    백오프(10s→60s) 적용
    - PaymentJpaRepository:
    findAllRequestedBefore(LocalDateTime) →
      findAllRequestedBeforeMinutesAgo(int)
    네이티브 쿼리로 동일 타입 불일치 해결
    - Kafka Producer value-serializer:
    JsonSerializer → StringSerializer 통일,
      모든 Producer에서
    ObjectMapper.writeValueAsString() 직접 직렬화
    - Streamer Consumer(CatalogEventProcessor,
    OrderEventProcessor):
      String JSON 메시지 ObjectMapper 파싱 지원
    추가
    - AdminCouponV1Dto: maxQuantity 필드 추가,
    선착순 쿠폰 Admin API 생성 지원
    - CouponService.createCoupon(): maxQuantity
    파라미터 추가
    - commerce-streamer application.yml: local,test
     프로필에 ddl-auto: none 명시
      (jpa.yml의 ddl-auto: create 덮어쓰기로 인한
    테이블 스키마 손실 방지)
    - k6/seed-session7.sh: 쿠폰 생성 API 필드명
    수정 (discountType→type, discountValue→value)
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

Important

Review skipped

Too many files!

This PR contains 188 files, which is 38 over the limit of 150.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 151aca26-1e8f-4952-9d6b-ec5cb44fce03

📥 Commits

Reviewing files that changed from the base of the PR and between 20aced9 and bf99e09.

📒 Files selected for processing (188)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/batch/CartRestoreRetryScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/batch/CouponActionCleanupScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/batch/CouponActionProcessor.java
  • apps/commerce-api/src/main/java/com/loopers/batch/CouponActionRelay.java
  • apps/commerce-api/src/main/java/com/loopers/batch/CouponRemainingSync.java
  • apps/commerce-api/src/main/java/com/loopers/batch/LikeCountReconciliationScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/batch/OutboxCleanupScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/batch/OutboxEventProcessor.java
  • apps/commerce-api/src/main/java/com/loopers/batch/OutboxEventRelay.java
  • apps/commerce-api/src/main/java/com/loopers/batch/PaymentPollingScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponActionStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponActionType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDeduplicationCache.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestMessage.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueResultModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueResultRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPendingActionModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPendingActionRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPendingActionService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRemainingCache.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductUnlikedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCancelledEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderExpiredEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRelayMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/LikeCountMismatch.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/async/AsyncConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponDeduplicationCacheImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueResultJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueResultRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPendingActionJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPendingActionRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRemainingCacheImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/monitoring/EventMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/monitoring/OutboxMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminCouponV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOpsV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/apiadmin/AdminOpsV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/support/enums/UserCouponStatus.java
  • apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/ProductViewEventHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/CartRestoreRetrySchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/CouponActionCleanupSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/CouponActionProcessorTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/CouponActionRelayTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/CouponRemainingSyncTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/LikeCountReconciliationSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/OutboxCleanupSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/OutboxEventProcessorTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/OutboxEventRelayTest.java
  • apps/commerce-api/src/test/java/com/loopers/batch/PaymentPollingSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/config/TestAsyncConfig.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssuanceConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueResultModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPendingActionModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPendingActionServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/OrderCouponConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/event/OrderCreatedEventTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventBackoffTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/monitoring/EventMetricsTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/monitoring/OutboxMetricsTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImplTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentRepositoryImplTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/apiadmin/AdminCouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/resources/application-test.yml
  • apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
  • apps/commerce-streamer/src/main/java/com/loopers/batch/EventCleanupScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/batch/MetricsReconciliationScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestMessage.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueResultModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueResultRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/UserCouponModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandledModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandledRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventLogModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventLogRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventLogStatus.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsLikeCountMismatch.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueResultJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueResultRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventLogJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventLogRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/KafkaConsumerConfig.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/monitoring/ConsumerMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogBatchAggregator.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogBatchProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventProcessor.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • apps/commerce-streamer/src/test/java/com/loopers/CommerceStreamerContextTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/batch/EventCleanupSchedulerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/batch/MetricsReconciliationSchedulerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/idempotency/EventHandledModelTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/idempotency/EventLogModelTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsServiceTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/infrastructure/idempotency/EventHandledRepositoryImplTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/infrastructure/idempotency/EventLogRepositoryImplTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/infrastructure/monitoring/ConsumerMetricsTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogBatchAggregatorTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventProcessorTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CouponIssueProcessorTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventProcessorTest.java
  • docs/DDL/session7-ddl.sql
  • k6/reset-rush-coupon.sh
  • k6/scripts/session7/event-like-throughput.js
  • k6/scripts/session7/event-order-isolation.js
  • k6/scripts/session7/full-mixed-load.js
  • k6/scripts/session7/kafka-consumer-lag.js
  • k6/scripts/session7/outbox-relay-steady.js
  • k6/scripts/session7/rush-coupon-polling.js
  • k6/scripts/session7/rush-coupon-redis-gate.js
  • k6/scripts/session7/rush-coupon-spike.js
  • k6/seed-session7.sh
  • modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
  • modules/kafka/src/main/resources/kafka.yml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dd-jiny dd-jiny changed the title [Volume 7] [Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현 Mar 27, 2026
의존성 역전 적용

    4건의 DIP(Dependency Inversion Principle) 위반을
    인터페이스 추출로 해결한다.

    1. OrderCreatedEvent: application.OrderInfo 역참조
     제거
       - from(OrderInfo) → from(OrderModel,
    List<OrderItemModel>)로 변경

    2. CouponIssueMetrics 인터페이스 추출
    (domain/coupon/)
       - CouponIssueFacade가
    infrastructure.EventMetrics 대신 도메인 인터페이스
     참조

    3. OutboxRelayMetrics 인터페이스 추출
    (domain/outbox/)
       - OutboxEventRelay가
    infrastructure.OutboxMetrics 대신 도메인
    인터페이스 참조
       - Timer.Sample → Object 추상화로 Micrometer
    의존 제거

    4. CouponRemainingCache + CouponDeduplicationCache
     인터페이스 추출 (domain/coupon/)
       - CouponIssueFacade, CouponRemainingSync의
    StringRedisTemplate 직접 참조 제거
       - infrastructure/coupon/에 Redis 구현체 생성
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant