- Published on
Kafka 기반 Saga 보상 트랜잭션 장애 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 분산 트랜잭션을 피하려고 Saga를 선택하면, 결국 핵심은 “보상 트랜잭션이 언제, 어떻게 실패할 수 있는가”로 귀결됩니다. 특히 Kafka 기반 오케스트레이션/코레오그래피는 높은 처리량과 느슨한 결합을 제공하지만, 메시지 전달·재처리·순서·중복·상태 저장소 등 여러 층에서 보상 실패 패턴이 발생합니다.
이 글은 Kafka 기반 Saga에서 자주 터지는 보상 트랜잭션 장애를 7가지 패턴으로 분류하고, 각 패턴마다 재현 조건, 증상, 탐지 방법, 그리고 설계/운영 대응책을 제공합니다.
전제: Kafka는 기본적으로
at-least-once전달을 전제로 운영되는 경우가 많습니다. 즉 “중복은 발생한다”를 기본값으로 두고, 순서·원자성·가시성 문제를 따로 설계해야 합니다.
1) 중복 소비로 보상이 과실행되는 패턴
어떻게 발생하나
- 컨슈머가 처리 후 오프셋 커밋 전에 크래시
- 리밸런스/세션 타임아웃으로 동일 레코드 재처리
- 프로듀서 재시도, 네트워크 재전송으로 동일 이벤트가 다시 들어옴
보상 트랜잭션은 “원 트랜잭션의 반대 작업”이므로, 중복 실행되면 환불이 2번 되거나 재고가 2번 복구되는 등 치명적인 역효과가 납니다.
전형적인 증상
- 동일
sagaId또는 동일 비즈니스 키에 대해 보상 로그가 2회 이상 - 잔액/재고가 기대값을 초과해서 복구됨
대응책
- 보상 핸들러는 반드시 멱등이어야 합니다.
- 멱등 키를
sagaId + stepId로 구성하고, 처리 여부를 저장소에 기록합니다. - 이벤트에
eventId를 넣고, 소비 측에서processed_event테이블(또는 Redis set)로 중복 제거합니다.
-- 멱등 처리용 테이블 예시
create table processed_event (
event_id varchar(64) primary key,
processed_at timestamp not null
);
-- 보상 처리 전 삽입 시도
insert into processed_event(event_id, processed_at)
values (:eventId, now());
-- PK 충돌이면 이미 처리됨
// Java 의사코드: 중복이면 바로 리턴
try {
processedEventRepository.insert(eventId);
} catch (DuplicateKeyException e) {
return; // already processed
}
compensate();
2) 이벤트 순서 역전으로 잘못된 보상이 실행되는 패턴
어떻게 발생하나
- 파티션 키를 잘못 잡아 동일 Saga의 이벤트가 서로 다른 파티션으로 분산
- 리트라이/지연으로 인해
Step2Failed가Step1Succeeded보다 늦게 도착 - 여러 토픽을 조합해 상태를 만들 때, 토픽 간 순서 보장이 없음
Kafka는 파티션 내에서만 순서 보장을 제공합니다. Saga 단위의 순서를 원하면, Saga 관련 이벤트가 같은 파티션으로 가도록 키를 강제해야 합니다.
전형적인 증상
- 아직 실행되지 않은 step에 대해 보상이 먼저 실행
- 상태 머신이 불가능한 전이를 기록(예:
COMPENSATED후SUCCEEDED)
대응책
- 파티션 키를
sagaId로 고정해 동일 Saga 이벤트가 같은 파티션으로 가도록 설계 - 컨슈머에서 상태 전이 검증을 수행하고, 불가능한 전이는 격리 토픽으로 보냅니다.
enum SagaState { STARTED, STEP1_DONE, STEP2_DONE, FAILED, COMPENSATING, COMPENSATED }
void onEvent(SagaEvent e) {
SagaState current = sagaStore.get(e.sagaId());
if (!isValidTransition(current, e)) {
publish("saga-invalid-transition", e); // 격리
return;
}
applyTransition(current, e);
}
3) 보상 이벤트 유실 또는 “보상 명령 미발행” 패턴
어떻게 발생하나
- 오케스트레이터가 DB에 실패 상태를 기록했지만, Kafka로 보상 커맨드를 발행하기 전에 크래시
- 반대로 Kafka 발행은 되었지만 DB 상태 저장이 실패
즉, 상태 저장과 이벤트 발행의 원자성 문제입니다. 이 패턴은 “보상이 아예 시작되지 않는다”는 점에서 특히 위험합니다.
전형적인 증상
- Saga 상태는
FAILED인데 보상 이벤트가 토픽에 없음 - 특정 시점 이후로 보상 진행률이 멈춤
대응책
- Outbox 패턴: 로컬 트랜잭션으로 DB에 outbox 레코드를 쓰고, 별도 릴레이가 Kafka로 발행
- 발행 성공 후 outbox를
SENT로 마킹
create table outbox (
id bigserial primary key,
aggregate_id varchar(64) not null,
event_type varchar(64) not null,
payload jsonb not null,
status varchar(16) not null default 'NEW',
created_at timestamp not null default now()
);
-- 비즈니스 상태 변경 + outbox 기록을 하나의 트랜잭션으로
begin;
update saga set state='FAILED' where saga_id=:sagaId;
insert into outbox(aggregate_id, event_type, payload)
values(:sagaId, 'CompensateRequested', :payload);
commit;
# 릴레이 의사코드
rows = db.query("select * from outbox where status='NEW' order by id limit 100")
for r in rows:
kafka.produce(topic="saga-command", key=r.aggregate_id, value=r.payload)
db.execute("update outbox set status='SENT' where id=?", r.id)
4) 보상 자체가 비결정적이어서 “부분 보상”이 되는 패턴
어떻게 발생하나
- 외부 결제/배송/메일 등 비가역적 또는 시간 의존적 작업을 수행
- 보상 API가 “원 상태로 되돌리기”가 아니라 “추가 작업”이 되어버림
- 보상 시점에 원 데이터가 이미 TTL로 삭제되어 복구 불가
Saga 보상은 엄밀히 말해 “롤백”이 아니라 “상태를 수용 가능한 방향으로 되돌리는 새로운 트랜잭션”입니다. 즉, 완벽한 원복이 불가능한 경우가 많고, 그걸 설계로 흡수해야 합니다.
전형적인 증상
- 환불은 되었는데 쿠폰 복구가 안 됨
- 배송이 이미 시작되어 취소 불가
대응책
- 보상 가능성을 기준으로 step을 재설계(가역 작업을 앞단에 배치)
- 외부 시스템에는 취소/환불/무효화 같은 명시적 API를 확보
- 보상 실패 시 사람 개입이 가능한 수동 처리 큐를 설계
# 수동 처리 큐로 보낼 최소 필드 예시
manual_compensation:
- sagaId
- step
- reason
- originalRequestSnapshot
- externalReference
5) 리밸런스/장시간 처리로 인한 “보상 중복 + 상태 경합” 패턴
어떻게 발생하나
- 보상 처리 시간이 길어
max.poll.interval.ms를 초과 - 컨슈머 그룹 리밸런스가 발생해 같은 파티션을 다른 인스턴스가 이어받음
- 이전 인스턴스도 늦게 살아나 처리 결과를 기록하며 경합
전형적인 증상
- 동일 Saga의 보상 step이 서로 다른 인스턴스에서 동시에 실행
- DB에 데드락/락 경합 증가
대응책
- 긴 작업은 폴링 스레드와 분리하고, 폴링은 자주 수행
max.poll.interval.ms와session.timeout.ms를 처리 시간에 맞게 튜닝- Saga 상태 저장소 업데이트 시 낙관적 락 또는 조건부 업데이트로 경합을 제어
-- 조건부 업데이트로 상태 경합 방지
update saga
set state = 'COMPENSATING', version = version + 1
where saga_id = :sagaId
and state = 'FAILED'
and version = :expectedVersion;
-- 업데이트 0건이면 이미 다른 워커가 선점
관련해서 운영 환경에서 리소스 병목이 겹치면 장애가 증폭됩니다. 네트워크 egress 문제가 동반될 때는 GCP Cloud NAT 포트 고갈로 egress 실패 진단법 같은 체크리스트로 외부 호출 실패 원인을 분리해보는 게 좋습니다.
6) DLQ가 “무덤”이 되어 보상이 영원히 끝나지 않는 패턴
어떻게 발생하나
- 보상 핸들러 예외 발생 시 DLQ로만 보내고, 재처리/관측 체계가 없음
- DLQ 메시지에 필요한 컨텍스트(스냅샷, 원 요청)가 없어 복구 불가
- DLQ 토픽이 파티션/보존기간 설정 미흡으로 유실
전형적인 증상
- 모니터링에는 실패율이 낮게 보이는데, 실제로는 DLQ가 계속 쌓임
- 특정 Saga가
COMPENSATING에서 멈춤
대응책
- DLQ는 “최종 목적지”가 아니라 “재처리 파이프라인의 한 단계”로 취급
- DLQ 메시지에 재처리에 필요한 최소 스냅샷 포함
- DLQ 소비자(리드라이버)를 두고, 재시도 정책(지수 백오프, 최대 횟수)을 명시
{
"eventId": "...",
"sagaId": "...",
"step": "ReserveInventory",
"payload": {"orderId": "...", "qty": 2},
"error": {"type": "Timeout", "message": "..."},
"firstFailedAt": "2026-02-25T01:02:03Z",
"retryCount": 3
}
7) 관측 불가능성으로 “성공처럼 보이는 실패”가 되는 패턴
어떻게 발생하나
- 로그에는 이벤트 발행만 있고, 실제 보상 효과(잔액/재고/상태) 검증이 없음
- 트레이싱이 서비스 경계를 넘지 못해, 어디서 끊겼는지 모름
- 지표가 토픽 레벨(레코드 수)만 있고, Saga 레벨(완료율/체류시간)이 없음
전형적인 증상
- 고객 CS로 먼저 장애가 알려짐
- “Kafka에는 다 쌓였는데요?”라는 말만 남음
대응책
- Saga 단위 KPI를 정의:
saga_completed_total,saga_compensation_total,saga_stuck_gauge,saga_duration_histogram - 각 이벤트에
traceId를 넣고, 로그/트레이싱에 공통 키로 남김 - 주기적으로 “정합성 점검 잡”을 돌려,
FAILED나COMPENSATING에서 오래 머무는 Saga를 탐지
-- 오래 멈춘 saga 탐지
select saga_id, state, updated_at
from saga
where state in ('FAILED', 'COMPENSATING')
and updated_at < now() - interval '10 minutes'
order by updated_at asc
limit 100;
운영에서 이런 “보이지 않는 병목”은 인프라 리소스 고갈과 함께 나타나는 경우가 많습니다. 예를 들어 쿠버네티스 환경에서 파드가 늘어나며 네트워크 IP가 고갈되면 재시도 폭풍이 생기고, 그 결과 보상 지연이 눈덩이처럼 커집니다. 이런 경우 EKS Pod Pending - CNI IP 고갈 원인과 해결 가이드를 함께 참고하면 원인 분리에 도움이 됩니다.
Kafka 기반 Saga 보상을 안전하게 만드는 체크리스트
설계 체크
- 파티션 키를
sagaId로 통일해 순서 보장 범위를 확보했는가 - 보상 핸들러가 멱등이며, 중복 제거 저장소가 있는가
- Outbox로 상태 변경과 이벤트 발행의 원자성을 확보했는가
- 보상 불가능한 step을 앞단에 두지 않았는가(가역성 기반 step 재배치)
운영 체크
- DLQ에 재처리 루프와 알람이 있는가
COMPENSATING체류시간, 보상 성공률, stuck saga 수를 지표로 보는가- 리밸런스/폴링 설정이 처리 시간과 맞는가
마무리
Kafka 기반 Saga에서 보상 트랜잭션 장애는 “예외 케이스”가 아니라 “정상적으로 발생하는 시스템 현상”에 가깝습니다. 따라서 해결책도 특정 버그 픽스가 아니라, 중복·순서·원자성·가역성·관측성을 전제로 한 설계 습관으로 접근해야 합니다.
특히 1) 멱등성, 2) 파티셔닝 전략, 3) Outbox, 4) DLQ 재처리, 5) Saga 레벨 지표 이 다섯 가지를 갖추면, 보상 장애의 대부분은 “발생”하더라도 “회복 가능”한 형태로 바뀝니다.