Library · Kotlin · Spring Boot · Outbox
bk-spring-outbox
Spring Boot용 Transactional Outbox. Kotlin-first, coroutine-native, autoconfigured. "DB에 쓰고 + 이벤트 발행"을 같은 트랜잭션 안에서 원자적으로 처리합니다. 모든 마이크로서비스 튜토리얼이 추천하지만 보일러플레이트가 끔찍해서 아무도 안 깔던 그 패턴, 가장 작은 형태로 정리했습니다. 김빌(Bill Kim)이 직접 설계하고 메인테이닝합니다.
한 줄 요약
같은 @Transactional 안에서 도메인 write + outbox.publish(...) 한 번. 백그라운드 코루틴 relay가 outbox_events 테이블을 드레인해서 Kafka(또는 직접 정의한 publisher)로 전달.
@RestController
class OrderController(
private val orders: JdbcTemplate,
private val outbox: OutboxPublisher,
) {
@PostMapping("/orders")
@Transactional // ← same TX wraps both writes
fun createOrder(@RequestBody req: CreateOrderRequest): OrderResponse {
val id = "o_${UUID.randomUUID()}"
orders.update("INSERT INTO orders (id, user_id, sku, qty) VALUES (?, ?, ?, ?)",
id, req.userId, req.sku, req.quantity)
outbox.publish( // ← persists in the SAME TX
topic = "orders",
aggregateType = "Order",
aggregateId = id,
eventType = "OrderCreated",
payload = mapper.writeValueAsBytes(OrderCreated(id, req)),
)
return OrderResponse(id, "CREATED")
}
}
커밋 후 백그라운드 relay가 outbox_events를 드레인해서 Kafka(또는 사용자 정의 publisher)로 전달합니다. 애플리케이션 관점에선 exactly-once, 브로커 관점에선 at-least-once.
왜 만들었나
"Dual write" 문제는 모든 마이크로서비스 팀의 입문 보스입니다. 주문을 저장하고 OrderCreated 이벤트를 발행하고 싶을 때, 둘 다 애플리케이션에서 (save() → producer.send()) 하면 그중 하나는 결국 실패하고 도메인 상태와 이벤트 로그가 어긋납니다. 한 시간 안에 누군가 reconciliation 잡을 짜고 있습니다.
Transactional Outbox 패턴은 이벤트를 같은 DB 트랜잭션 안에서 로컬 테이블에 기록해 이 문제를 풀어냅니다. 백그라운드 워커(relay)가 테이블을 읽어 브로커로 forward합니다.
Kotlin/Spring 생태계에는 옵션이 있지만 대부분 자바 우선(Eventuate Tram, Debezium)이거나 heavyweight 런타임/CDC 파이프라인을 요구합니다. bk-spring-outbox는 인-프로세스 가장 작은 중간 옵션 — 주입할 OutboxPublisher, JDBC 테이블, 폴링/발행하는 코루틴 워커.
주요 기능
OutboxStore→ 기본 JDBC. 시작 시outbox_events생성. H2 / PostgreSQL 자동 감지.OutboxPublisher→ 서비스에 주입.OutboxRelay→ 코루틴 기반 폴링 워커.ApplicationReadyEvent에서 시작.- Kafka publisher 옵션 모듈 —
outbox-publisher-kafkastarter 추가하면 Kafka로 forward. - bk-spring-saga와 짝으로 동작.