Transactional Outbox 패턴을 직접 구현하며 학습한 예제 레포입니다.
- order-app (
:8080) — 주문 생성 + Outbox 기록 + Polling Relay (생산자) - stock-app (
:8081) — 주문 이벤트 소비 + 재고 차감 (멱등 소비자)
주문을 저장하고 Kafka로 이벤트를 발행하는 흔한 코드:
orderRepository.save(order); // 1) DB 트랜잭션
kafkaTemplate.send("order-created", event); // 2) 외부 시스템 호출이 둘은 하나의 원자적 단위로 묶이지 않는다(dual-write). 그래서:
| 장애 시점 | 결과 |
|---|---|
| 1 성공, 2 실패 | 주문은 있는데 이벤트 유실 → 재고가 안 깎임 |
| 2 성공, 1 롤백 | 이벤트는 나갔는데 주문 없음 → 유령 메시지 |
→ DB 상태 변경과 "발행하겠다는 의도"를 같은 트랜잭션에 함께 커밋해야 한다. 그게 Outbox다.
[order-app]
POST /orders
│ @Transactional ─────────────────────────────┐
▼ │ 하나의 트랜잭션
orders 테이블 INSERT │ (원자적 커밋)
│ ApplicationEventPublisher.publishEvent │
▼ (동기 @EventListener) │
outbox 테이블 INSERT (status=PENDING) ───────────────┘
│
│ @Scheduled OutboxRelay (Polling)
▼ PENDING 조회 → 발행 → ack 확인 → PUBLISHED 마킹
Kafka("order-created", key=orderNumber, value=payload, header=eventId)
│
▼
[stock-app]
@KafkaListener
│ @Transactional
▼
processed_event 에 eventId 있나? ──── 있음 → skip (멱등)
│ 없음
▼
재고 차감(UPDATE) + processed_event INSERT (같은 트랜잭션)
핵심 포인트 2가지:
- 원자적 Outbox 기록 —
OrderService.createOrder안에서 일반@EventListener(동기)가 같은 트랜잭션으로 outbox에 INSERT한다. 주문과 outbox는 함께 커밋/롤백되므로 dual-write가 사라진다. (OutboxService)@TransactionalEventListener(AFTER_COMMIT)을 쓰면 커밋 후 실행되어 원자성이 깨지므로 일부러 일반@EventListener를 사용. - 멱등 소비 — Relay는 발행 실패 시 행을 PENDING으로 남겨 재시도하므로 전송 보장은 at-least-once다.
따라서 소비자는
eventId기반 inbox 테이블(processed_event, unique 제약)로 중복을 걸러 재고를 정확히 한 번만 차감한다. (StockService)
레이어드 아키텍처로 구성했고, Kafka 연동만 infrastructure 로 분리했다.
controller → REST 엔드포인트 (presentation)
service → 비즈니스 로직 + 트랜잭션 경계 (OrderService / OutboxService / OutboxRelay / StockService)
repository → Spring Data JPA
domain → JPA 엔티티 · 도메인 이벤트
infrastructure
└ kafka → KafkaTemplate Producer / @KafkaListener Consumer
order-app—controller→service→repository/domain, 발행은infrastructure.kafka.OrderProducerstock-app—infrastructure.kafka.StockKafkaConsumer→service→repository/domain
# 1) Kafka 기동 (KRaft, Zookeeper 불필요)
docker-compose up -d
# 2) 두 앱 실행 (각각 별도 터미널)
cd order-app && ./gradlew bootRun
cd stock-app && ./gradlew bootRun
# 3) 주문 생성
curl -X POST localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"productId": 1}'확인 포인트:
- order-app 로그 / H2 콘솔(
localhost:8080/h2-console, jdbc:jdbc:h2:mem:orders):outbox행이PENDING→ (최대 2초 뒤 relay 동작) →PUBLISHED로 전이 - stock-app 로그 / H2 콘솔(
localhost:8081/h2-console, jdbc:jdbc:h2:mem:stock):stock의quantity가 100 → 99,processed_event에 1건 기록
수동 데모 없이도 핵심 동작을 EmbeddedKafka로 자동 검증한다.
cd order-app && ./gradlew test
cd stock-app && ./gradlew testorder-app- 주문 생성 시 outbox에 PENDING 행이 같은 트랜잭션으로 기록됨
- relay 발행 성공 시 payload·eventId가 Kafka로 나가고 행이 PUBLISHED로 전이됨
- 발행 실패 시 행이 PENDING으로 남아 재시도 가능함
stock-app- 정상 이벤트 1건 → 재고 1 감소 + 처리 기록 1건
- 동일
eventId2건이 와도 재고는 한 번만 감소(멱등성)
- Java 21, Spring Boot 3.3.x, Gradle (Wrapper)
- Spring Data JPA, H2 (in-memory)
- Spring for Apache Kafka (+ spring-kafka-test EmbeddedKafka)
- Layered 아키텍처 (controller / service / repository / domain, Kafka 연동은 infrastructure)