- Published on
MSA 사가 패턴 보상 트랜잭션 설계 7단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 결제, 재고, 배송처럼 여러 서비스가 얽힌 비즈니스 흐름을 만들면, 언젠가는 분산 트랜잭션 문제가 터집니다. 전통적인 2PC 는 운영 복잡도와 가용성 비용이 커서 현실적으로 선택하기 어렵고, 결국 많은 팀이 사가 패턴을 택합니다.
사가의 본질은 간단합니다. 여러 로컬 트랜잭션을 순서대로 실행하고, 중간에 실패하면 이미 완료된 단계들을 보상 트랜잭션으로 되돌립니다. 하지만 “되돌린다”는 말이 생각보다 훨씬 어렵습니다. 보상은 단순 DELETE 가 아니라, 도메인 규칙을 지키면서도 멱등·재시도·부분 실패·순서 역전을 견디는 설계가 되어야 합니다.
이 글은 보상 트랜잭션을 중심으로 사가를 설계할 때 실무에서 바로 쓰는 7단계 절차를 정리합니다.
1단계: 사가 경계와 오케스트레이션 모델부터 확정
사가 설계의 첫 단추는 “누가 흐름을 제어하는가”입니다.
- 오케스트레이션(Orchestration): 중앙 사가 오케스트레이터가 각 서비스를 호출하고 상태를 관리
- 코레오그래피(Choreography): 이벤트 기반으로 서비스들이 자율적으로 반응
보상 트랜잭션 설계 난이도는 보통 오케스트레이션이 낮습니다. 이유는 다음과 같습니다.
- 보상 호출 순서를 명확히 강제하기 쉬움
- 타임아웃, 재시도, 중단, 수동 개입을 한 곳에서 관리 가능
반면 코레오그래피는 이벤트 순서 역전, 중복, 지연, 소비자별 처리 차이로 인해 보상 설계가 훨씬 어려워집니다.
실무 팁:
- 결제처럼 “정합성 비용이 큰” 도메인은 오케스트레이션이 유리
- 이벤트 기반이더라도 “사가 상태 저장소”는 결국 필요해지는 경우가 많음
2단계: 단계별 로컬 트랜잭션과 보상의 의미를 도메인으로 정의
보상은 기술이 아니라 도메인 행위입니다. 예를 들어 재고 단계가 실패했을 때 결제 보상은 “결제 취소”일 수 있지만, 이미 부분 승인된 상태라면 “부분 취소” 또는 “환불”이 됩니다.
각 단계에 대해 아래를 문서로 고정하세요.
- 로컬 트랜잭션이 만드는 상태 전이
- 실패 시 보상 트랜잭션이 만드는 상태 전이
- 보상이 불가능한 케이스와 대안(수동 처리, 추가 보정 단계)
예시: 주문 사가
OrderService: 주문 생성PENDINGPaymentService: 결제 승인AUTHORIZEDInventoryService: 재고 예약RESERVEDShippingService: 배송 요청REQUESTED
보상 정의(역순):
- 배송 요청 취소(가능하면)
- 재고 예약 해제
- 결제 승인 취소 또는 환불
- 주문 취소
CANCELLED
여기서 중요한 포인트는 “취소”가 항상 가능한 게 아니라는 점입니다. 배송은 이미 출고되었을 수 있고, 결제는 승인 취소 윈도우가 지났을 수 있습니다. 즉, 보상은 항상 Undo 가 아니라 새로운 비즈니스 행위가 됩니다.
3단계: 보상 트랜잭션을 멱등하게 만들기
분산 환경에서 보상 호출은 중복될 수밖에 없습니다.
- 오케스트레이터 재시작
- 네트워크 타임아웃으로 인한 재시도
- 메시지 브로커의 at-least-once 전달
따라서 보상 API는 반드시 멱등해야 합니다.
멱등 키 설계
sagaId와step또는commandId조합을 멱등 키로 사용- 서비스는 “이미 처리한 보상 요청인지”를 저장하고, 중복이면 같은 결과를 반환
아래는 Node.js 기반의 간단한 멱등 처리 예시입니다.
// payment-service/src/compensate.ts
import { db } from "./db";
export async function compensatePayment(params: {
sagaId: string;
commandId: string; // 멱등 키
paymentId: string;
}) {
return db.tx(async (tx) => {
const existed = await tx.idempotency.findUnique({
where: { commandId: params.commandId },
});
if (existed) {
return { status: "OK", deduped: true };
}
// 도메인 상태 확인 후 취소/환불 결정
const payment = await tx.payment.findUnique({ where: { id: params.paymentId } });
if (!payment) {
// 이미 삭제/정리된 경우도 멱등 처리로 OK
await tx.idempotency.create({ data: { commandId: params.commandId } });
return { status: "OK", deduped: false };
}
if (payment.state === "AUTHORIZED") {
await tx.payment.update({ where: { id: payment.id }, data: { state: "VOIDED" } });
} else if (payment.state === "CAPTURED") {
await tx.payment.update({ where: { id: payment.id }, data: { state: "REFUND_REQUESTED" } });
}
await tx.idempotency.create({ data: { commandId: params.commandId } });
return { status: "OK", deduped: false };
});
}
핵심은 “중복 호출이 오면 같은 결과를 내고, 상태를 더 망가뜨리지 않는다”입니다.
4단계: 보상 순서와 동시성 규칙을 명시
보상은 보통 정방향의 역순으로 실행합니다. 하지만 역순이 항상 안전한 건 아닙니다.
- 재고 해제를 먼저 하고 결제 취소를 나중에 하면, 잠깐이지만 “재고는 풀렸는데 결제는 살아있는” 창이 생김
- 반대로 결제부터 취소하면, 환불이 지연될 때 “돈은 묶였는데 재고는 이미 해제”될 수 있음
정답은 도메인 우선순위에 따라 달라집니다. 그래서 아래를 명시해야 합니다.
- 어떤 단계는 반드시 역순인가
- 어떤 단계는 병렬 보상이 가능한가
- 병렬 보상 시 충돌 방지 락이 필요한가
오케스트레이터가 있다면 보상 상태 머신을 둡니다.
type Step = "PAYMENT" | "INVENTORY" | "SHIPPING";
type SagaState = {
sagaId: string;
completed: Step[];
compensating: boolean;
compensated: Step[];
};
const compensationOrder: Step[] = ["SHIPPING", "INVENTORY", "PAYMENT"]; // 기본 역순
그리고 “이미 보상된 단계는 다시 보상하지 않는다”는 규칙을 상태로 보장합니다.
5단계: 실패 모드별 재시도·백오프·서킷 브레이커를 설계
보상은 실패할 수 있습니다. 그리고 “실패한 보상”은 단순 실패보다 더 위험합니다. 왜냐하면 시스템이 이미 부분적으로 진행된 뒤이기 때문입니다.
실무에서는 보상 호출도 일반 호출과 동일하게 복원력을 가져야 합니다.
- 타임아웃
- 지수 백오프 재시도
- 최대 재시도 초과 시 DLQ 또는 수동 처리 큐
- 서킷 브레이커로 장애 전파 차단
재시도 전략은 외부 API 장애 사례에서 배운 패턴을 그대로 가져오는 게 좋습니다. 예를 들어 레이트 리밋이나 과부하 응답에 대한 백오프는 아래 글의 설계 원칙을 그대로 적용할 수 있습니다.
보상 호출 재시도 예시
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export async function retryWithBackoff<T>(fn: () => Promise<T>, opts: {
maxRetries: number;
baseMs: number;
maxMs: number;
}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (e) {
attempt += 1;
if (attempt > opts.maxRetries) throw e;
const backoff = Math.min(opts.maxMs, opts.baseMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * 100);
await sleep(backoff + jitter);
}
}
}
여기서 중요한 점은 “무조건 재시도”가 아니라, 실패 원인별로 정책을 달리하는 것입니다.
4xx중 일부는 영구 실패이므로 즉시 중단5xx, 타임아웃, 일시적 네트워크 오류는 재시도
6단계: 보상 불가능 케이스를 위한 세이프티넷 설계
현실에서는 보상이 불가능하거나, 보상이 외부 시스템 정책 때문에 지연됩니다.
- 카드 승인 취소 가능 시간이 지남
- 배송이 이미 출고됨
- 외부 파트너 API가 장시간 장애
이때 필요한 것이 “기술적 보상”이 아니라 운영 가능한 세이프티넷입니다.
권장 구성:
- 사가 상태 테이블에
FAILED_COMPENSATION상태 추가 - “수동 처리 큐”로 라우팅
- 운영자가 재시도 버튼을 누르거나, 대체 보정 플로우 실행
- 고객 커뮤니케이션(알림, CS 템플릿)까지 포함
예시 데이터 모델(개념):
-- saga_orchestration 테이블 예시
-- 주의: 본문에 부등호 문자를 쓰지 않기 위해 제약 조건 표현은 생략
CREATE TABLE saga_orchestration (
saga_id TEXT PRIMARY KEY,
state TEXT NOT NULL,
current_step TEXT,
error_code TEXT,
error_message TEXT,
updated_at TIMESTAMP NOT NULL
);
운영 팁:
- “보상이 안 됨”은 장애가 아니라 업무 프로세스가 됩니다. 알람, 대시보드, 담당자 라우팅이 필수입니다.
7단계: 관측 가능성(로그·메트릭·트레이싱)으로 디버깅 가능하게 만들기
보상 트랜잭션이 무서운 이유는 “장애가 나면 재현이 어렵고, 상태가 이미 흩어져 있다”는 점입니다. 그래서 처음부터 관측 가능성을 설계해야 합니다.
필수로 남길 것:
- 모든 커맨드에
sagaId,commandId,step를 공통 필드로 로깅 - 단계별 성공/실패 카운터 메트릭
- 보상 재시도 횟수, 최종 실패 횟수
- 분산 트레이싱에서 사가 흐름이 한 눈에 보이도록 span 연결
로그 예시(JSON):
{
"level": "error",
"msg": "compensation failed",
"sagaId": "saga_20260224_001",
"step": "PAYMENT",
"commandId": "cmd_9f2b...",
"attempt": 4,
"error": "timeout"
}
여기까지 갖추면 “보상 실패”가 발생했을 때도 아래 질문에 답할 수 있습니다.
- 어떤 사가가 실패했나
- 어느 단계가 원인인가
- 보상은 어디까지 진행됐나
- 재시도는 몇 번 했고, 지금은 무엇을 기다리나
종합 예시: 주문 생성 사가의 설계 스케치
오케스트레이터 기준으로, 정방향 실행과 실패 시 보상 실행을 한 흐름으로 묶으면 아래처럼 정리됩니다.
type SagaContext = {
sagaId: string;
orderId: string;
paymentId?: string;
reservationId?: string;
shipmentId?: string;
};
export async function runOrderSaga(ctx: SagaContext) {
const completed: string[] = [];
try {
await createOrder(ctx); // PENDING
completed.push("ORDER");
ctx.paymentId = await authorizePayment(ctx);
completed.push("PAYMENT");
ctx.reservationId = await reserveInventory(ctx);
completed.push("INVENTORY");
ctx.shipmentId = await requestShipping(ctx);
completed.push("SHIPPING");
await confirmOrder(ctx); // CONFIRMED
return { status: "OK" };
} catch (e) {
await compensate(ctx, completed);
await cancelOrder(ctx);
throw e;
}
}
async function compensate(ctx: SagaContext, completed: string[]) {
const order = [...completed].reverse();
for (const step of order) {
await retryWithBackoff(async () => {
if (step === "SHIPPING" && ctx.shipmentId) {
return cancelShipping({ sagaId: ctx.sagaId, shipmentId: ctx.shipmentId });
}
if (step === "INVENTORY" && ctx.reservationId) {
return releaseInventory({ sagaId: ctx.sagaId, reservationId: ctx.reservationId });
}
if (step === "PAYMENT" && ctx.paymentId) {
return compensatePayment({ sagaId: ctx.sagaId, paymentId: ctx.paymentId });
}
return;
}, { maxRetries: 5, baseMs: 200, maxMs: 5000 });
}
}
이 스케치는 단순하지만, 앞서 말한 7단계 원칙(도메인 정의, 멱등, 순서, 재시도, 세이프티넷, 관측)을 실제 코드로 연결하는 데 도움이 됩니다.
체크리스트: 보상 트랜잭션 설계에서 자주 놓치는 것
- 보상 API가 멱등이 아니라서 “재시도”가 “추가 손실”이 되는 문제
- 보상 가능/불가능 조건을 도메인 레벨로 정의하지 않아 운영이 무너지는 문제
- 코레오그래피에서 이벤트 순서 역전과 중복 소비를 과소평가하는 문제
- 보상 실패를 DLQ 로만 보내고, 사람이 처리할 수 있는 도구를 안 만드는 문제
sagaId로 끝까지 추적이 안 되어 장애 분석이 불가능한 문제
마무리
사가 패턴은 분산 트랜잭션의 만능키가 아니라, “실패를 정상 흐름으로 만들기 위한 설계 프레임”입니다. 그 중심에 보상 트랜잭션이 있고, 보상은 단순 롤백이 아니라 도메인 규칙을 지키는 별도의 트랜잭션입니다.
7단계(경계 확정, 도메인 정의, 멱등, 순서/동시성, 재시도/백오프, 세이프티넷, 관측 가능성)를 체크리스트로 삼아 설계하면, 장애가 나도 복구 가능한 사가를 만들 수 있습니다.