- Published on
Kafka SAGA 보상 트랜잭션 설계 실전 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 각자 DB를 가진 구조에서, 분산 트랜잭션을 2PC로 묶는 대신 SAGA로 “결과적 일관성”을 달성하는 경우가 많습니다. 문제는 SAGA의 핵심인 보상 트랜잭션(Compensation) 이 생각보다 자주 누락되거나, 중복 실행되거나, 순서가 꼬여 장애로 이어진다는 점입니다.
Kafka를 이벤트 버스로 쓰는 순간, 우리는 다음을 전제로 설계해야 합니다.
- 메시지는 중복될 수 있다(프로듀서 재시도, 컨슈머 리밸런스, 네트워크 이슈)
- 메시지는 지연되거나 순서가 바뀐 것처럼 보일 수 있다(파티션/키 설계, 재처리)
- 서비스는 언제든 부분 실패한다(DB는 커밋됐는데 이벤트 발행 실패 등)
이 글은 Kafka 기반 SAGA에서 보상 트랜잭션을 “운영 가능한 수준”으로 만드는 7가지 패턴을 실전 관점에서 정리합니다. 보상 누락을 디버깅하는 관점은 아래 글도 함께 보면 연결이 잘 됩니다.
전제: Kafka SAGA에서 보상이 어려운 이유
보상 트랜잭션은 단순히 “반대로 한 번 더”가 아닙니다. 현실에서는 다음이 복합적으로 얽힙니다.
- 비가역 작업: 외부 결제 승인, 쿠폰 소진, 재고 예약이 실제 출고로 전환 등
- 시간 의존성: 취소 가능 시간이 지났거나, 환불 수수료 정책이 바뀜
- 다단계 누적 효과: A 성공 후 B 성공 후 C 실패 시, A와 B를 어떤 순서로 어떻게 되돌릴지
- 관측 불가능성: 어떤 단계까지 진행됐는지, 이벤트가 유실됐는지 즉시 알기 어려움
따라서 보상 설계는 “정확히 한 번” 환상을 버리고, 중복 실행 가능하고 부분 실패를 재시도 가능하게 만들며, 상태를 외부화해야 합니다.
패턴 1) 보상은 “역연산”이 아니라 “상태 전이”로 모델링
가장 흔한 실패는 보상을 cancelX() 같은 역연산 호출로만 생각하는 것입니다. 실전에서는 보상도 하나의 비즈니스 상태 전이로 보고, 도메인 상태를 명확히 둬야 합니다.
예: 주문 SAGA에서 결제 단계
- 결제 승인 성공:
PAYMENT_AUTHORIZED - 보상(승인 취소) 성공:
PAYMENT_VOIDED - 보상 불가(이미 매입됨):
PAYMENT_CAPTURED상태에서REFUND_REQUESTED로 전이
즉 “보상=반대”가 아니라 “실패 시 도달해야 하는 목표 상태”를 정의합니다.
이벤트 스키마 예시
{
"eventId": "9b5f...",
"sagaId": "order-20250224-0001",
"type": "PaymentAuthorized",
"step": "PAYMENT",
"version": 1,
"occurredAt": "2026-02-24T10:20:30Z",
"payload": {
"orderId": "O-1004",
"paymentId": "P-7788",
"amount": 12000,
"currency": "KRW"
}
}
보상 이벤트도 별도 타입으로 분리합니다.
{
"eventId": "a1c2...",
"sagaId": "order-20250224-0001",
"type": "PaymentVoided",
"step": "PAYMENT",
"version": 1,
"occurredAt": "2026-02-24T10:21:05Z",
"payload": {
"orderId": "O-1004",
"paymentId": "P-7788",
"reason": "INVENTORY_RESERVE_FAILED"
}
}
핵심은 “취소 API 호출”이 아니라 상태 전이 이벤트로 기록하고, 이후 시스템은 그 상태를 기준으로 동작하게 만드는 것입니다.
패턴 2) 보상도 멱등하게: sagaId + step 단위의 실행 로그
Kafka 컨슈머는 재처리될 수 있습니다. 보상은 특히 “중복 실행”의 피해가 큽니다(이중 환불, 재고 2번 복원 등). 따라서 보상 핸들러는 반드시 멱등해야 합니다.
가장 단순하고 강력한 방법은 실행 로그 테이블을 두고 sagaId와 step을 유니크 키로 잡는 것입니다.
DB 테이블 예시(PostgreSQL)
create table saga_step_log (
saga_id varchar(100) not null,
step varchar(50) not null,
action varchar(20) not null, -- EXECUTE or COMPENSATE
status varchar(20) not null, -- STARTED, SUCCEEDED, FAILED
updated_at timestamptz not null default now(),
primary key (saga_id, step, action)
);
보상 처리 의사코드
def compensate_payment(event):
saga_id = event["sagaId"]
step = "PAYMENT"
inserted = try_insert_log(saga_id, step, "COMPENSATE", "STARTED")
if not inserted:
# 이미 보상이 처리됐거나 처리 중
return
try:
void_or_refund(event)
update_log(saga_id, step, "COMPENSATE", "SUCCEEDED")
publish("PaymentVoided", saga_id)
except Exception:
update_log(saga_id, step, "COMPENSATE", "FAILED")
raise
이 패턴은 보상 누락뿐 아니라 “보상은 됐는데 이벤트가 다시 들어와 또 보상” 같은 문제도 막습니다.
패턴 3) Outbox로 “DB 커밋과 이벤트 발행”을 원자적으로 묶기
보상 설계가 아무리 좋아도, DB 커밋 성공 후 Kafka 발행 실패가 발생하면 SAGA는 쉽게 꼬입니다. 특히 보상 이벤트가 발행되지 않으면 다음 서비스가 영원히 보상을 모릅니다.
정석은 Transactional Outbox 입니다.
- 비즈니스 데이터 변경과 함께 outbox 테이블에 이벤트를 기록
- 별도 퍼블리셔가 outbox를 폴링하거나 CDC로 Kafka에 발행
Outbox 테이블 예시
create table outbox (
id bigserial primary key,
aggregate_id varchar(100) not null,
event_type varchar(100) not null,
payload jsonb not null,
status varchar(20) not null default 'NEW',
created_at timestamptz not null default now()
);
create index on outbox(status, created_at);
한 트랜잭션에서 함께 처리
begin;
update payments
set status = 'VOIDED'
where payment_id = 'P-7788';
insert into outbox(aggregate_id, event_type, payload)
values (
'P-7788',
'PaymentVoided',
'{"sagaId":"order-20250224-0001","paymentId":"P-7788"}'::jsonb
);
commit;
이 패턴이 없으면, “보상 로직은 수행됐는데 이벤트가 안 나가서 다운스트림이 모르는” 종류의 장애가 반복됩니다.
패턴 4) 보상 커맨드와 보상 완료 이벤트를 분리(비동기 보상)
보상을 동기 호출로 끝내려 하면, 장애 시 SAGA 오케스트레이터가 타임아웃과 재시도를 반복하면서 더 큰 중복을 만들 수 있습니다.
실전에서는 보상을 다음처럼 두 단계로 나누는 방식이 안정적입니다.
CompensationRequested(커맨드 성격)CompensationCompleted또는CompensationFailed(결과 이벤트)
토픽 설계 예시
saga.compensation.commandssaga.compensation.events
커맨드 메시지 예시
{
"eventId": "c9d0...",
"sagaId": "order-20250224-0001",
"type": "CompensatePaymentRequested",
"step": "PAYMENT",
"payload": {
"orderId": "O-1004",
"paymentId": "P-7788",
"reason": "SHIPMENT_CREATE_FAILED"
}
}
보상 워커는 커맨드를 받아 멱등 처리 후 결과 이벤트를 발행합니다. 오케스트레이터는 결과 이벤트를 보고 다음 보상 단계로 진행합니다.
이렇게 하면 보상 처리 지연이 있어도 시스템은 “진행 중”으로 관측 가능하고, 재시도는 커맨드 단에서 통제할 수 있습니다.
패턴 5) 보상 순서 고정: “역순 보상 스택”과 단계별 의존성 선언
SAGA 단계가 A -> B -> C로 진행됐다가 C에서 실패하면 보상은 일반적으로 B -> A 역순입니다. 하지만 실제로는 다음 같은 예외가 존재합니다.
- B 보상 전에 A 보상을 먼저 해야 외부 제약을 만족
- 어떤 단계는 보상이 아니라 “대체 작업”이 필요
따라서 오케스트레이터는 단계 정의에 보상 순서와 의존성을 명시해야 합니다.
단계 정의 예시(YAML)
saga: order
steps:
- name: INVENTORY_RESERVE
compensate: INVENTORY_RELEASE
- name: PAYMENT_AUTHORIZE
compensate: PAYMENT_VOID
dependsOn:
- INVENTORY_RESERVE
- name: SHIPMENT_CREATE
compensate: SHIPMENT_CANCEL
dependsOn:
- PAYMENT_AUTHORIZE
compensationPolicy:
order: REVERSE
오케스트레이터는 성공한 step들을 스택으로 쌓고, 실패 시 스택을 pop하며 보상 커맨드를 발행합니다. “성공 여부”는 이벤트 기반으로 확정하고, 단순 메모리 상태로만 들고 있지 않게(재시작 대비) 해야 합니다.
패턴 6) 재처리 안전장치: 파티션 키를 sagaId로 고정
Kafka에서 순서 보장은 “토픽 전체”가 아니라 “파티션 단위”입니다. SAGA의 동일 인스턴스(sagaId) 이벤트가 서로 다른 파티션으로 흩어지면 오케스트레이터가 상태를 꼬이게 읽을 수 있습니다.
권장:
- 오케스트레이터가 읽는 SAGA 관련 이벤트 토픽은 키를
sagaId로 고정 - 동일
sagaId는 동일 파티션으로 들어가므로 상대적 순서가 보장됨
프로듀서 예시(Java, key 지정)
ProducerRecord<String, String> record =
new ProducerRecord<>("saga.events", sagaId, payloadJson);
producer.send(record);
추가로, 컨슈머 측에서는 다음을 함께 권장합니다.
- 오케스트레이터 컨슈머 그룹은 상태 저장소(DB) 업데이트와 오프셋 커밋 순서를 일관되게
- 가능하면 “처리 완료 후 커밋” 전략을 택하되, 멱등 로그와 함께 사용
이 패턴은 보상 자체의 로직이 아니라 “보상이 실행되는 순서”를 안정화하는 기반입니다.
패턴 7) 보상 실패를 숨기지 말고 “사람이 처리 가능한 큐”로 격리
보상은 실패할 수 있습니다. 특히 외부 결제/배송/파트너 API는 일시 장애나 정책 변경으로 실패합니다. 이때 무한 재시도는 비용과 장애를 키웁니다.
실전에서는 보상 실패를 다음처럼 계층화합니다.
- 재시도로 해결되는 실패: 네트워크 타임아웃, 일시 5xx
- 재시도로 해결 안 되는 실패: 이미 출고됨, 취소 불가 상태, 금액 불일치
따라서 보상 워커는 실패를 분류하고, 일정 횟수 초과 또는 비재시도 유형이면 격리 토픽(DLQ) 또는 운영 큐로 보냅니다.
DLQ 메시지 예시
{
"originalEvent": {"type":"CompensatePaymentRequested","sagaId":"order-20250224-0001"},
"errorType": "NON_RETRYABLE",
"errorMessage": "Payment already captured; refund required",
"failedAt": "2026-02-24T10:22:10Z"
}
그리고 운영자가 처리할 수 있도록 다음을 같이 남깁니다.
sagaId,orderId,step, 외부 시스템 트랜잭션 키- 현재 도메인 상태 스냅샷
- 추천 조치(환불 전환, 고객 안내 등)
운영 관점에서 장애를 줄이는 방법론은 인프라/런타임 이슈 대응과도 결이 비슷합니다. 예를 들어 장애가 반복될 때는 원인 격리와 재현이 핵심인데, 아래 글의 접근 방식(원인 분류, 재시도/복구 경계 설정)이 SAGA 운영에도 유용합니다.
실전 체크리스트: “보상 가능성”을 설계 단계에서 검증하기
아래 질문에 답이 안 되면, 구현을 시작하기 전에 이벤트/상태 모델을 다시 잡는 게 좋습니다.
- 각 step의 성공 조건은 무엇이며, 성공을 어떤 이벤트로 확정하는가?
- 각 step의 보상 목표 상태는 무엇인가? 단순 역연산이 불가능하면 대체 상태는?
- 보상은 멱등한가?
sagaId + step + action기준으로 중복을 막는가? DB 커밋과이벤트 발행사이의 간극을 outbox로 메웠는가?- 동일
sagaId이벤트가 같은 파티션으로 가도록 키를 고정했는가? - 보상 실패를 재시도/비재시도로 분류하고 DLQ로 격리하는가?
- 오케스트레이터 재시작 시에도 SAGA 진행 상태를 복원할 수 있는가?
예시 아키텍처: Kafka 오케스트레이션 SAGA 흐름(요약)
OrderOrchestrator- 입력:
OrderCreated, 각 서비스의StepCompleted/StepFailed - 출력:
ReserveInventoryRequested,AuthorizePaymentRequested, 실패 시CompensateXRequested - 상태 저장:
saga_instance테이블에 현재 step과 성공한 step 스택 저장
- 입력:
InventoryService,PaymentService,ShipmentService- 커맨드 컨슘
- 로컬 트랜잭션 수행
- outbox로 결과 이벤트 발행
- 보상 커맨드 컨슘 시 멱등 로그 기반으로 보상 수행
이 구조에서 핵심은 “오케스트레이터가 모든 것을 믿지 않는다”는 것입니다. 각 서비스는 자신의 로컬 상태와 outbox로 사실을 남기고, 오케스트레이터는 이벤트를 근거로 상태를 전이합니다.
마무리
Kafka 기반 SAGA에서 보상 트랜잭션은 기능 구현보다 실패를 전제로 한 설계가 더 중요합니다. 이번 7패턴을 한 문장으로 요약하면 다음입니다.
- 보상은 상태 전이로 모델링하고
- 멱등성과 outbox로 중복/유실을 견디며
- 파티션 키와 역순 스택으로 순서를 안정화하고
- 실패는 DLQ로 격리해 사람이 처리 가능한 형태로 만든다
보상 누락/중복/순서 꼬임이 실제로 발생했을 때의 디버깅 관점은 아래 글에서 더 깊게 다룹니다.