- Published on
Kafka Saga에서 보상 누락·중복 방지 구현법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 Saga를 Kafka로 오케스트레이션하거나 코레오그래피로 구성하면, 결국 가장 자주 터지는 장애는 두 가지입니다.
- 보상이 누락되어 데이터가 영구히 불일치 상태로 남는다
- 보상이 중복 실행되어 재고가 두 번 복구되거나 환불이 중복 처리된다
Kafka는 기본적으로 at-least-once 전달을 전제로 운영되는 경우가 많고(정확히 한 번은 조건이 까다롭습니다), 컨슈머 재시작·리밸런스·네트워크 지연·프로듀서 재시도 같은 현실적인 이벤트가 겹치면 “정상 흐름”만 가정한 Saga는 쉽게 깨집니다.
이 글에서는 Kafka Saga에서 보상 누락과 보상 중복을 구조적으로 방지하기 위한 구현 패턴을, 데이터 모델과 코드 중심으로 정리합니다. Saga 자체의 중복 실행/보상 버그 사례는 이전 글인 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결도 함께 참고하면 좋습니다.
문제 정의: “보상”은 왜 누락되거나 중복될까
보상 누락의 대표 원인
- 커맨드 처리 성공 후 이벤트 발행 실패
- DB 업데이트는 커밋됐는데 Kafka publish가 실패하면, 다음 단계가 진행되지 않거나 실패 이벤트가 전달되지 않아 보상 트리거가 사라집니다.
- 소비는 했는데 오프셋 커밋 실패
- 컨슈머가 메시지를 처리한 뒤 커밋이 실패하면 같은 메시지를 다시 받아 동일 단계가 두 번 실행될 수 있고, 그 결과 보상도 “필요 이상”으로 발생하거나, 반대로 특정 상태 전이가 꼬여 보상이 누락되기도 합니다.
- 사가 오케스트레이터 상태 유실
- 오케스트레이터가 인메모리로만 상태를 들고 있거나, 상태 저장이 이벤트 처리와 원자적으로 묶여 있지 않으면 재시작 시 누락이 발생합니다.
- 순서 역전(out-of-order)과 지연
- 동일 키 파티셔닝을 보장하지 않거나, 서로 다른 토픽/파티션을 섞어 쓰면 “실패 이벤트가 성공 이벤트보다 먼저 도착” 같은 비정상 순서가 생길 수 있습니다.
보상 중복의 대표 원인
- Kafka의
at-least-once로 인한 재전달 - 프로듀서 재시도로 인한 중복 이벤트 발행
- 컨슈머 리밸런스 중 동일 레코드 재처리
- 보상 API 자체가 멱등하지 않음
정리하면, Kafka Saga의 핵심은 “메시지는 중복될 수 있고, 순서가 엇갈릴 수 있으며, 일부는 지연되거나 유실된 것처럼 보인다”를 전제로 설계하는 것입니다.
목표: 보상 누락·중복을 동시에 막는 4가지 축
실전에서 가장 효과적인 조합은 아래 4가지를 함께 적용하는 것입니다.
- Outbox 패턴으로 “DB 커밋과 이벤트 발행의 원자성” 확보
- Inbox(또는 Processed Message) 테이블로 “컨슈머 멱등 처리” 확보
- Saga 상태머신(state machine) 으로 “보상 트리거 조건”을 명시적으로 관리
- 보상 커맨드/보상 API 멱등키(idempotency key) 로 “외부 사이드이펙트 중복” 차단
이 4개가 합쳐지면,
- 이벤트가 중복되어도 상태 전이가 한 번만 일어나고
- 보상 커맨드가 중복되어도 실제 보상은 한 번만 실행되며
- 이벤트 발행이 실패해도 Outbox 재시도로 결국 발행되어
- 보상 누락이 구조적으로 줄어듭니다.
1) Outbox로 “보상 트리거 이벤트” 누락 방지
가장 흔한 누락은 “DB 업데이트 성공 + Kafka publish 실패” 조합입니다. 이를 막는 정석이 Transactional Outbox 입니다.
테이블 예시
-- 비즈니스 테이블 예: 주문
create table orders (
order_id varchar(50) primary key,
status varchar(30) not null,
updated_at timestamptz not null default now()
);
-- Outbox 테이블
create table outbox_events (
id bigserial primary key,
aggregate_type varchar(50) not null,
aggregate_id varchar(50) not null,
event_type varchar(100) not null,
payload jsonb not null,
saga_id varchar(50),
step varchar(50),
created_at timestamptz not null default now(),
published_at timestamptz,
publish_attempts int not null default 0
);
create index idx_outbox_unpublished on outbox_events(published_at) where published_at is null;
주문 상태 변경과 이벤트 기록을 “같은 트랜잭션”으로
// TypeScript 예시 (의사코드)
async function markOrderFailedAndEnqueueEvent(db, { orderId, sagaId, reason }) {
await db.tx(async (tx) => {
await tx.query(
`update orders set status = $1, updated_at = now() where order_id = $2`,
['FAILED', orderId]
);
await tx.query(
`insert into outbox_events(aggregate_type, aggregate_id, event_type, payload, saga_id, step)
values ($1, $2, $3, $4::jsonb, $5, $6)`,
['Order', orderId, 'OrderFailed', JSON.stringify({ orderId, reason }), sagaId, 'ORDER']
);
});
}
이제 Kafka publish가 실패해도 Outbox 레코드는 남습니다. 별도 퍼블리셔 워커가 published_at is null 인 이벤트를 재시도하여 결국 발행합니다.
Outbox 퍼블리셔(폴링 방식) 예시
async function outboxPublisherLoop(db, kafkaProducer) {
while (true) {
const rows = await db.query(
`select id, event_type, payload, saga_id, step
from outbox_events
where published_at is null
order by id
limit 100`
);
for (const r of rows) {
try {
await kafkaProducer.send({
topic: 'saga-events',
messages: [
{
key: r.saga_id ?? String(r.id),
value: JSON.stringify({
eventType: r.event_type,
payload: r.payload,
sagaId: r.saga_id,
step: r.step,
outboxId: r.id
})
}
]
});
await db.query(
`update outbox_events
set published_at = now(), publish_attempts = publish_attempts + 1
where id = $1`,
[r.id]
);
} catch (e) {
await db.query(
`update outbox_events
set publish_attempts = publish_attempts + 1
where id = $1`,
[r.id]
);
}
}
await new Promise((r) => setTimeout(r, 300));
}
}
핵심은 “이벤트 발행 성공 여부”가 아니라 “DB에 Outbox로 남겼는가”입니다. 보상 트리거 이벤트가 사라지지 않게 만드는 1차 방어선입니다.
2) Inbox로 컨슈머 멱등 처리: 중복 이벤트로 인한 보상 중복 방지
Outbox를 해도 Kafka는 중복 전달될 수 있습니다. 따라서 컨슈머는 반드시 멱등 이어야 합니다.
가장 단순한 방식은 “처리한 메시지 ID를 저장하고, 이미 처리했으면 스킵”입니다. 보통 messageId 는 다음 중 하나로 잡습니다.
- 프로듀서가 생성한
eventId(UUID) - Outbox의
id(위 예시의outboxId)
Inbox 테이블 예시
create table inbox_processed (
consumer_group varchar(200) not null,
message_id varchar(100) not null,
processed_at timestamptz not null default now(),
primary key (consumer_group, message_id)
);
컨슈머 처리 로직 예시
async function handleSagaEvent(db, consumerGroup, msg) {
const parsed = JSON.parse(msg.value.toString());
const messageId = String(parsed.outboxId ?? parsed.eventId);
await db.tx(async (tx) => {
// 1) 이미 처리했는지 먼저 기록 시도
try {
await tx.query(
`insert into inbox_processed(consumer_group, message_id)
values ($1, $2)`,
[consumerGroup, messageId]
);
} catch (e) {
// PK 충돌이면 이미 처리한 메시지
return;
}
// 2) 여기부터는 “정확히 한 번 처리” 구간
await applyBusinessLogicAndMaybeCompensate(tx, parsed);
});
}
이 패턴은 오프셋 커밋 실패로 동일 메시지가 다시 들어와도, DB PK로 중복을 차단합니다.
3) Saga 상태머신으로 “보상 조건”을 데이터로 고정
보상 누락은 종종 “코드의 if 분기”에서 발생합니다. 예를 들어 특정 단계 실패 이벤트가 오면 보상해야 하는데, 오케스트레이터가 재시작하면서 “이미 보상했는지”를 잊어버리는 경우입니다.
따라서 오케스트레이터(또는 각 서비스)가 사가의 진행 단계를 명시적 상태머신으로 저장해야 합니다.
Saga 인스턴스 테이블 예시
create table saga_instances (
saga_id varchar(50) primary key,
state varchar(30) not null,
current_step varchar(50) not null,
last_event_id varchar(100),
updated_at timestamptz not null default now()
);
create table saga_steps (
saga_id varchar(50) not null,
step varchar(50) not null,
status varchar(30) not null,
updated_at timestamptz not null default now(),
primary key (saga_id, step)
);
status 는 예를 들어 PENDING, DONE, COMPENSATING, COMPENSATED, FAILED 처럼 잡습니다.
상태 전이 규칙(예시)
- 결제 성공
PaymentAuthorized가 오면PAYMENT = DONE - 재고 예약 성공
InventoryReserved가 오면INVENTORY = DONE - 배송 생성 실패
ShipmentCreateFailed가 오면- 아직
INVENTORY = DONE이면INVENTORY보상으로 전이 - 아직
PAYMENT = DONE이면PAYMENT보상으로 전이
- 아직
중요 포인트는 “이 이벤트가 왔을 때 무엇을 보상할지”가 코드의 즉흥 분기가 아니라, 저장된 단계 상태를 기반으로 결정된다는 점입니다.
오케스트레이터의 보상 커맨드 발행(Outbox 활용)
async function onShipmentFailed(tx, { sagaId, reason }) {
// 현재까지 완료된 step 조회
const steps = await tx.query(
`select step, status from saga_steps where saga_id = $1`,
[sagaId]
);
const done = new Set(steps.rows.filter(r => r.status === 'DONE').map(r => r.step));
// 보상은 역순으로
if (done.has('INVENTORY')) {
await tx.query(
`update saga_steps set status = 'COMPENSATING', updated_at = now()
where saga_id = $1 and step = 'INVENTORY' and status = 'DONE'`,
[sagaId]
);
await tx.query(
`insert into outbox_events(aggregate_type, aggregate_id, event_type, payload, saga_id, step)
values ('Saga', $1, 'CompensateInventory', $2::jsonb, $1, 'INVENTORY')`,
[sagaId, JSON.stringify({ sagaId, reason, idempotencyKey: `comp-inv-${sagaId}` })]
);
}
if (done.has('PAYMENT')) {
await tx.query(
`update saga_steps set status = 'COMPENSATING', updated_at = now()
where saga_id = $1 and step = 'PAYMENT' and status = 'DONE'`,
[sagaId]
);
await tx.query(
`insert into outbox_events(aggregate_type, aggregate_id, event_type, payload, saga_id, step)
values ('Saga', $1, 'CompensatePayment', $2::jsonb, $1, 'PAYMENT')`,
[sagaId, JSON.stringify({ sagaId, reason, idempotencyKey: `comp-pay-${sagaId}` })]
);
}
}
여기서도 핵심은 “보상 커맨드 발행”을 Outbox에 넣어, 오케스트레이터 재시작이나 Kafka publish 실패에도 보상 커맨드가 결국 나가게 하는 것입니다.
4) 보상 실행 자체를 멱등하게: Idempotency Key + 유니크 제약
컨슈머 Inbox로도 막히지 않는 중복이 있습니다.
- 서로 다른 메시지가 “같은 보상”을 유발하는 경우(예: 타임아웃 기반 리트라이, 운영자 재처리)
- 보상 서비스가 여러 인스턴스로 떠 있고, 네트워크 오류로 호출 재시도가 발생하는 경우
따라서 “보상 API”는 반드시 멱등이어야 합니다.
보상 실행 기록 테이블
create table compensation_executions (
idempotency_key varchar(120) primary key,
saga_id varchar(50) not null,
step varchar(50) not null,
status varchar(30) not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
보상 핸들러 예시
async function compensateInventory(db, { sagaId, idempotencyKey }) {
await db.tx(async (tx) => {
// 1) 멱등키 선점
const inserted = await tx.query(
`insert into compensation_executions(idempotency_key, saga_id, step, status)
values ($1, $2, 'INVENTORY', 'STARTED')
on conflict (idempotency_key) do nothing`,
[idempotencyKey, sagaId]
);
if (inserted.rowCount === 0) {
// 이미 실행(또는 실행 중)
return;
}
// 2) 실제 보상 로직 (재고 복구 등)
await tx.query(
`update inventory_reservations
set status = 'CANCELLED'
where saga_id = $1 and status = 'RESERVED'`,
[sagaId]
);
// 3) 완료 마킹
await tx.query(
`update compensation_executions
set status = 'DONE', updated_at = now()
where idempotency_key = $1`,
[idempotencyKey]
);
});
}
이 방식은 “보상 커맨드가 중복으로 10번 들어와도” 실제 재고 복구는 한 번만 수행하도록 강제합니다.
토픽/키 설계로 순서 역전 리스크 줄이기
보상 누락·중복의 직접 원인은 아니지만, 순서가 꼬이면 상태머신이 복잡해지고 예외 케이스가 늘어납니다.
- 동일 Saga 단위로는 같은 파티션에 들어가도록
key = sagaId를 강제 - 이벤트 타입이 여러 토픽으로 흩어져 있다면, 오케스트레이터 관점에서 “최종적으로 합쳐 처리”할 때 순서 보장이 깨질 수 있음
실무 팁은 다음 중 하나입니다.
- 사가 이벤트를 단일 토픽(예:
saga-events)에 모으고key = sagaId - 또는 단계별 토픽을 쓰더라도, “사가 상태 업데이트”는 반드시 Inbox+상태머신으로 방어
운영 관점: 누락을 ‘0’으로 만들기 위한 감시 지표
구현이 끝나도 운영에서 누락이 생기면 빨리 감지해야 합니다.
saga_instances에서 특정 상태가 일정 시간 이상 정체된 건수- 예:
COMPENSATING상태가 10분 이상 유지
- 예:
- Outbox 미발행 적체량
published_at is nullcount
- Inbox 중복 스킵 비율
- 갑자기 증가하면 리밸런스/커밋 실패/프로듀서 재시도 폭증 신호
- 보상 멱등키 충돌(이미 실행됨) 건수
- 설계상 정상일 수 있으나, 급증하면 상위 단계에서 중복 트리거가 발생 중
Kafka Saga는 애플리케이션 레벨에서 “중복을 받아들이는 설계”가 필수입니다. 이런 장애로 컨슈머가 반복 재시작하면 쿠버네티스에서 CrashLoopBackOff 로 이어지는 경우도 흔하니, 장애 대응 관점에서는 Kubernetes CrashLoopBackOff 원인 12가지와 진단도 함께 보면 연결해서 원인 파악이 빨라집니다.
체크리스트: 보상 누락·중복 방지 최소 요건
- 이벤트 발행은 Outbox로 전환했는가
- 컨슈머는 Inbox(Processed)로 멱등 처리하는가
- Saga 진행/단계 상태가 DB에 영속화되는가
- 보상 커맨드 발행도 Outbox로 처리하는가
- 보상 실행은 멱등키 + 유니크 제약으로 중복을 차단하는가
-
key = sagaId로 파티셔닝하여 순서 역전 가능성을 줄였는가
마무리
Kafka Saga에서 보상 누락·중복은 “예외 상황”이 아니라 “기본적으로 발생하는 현상”에 가깝습니다. 따라서 해결책도 특정 버그 패치가 아니라,
- Outbox로 발행 누락을 구조적으로 제거하고
- Inbox와 멱등키로 중복 실행을 흡수하며
- 상태머신으로 보상 조건을 데이터로 고정
하는 방식으로 접근해야 합니다.
위 4가지 축을 적용하면, Kafka가 at-least-once 로 동작하더라도 보상은 누락되지 않고, 중복되어도 안전하게 무력화할 수 있습니다.