07 DB와 이벤트의 일관성

주문 DB 저장과 Kafka 발행은 서로 다른 시스템에 쓰는 작업(dual-write)이다. 한쪽이 성공해도 다른 쪽의 성공은 보장되지 않는다.

1. Dual-write가 깨지는 지점

DB 커밋 → [프로세스 종료 / Kafka 장애] → 이벤트 누락
Kafka 발행 → [DB 롤백]              → 존재하지 않는 주문 이벤트
  • 이 예제의 @Transactional은 DB 트랜잭션 매니저로 DB 작업만 묶는다. Kafka 트랜잭션을 같이 써도 DB 커밋과 Kafka 커밋은 원자적으로 묶이지 않는다(06 전달 보장과 중복 처리).
  • send()를 먼저 하거나 순서를 바꿔도 해결되지 않는다.
  • 업무 상태와 발행할 이벤트를 한 DB 트랜잭션에 기록하고, 별도 전달자가 Kafka로 보내야 한다.

2. Transactional Outbox: 기록의 원자성

주문 서비스 ──[한 DB 트랜잭션]──> orders 변경 + outbox_event INSERT
                                     │ 커밋 후
                                     └── 전달자 ──> Kafka ──> 소비 서비스
  • DB 커밋이 실패하면 주문과 이벤트 기록이 함께 롤백된다.
  • 커밋되면 이벤트가 outbox에 남는다. Kafka가 잠시 멈춰도 나중에 발행할 근거가 있다.
  • 전달과 소비 측 반영은 DB 커밋 뒤에 일어난다. 주문 서비스와 소비 서비스 상태는 최종적으로 일치하며, 요청 성공 직후의 Kafka 수신까지는 보장하지 않는다.

Transactional Outbox와 Debezium CDC 구조

서비스는 DB에만 쓰고(업무 테이블 + outbox), Debezium이 DB 트랜잭션 로그에서 outbox 변경을 읽어 Kafka로 보낸다. 이미지 출처: Red Hat Developer — Application modernization patterns with Apache Kafka, Debezium, and Kubernetes

  • 장점: DB와 이벤트 기록이 원자적이다. Kafka 장애 중에도 업무 DB 쓰기를 바로 멈추지 않아도 된다. 이벤트 payload·버전을 내부 테이블 스키마와 따로 설계한다.
  • 한계: 발행 지연, outbox 적체, 정리 작업이 생긴다.
    • 전달 중 장애가 나면 중복 발행될 수 있다. 고정된 eventId와 소비 측 멱등 처리가 필수다(06 전달 보장과 중복 처리).
    • 장애가 길면 outbox나 DB 로그가 쌓여 저장 공간이 모자라고 DB 쓰기에도 영향이 간다.

3. 전달 방식 비교

방식장점단점·운영 부담적합한 경우
Outbox + 폴링SQL·앱 작업자로 시작 가능. DB 복제 로그 권한/커넥터 불필요. 재시도 상태를 테이블에서 직접 관리주기만큼 지연, 반복 조회·인덱스·삭제 부하. 다중 작업자에서 잠금·순서 처리 어려움이벤트량 적고, 지연 허용. CDC 운영 기반이 없을 때
Outbox + CDCDB 커밋 로그에서 변경 포착. 주기적 테이블 조회 없이 낮은 지연. 업무 이벤트 스키마 유지Debezium/Kafka Connect·복제 슬롯·로그 보존·스냅샷·장애 복구 운영. DB별 지원/권한 필요이벤트량 많고 지연에 민감하며 CDC 운영 기반이 있을 때
업무 테이블 직접 CDC앱에 outbox 쓰기 추가 없이 기존 데이터 변경을 전달. 초기 스냅샷으로 현재 상태 동기화 가능행 변경은 업무 이벤트가 아님. 내부 스키마 노출·변경 전파·민감정보 필터링·여러 테이블 조합 부담검색 인덱스·캐시·분석 저장소에 현재 데이터 상태를 복제할 때

Outbox + 폴링의 실제 경계

  • 작업자가 미발행 행을 읽어 Kafka에 보내고, 발행 성공을 확인한 뒤 published로 표시한다.
    • 발행 후 표시 전에 죽으면 같은 행을 다시 보내 중복이 생긴다.
    • 표시부터 하고 발행하면 유실될 수 있다.
  • 다중 작업자는 FOR UPDATE SKIP LOCKED 등으로 경쟁을 줄인다. 다만 잠긴 앞 이벤트를 건너뛰면 같은 주문의 순서가 깨질 수 있다.
  • created_at이나 증가 ID 순서가 실제 DB 커밋 순서와 항상 같지는 않다.
  • 순서가 중요하면 같은 aggregate를 한 작업자가 직렬 처리하도록 설계·검증한다.
    • aggregate: 순서를 함께 관리할 업무 단위. 이 예제에서는 주문 하나.
  • 참고: Polling Publisher 패턴 · PostgreSQL SKIP LOCKED

Outbox + CDC의 실제 경계

  • PostgreSQL ex) Debezium이 WAL(트랜잭션 로그)에서 커밋된 outbox INSERT를 읽어 Kafka에 발행한다.
  • Outbox Event Router는 id를 이벤트 ID 헤더로, aggregateid를 Kafka 키로 쓸 수 있다.
    • 같은 주문 ID를 같은 파티션으로 보내야 파티션 내 순서를 활용한다.
    • 파티션 수 변경, 재발행, 소비 측 비동기 처리는 따로 고려한다.
  • 커넥터가 Kafka 발행 후 소스 오프셋을 저장하기 전에 죽으면, 재시작 때 저장된 위치부터 다시 읽어 이미 발행한 이벤트를 다시 보낸다.
  • 복제 슬롯은 처리되지 않은 WAL을 보존한다. 커넥터 장애가 길면 DB 디스크 사용량이 늘어난다. 슬롯, 커넥터 지연, Kafka 발행 오류를 감시해야 한다.
  • 초기 스냅샷이나 재스냅샷 때 테이블에 남은 outbox 행이 발행될 수 있다. 스냅샷 실행 조건과 이벤트 보존·정리 정책을 함께 정한다.
  • 참고: Debezium PostgreSQL 커넥터 · Outbox Event Router

4. 코드 ex) Kotlin + Spring Boot + PostgreSQL

orders(id, status) 테이블이 있다고 가정한다. spring-jdbc, jackson-module-kotlin, kotlin-spring을 쓴다. 업무 변경과 outbox INSERT가 같은 DB 트랜잭션에 있어야 한다.

CREATE TABLE outbox_event (
    id UUID PRIMARY KEY,
    aggregatetype TEXT NOT NULL,
    aggregateid TEXT NOT NULL,
    type TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID

@Service
class OrderService(
    private val jdbc: JdbcTemplate,
    private val json: ObjectMapper,
) {
    @Transactional
    fun createOrder(orderId: Long) {
        require(orderId > 0)
        val eventId = UUID.randomUUID()
        jdbc.update("INSERT INTO orders (id, status) VALUES (?, 'PENDING')", orderId)
        jdbc.update(
            """INSERT INTO outbox_event (id, aggregatetype, aggregateid, type, payload)
               VALUES (?, 'orders', ?, 'OrderCreated', ?::jsonb)""",
            eventId,
            orderId.toString(), // Kafka key: 같은 주문 이벤트를 같은 파티션으로
            json.writeValueAsString(mapOf("eventId" to eventId, "orderId" to orderId)),
        )
    }
}
  • 두 번째 INSERT가 실패하면 주문도 롤백된다. DB 커밋 후 서비스가 죽어도 outbox 행은 남는다.
  • 이 코드는 Kafka에 발행하지 않는다. 아래 전달자 중 하나가 필요하다.
    • 폴링: 위 스키마에 published_at 같은 완료 컬럼과 미발행 행 조회용 인덱스를 추가한다. 조회 → Kafka 발행 결과 확인 → 완료 표시 순서로 처리하고, 실패한 행은 그대로 두고 재시도한다. 정리·알림·동시 작업자 정책은 필수다.
    • CDC: Debezium PostgreSQL 커넥터에 아래 속성을 설정한다. 기본 라우팅에서 aggregatetype=orders는 outbox.event.orders 토픽으로 간다. DB 접속, 복제 슬롯, Kafka Connect 설정은 따로 필요하다. Debezium 설정
table.include.list=public.outbox_event
transforms=outbox
transforms.outbox.type=io.debezium.transforms.outbox.EventRouter
transforms.outbox.table.expand.json.payload=true
transforms.outbox.table.fields.additional.placement=type:header
value.converter=org.apache.kafka.connect.json.JsonConverter
value.converter.schemas.enable=false
  • table.expand.json.payload=true: JSON payload를 문자열로 한 번 더 감싸지 않고 JSON으로 내보낸다. 실제 수신 값 형식은 소비자 역직렬화 설정과 함께 확인한다.
  • table.fields.additional.placement=type:header: outbox type 컬럼 값 OrderCreated를 Kafka 메시지 헤더 type으로 보낸다. 소비자는 이 헤더로 이벤트 종류를 구분한다.

5. 선택 기준

  1. 업무 의미가 있는 이벤트가 필요하면 Outbox, 행의 현재 상태 복제가 목적이면 직접 CDC를 검토한다.
  2. Outbox라면 지연 허용·이벤트량·운영 역량으로 전달자를 고른다. 작은 규모·단순 운영은 폴링, 낮은 지연·큰 규모·CDC 기반 보유는 CDC.
  3. 순서가 필수라면 aggregate별 키, 커밋 순서, 전달자 병렬성, 소비 재시도까지 함께 검증한다. Outbox만 쓴다고 끝단 처리 순서까지 보장되지는 않는다.
  4. 중복 허용 범위를 정한다. 두 전달 방식 모두 장애 때 중복이 생길 수 있다. 이벤트 ID, 소비 멱등성, 재처리 절차가 필요하다.

참고: Transactional Outbox 패턴 · Polling Publisher · Debezium Outbox Event Router · Debezium PostgreSQL · Netflix TechBlog — DBLog