Skip to content

yunhobb/spring-outbox-pattern-example

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

spring-outbox-pattern-example

Transactional Outbox 패턴을 직접 구현하며 학습한 예제 레포입니다.

  • order-app (:8080) — 주문 생성 + Outbox 기록 + Polling Relay (생산자)
  • stock-app (:8081) — 주문 이벤트 소비 + 재고 차감 (멱등 소비자)

1. 왜 Outbox 패턴인가 — dual-write 문제

주문을 저장하고 Kafka로 이벤트를 발행하는 흔한 코드:

orderRepository.save(order);        // 1) DB 트랜잭션
kafkaTemplate.send("order-created", event);  // 2) 외부 시스템 호출

이 둘은 하나의 원자적 단위로 묶이지 않는다(dual-write). 그래서:

장애 시점 결과
1 성공, 2 실패 주문은 있는데 이벤트 유실 → 재고가 안 깎임
2 성공, 1 롤백 이벤트는 나갔는데 주문 없음 → 유령 메시지

DB 상태 변경과 "발행하겠다는 의도"를 같은 트랜잭션에 함께 커밋해야 한다. 그게 Outbox다.

2. 해결 흐름

[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가지:

  1. 원자적 Outbox 기록OrderService.createOrder 안에서 일반 @EventListener(동기)가 같은 트랜잭션으로 outbox에 INSERT한다. 주문과 outbox는 함께 커밋/롤백되므로 dual-write가 사라진다. (OutboxService)

    @TransactionalEventListener(AFTER_COMMIT)을 쓰면 커밋 실행되어 원자성이 깨지므로 일부러 일반 @EventListener를 사용.

  2. 멱등 소비 — Relay는 발행 실패 시 행을 PENDING으로 남겨 재시도하므로 전송 보장은 at-least-once다. 따라서 소비자는 eventId 기반 inbox 테이블(processed_event, unique 제약)로 중복을 걸러 재고를 정확히 한 번만 차감한다. (StockService)

3. 패키지 구조 (Layered)

레이어드 아키텍처로 구성했고, Kafka 연동만 infrastructure 로 분리했다.

controller       → REST 엔드포인트 (presentation)
service          → 비즈니스 로직 + 트랜잭션 경계 (OrderService / OutboxService / OutboxRelay / StockService)
repository       → Spring Data JPA
domain           → JPA 엔티티 · 도메인 이벤트
infrastructure
  └ kafka        → KafkaTemplate Producer / @KafkaListener Consumer
  • order-appcontrollerservicerepository/domain, 발행은 infrastructure.kafka.OrderProducer
  • stock-appinfrastructure.kafka.StockKafkaConsumerservicerepository/domain

4. 실행 (수동 e2e 데모)

# 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): stockquantity가 100 → 99, processed_event에 1건 기록

5. 자동 검증 (테스트)

수동 데모 없이도 핵심 동작을 EmbeddedKafka로 자동 검증한다.

cd order-app && ./gradlew test
cd stock-app && ./gradlew test
  • order-app
    • 주문 생성 시 outbox에 PENDING 행이 같은 트랜잭션으로 기록됨
    • relay 발행 성공 시 payload·eventId가 Kafka로 나가고 행이 PUBLISHED로 전이됨
    • 발행 실패 시 행이 PENDING으로 남아 재시도 가능함
  • stock-app
    • 정상 이벤트 1건 → 재고 1 감소 + 처리 기록 1건
    • 동일 eventId 2건이 와도 재고는 한 번만 감소(멱등성)

6. 기술 스택

  • 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)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages