- Published on
MSA Saga 패턴 실패 복구 - 보상·재시도 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 분산 트랜잭션을 다루는 순간, 실패는 예외가 아니라 정상 상태입니다. 네트워크 단절, 타임아웃, 부분 성공, 중복 호출, 메시지 지연 같은 사건이 매일 발생합니다. Saga 패턴은 이런 환경에서 2PC 같은 강한 일관성 대신, 여러 로컬 트랜잭션을 연결하고 실패 시 보상(Compensation)으로 되돌리는 방식으로 현실적인 정합성을 달성합니다.
하지만 Saga를 “적용했다”와 “운영에서 실패를 복구한다”는 완전히 다른 문제입니다. 실제 장애는 보상 로직의 누락, 재시도 폭주, 중복 처리 미흡, 관측성 부족, 수동 개입 절차 부재에서 터집니다. 이 글은 Saga 실패 복구를 설계 관점과 운영 관점에서 끝까지 밀어붙이는 방법을 정리합니다.
관련해서 운영 중 로그 누락이나 지연이 생기면 장애 원인 파악이 급격히 어려워집니다. EKS에서 로그 파이프라인 이슈가 있다면 EKS에서 fluent-bit 로그 누락·지연 원인 9가지도 함께 점검해두는 것이 좋습니다.
Saga 실패 복구의 목표를 명확히 하기
Saga의 실패 복구 목표는 보통 다음 4가지를 동시에 만족시키는 것입니다.
- 정합성 회복: 부분 성공으로 남은 잔여 상태를 제거하거나 “의도한 상태”로 수렴
- 중복 안전성: 재시도, 중복 이벤트, 중복 메시지에 흔들리지 않기
- 가시성: 현재 Saga가 어디서 멈췄고, 왜 멈췄는지 즉시 알기
- 운영 가능성: 자동 복구가 안 되는 케이스에 대해 수동 재처리/롤백 Runbook 제공
특히 1번은 “완벽한 되돌림”이 아니라 “비즈니스적으로 허용 가능한 수렴”이라는 점이 중요합니다. 예를 들어 결제 승인 후 배송 요청이 실패했다면 결제 취소(보상)로 수렴시키거나, 반대로 배송을 재시도하여 주문 완료로 수렴시키는 두 선택지가 있습니다. 어느 쪽이 맞는지는 기술이 아니라 정책입니다.
오케스트레이션 vs 코레오그래피: 실패 복구 난이도 차이
오케스트레이션(Orchestration)
중앙 오케스트레이터(워크플로 엔진 또는 주문 서비스 등)가 각 단계 실행과 다음 단계 결정을 담당합니다.
- 장점: 상태가 한 곳에 모여 관측/재시도/보상이 쉽습니다.
- 단점: 오케스트레이터가 장애 지점이 될 수 있고, 설계가 무거워질 수 있습니다.
코레오그래피(Choreography)
각 서비스가 이벤트를 발행하고, 다른 서비스가 구독하여 다음 행동을 수행합니다.
- 장점: 결합도가 낮고 확장성이 좋습니다.
- 단점: 실패 지점 추적이 어렵고, 보상 경로가 복잡해지기 쉽습니다.
실무적으로 “실패 복구”를 최우선으로 두면 오케스트레이션이 유리합니다. 코레오그래피를 쓰더라도, 최소한 Saga 상태를 집계하는 뷰(Projection) 서비스나 상관관계 ID 기반 추적을 강제해야 운영이 가능합니다.
실패 유형을 분류하면 복구 전략이 보인다
Saga에서 흔한 실패를 아래처럼 분류하면 복구 설계가 선명해집니다.
1) 일시적 실패(Transient)
- 타임아웃
5xx응답- 메시지 브로커 일시 장애
복구 전략: 재시도 + 백오프 + 회로 차단
2) 영구적 실패(Permanent)
- 재고 부족
- 결제 거절
- 비즈니스 규칙 위반
복구 전략: 즉시 보상 또는 대체 흐름
3) 불확실 실패(Unknown)
- 타임아웃인데 실제로는 처리되었을 수 있음
- 응답 유실
복구 전략: 중복 제거(멱등성) + 상태 조회(Confirm) + 재시도
여기서 3번이 운영을 가장 어렵게 만듭니다. “성공인지 실패인지 모른다”가 분산 시스템의 본질이기 때문입니다.
핵심 1: 보상 트랜잭션을 ‘진짜’로 만들기
보상 트랜잭션은 단순히 cancel() API 하나 만드는 수준이 아닙니다. 다음 체크리스트를 만족해야 합니다.
- 부분 보상 가능: 이미 일부 리소스가 해제되었거나 만료된 경우에도 안전
- 멱등성: 같은 보상 요청이 여러 번 와도 결과가 동일
- 보상 불가 상태 정의: 예를 들어 배송이 이미 출고되면 결제 취소가 불가할 수 있음
- 보상 실패 처리: 보상 자체도 실패할 수 있으므로 재시도/수동 개입 경로 필요
예시: 주문 Saga 단계와 보상
ReserveInventory성공, 보상은ReleaseInventoryAuthorizePayment성공, 보상은VoidPayment또는RefundPaymentCreateShipment성공, 보상은CancelShipment(단 출고 전)
보상은 “원상복구”가 아니라 “비즈니스적으로 허용 가능한 반대 동작”입니다.
핵심 2: 멱등성과 중복 제거는 Saga 복구의 전제조건
재시도는 필수입니다. 재시도를 한다는 것은 곧 중복 호출이 발생한다는 뜻입니다.
멱등 키(Idempotency Key) 패턴
- 클라이언트 또는 오케스트레이터가
idempotencyKey를 생성 - 각 단계 API는
(idempotencyKey, stepName)조합으로 결과를 저장 - 동일 키로 재호출되면 “이전 결과”를 반환
아래는 Node.js(Express) + PostgreSQL 기준의 간단한 멱등 처리 예시입니다.
// POST /payments/authorize
import type { Request, Response } from "express";
export async function authorizePayment(req: Request, res: Response) {
const { idempotencyKey, orderId, amount } = req.body;
// 1) 이미 처리된 요청인지 확인
const existing = await db.query(
`SELECT status, payment_id
FROM idempotency_records
WHERE key = $1 AND scope = $2`,
[idempotencyKey, "PAYMENT_AUTHORIZE"]
);
if (existing.rowCount > 0) {
return res.json({
ok: true,
status: existing.rows[0].status,
paymentId: existing.rows[0].payment_id,
deduped: true,
});
}
// 2) 멱등 레코드를 먼저 "IN_PROGRESS"로 기록 (유니크 제약 필요)
await db.query(
`INSERT INTO idempotency_records(key, scope, status)
VALUES ($1, $2, $3)`,
[idempotencyKey, "PAYMENT_AUTHORIZE", "IN_PROGRESS"]
);
// 3) 실제 결제 승인 로직
const paymentId = await paymentProvider.authorize({ orderId, amount });
// 4) 결과 기록
await db.query(
`UPDATE idempotency_records
SET status = $1, payment_id = $2
WHERE key = $3 AND scope = $4`,
["AUTHORIZED", paymentId, idempotencyKey, "PAYMENT_AUTHORIZE"]
);
return res.json({ ok: true, status: "AUTHORIZED", paymentId });
}
주의할 점은 IN_PROGRESS 상태에서 프로세스가 죽을 수 있다는 것입니다. 이 경우 운영 정책으로 “일정 시간이 지난 IN_PROGRESS는 재처리 가능” 같은 TTL 규칙을 두거나, 결제사에 상태 조회(Confirm) API가 있다면 그걸로 수렴시켜야 합니다.
핵심 3: Outbox + Inbox로 메시지 기반 Saga를 안정화
메시지 브로커(Kafka, SQS, RabbitMQ 등)를 쓰는 Saga에서 가장 흔한 장애는 “DB는 커밋됐는데 이벤트 발행이 실패” 또는 그 반대입니다. 이를 막는 대표 해법이 Transactional Outbox입니다.
Outbox 기본 구조
- 로컬 트랜잭션으로 비즈니스 데이터 변경과 Outbox 레코드 insert를 함께 커밋
- 별도 퍼블리셔가 Outbox 테이블을 폴링하거나 CDC로 이벤트 발행
-- 주문 생성 시
BEGIN;
INSERT INTO orders(id, status, total_amount)
VALUES ($1, 'CREATED', $2);
INSERT INTO outbox(id, aggregate_id, event_type, payload, published_at)
VALUES ($3, $1, 'OrderCreated', $4, NULL);
COMMIT;
컨슈머 측은 Inbox(Processed Messages) 테이블을 두고 메시지 ID 기준으로 중복 처리를 막습니다.
-- 컨슈머가 메시지를 처리하기 전
INSERT INTO inbox(message_id, received_at)
VALUES ($1, NOW());
-- 유니크 제약으로 중복 메시지는 여기서 차단
이 조합이 갖춰져야 “재시도 가능한 이벤트 기반 Saga”가 됩니다.
핵심 4: 재시도는 ‘정책’이다 (백오프, 상한, 격리)
재시도는 단순 루프가 아니라 장애를 증폭시키지 않는 정책이어야 합니다.
- 지수 백오프:
1s, 2s, 4s, 8s ... - 지터(Jitter): 같은 시점에 몰리는 재시도 폭주 방지
- 최대 시도 횟수: 무한 재시도는 비용과 장애를 키움
- Dead Letter Queue(DLQ): 자동 복구 실패 건을 격리
외부 API에서 429가 자주 발생하는 환경이라면 백오프 설계가 특히 중요합니다. 원리는 동일하므로 OpenAI 429 rate_limit_exceeded 재시도·백오프 설계에서 설명한 백오프 패턴을 Saga 재시도 정책에도 그대로 적용할 수 있습니다.
핵심 5: Saga 상태 모델링과 “재구동 가능한” 오케스트레이터
오케스트레이션 Saga의 정석은 상태 머신입니다.
- Saga 인스턴스 ID(보통
orderId) - 현재 단계
- 각 단계의 실행 결과
- 보상 필요 여부
- 다음 재시도 시각
간단한 상태 머신 예시 (TypeScript)
< > 문자가 일반 텍스트로 노출되지 않도록 제네릭은 쓰지 않고, 유니온 타입으로만 예시를 구성합니다.
type Step = "RESERVE_INVENTORY" | "AUTHORIZE_PAYMENT" | "CREATE_SHIPMENT";
type SagaStatus = "RUNNING" | "COMPENSATING" | "COMPLETED" | "FAILED";
type StepResult = {
step: Step;
status: "PENDING" | "SUCCEEDED" | "FAILED";
errorCode?: string;
};
type SagaState = {
sagaId: string;
status: SagaStatus;
currentStep: Step;
results: StepResult[];
retryCount: number;
nextRetryAt?: string;
};
export function canRetry(errorCode?: string) {
return errorCode === "TIMEOUT" || errorCode === "UPSTREAM_5XX";
}
export function shouldCompensate(errorCode?: string) {
return errorCode === "OUT_OF_STOCK" || errorCode === "PAYMENT_DECLINED";
}
중요 포인트는 오케스트레이터가 장애로 재시작되어도 DB에 저장된 SagaState를 읽어 중간부터 재개할 수 있어야 한다는 점입니다. 이를 위해 각 단계 실행은 반드시 멱등해야 하며, “이미 성공한 단계는 건너뛰기”가 가능해야 합니다.
실패 복구 시나리오 3가지로 보는 실전 대응
시나리오 A: 결제 승인 성공 후 배송 생성 실패
- 원인: 배송 서비스 장애 또는 타임아웃
- 선택지:
- 배송을 재시도하여 주문 완료로 수렴
- 일정 시간 내 복구가 안 되면 결제 취소로 수렴
권장 설계:
- 배송 생성은 재시도 가능(Transient)로 분류
- 재시도 상한 초과 시 Saga를
COMPENSATING으로 전환 - 결제 취소가 실패하면 DLQ로 보내고 수동 처리
시나리오 B: 재고 예약이 타임아웃, 실제로는 예약됨
- 원인: 불확실 실패(Unknown)
권장 설계:
ReserveInventory호출 후 타임아웃이면 재시도하기 전에GetReservationStatus같은 확인 API로 상태를 조회- 확인 API가 없다면 예약 API 자체가 멱등해야 하며, 같은 키로 재호출 시 동일 예약을 반환해야 함
시나리오 C: 보상 트랜잭션이 실패
- 예: 결제 취소 API가
5xx
권장 설계:
- 보상도 일반 단계와 동일하게 재시도 정책을 적용
- 상한 초과 시
FAILED로 두고, 운영자가 재처리할 수 있는 도구 제공 - “보상 실패는 곧 금전 사고”일 수 있으므로 알림 우선순위를 높게 설정
관측성과 운영: 실패 복구를 ‘보이게’ 만들기
Saga는 실패해도 “언젠가 수렴”하면 된다는 말이 자주 나오지만, 운영에서는 “지금 무엇이 얼마나 쌓였는지”가 더 중요합니다.
필수 지표
saga_running_count,saga_failed_count,saga_compensating_count- 단계별 성공/실패/재시도 횟수
- DLQ 적재량
- 평균 수렴 시간(생성부터 완료까지)
필수 로그
sagaId(주문 ID 등),step,idempotencyKey,messageId,traceId- 실패 시
errorCode, 업스트림 응답 코드, 타임아웃 여부
로그가 누락되거나 지연되면 “재시도 폭주”와 “실패 누적”을 늦게 발견합니다. 특히 쿠버네티스 환경에서는 수집 파이프라인 자체가 병목이 되기도 하니, 앞서 언급한 fluent-bit 점검 글을 같이 보는 것이 좋습니다.
분산 추적
- OpenTelemetry로 각 단계 호출을 span으로 연결
traceId를 이벤트 payload에도 포함
이렇게 해야 “어디서 끊겼는지”를 UI 한 번으로 확인할 수 있습니다.
수동 복구 Runbook: 자동화가 실패했을 때의 마지막 안전장치
아무리 잘 설계해도 자동 복구가 100퍼센트는 아닙니다. 따라서 다음을 준비해야 합니다.
- Saga 인스턴스 조회 API 또는 관리자 UI
- 특정 sagaId에 대해
- 재시도 강제 실행
- 특정 단계부터 재개
- 보상 강제 실행
- 최종 상태를 수동 확정(단, 감사 로그 필수)
수동 확정은 위험하지만, 예를 들어 고객 CS로 “배송은 갔는데 주문이 실패로 남음” 같은 케이스를 처리하려면 필요합니다. 이때는 반드시 “누가, 언제, 왜”를 남기는 감사 로그가 있어야 합니다.
체크리스트: Saga 실패 복구 설계 점검
- 각 단계와 보상 단계가 멱등인가
- 불확실 실패에 대해 Confirm(상태 조회) 경로가 있는가
- Outbox와 Inbox로 이벤트 발행/소비를 중복 안전하게 만들었는가
- 재시도 정책(백오프, 지터, 상한, DLQ)이 있는가
- Saga 상태가 영속화되고 재시작 후 재개 가능한가
- 관측성(지표/로그/트레이싱)으로 병목과 실패를 즉시 찾을 수 있는가
- 수동 복구 Runbook과 도구가 있는가
마무리
Saga 패턴은 “분산 트랜잭션을 대체하는 패턴”이라기보다, 실패를 전제로 시스템을 운영 가능하게 만드는 프레임워크에 가깝습니다. 실패 복구를 제대로 하려면 보상 트랜잭션을 멱등하게 만들고, Outbox/Inbox로 메시지 신뢰성을 확보하며, 재시도 정책을 기술이 아닌 운영 정책으로 정의하고, 마지막으로 관측성과 수동 복구 절차까지 완성해야 합니다.
이 4가지를 갖추면 Saga는 더 이상 불안한 타협이 아니라, MSA에서 현실적으로 가장 강력한 정합성 전략이 됩니다.