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 수신까지는 보장하지 않는다.

서비스는 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 + CDC | DB 커밋 로그에서 변경 포착. 주기적 테이블 조회 없이 낮은 지연. 업무 이벤트 스키마 유지 | 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: outboxtype컬럼 값OrderCreated를 Kafka 메시지 헤더type으로 보낸다. 소비자는 이 헤더로 이벤트 종류를 구분한다.
5. 선택 기준
- 업무 의미가 있는 이벤트가 필요하면 Outbox, 행의 현재 상태 복제가 목적이면 직접 CDC를 검토한다.
- Outbox라면 지연 허용·이벤트량·운영 역량으로 전달자를 고른다. 작은 규모·단순 운영은 폴링, 낮은 지연·큰 규모·CDC 기반 보유는 CDC.
- 순서가 필수라면 aggregate별 키, 커밋 순서, 전달자 병렬성, 소비 재시도까지 함께 검증한다. Outbox만 쓴다고 끝단 처리 순서까지 보장되지는 않는다.
- 중복 허용 범위를 정한다. 두 전달 방식 모두 장애 때 중복이 생길 수 있다. 이벤트 ID, 소비 멱등성, 재처리 절차가 필요하다.
참고: Transactional Outbox 패턴 · Polling Publisher · Debezium Outbox Event Router · Debezium PostgreSQL · Netflix TechBlog — DBLog