- Published on
Kafka+Debezium으로 Saga 분산트랜잭션 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 분산트랜잭션을 구현하려고 2PC를 떠올리면, 곧바로 가용성·지연·락 경합·운영 복잡도라는 벽을 만납니다. 현실적으로는 로컬 트랜잭션 + 이벤트 기반 동기화 + 보상 트랜잭션으로 일관성을 맞추는 Saga가 주류입니다.
문제는 “이벤트를 어떻게 정확히 한 번처럼(effectively-once) 발행하고, 서비스 간 상태를 어떻게 재처리/중복에 강하게 만들 것인가”입니다. 여기서 Debezium CDC와 Kafka를 결합하면, 흔히 말하는 Transactional Outbox를 인프라 레벨에서 안정적으로 구현할 수 있습니다.
이 글은 다음을 목표로 합니다.
- Debezium CDC로 Outbox 이벤트를 Kafka로 안전하게 흘려보내기
- Kafka 토픽 설계와 키(파티셔닝)로 순서 보장하기
- Saga 오케스트레이션/코레오그래피 중 무엇을 선택할지 기준 잡기
- 멱등성·중복처리·보상 누락 관측까지 운영 관점으로 마무리하기
관련해서 멱등성과 중복처리 패턴은 아래 글을 함께 보면 이해가 더 빨라집니다.
왜 Kafka+Debezium인가: Outbox의 “발행 원자성” 문제
Saga에서 핵심은 각 서비스가 로컬 DB 트랜잭션으로 상태를 변경한 뒤, 그 사실을 이벤트로 외부에 알리는 것입니다.
여기서 가장 흔한 실패 시나리오는 이겁니다.
- DB 커밋 성공
- Kafka publish 실패(네트워크/브로커/타임아웃)
- 외부 서비스는 이벤트를 못 받아 상태가 영원히 불일치
이를 피하려고 Outbox 테이블에 이벤트를 함께 저장하고, 별도 퍼블리셔가 Outbox를 폴링해 Kafka로 보내는 패턴을 씁니다. 그런데 폴링 퍼블리셔는 또 다른 운영 포인트가 됩니다.
- 폴링 주기와 지연
- 배치 처리 중복
- 퍼블리셔 장애 시 재시작 처리
- DB 부하(지속적인 쿼리)
Debezium은 여기서 Outbox 테이블 변경을 트랜잭션 로그 기반(CDC) 으로 캡처해 Kafka로 스트리밍합니다. 즉,
- 애플리케이션은 DB 트랜잭션 안에서
도메인 변경 + outbox insert만 하면 끝 - Debezium이 커밋된 변경만 Kafka로 전달
결과적으로 “DB에 기록된 사실”과 “이벤트 발행”의 간극을 크게 줄입니다.
전체 아키텍처: Outbox + Debezium + Kafka + Saga
구성 요소를 역할별로 나누면 아래와 같습니다.
1) 서비스 로컬 트랜잭션
- 주문 서비스: 주문 생성, 상태 변경
- 결제 서비스: 결제 승인/거절
- 재고 서비스: 재고 예약/차감
각 서비스는 자신의 DB에만 트랜잭션을 걸고, 외부 호출은 이벤트로 대체합니다.
2) Outbox 테이블
- 도메인 변경과 함께 이벤트를 같은 트랜잭션으로 저장
3) Debezium CDC
- Outbox 테이블의 insert를 캡처해서 Kafka 토픽으로 발행
4) Kafka
- 서비스 간 이벤트 버스
- 파티션 키로 엔티티 단위 순서 보장
5) Saga Coordinator(선택)
- 오케스트레이션 방식에서 중앙 조정자 역할
Outbox 스키마 설계: 이벤트는 “데이터”다
Outbox를 단순히 “메시지 큐 대용”으로 보면 나중에 운영에서 터집니다. 최소한 아래 필드는 권장합니다.
id: outbox row id(단조 증가 PK)aggregate_type: 예:orderaggregate_id: 예:orderIdevent_type: 예:OrderCreated,PaymentApprovedpayload: JSONheaders: trace id, idempotency key 등created_at
예시(PostgreSQL):
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_events_agg_idx
ON outbox_events (aggregate_type, aggregate_id, id);
aggregate_id는 Kafka 메시지 키로 쓰기 좋습니다. 같은 주문(orderId)의 이벤트는 같은 파티션으로 가게 만들어 순서 보장을 얻습니다.
도메인 로직에서 Outbox 쓰기: “같은 트랜잭션”이 핵심
주문 생성 시나리오를 예로 들면, 주문 row insert와 outbox insert를 같은 트랜잭션으로 묶습니다.
BEGIN;
INSERT INTO orders (id, user_id, total_amount, status)
VALUES (:order_id, :user_id, :amount, 'PENDING');
INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, headers)
VALUES (
'order',
:order_id,
'OrderCreated',
jsonb_build_object(
'orderId', :order_id,
'userId', :user_id,
'amount', :amount
),
jsonb_build_object(
'traceId', :trace_id,
'idempotencyKey', :idem_key
)
);
COMMIT;
여기까지가 애플리케이션의 책임입니다. Kafka publish는 코드에서 직접 하지 않습니다.
Debezium Outbox 라우팅: 토픽과 키를 올바르게 만들기
Debezium은 DB 변경을 Kafka로 내보내는데, Outbox 패턴에서는 “테이블 변경 이벤트”가 아니라 “도메인 이벤트”를 만들고 싶습니다. 이를 위해 흔히 Outbox Event Router SMT(Single Message Transform)를 사용합니다.
Debezium 커넥터 설정 예시(개념):
{
"name": "orders-postgres-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "debezium",
"database.dbname": "orders",
"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.route.topic.replacement": "domain.events.${routedByValue}",
"transforms.outbox.route.by.field": "aggregate_type"
}
}
주의할 점:
aggregate_id를 Kafka key로 설정해 파티션 일관성을 확보aggregate_type이나event_type기반으로 토픽을 나누되, 지나친 토픽 쪼개기는 운영 복잡도를 올림- payload는 “현재 상태 스냅샷”보다 “의미 있는 사건” 중심으로 설계
Saga 스타일 선택: 오케스트레이션 vs 코레오그래피
Kafka로 이벤트를 흘리기 시작하면, Saga는 보통 두 가지로 나뉩니다.
오케스트레이션(중앙 조정자)
- Coordinator 서비스가 상태 머신을 가지고 다음 커맨드를 발행
- 흐름이 명확하고 디버깅이 쉬움
- Coordinator가 병목/단일 장애점이 될 수 있어 HA 설계 필요
코레오그래피(이벤트에 반응)
- 각 서비스가 이벤트를 구독하고 다음 이벤트를 발행
- 중앙이 없어 느슨하게 결합
- 흐름이 분산되어 추적이 어려움(관측/표준화가 중요)
실무 기준으로는 다음이 유용합니다.
- 비즈니스 흐름이 길고 예외 케이스가 많다: 오케스트레이션이 유리
- 팀/서비스가 많고 독립 배포가 중요하다: 코레오그래피가 유리
다만 어떤 방식을 택하든, 아래 운영 이슈는 동일하게 등장합니다.
- 중복 이벤트
- 재처리(consumer restart)
- 부분 실패 후 보상
- 보상 누락 탐지
보상 누락 관측은 아래 글이 좋은 확장 읽을거리입니다.
예시 시나리오: 주문-결제-재고 Saga
목표
- 주문 생성 후 결제 승인, 재고 예약까지 성공하면 주문을
CONFIRMED - 결제 실패면 주문
CANCELLED - 재고 예약 실패면 결제 취소(보상) 후 주문
CANCELLED
이벤트 흐름(코레오그래피 예)
- Order Service
OrderCreated발행
- Payment Service
OrderCreated수신- 결제 승인 시
PaymentApproved, 실패 시PaymentRejected
- Inventory Service
PaymentApproved수신- 재고 예약 성공 시
InventoryReserved, 실패 시InventoryReserveFailed
- Order Service
InventoryReserved수신 후 주문CONFIRMEDPaymentRejected또는InventoryReserveFailed수신 후 주문CANCELLED
- Payment Service(보상)
InventoryReserveFailed수신 시 결제 취소 실행 후PaymentCancelled
컨슈머 멱등성: “같은 이벤트를 두 번 처리해도” 안전해야 한다
Kafka는 적어도 한 번(at-least-once) 처리 구성이 흔합니다. 즉, 컨슈머 장애/리밸런스/재시도에서 같은 이벤트가 재전달될 수 있습니다.
따라서 각 서비스는 이벤트 처리 시 다음 중 하나(또는 조합)를 반드시 가져야 합니다.
- 이벤트
id기반 dedup 테이블 - 상태 전이 조건 체크(이미 처리된 상태면 no-op)
- 외부 API 호출에 idempotency key 적용
예시: 결제 서비스가 OrderCreated를 처리할 때 이벤트 중복을 방지하는 테이블.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
컨슈머 처리 로직(의사 코드):
onMessage(event):
begin tx
if exists(processed_events where event_id = event.id):
commit; return
handleBusiness(event) // 결제 승인 시도 등
insert processed_events(event.id)
insert outbox_events(...) // PaymentApproved 같은 후속 이벤트
commit
중요 포인트는 여기서도 동일합니다.
- “이벤트 처리 결과(상태 변경)”와 “후속 이벤트(outbox)”를 같은 트랜잭션으로 묶기
- 중복은 정상 상황으로 가정하고 설계하기
멱등성과 중복처리는 실제로 가장 많이 터지는 지점이라, 패턴을 더 깊게 보려면 아래 글을 권합니다.
Kafka 토픽/파티션 설계: 순서 보장은 “키”에서 나온다
Saga에서 자주 착각하는 부분이 “Kafka가 순서를 보장한다”입니다. 정확히는 같은 파티션 안에서만 순서를 보장합니다.
권장 규칙:
- 주문 단위 흐름이면 key를
orderId로 고정 - 결제 단위 흐름이면 key를
paymentId로 고정하되, 주문과 교차 연관이 크면orderId를 우선 - 토픽은 지나치게 세분화하지 말고, 이벤트 타입은 메시지 필드로 구분
예: domain.events.order 토픽에 OrderCreated, OrderCancelled, OrderConfirmed를 함께 두고, key는 orderId로 통일.
보상 트랜잭션 설계: “되돌리기”가 아니라 “상태를 전진”시키기
보상은 단순 롤백이 아니라, 이미 외부로 나간 효과를 상쇄하는 새로운 비즈니스 행위입니다.
- 재고 예약 실패 시 결제 취소는
CancelPayment라는 커맨드/이벤트로 모델링 - 주문 취소도
OrderCancelled라는 사건으로 남겨야 함
보상 이벤트도 일반 이벤트와 동일하게:
- 멱등해야 하고
- 재시도 가능해야 하고
- 관측 가능해야 합니다
특히 결제 취소는 외부 PG 연동이 들어가면 “취소 요청 성공”과 “실제 취소 완료”의 시점이 달라질 수 있어, 상태 머신을 더 촘촘히 잡는 게 안전합니다.
예: CANCEL_REQUESTED → CANCELLED → CANCEL_FAILED
운영에서 자주 터지는 이슈와 방어선
1) 스키마 변경과 이벤트 호환성
payload JSON에 필드 추가/변경이 잦다면, 컨슈머가 깨지지 않도록 다음을 지킵니다.
- 필드 추가는 허용, 필드 삭제/의미 변경은 신중
event_type버전(v1,v2)을 분리하거나 payload에schemaVersion포함
2) Debezium 커넥터 장애/재시작
Debezium은 오프셋을 저장하고 재시작 시 이어서 읽습니다. 하지만 다음을 점검해야 합니다.
- Kafka Connect 클러스터 HA
- 커넥터 태스크 재시작 정책
- DB replication slot 관리(PostgreSQL 기준)
3) 보상 누락/정체 탐지
Saga는 “언젠가 끝나야” 합니다. 특정 주문이 PENDING에 오래 머무르면 알람을 울려야 합니다.
- 주문 상태별 체류 시간 SLO
OrderCreated대비OrderConfirmed비율- 보상 이벤트(
PaymentCancelled) 미도착 주문 수
이 부분은 OTel 트레이싱과 Kafka lag, 비즈니스 지표를 함께 엮어야 실효성이 나옵니다.
테스트 전략: 단위 테스트보다 “흐름” 테스트가 중요
Saga는 단위 테스트만으로는 빈틈이 생깁니다. 최소한 아래 레벨을 권장합니다.
- 컨슈머 재처리 테스트: 같은 이벤트를 2번 넣었을 때 결과가 동일한지
- 부분 실패 테스트: 결제 성공 후 재고 실패 시 보상이 수행되는지
- 역순/지연 테스트:
PaymentApproved가 늦게 도착했을 때 상태 전이가 안전한지
Kafka 기반의 통합 테스트에서는 토픽을 실제로 띄우거나(테스트 컨테이너), 최소한 메시지 핸들러를 “재전달 가능한 형태”로 구성하는 것이 좋습니다.
정리: 구현 체크리스트
Kafka+Debezium으로 Saga를 구현할 때, 성공 확률을 올리는 체크리스트는 아래와 같습니다.
- 로컬 트랜잭션 안에서
도메인 변경 + outbox insert를 반드시 함께 처리 - Debezium Outbox 라우팅으로 Kafka key를
aggregate_id에 고정 - 컨슈머는 dedup 또는 상태 전이 조건으로 멱등성 확보
- 보상은 롤백이 아니라 “전진하는 상태 머신”으로 모델링
- 보상 누락/정체를 관측 지표로 만들고 알람까지 연결
이 조합은 “분산트랜잭션을 없애는” 것이 아니라, 일관성의 비용을 이벤트와 상태 머신으로 명시화합니다. 그 대가로 확장성과 장애 격리, 운영 유연성을 얻습니다. 이를 팀이 받아들일 수 있게 만드는 마지막 퍼즐은 결국 멱등성, 관측, 그리고 명확한 상태 모델입니다.