- Published on
MSA에서 Saga 보상 트랜잭션 설계 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스가 각자 DB를 소유하는 MSA에서는 전통적인 2PC 기반 분산 트랜잭션을 현실적으로 적용하기 어렵습니다. 그래서 많이 선택하는 패턴이 Saga이며, 그 핵심은 실패 시 원상복구를 담당하는 보상 트랜잭션(Compensation Transaction) 입니다.
하지만 “실패하면 취소 API 호출하면 되지” 수준으로 접근하면 운영 단계에서 바로 문제가 터집니다. 보상은 단순 롤백이 아니라 비즈니스적으로 일관성을 회복하는 또 하나의 상태 전이이기 때문입니다. 이 글은 Saga 보상 트랜잭션을 설계할 때 놓치기 쉬운 항목을 체크리스트로 정리하고, 구현 시 유용한 코드 패턴까지 포함합니다.
본문에서 재처리(재시도, DLQ, 수동 오퍼레이션)까지 이어지는 운영 설계는 아래 글과 함께 보면 더 입체적으로 정리됩니다.
1) 먼저 정리: 보상은 “되돌리기”가 아니라 “상태 전이”
보상 트랜잭션은 DB 롤백처럼 과거로 시간을 되감는 것이 아니라, 이미 외부에 노출된 사실(주문 생성, 결제 승인, 재고 감소 등)을 새로운 이벤트로 상쇄하는 방식입니다.
따라서 보상 설계의 출발점은 다음 질문입니다.
- 어떤 상태를 “완료”로 볼 것인가
- 어느 시점부터는 “취소 불가”로 볼 것인가
- 취소가 불가하다면 대체 플로우(환불, 부분 취소, CS 개입)는 무엇인가
이 답이 정리되지 않으면 보상 로직은 기술적으로는 동작해도 비즈니스적으로는 실패합니다.
2) Saga 보상 설계 체크리스트(핵심 15개)
아래 체크리스트는 코레오그래피/오케스트레이션 어떤 방식이든 공통으로 적용됩니다.
2.1 보상 가능 범위와 “되돌릴 수 없는 지점” 정의
- 각 단계별로 보상 가능 여부를 명시했는가
- 보상 불가 단계가 있다면, 그 이후 실패 시 대체 처리(환불/정산/CS 티켓)가 정의되어 있는가
- “취소 마감 시간” 같은 정책이 있다면 시스템적으로 강제되는가
예: 배송이 시작되면 재고를 원복하는 대신 “반품 프로세스”로 전환해야 할 수 있습니다.
2.2 보상 트랜잭션의 멱등성(Idempotency)
보상 호출은 네트워크/브로커 특성상 중복될 수 있습니다. 따라서 아래를 만족해야 합니다.
- 동일한
sagaId또는operationId로 보상이 여러 번 호출되어도 결과가 변하지 않는가 - 이미 보상 완료된 상태에서 또 보상이 오면
200 OK또는 “이미 처리됨”으로 정상 종료하는가
멱등성 키는 보통 sagaId + stepName 또는 sagaId + resourceId 조합이 실무에서 관리하기 쉽습니다.
2.3 보상의 정합성 단위: “무엇을 원복할 것인가”
- 단일 리소스(주문 1건) 단위인지, 묶음(주문 라인/장바구니) 단위인지 정의했는가
- 부분 성공/부분 실패가 가능한 구조라면 보상도 부분 보상이 가능한가
예: 결제는 성공했는데 재고 3개 중 2개만 확보 실패라면, 전액 환불이 아니라 부분 환불이 필요할 수 있습니다.
2.4 보상 순서(역순)와 의존성
일반적으로 Saga는 실행의 역순으로 보상합니다.
- 보상 순서가 역순으로 정의되어 있는가
- 역순이 항상 가능한지(예: 쿠폰 복원은 결제 취소 전에 해야 하는지) 의존성을 점검했는가
2.5 타임아웃과 데드라인(Deadline) 전파
보상은 “최대한 빨리”가 아니라 “정해진 시간 내”가 중요합니다.
- 각 보상 호출에 타임아웃이 설정되어 있는가
- 상위 오케스트레이터의 데드라인이 하위 서비스로 전파되는가
gRPC를 사용한다면 데드라인 전파가 특히 중요합니다. 관련 장애 패턴은 아래 글이 참고가 됩니다.
2.6 보상 실패 시 재시도 전략과 한계
- 재시도는 무한인가, 최대 횟수/기간이 있는가
- 재시도 간격은 고정인지, 지수 백오프인지
- 영구 실패(Permanent)와 일시 실패(Transient)를 구분하는가
보상 실패 재처리 운영 설계는 앞서 링크한 글에서 더 깊게 다룹니다.
2.7 “보상 이벤트”의 발행과 Outbox 패턴
보상은 서비스 내부 DB 업데이트와 이벤트 발행을 같이 수반하는 경우가 많습니다.
- DB 업데이트와 이벤트 발행이 원자적으로 보장되는가
- 브로커 발행 실패 시 이벤트 유실이 없는가
실무에서는 Outbox 테이블을 두고 트랜잭션 안에서 outbox 레코드를 기록한 뒤, 별도 퍼블리셔가 브로커로 전송하는 방식이 안정적입니다.
2.8 관측 가능성(Observability): 추적 가능한가
- 모든 단계에
sagaId,step,attempt,cause가 로그/메트릭/트레이스에 남는가 - 보상 진행률을 한눈에 보는 대시보드가 있는가
- 알람은 “보상 지연”과 “보상 영구 실패”를 구분하는가
2.9 보상 전용 상태 머신(State Machine) 설계
보상은 단발성 함수가 아니라 상태 머신으로 관리해야 운영이 편합니다.
- Saga 인스턴스 상태가 명확한가(예:
RUNNING,COMPENSATING,COMPENSATED,FAILED) - 각 단계 상태가 명확한가(예:
DONE,COMPENSATION_PENDING,COMPENSATED,COMPENSATION_FAILED)
2.10 동시성/경합 처리
- 같은 주문/결제에 대해 “정상 처리”와 “보상 처리”가 레이스 컨디션을 만들지 않는가
- 낙관적 락(버전) 또는 조건부 업데이트로 상태 전이를 보호하는가
예: 주문 상태가 PAID에서 CANCELLED로 바뀌는 동안 배송 시작 이벤트가 들어오는 경우를 반드시 가정해야 합니다.
2.11 보상 API 계약: 동기/비동기, 응답 의미
- 보상 API가 동기 처리인지(즉시 완료), 비동기 요청인지(접수만) 정의했는가
- 응답 코드가 “성공/이미 처리됨/재시도 필요/영구 실패”로 구분되는가
2.12 데이터 모델: 취소/환불 이력은 남는가
- 원복을 단순
DELETE로 처리하지 않고 이력(감사 로그)을 남기는가 - 회계/정산이 필요한 도메인은 “취소 레코드”가 별도 엔티티로 존재하는가
보상은 흔적을 지우는 게 아니라 흔적을 남기고 상쇄하는 편이 규정/감사 대응에 유리합니다.
2.13 보상 시 외부 시스템(결제사, 배송사) 호출의 위험 관리
- 외부 시스템 장애 시 서킷 브레이커/폴백이 있는가
- 외부 시스템이 “중복 취소”를 허용하지 않는다면, 멱등 키를 외부에도 전달하는가
2.14 보상 테스트 전략: 실패 주입(Chaos)과 리플레이
- 각 단계 실패를 강제로 주입할 수 있는가
- 이벤트 리플레이(같은 메시지 재전송) 시 멱등성이 유지되는가
- 통합 테스트에서 “부분 성공 후 보상” 시나리오가 커버되는가
2.15 운영 도구: 수동 개입 플로우
- 운영자가 특정
sagaId를 조회하고 현재 상태를 확인할 수 있는가 - “재시도 버튼”과 “강제 실패 처리(수동 완료)” 같은 도구가 있는가
- 수동 처리 시 감사 로그가 남는가
3) 구현 패턴 1: 오케스트레이터 기반 Saga 상태 머신 예시
아래는 TypeScript로 간단한 오케스트레이터를 구성한 예시입니다. 핵심은 sagaId 기반 상태 저장, 단계별 멱등 처리, 실패 시 역순 보상입니다.
type StepName = 'CreateOrder' | 'ReserveInventory' | 'CapturePayment';
type StepResult = {
ok: boolean;
reason?: string;
};
type SagaState = {
sagaId: string;
status: 'RUNNING' | 'COMPENSATING' | 'COMPENSATED' | 'FAILED' | 'SUCCEEDED';
executedSteps: StepName[]; // 성공적으로 완료된 단계들
attempts: Record<string, number>;
};
interface Step {
name: StepName;
execute: (sagaId: string) => Promise<StepResult>;
compensate: (sagaId: string) => Promise<StepResult>; // 멱등 보장 필요
}
const steps: Step[] = [
{
name: 'CreateOrder',
execute: async (sagaId) => ({ ok: true }),
compensate: async (sagaId) => ({ ok: true }),
},
{
name: 'ReserveInventory',
execute: async (sagaId) => ({ ok: true }),
compensate: async (sagaId) => ({ ok: true }),
},
{
name: 'CapturePayment',
execute: async (sagaId) => ({ ok: false, reason: 'PAYMENT_GATEWAY_TIMEOUT' }),
compensate: async (sagaId) => ({ ok: true }),
},
];
async function runSaga(state: SagaState): Promise<SagaState> {
for (const step of steps) {
const r = await step.execute(state.sagaId);
if (r.ok) {
state.executedSteps.push(step.name);
continue;
}
state.status = 'COMPENSATING';
// 역순 보상
for (const executed of [...state.executedSteps].reverse()) {
const s = steps.find((x) => x.name === executed)!;
const cr = await s.compensate(state.sagaId);
if (!cr.ok) {
state.status = 'FAILED';
return state;
}
}
state.status = 'COMPENSATED';
return state;
}
state.status = 'SUCCEEDED';
return state;
}
이 예시는 단순하지만, 실무에서는 다음을 추가해야 합니다.
- 상태 저장소(DB)에
SagaState를 영속화 - 각 단계 호출에 타임아웃/재시도/서킷 브레이커
- 보상 실패 시 DLQ 또는 재처리 큐로 이관
- 트레이싱에
sagaId를 공통 태그로 포함
4) 구현 패턴 2: 보상 멱등성의 현실적인 구현(조건부 업데이트)
멱등성은 “요청 ID 저장”으로도 구현할 수 있지만, 보상은 보통 상태 전이를 동반하므로 조건부 업데이트가 가장 단단합니다.
예: 주문 상태가 PAID일 때만 CANCELLED로 바꾸고, 이미 CANCELLED면 성공으로 간주합니다.
-- 주문 취소(보상) 예시: 조건부 업데이트
UPDATE orders
SET status = 'CANCELLED', cancelled_at = NOW()
WHERE order_id = ?
AND status IN ('CREATED', 'PAID');
그리고 애플리케이션에서는 영향받은 row 수로 분기합니다.
type CancelResult = 'CANCELLED' | 'ALREADY_CANCELLED' | 'NOT_CANCELLABLE';
async function compensateCancelOrder(orderId: string): Promise<CancelResult> {
const updated = await db.updateOrderToCancelledIfPossible(orderId);
if (updated === 1) return 'CANCELLED';
const status = await db.getOrderStatus(orderId);
if (status === 'CANCELLED') return 'ALREADY_CANCELLED';
return 'NOT_CANCELLABLE';
}
여기서 중요한 점은 NOT_CANCELLABLE이 발생하는 경우를 “보상 실패”로 볼지, “대체 플로우로 전환”으로 볼지 정책을 정해야 한다는 것입니다.
5) 코레오그래피 Saga에서 보상 이벤트 설계 포인트
코레오그래피 방식(이벤트 기반)에서는 중앙 오케스트레이터가 없으므로 보상 이벤트의 스키마와 라우팅이 매우 중요합니다.
- 이벤트에
sagaId,step,action(예:COMPENSATE)를 포함 - 소비자는 “내가 수행했던 작업인지”를
sagaId와 로컬 저장 상태로 판별 - 보상 이벤트도 일반 이벤트처럼 Outbox로 발행
예시 이벤트(JSON)은 아래처럼 설계할 수 있습니다.
{
"eventType": "InventoryReservationCompensateRequested",
"sagaId": "saga_20250224_0001",
"orderId": "ord_123",
"reason": "PAYMENT_FAILED",
"occurredAt": "2026-02-24T10:00:00Z"
}
이때도 멱등성은 필수입니다. 브로커의 중복 전달, 컨슈머 재시작, 리밸런스는 언제든 발생합니다.
6) 운영에서 자주 터지는 함정 6가지
6.1 “보상은 성공했는데 사용자 화면은 실패”
결제 취소는 성공했지만 주문 상태 갱신 이벤트가 유실되면 사용자에게는 취소가 반영되지 않습니다. Outbox와 컨슈머 재처리 설계가 필요한 이유입니다.
6.2 보상 지연으로 인한 재고/한도 잠김
재고 예약이나 한도 홀드가 길게 유지되면 장애가 확산됩니다. 보상 지연 알람을 별도로 두고, 일정 시간 이후 자동 만료(lease) 모델을 고려하세요.
6.3 외부 결제사 취소 API가 멱등하지 않음
외부가 멱등을 보장하지 않으면 내부에서 “취소 요청 1회만”을 강제해야 합니다. 취소 요청 레코드를 저장하고 상태 머신으로 관리하세요.
6.4 부분 성공이 설계에 없어서 전체 취소만 존재
부분 환불, 부분 취소가 비즈니스적으로 필요해지는 순간 전체 보상만 있는 설계는 한계에 부딪힙니다. 주문 라인 단위 상태를 고려하세요.
6.5 재시도가 곧 장애 증폭
보상 재시도가 공격적으로 설정되면 외부 시스템 장애 시 트래픽 폭탄이 됩니다. 지수 백오프와 서킷 브레이커를 같이 적용하세요.
6.6 보상 실패의 최종 종착지가 없음
재시도만 있고 “최종 실패 후 무엇을 할지”가 없으면 운영자는 끝없이 쌓이는 실패를 보게 됩니다. DLQ, 티켓 발행, 수동 처리 도구가 필요합니다.
7) 최종 점검용 요약 체크리스트
아래 항목을 설계 문서의 “완료 조건”으로 박아두면 품질이 올라갑니다.
- 보상 가능/불가능 경계와 대체 플로우가 정의되어 있다
- 모든 보상은 멱등이며, 중복 호출 시 안전하다
- 보상 순서는 역순이며 의존성이 검증되었다
- 타임아웃/데드라인/재시도/서킷 브레이커가 있다
- Outbox 등으로 이벤트 유실을 방지한다
- Saga 및 단계별 상태 머신이 영속화된다
- 보상 실패의 최종 처리(DLQ, 수동 개입)가 있다
- 로그/메트릭/트레이스에서
sagaId로 추적 가능하다 - 실패 주입 테스트와 리플레이 테스트가 있다
Saga는 “성공 경로”보다 “실패 경로”에서 실력이 드러납니다. 보상 트랜잭션을 기능이 아니라 제품 운영의 일부로 보고, 상태 머신과 관측 가능성까지 포함해 설계하면 장애가 나도 복구 가능한 시스템으로 진화합니다.