- Published on
MSA 사가 패턴 - Outbox+Debezium+Kafka 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 가장 자주 부딪히는 문제는 분산 트랜잭션 없이도 서비스 간 상태를 일관되게 맞추는 것입니다. 주문 서비스가 결제를 요청하고, 결제 서비스가 승인하면, 재고 서비스가 차감하고, 배송이 생성되는 흐름은 단일 DB 트랜잭션으로 묶을 수 없습니다. 이때 흔히 선택하는 접근이 사가(Saga) 패턴이고, 그중에서도 이벤트 기반 사가를 안정적으로 굴리기 위한 표준 조합이 Outbox + Debezium(CDC) + Kafka입니다.
이 글은 “이론 설명”보다 실제로 장애 없이 운영 가능한 구현 디테일에 초점을 둡니다. 특히 다음을 다룹니다.
- Outbox 테이블 설계(이벤트 스키마, 키 전략)
- Debezium 커넥터 구성과 라우팅(SMT)
- Kafka 토픽/파티션 설계(순서 보장)
- 소비자 멱등성, 중복/재처리, DLQ
- 사가 오케스트레이션 vs 코레오그래피에서의 적용 포인트
운영 환경에서 CI/CD로 커넥터 설정을 배포할 때는 OIDC 기반으로 AWS 자격증명 없이 배포하는 패턴도 자주 함께 씁니다. 관련해서는 GitHub Actions OIDC로 AWS 키 없이 배포하기도 참고하면 좋습니다.
사가 패턴에서 Outbox가 필요한 이유
사가를 이벤트로 구현할 때 가장 흔한 실패 모드는 DB 업데이트는 성공했는데 이벤트 발행이 실패하거나, 반대로 이벤트는 발행됐는데 DB 커밋이 롤백되는 경우입니다. 즉, 아래 두 작업을 원자적으로 묶고 싶습니다.
- 로컬 트랜잭션으로 도메인 상태 변경(예:
orders테이블 업데이트) - 그 변경 사실을 다른 서비스에 알리는 이벤트 발행
애플리케이션이 DB에 커밋한 뒤 Kafka에 produce하는 방식은 네트워크/브로커 오류로 쉽게 깨집니다. 이를 해결하는 대표 해법이 Transactional Outbox입니다.
- 애플리케이션은 도메인 변경과 함께 같은 DB 트랜잭션으로 outbox 테이블에 이벤트를 적재
- Debezium이 DB WAL/binlog를 읽어 outbox insert를 감지(CDC)
- Debezium이 Kafka로 이벤트를 발행
이렇게 하면 “DB 커밋”과 “이벤트 기록”이 하나의 로컬 트랜잭션으로 묶이므로, 이벤트 유실 가능성이 크게 줄어듭니다.
전체 아키텍처 흐름
구성 요소는 단순합니다.
- Producer 서비스(예: Order Service)
- Producer DB(예: Postgres)
- Outbox 테이블(Producer DB 내부)
- Debezium Connect(카프카 커넥트 워커)
- Kafka 토픽(예:
order.events) - Consumer 서비스들(예: Payment/Inventory/Shipping)
핵심은 애플리케이션은 Kafka를 직접 모르게 만들고, DB 트랜잭션만 신뢰하도록 설계하는 것입니다.
Outbox 테이블 설계
Outbox는 단순히 “이벤트 JSON을 넣는 테이블”이 아닙니다. 운영 가능한 설계를 위해 다음을 포함하는 것을 권장합니다.
- 이벤트 고유 ID(전역 유니크)
- aggregate(주문/결제 등) 식별자와 타입
- 이벤트 타입(예:
OrderCreated) - payload(JSON)
- 발생 시각
- (선택) 헤더/메타데이터(트레이싱, 버전 등)
Postgres 예시 스키마
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB NOT NULL DEFAULT '{}'::jsonb,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_events_aggregate_idx
ON outbox_events (aggregate_type, aggregate_id, occurred_at);
키 전략(순서 보장)
Kafka에서 특정 주문(orderId)에 대한 이벤트 순서를 보장하려면 같은 키로 같은 파티션에 들어가야 합니다. 따라서 outbox 레코드에 aggregate_id를 두고, Debezium이 이를 Kafka 메시지 키로 쓰도록 구성합니다.
애플리케이션 트랜잭션: 도메인 변경과 Outbox 적재
아래는 주문 생성 시 orders에 insert하고 outbox에도 이벤트를 적재하는 흐름입니다.
Node.js(예시) 트랜잭션 코드
import { randomUUID } from 'crypto'
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function createOrder(userId: string, items: Array<{ sku: string; qty: number }>) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const orderId = randomUUID()
await client.query(
`INSERT INTO orders (id, user_id, status)
VALUES ($1, $2, 'CREATED')`,
[orderId, userId]
)
const eventId = randomUUID()
const payload = {
orderId,
userId,
items,
status: 'CREATED',
version: 1
}
await client.query(
`INSERT INTO outbox_events
(id, aggregate_type, aggregate_id, event_type, payload, headers)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`,
[
eventId,
'Order',
orderId,
'OrderCreated',
JSON.stringify(payload),
JSON.stringify({ traceId: eventId })
]
)
await client.query('COMMIT')
return { orderId }
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}
여기서 중요한 점은 애플리케이션이 Kafka 발행을 전혀 하지 않는다는 것입니다. 이벤트 발행은 Debezium이 담당합니다.
Debezium으로 Outbox를 Kafka 이벤트로 변환(CDC)
Debezium은 DB의 변경 로그를 읽어서 Kafka로 내보냅니다. Outbox 패턴에서는 보통 두 가지 방식이 있습니다.
- Outbox 테이블 변경을 “그대로” Kafka에 싣고, 소비자가 해석
- Debezium SMT로 outbox 레코드를 “이벤트 메시지 형태”로 변환
실무에서는 두 번째가 선호됩니다. 특히 Debezium의 EventRouter SMT가 Outbox에 최적화되어 있습니다.
Docker Compose 예시(개념)
services:
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
image: confluentinc/cp-kafka:7.6.0
depends_on: [zookeeper]
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
command: ["postgres", "-c", "wal_level=logical"]
connect:
image: debezium/connect:2.6
depends_on: [kafka, postgres]
environment:
BOOTSTRAP_SERVERS: kafka:9092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: connect-configs
OFFSET_STORAGE_TOPIC: connect-offsets
STATUS_STORAGE_TOPIC: connect-status
wal_level=logical은 Postgres CDC에 필요합니다.
Debezium Postgres 커넥터 설정(Outbox 라우팅)
아래 설정은 outbox 테이블을 읽어 event_type을 기준으로 토픽을 라우팅하고, aggregate_id를 Kafka key로 설정하는 전형적인 형태입니다.
주의: 본문에서 부등호 문자는 MDX 빌드 에러를 유발할 수 있어, 비교/화살표 표기를 할 때는 반드시 인라인 코드로 처리해야 합니다.
{
"name": "orders-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "postgres",
"database.password": "postgres",
"database.dbname": "postgres",
"topic.prefix": "dbserver1",
"plugin.name": "pgoutput",
"slot.name": "orders_outbox_slot",
"publication.autocreate.mode": "filtered",
"table.include.list": "public.outbox_events",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.id": "id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.table.field.event.timestamp": "occurred_at",
"transforms.outbox.route.by.field": "event_type",
"transforms.outbox.route.topic.replacement": "order.events.${routedByValue}",
"key.converter": "org.apache.kafka.connect.storage.StringConverter",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}
}
이 설정의 의미는 다음과 같습니다.
public.outbox_events만 CDC 대상으로 포함- outbox 레코드를 이벤트 메시지로 변환
- Kafka key는
aggregate_id로 설정(주문 단위 순서 보장) - 토픽은
order.events.OrderCreated처럼 이벤트 타입별로 분리(운영 전략에 따라 단일 토픽으로도 가능)
토픽 전략: 단일 vs 이벤트 타입별
- 단일 토픽(예:
order.events) 장점: 소비자 단순, ACL 단순, 파티션 전략 단순 - 타입별 토픽(예:
order.events.OrderCreated) 장점: 구독/권한 분리 쉬움, 리텐션/컴팩션 분리 가능
규모가 크지 않다면 단일 토픽을 추천합니다. 타입별 토픽은 “이벤트 종류가 늘어날수록 토픽이 폭증”할 수 있습니다.
Kafka에서 반드시 챙길 운영 포인트
1) 최소 한 번(at-least-once)과 중복 처리
Debezium+Kafka는 기본적으로 최소 한 번 전달입니다. 즉, 소비자는 중복 이벤트를 받을 수 있습니다.
따라서 소비자는 멱등해야 합니다.
- 이벤트 ID(
id)를 소비자 DB에 저장하고 “이미 처리한 이벤트면 무시” - 또는 aggregate 버전(낙관적 버전)으로 “이미 반영된 상태면 skip”
2) 소비자 멱등 테이블 예시
CREATE TABLE consumed_events (
consumer_name TEXT NOT NULL,
event_id UUID NOT NULL,
consumed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
3) 소비자 처리 코드(의사 코드)
async function handleEvent(consumerName: string, event: { id: string; type: string; payload: any }) {
await db.tx(async (tx) => {
const inserted = await tx.query(
`INSERT INTO consumed_events (consumer_name, event_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[consumerName, event.id]
)
// 이미 처리한 이벤트면 종료
if (inserted.rowCount === 0) return
// 실제 비즈니스 반영
if (event.type === 'OrderCreated') {
await tx.query(
`INSERT INTO payments (order_id, status)
VALUES ($1, 'PENDING')
ON CONFLICT (order_id) DO NOTHING`,
[event.payload.orderId]
)
}
})
}
핵심은 “이벤트 처리”와 “멱등 체크 기록”을 소비자 DB 트랜잭션으로 묶는 것입니다.
4) 파티션과 순서 보장
Kafka에서 순서 보장은 “토픽 전체”가 아니라 파티션 내부에서만 보장됩니다.
- 주문 단위로 순서가 중요하면 key를
orderId로 - 결제 단위로 순서가 중요하면 key를
paymentId로
Outbox에서 aggregate_id를 key로 쓰는 이유가 여기에 있습니다.
사가 구현: 오케스트레이션 vs 코레오그래피
Outbox+Debezium+Kafka는 “이벤트 전달의 신뢰성”을 올려줄 뿐, 사가의 형태를 강제하지는 않습니다.
코레오그래피(이벤트 주도)
- OrderCreated 이벤트를 Payment 서비스가 구독
- PaymentApproved 이벤트를 Inventory 서비스가 구독
- InventoryReserved 이벤트를 Shipping 서비스가 구독
장점: 중앙 조정자 없음, 느슨한 결합 단점: 흐름 파악/디버깅이 어려움, 보상 트랜잭션 설계가 복잡
오케스트레이션(사가 오케스트레이터)
- 오케스트레이터가 상태 머신을 가지고 각 서비스에 커맨드/이벤트를 조율
장점: 흐름 가시성, 타임아웃/재시도/보상 제어 쉬움 단점: 오케스트레이터가 병목/단일 책임 과다 가능
실무 팁은 “복잡도가 일정 수준을 넘으면 오케스트레이션이 운영 난이도를 낮춘다”입니다.
보상 트랜잭션(Compensation) 설계 체크리스트
사가에서 실패는 정상입니다. 중요한 것은 “되돌리기”가 가능한지입니다.
- 결제 승인 후 재고 확보 실패
=>결제 취소 이벤트 발행 - 재고 확보 후 배송 생성 실패
=>재고 릴리즈 이벤트 발행
보상 이벤트도 동일하게 Outbox로 발행해야 합니다. 그래야 보상 흐름 자체가 유실되지 않습니다.
Outbox 테이블 청소(보관/아카이빙)
Debezium은 outbox 레코드를 읽어 Kafka로 내보낸 뒤에도 DB에서 자동 삭제해주지 않습니다. outbox가 무한히 쌓이면 성능과 비용 문제가 됩니다.
대표 전략:
- 일정 기간(예: 7일) 지난 outbox 레코드 삭제
- 또는 파티셔닝 후 오래된 파티션 drop
삭제는 “Debezium이 읽었는지”를 어떻게 보장하느냐가 관건인데, 일반적으로는 Kafka가 내구성 저장소이므로 시간 기반 보관이 많이 쓰입니다(예: 7일이면 Debezium 장애 복구/재처리에 충분).
장애/운영에서 자주 터지는 포인트
1) Debezium 커넥터 재시작 루프
커넥터가 계속 재시작되면 로그에서 원인을 좁혀야 합니다. 시스템 전반의 재시작 루프를 추적하는 방법론은 systemd 서비스 재시작 반복 원인 추적 체크리스트의 접근이 커넥터/워커 트러블슈팅에도 그대로 적용됩니다.
2) 스키마 변경과 호환성
payload를 JSON으로 넣으면 초기 속도는 빠르지만, 이벤트 스키마 버전 관리가 필요합니다.
- payload에
version필드를 넣고 소비자가 버전별로 파싱 - 또는 Schema Registry(Avro/Protobuf) 도입
3) 중복 이벤트 폭발
- 커넥터 재배포/재시작
- 리밸런싱
- 소비자 장애 후 재처리
이런 상황에서 중복이 늘어납니다. “중복을 막는 것”보다 “중복을 허용하고 멱등하게 처리”가 정답인 경우가 많습니다.
4) 재처리(replay) 전략
운영 중 특정 기간 이벤트를 다시 흘려야 할 때가 있습니다.
- Kafka 토픽 리텐션이 충분하면 컨슈머 그룹 오프셋을 되돌려 재처리
- 리텐션이 짧으면 outbox 아카이브(또는 별도 이벤트 스토어)가 필요
재처리는 비용이 커질 수 있으니, 대량 재처리 시에는 백오프/재시도 정책을 명확히 두는 것이 좋습니다. API 호출 재시도 설계는 OpenAI API 429 RateLimit 재시도·백오프 실무의 백오프 사고방식도 참고할 만합니다.
최소 구현에서 프로덕션까지: 권장 체크리스트
- Outbox는 도메인 트랜잭션과 반드시 동일 트랜잭션으로 적재
- Kafka key를
aggregate_id로 설정해 파티션 순서 보장 - 소비자는
event_id기반 멱등 처리(소비자 DB에 기록) - 실패 이벤트는 DLQ 토픽으로 분리(또는 별도 에러 저장소)
- outbox 청소 정책(파티셔닝 또는 TTL 삭제)
- 이벤트 스키마 버전 전략 수립
- 관측성:
traceId를 headers에 넣고 로그/메트릭에 전파
마무리
Outbox+Debezium+Kafka 조합은 사가 패턴에서 가장 골치 아픈 “이벤트 유실” 문제를 구조적으로 줄여줍니다. 다만 이것만으로 정합성이 완성되지는 않습니다. 최종적으로 시스템을 안정화시키는 것은
- Kafka 파티션 키로 순서를 설계하는 것
- 소비자 멱등으로 중복을 흡수하는 것
- 보상 트랜잭션을 이벤트로 모델링하는 것
- outbox 보관/청소와 재처리 전략을 갖추는 것
입니다.
원하면 다음 단계로, 단일 토픽 전략에서 event_type을 헤더로 넣는 구성, Schema Registry(Avro/Protobuf) 적용, 커넥터를 Kubernetes에서 운영할 때의 리소스/리밸런싱 튜닝까지 확장한 구현 예시도 이어서 정리할 수 있습니다.