[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현#282
Open
dd-jiny wants to merge 3 commits intoLoopers-dev-lab:dd-jinyfrom
Open
[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현#282dd-jiny wants to merge 3 commits intoLoopers-dev-lab:dd-jinyfrom
dd-jiny wants to merge 3 commits intoLoopers-dev-lab:dd-jinyfrom
Conversation
파이프라인, 선착순 쿠폰 동기 결합된 부가 로직을 이벤트 기반으로 분리하고, 시스템 간 비동기 통신을 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)
|
Important Review skippedToo many files! This PR contains 188 files, which is 38 over the limit of 150. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (188)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
의존성 역전 적용
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 구현체 생성
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📌 Summary
커머스 백엔드에 이벤트 드리븐 아키텍처를 도입하여, 주문–쿠폰–좋아요 간 동기 의존성을 이벤트 경계로 분리하고,
Transactional Outbox + Kafka 파이프라인으로 시스템 간 이벤트 전파를 구현했다.
선착순 쿠폰(100장 한정)은 Redis DECR 게이트키퍼 + DB CAS 이중 방어로 정확성을 보장한다.
🧭 Context & Decision
문제 정의
문제 1 — 주문 TX에 쿠폰 확정이 동기로 결합 + 도메인 간 이벤트 전파 부재 (Step 1)
두 가지 문제가 동시에 존재한다:
A. 주문 TX 내 쿠폰 확정 동기 결합: 주문 생성 메서드가 재고 hold + 쿠폰 확정을 하나의 트랜잭션에서 처리하고 있어, 쿠폰 서비스 장애 시 주문까지 롤백되는 구조적 결함이 존재한다.
B. 도메인 간 이벤트 전파 부재: 좋아요 카운트, 조회수 갱신 등 메트릭 이벤트는 주문 TX와 무관하게 각 도메인(LikeService, ProductFacade)에서 독립적으로 발생하지만, 이를 다른 시스템(commerce-streamer)에 전파할 이벤트 기반 구조가 없다. 각 도메인이 직접 동기 호출로 처리하고 있어 확장이 어렵다.
문제 2 — 시스템 간 이벤트 전파에 보장 메커니즘이 없다 (Step 2)
주문/좋아요/조회수 등의 이벤트를 다른 시스템(commerce-streamer)으로 전파할 구조가 없다. 이벤트를 단순 Kafka 발행하면 TX 커밋과 Kafka 발행의 원자성이 보장되지 않아 메시지 유실이나 순서 역전이 발생할 수 있다.
문제 3 — 선착순 쿠폰의 동시성 제어와 리소스 경합 (Step 3)
선착순 쿠폰(100장 한정)에 1,000명이 동시에 요청하면, 동기 DB 접근으로 커넥션 풀이 고갈되어 상품 조회 등 다른 API까지 영향을 받는다. 또한 동일 리소스(하나의 couponId)에 트래픽이 집중되어 Redis 핫키와 Kafka 핫파티션이 발생한다.
coupon:remaining단일 키에 1,000+ ops/sec 집중 → Cluster 환경 단일 샤드 병목핵심 결정 요약
AFTER_COMMIT+@AsyncOrderFacade.createDirectOrder()→OrderEventHandler.handleOrderCreated()OutboxEventRelay→ Kafka / B:LikeEventHandler→ Kafka 직접 / C:CouponActionRelay→ 내부 처리CouponService.reserveCoupon()→confirmCouponUsed()→restoreCouponByAction()OutboxEventRelay.relay()→OutboxEventProcessor.publishAndMark()(3쌍 동일 패턴)CouponIssueFacade.requestRushIssue()(Redis only) →CouponIssueProcessor.process()(DB TX)OutboxEventProcessor.publishAndMark()— retryCount, nextRetryAt, DEAD 전이KafkaConsumerConfig—NonRetryableEventExceptioncatch → topic + "-dlq" 발행CatalogEventProcessor.process()→ProductMetricsJpaRepositoryincrementLikeCount()LikeCountReconciliationScheduler(05:00) →MetricsReconciliationScheduler(06:00)CouponIssueFacade— Redis DECR 단일 키 /KafkaTopicConfig— partitions(1)Step 1 — 이벤트 경계 분리
결정 1 — 이벤트 실행 시점: AFTER_COMMIT + @async
의도: 주문 생성 메서드가 쿠폰 확정, 좋아요 카운트, 조회수 갱신을 동기로 처리하고 있어 "쿠폰 서비스 장애 → 주문 롤백"이라는 구조적 결함이 존재한다. 본 결정의 원칙은 **"주문은 반드시 성공해야 하고, 부수 효과는 실패해도 주문에 영향을 주면 안 된다"**이며, 이를 코드 구조로 강제하는 방식을 채택한다. 단순 try-catch는 "실패를 무시"하는 것이지 "격리"가 아니다. TX 커밋 이후에만 부수 효과를 실행하면, 실패 여부와 관계없이 주문은 이미 확정된 상태이므로 구조적 격리가 보장된다.
쿠폰은 금전적 자산인데 왜 후속 조치(AFTER_COMMIT)로 분리했는가:
쿠폰 차감은 좋아요나 조회수와는 성격이 다르다. 쿠폰은 할인이라는 금전적 가치를 가지므로, 직관적으로는 주문과 같은 TX에서 동기로 처리하는 것이 안전해 보인다. 동기 유지 여부가 본 설계에서 가장 높은 판단 난이도를 가진다.
그러나 Session 7의 핵심 목표는 **"이벤트 드리븐으로 도메인 간 경계를 분리하는 것"**이다. 쿠폰만 동기로 남기면, 주문 TX 안에 쿠폰 서비스 의존성이 그대로 유지되어 이벤트 분리의 핵심 목적이 훼손된다. "금전적 가치가 있으니 동기가 맞다"는 판단은 모놀리식 구조에서는 유효하나, 이벤트 드리븐 전환의 맥락에서는 **"비동기로 분리하되, 금전적 가치에 걸맞는 보상 전략을 갖추는 것"**이 적합한 방향이다.
따라서 쿠폰 차감을 AFTER_COMMIT 후속 조치로 분리하되, 좋아요/조회수처럼 "유실 허용"으로 두지 않고 3중 안전장치를 설계하여 금전적 가치에 걸맞는 보장 수준을 확보한다:
coupon_pending_actions)이 3중 안전장치의 설계 근거는 결정 3에서 상세히 다룬다.
AFTER_COMMIT만으로는 부족한 이유: @async 없이 AFTER_COMMIT만 사용하면 같은 스레드에서 리스너가 실행되어 응답이 지연된다. @async를 추가하면 별도 스레드에서 실행되어 응답은 빠르지만, JVM 크래시 시 큐에 있던 태스크가 유실될 수 있다.
유실 대응:
coupon_pending_actions내부 Outbox로 보장outbox_eventsOutbox로 보장AsyncConfigcommerce-api/.../infrastructure/async/eventTaskExecutor()ThreadPoolTaskExecutor(core=4, max=8, queue=100, CallerRunsPolicy)OrderEventHandlercommerce-api/.../application/event/handleOrderCreated(),handleOrderCancelled(),handleOrderExpired()@TransactionalEventListener(AFTER_COMMIT)+@Async("eventTaskExecutor")LikeEventHandlercommerce-api/.../application/event/handleProductLiked(),handleProductUnliked()ProductViewEventHandlercommerce-api/.../application/event/handleProductViewed()대안 비교 및 선택 근거
결정 2 — 3경로 이벤트 발행 전략
의도: 단일 파이프라인(모든 이벤트를 Kafka로 발행)이 운영 단순성 측면에서 유리하나, 모든 이벤트를 같은 경로로 보내는 것은 과도한 일관성에 해당한다. 좋아요 하나에 Outbox INSERT를 위해 write TX를 여는 것은 비용 대비 효과가 맞지 않으며, 쿠폰 확정을 같은 앱 안에서 처리하는데 Kafka를 경유하면 불필요한 네트워크 홉만 추가된다. **"이벤트의 중요도와 소비자 위치에 따라 경로가 달라야 한다"**는 원칙 하에, 유실 시 피해 크기를 기준으로 3경로를 분리한다.
3가지 제약이 단일 경로를 불가능하게 만든다:
@Transactional(readOnly=true)→ Outbox INSERT 불가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 --> CAPOrderFacade→ outbox_events INSERT →OutboxEventRelay5초 폴링 → order-eventsLikeEventHandler/ProductViewEventHandler→ catalog-events 직접 발행OrderEventHandler→ coupon_pending_actions INSERT →CouponActionRelay3초 폴링 → 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"]Outbox가 2개인 이유:
outbox_events(Kafka 발행용, 7일 보관)와coupon_pending_actions(내부 처리용, 3일 보관)는 생명주기, 처리 방식, 장애 격리가 다르다. 하나로 합치면 Kafka 발행 실패가 쿠폰 확정을 막거나 그 역이 발생할 수 있다.OrderFacadecommerce-api/.../application/order/createDirectOrder()OutboxEventRelay+OutboxEventProcessorcommerce-api/.../batch/relay()/publishAndMark()LikeEventHandlercommerce-api/.../application/event/handleProductLiked(),handleProductUnliked()KafkaTemplate.send()직접 발행ProductViewEventHandlercommerce-api/.../application/event/handleProductViewed()KafkaTemplate.send()직접 발행OrderEventHandlercommerce-api/.../application/event/handleOrderCreated()CouponPendingActionService.saveConfirm()CouponActionRelay+CouponActionProcessorcommerce-api/.../batch/relay()/process()대안 비교 및 선택 근거
결정 3 — 쿠폰 CAS 상태 머신
의도: 결정 1에서 쿠폰 확정을 AFTER_COMMIT으로 분리하였으므로, **"분리로 인해 생기는 불일치를 어떻게 구조적으로 막을 것인가"**가 본 결정의 핵심 과제이다.
주문 TX와 쿠폰 확정 TX가 분리되면, 주문은 커밋됐는데 쿠폰 확정이 실패할 경우 "쿠폰이 사용됐는데 AVAILABLE 상태" 또는 **"주문이 롤백됐는데 쿠폰은 USED 상태"**라는 불일치가 발생할 수 있다. 이 문제를 중간 상태(RESERVED) 도입으로 해결한다. RESERVED는 "쿠폰을 잡아뒀지만 아직 확정은 아닌" 상태로, 주문 TX에서 RESERVED로 전이한 뒤 AFTER_COMMIT에서 USED로 확정한다. 주문 롤백 시에는 RESERVED가 그대로 남아 배치가 복구하며, 주문 성공 후에만 USED로 전이된다. Session 6의 결제 상태 머신(REQUESTED → SUCCESS/FAILED)과 동일하게, "불확실한 구간에는 중간 상태를 두는" 패턴을 적용한다.
쿠폰의 금전적 가치를 보호하기 위한 전략 — RESERVED + 3중 보상
쿠폰은 할인이라는 금전적 가치를 가진다. 동기 TX에서 분리한 대가로, 다음 5가지 실패 시나리오를 모두 닫아야 한다:
coupon_pending_actions)CouponActionRelay가 3초 폴링 → CAS 확정created_at < now - 1hour인 RESERVED를 AVAILABLE로 복구OrderFacade.cancelOrder()/OrderExpiryScheduler→ CAS: RESERVED → 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으로 무시. 재시도 안전.CouponServicecommerce-api/.../domain/coupon/reserveCoupon()CouponServiceconfirmCouponUsed()CouponServicerestoreCouponByAction()CouponPendingActionServicecommerce-api/.../domain/coupon/saveConfirm()/saveRestore()CouponActionRelay+CouponActionProcessorcommerce-api/.../batch/relay()/process()CouponActionCleanupSchedulercommerce-api/.../batch/cleanup()대안 비교 및 선택 근거
restoreCoupon()복구결정 4 — Relay + Processor 분리 (AOP 자기호출 방지)
의도: Outbox Relay와 쿠폰 확정 Relay 구현 시,
@Scheduled메서드 안에서@Transactional메서드를 호출하는 구조가 자연스럽게 도출된다. 단일 클래스에서 "스케줄링 + 트랜잭션 처리"를 담당하면 코드가 간결하나, Spring AOP의 프록시 기반 동작 방식으로 인해 같은 클래스 내 메서드 호출은 프록시를 우회하여@Transactional이 무시된다. 이는 런타임에서야 드러나는 버그이며 테스트에서 발견하기도 어렵다. **"TX 경계를 구조적으로 보장"**하기 위해 Relay(스케줄링 담당)와 Processor(TX 처리 담당)를 별도 빈으로 분리한다.이 패턴이 3쌍 반복된다:
OutboxEventRelayOutboxEventProcessorCouponActionRelayCouponActionProcessorCouponIssueConsumerCouponIssueProcessor트레이드오프: 클래스 수 증가(3쌍 = 6클래스). 대신 TX 경계가 명확하고 각 Processor를 독립 테스트 가능.
대안 비교 및 선택 근거
@Scheduled+@Transactional같은 클래스@Lazy자기 주입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회 연속 실패한 원인"을 운영자가 확인한 후 조치하는 것이 더 안전하다.
DEAD 이벤트는 자동 복구하지 않는다. 운영자가 모니터링에서
outbox.events.dead메트릭을 감지하여 수동 조치한다.OutboxEventProcessorcommerce-api/.../batch/publishAndMark()OutboxEventModelcommerce-api/.../domain/outbox/retryCount,nextRetryAt,status(PENDING/PUBLISHED/DEAD)OutboxEventRelaycommerce-api/.../batch/relay()WHERE status = 'PENDING' AND nextRetryAt <= now대안 비교 및 선택 근거
결정 7 — Consumer 예외 분류 + DLQ
의도: 모든 예외를 동일하게 처리하면, JSON 파싱 실패처럼 100번 재시도해도 실패하는 **"고칠 수 없는 메시지"**가 재시도 루프에 걸려 정상 메시지까지 처리가 차단된다(Head-of-Line Blocking). 반면 DB 커넥션 일시 장애는 수 초 후 재시도하면 성공할 수 있다. **"재시도해서 나아질 수 있는가?"**를 기준으로 예외를 두 계층으로 분리하고, 재시도 불가 메시지는 DLQ로 즉시 격리하여 정상 소비 흐름을 보호한다. Session 6에서 PG 예외를 분류한 패턴(SocketTimeout → 재시도 금지, ConnectException → 재시도 허용)과 동일한 판단 기준을 적용한다.
NonRetryableEventExceptioncommerce-streamer/.../domain/event/RetryableEventExceptioncommerce-streamer/.../domain/event/KafkaConsumerConfigcommerce-streamer/.../infrastructure/kafka/KafkaTopicConfigcommerce-api/.../infrastructure/kafka/대안 비교 및 선택 근거
결정 8 — version 기반 순서 역전 방어
의도: Kafka는 같은 파티션 내에서 순서를 보장하지만, Consumer 배치 집계나 재처리(rebalance) 상황에서는 순서가 뒤집힐 수 있다. 좋아요 이벤트처럼 스냅샷 방식(
like_count=5→like_count=6)으로 upsert하는 경우, 이전 이벤트가 나중에 도착하면 최신 값을 구버전으로 덮어쓰는 문제가 발생한다.updated_at타임스탬프 비교는 고부하 시 동일 마이크로초 충돌이나 서버 간 NTP 동기화 불완전으로 비교가 무의미해질 수 있다. **"시간은 거짓말할 수 있지만, 정수 시퀀스는 거짓말하지 않는다"**는 원칙에 따라 정수 version을 순서 판단 기준으로 채택한다.order_count += 1)like_count += 1/like_count -= 1)view_count += 1)CatalogEventProcessorcommerce-streamer/.../interfaces/consumer/process()ON DUPLICATE KEY UPDATE like_count = like_count + 1OrderEventProcessorcommerce-streamer/.../interfaces/consumer/process()ProductMetricsJpaRepositorycommerce-streamer/.../infrastructure/metrics/incrementLikeCount(),decrementLikeCount()대안 비교 및 선택 근거
결정 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 배치가 실행되도록 구성한다.05:00 → 06:00 의존성: LikeCountReconciliation이 products.like_count를 정확하게 맞춘 후, MetricsReconciliation이 product_metrics를 보정해야 한다. 순서가 바뀌면 잘못된 값으로 보정.
LikeCountReconciliationSchedulercommerce-api/.../batch/reconcileLikeCounts()@Scheduled(cron="0 0 5 * * *")MetricsReconciliationSchedulercommerce-streamer/.../batch/reconcileMetrics()@Scheduled(cron="0 0 6 * * *")CouponRemainingSynccommerce-api/.../batch/syncRemainingFromDb()@Scheduled(fixedDelay=3600000)EventCleanupSchedulercommerce-streamer/.../batch/cleanupEventHandled(),cleanupEventLog()OutboxCleanupSchedulercommerce-api/.../batch/cleanupOutbox()대안 비교 및 선택 근거
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의 핵심이다.
세 가지 관점을 동시에 만족시켜야 한다:
Redis와 DB의 역할 분리 — 성능 계층 vs 정확성 계층:
Redis DECR는 쿠폰 정합성을 보장하는 수단이 아니다. 정확성의 최종 보장은 DB CAS가 담당한다. Redis는 1,000건 중 ~900건을 1ms 이내에 거절하여 Kafka·Consumer·DB 부하를 줄이는 **게이트키퍼(성능 계층)**이다. Redis가 장애로 동작하지 않아도 DB CAS가 101장째를 차단하므로 오버이슈는 발생하지 않는다.
4-Layer 멱등성 — 각 레이어가 없으면 어떻게 깨지는가:
Redis 장애 시 동작: Redis DECR 실패 → Kafka 발행 허용 (게이트키퍼 무력화) → 모든 요청이 Consumer에 도달 → DB CAS가 최종 방어. 매시간
CouponRemainingSync배치가 Redis ↔ DB 정합성 보정.왜 Outbox 패턴을 사용하지 않았는가 — Command와 Event의 차이
주문 생성(Step 2)에서는 Outbox를 사용하고, 선착순 쿠폰(Step 3)에서는 사용하지 않는다. 이 차이는 Kafka 메시지의 의미론적 성격 차이에서 온다.
Outbox가 선착순 쿠폰에 적합하지 않은 이유:
CouponIssueFacadecommerce-api/.../application/coupon/requestRushIssue()CouponIssueConsumercommerce-streamer/.../interfaces/consumer/listen()CouponIssueProcessorcommerce-streamer/.../interfaces/consumer/process()@Transactional: event_handled + CAS + user_coupon + issue_result 원자적CouponRemainingSynccommerce-api/.../batch/syncRemainingFromDb()대안 비교 및 선택 근거
사용자 경험 (1,000명 동시 응답)
멱등성 레이어
전략 총합 비교
결정 10 — 핫키 · 핫파티션 대응 전략
의도: 선착순 쿠폰은 동일 시점에 동일 리소스(하나의 couponId)로 트래픽이 집중되는 구조이다. 이로 인해 Redis 핫키(단일 키에 1,000+ ops/sec 집중)와 Kafka 핫파티션(단일 파티션에 모든 메시지 집중)이 발생한다. 두 문제는 성격이 다르며 대응 전략도 구분해야 한다.
문제 1 — Redis 핫키
선착순 쿠폰 요청 시
coupon:{couponId}:remaining키에 1,000 VU가 동시에 DECR를 수행한다.확장 시 서브키 분산 전략:
단일 키를 N개 서브키로 분할하여 Redis Cluster의 여러 샤드에 분산한다.
문제 2 — Kafka 핫파티션
catalog-eventsproductIdorder-eventsorderIdcoupon-issue-requestscouponIdcoupon-issue-requests의 단일 파티션은 의도적 선택이다. 100장 규모에서 단일 Consumer의 처리량(초당 수백 건)으로 충분하므로 병목이 아니다.
확장 시 파티셔닝 전략:
10,000장 규모로 확장 시 couponId 기반 파티셔닝으로 전환한다.
catalog-events 인기 상품 편중 대응:
KafkaTopicConfigcommerce-api/.../infrastructure/kafka/partitions(1)/partitions(3)CouponIssueFacadecommerce-api/.../application/coupon/requestRushIssue()—kafkaTemplate.send(topic, couponId, ...)LikeEventHandlercommerce-api/.../application/event/handleProductLiked()—kafkaTemplate.send(topic, productId, ...)CouponRemainingSynccommerce-api/.../batch/syncRemainingFromDb()EventMetricscommerce-api/.../infrastructure/monitoring/incrementRedisFallback()—coupon.redis.decr.fallback대안 비교 및 선택 근거
Redis 핫키 대안
Kafka 핫파티션 대안 (coupon-issue-requests)
k6 부하 테스트 결과
테스트 방법
인프라: 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 — ApplicationEvent
event-like-throughput: 100 VU가 3분간 좋아요 toggle(등록/취소) 반복.@Async + AFTER_COMMIT이벤트 분리 후 like_count 갱신이 정상 작동하는지 확인.event-order-isolation: 30 VU가 2분간 주문 생성/취소 반복. 리스너에서 예외가 발생해도 주문 TX에 영향 없이 **성공률 100%**를 유지하는지 확인.Step 2 — Kafka Pipeline
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장 정확 발급 검증:
VU 단계별 Producer 검증:
핫파티션 분석
coupon:{id}:remaining)partitionKey=couponId)기능 정합성 종합
총평
부하 테스트는 성능 벤치마크로서는 환경 한계(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 발행 추가CouponService—reserveCoupon(),confirmCouponUsed(),restoreCoupon()CAS 메서드 추가UserCouponModel—status필드 추가 (AVAILABLE, RESERVED, USED)CouponModel—maxQuantity,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 경계 분리):
OrderCreatedEvent,OrderCancelledEvent,OrderExpiredEvent,ProductLikedEvent,ProductUnlikedEvent,ProductViewedEventOrderEventHandler,LikeEventHandler,ProductViewEventHandlerCouponPendingActionModel,CouponPendingActionService,CouponActionRelay,CouponActionProcessor,CouponActionCleanupSchedulerCouponPendingActionType,CouponPendingActionStatus,UserCouponStatus신규 — Step 2 (Outbox + Kafka 파이프라인):
OutboxEventRelay+OutboxEventProcessorCatalogEventConsumer+CatalogEventProcessor,OrderEventConsumer+OrderEventProcessorProductMetricsModel— product_metrics 엔티티 (ON DUPLICATE KEY UPDATE 증분 집계)LikeCountReconciliationScheduler,MetricsReconciliationScheduler신규 — Step 3 (선착순 쿠폰):
CouponIssueFacade(Thin Producer, DB TX 없음)CouponIssueConsumer+CouponIssueProcessorCouponIssueResultModel— 발급 결과 추적 (ISSUED / REJECTED)CouponIssueRequestMessage— Kafka 메시지 DTOCouponRemainingSync— Redis ↔ DB 매시간 동기화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)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 컬럼 추가주요 컴포넌트 & 핵심 메서드
OrderFacadecommerce-api/.../application/order/createDirectOrder()— 쿠폰 RESERVED + 재고 hold + Outbox INSERT + Event 발행CouponServicecommerce-api/.../domain/coupon/reserveCoupon(),confirmCouponUsed(),restoreCouponByAction()OrderEventHandlercommerce-api/.../application/event/handleOrderCreated(),handleOrderCancelled(),handleOrderExpired()— AFTER_COMMIT + @asyncLikeEventHandlercommerce-api/.../application/event/handleProductLiked(),handleProductUnliked()— AFTER_COMMIT + @asyncOutboxEventRelaycommerce-api/.../batch/relay()— @scheduled(5초), PENDING 조회OutboxEventProcessorcommerce-api/.../batch/publishAndMark()— Kafka send + PUBLISHED, 5회 실패 시 DEADCouponActionRelaycommerce-api/.../batch/relay()— @scheduled(3초)CouponActionProcessorcommerce-api/.../batch/process()— confirmCouponUsed() CASCouponIssueFacadecommerce-api/.../application/coupon/requestRushIssue()— Redis SETNX → DECR → Kafka send → 200CouponIssueProcessorcommerce-streamer/.../interfaces/consumer/process()— event_handled + CAS + user_coupon + issue_resultCatalogEventProcessorcommerce-streamer/.../interfaces/consumer/process()— ON DUPLICATE KEY UPDATE 증분 집계OrderEventProcessorcommerce-streamer/.../interfaces/consumer/process()— sales_count 증분CouponRemainingSynccommerce-api/.../batch/syncRemainingFromDb()— 매시간 동기화EventMetricscommerce-api/.../infrastructure/monitoring/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_actionsOutbox 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선착순 쿠폰 (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✅ Checklist
Step 0 — 공통 인프라
Step 1 — ApplicationEvent 경계 분리
Step 2 — Outbox + Kafka 파이프라인
version 기반ON DUPLICATE KEY UPDATE upsert단일배치: 판매량 집계)Step 3 — 선착순 쿠폰
202200) + issue-result 조회Step Ops — 운영 모니터링
Grafana 대시보드Prometheus 설정 + PromQL (대시보드 JSON 미포함)검증
🔍 리뷰 포인트
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코드 위치:
CouponIssueFacade.requestRushIssue()— Redis SETNX(L44) → DECR(L49) → Kafka send(L55)CouponIssueProcessor.process()— event_handled(L52) → issue_result(L58) → DB CAS(L65)이렇게 선택한 이유: 각 레이어를 빼면 깨지는 시나리오가 존재합니다.
인지하고 있는 트레이드오프: 4-Layer로 인해 구현 복잡도가 증가합니다. MVP에서 L1(Redis) + L4(DB CAS) 2겹만으로도 대부분의 시나리오를 커버할 수 있으며, L2~L3은 Kafka at-least-once 재전달이나 Consumer 재처리 같은 인프라 레벨 장애에 대한 방어입니다.
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코드 위치:
OutboxEventRelay.relay()—@Scheduled(fixedDelay = 5000)OutboxEventProcessor.publishAndMark()— Kafka 발행 + Exponential Backoff(3^n초) + 5회 DEAD이렇게 선택한 이유:
인지하고 있는 트레이드오프: 5초는 "실시간"은 아니지만, 대부분의 커머스 시나리오에서 허용 가능한 지연이라고 판단했습니다. 다만 주문 직후 product_metrics 조회 시 최대 5초간 반영 지연이 존재합니다. 궁극적으로는 CDC(Change Data Capture)로 전환하면 polling 없이 준실시간 전파가 가능할 것으로 생각합니다.