- Published on
MSA 트랜잭션 Saga 패턴 보상로직 설계 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 트랜잭션을 다루는 순간, 단일 DB의 BEGIN/COMMIT 같은 ACID 감각은 빠르게 무너집니다. 주문 서비스가 결제를 호출하고, 결제는 재고를 확인하고, 재고는 배송을 예약하는 식으로 서비스가 얽히면 “전체 성공 또는 전체 실패”를 한 번에 보장하기 어렵습니다.
이때 흔히 선택하는 해법이 Saga 패턴입니다. Saga는 분산 트랜잭션을 로컬 트랜잭션들의 연쇄로 쪼개고, 중간 실패 시 이미 완료된 단계들을 되돌리는 보상 트랜잭션을 실행해 결과적으로 일관성을 맞춥니다.
하지만 Saga의 난이도는 “오케스트레이션이냐 코레오그래피냐”보다 보상 로직을 어떻게 설계하느냐에 있습니다. 보상은 단순한 rollback이 아니라, 이미 외부 세계에 반영된 상태(결제 승인, 쿠폰 사용, 포인트 적립, 재고 차감)를 현실적으로 되돌리는 제품/도메인 설계이기 때문입니다.
아래에서는 보상 로직을 설계할 때 필요한 원칙과 구현 패턴을 실전 관점에서 정리합니다.
Saga 패턴에서 보상 로직이 어려운 이유
1) 보상은 “역연산”이 아닐 수 있음
예를 들어 결제 승인 취소는 카드사 정책, 시간 제한, 부분 취소, 수수료 등 도메인 제약이 많습니다. 재고 차감도 단순히 +1 복구가 아니라, 다른 주문이 이미 해당 재고를 소진했을 수 있습니다.
즉 보상은 수학적 역함수라기보다 “비즈니스적으로 허용 가능한 취소/정정 프로세스”입니다.
2) 실패는 언제든, 어디서든, 여러 번 발생
네트워크 타임아웃, 메시지 중복, 컨슈머 재시작, 부분 장애로 인해 같은 보상 명령이 여러 번 실행될 수 있습니다. 보상은 반드시 멱등성을 가져야 합니다.
3) 관측 불가능하면 운영이 불가능
Saga는 결국 비동기/분산 상태 머신입니다. 각 단계와 보상 단계가 어디까지 진행됐는지, 왜 멈췄는지 추적할 수 없으면 장애 대응이 힘들어집니다.
운영 관점에서는 재시도/백오프도 중요합니다. 재시도 설계는 분산 트랜잭션에서도 그대로 적용됩니다. 필요하면 다음 글의 관점을 함께 참고해도 좋습니다: OpenAI 429·insufficient_quota 재시도와 백오프 설계
보상 설계의 핵심 원칙 7가지
1) 보상은 “원복”이 아니라 “상태 전이”로 모델링
보상을 undo() 함수처럼 생각하면 구현이 꼬입니다. 대신 각 서비스는 자신의 리소스에 대해 상태 머신을 갖고, 보상은 상태를 되돌리는 것이 아니라 취소/만료/무효화 같은 명시적 상태로 전이시키는 것이 안전합니다.
예시(결제):
AUTHORIZED(승인)CAPTURED(매입)CANCELLED(승인 취소)REFUNDED(환불)
보상은 “이전 상태로 돌아가기”가 아니라 “취소/환불 상태로 이동”입니다.
2) 멱등성은 선택이 아니라 필수
보상 커맨드가 중복으로 도착해도 결과가 동일해야 합니다.
대표 전략:
- 요청에
idempotencyKey(예: sagaId + stepName) 포함 - 처리 결과를 DB에 기록하고, 동일 키 재요청 시 기존 결과 반환
- 외부 API 호출도 가능하면 멱등 키를 전달
3) 보상 가능성(Compensatability)을 단계 설계에서 먼저 검증
Saga 단계는 “성공하면 다음 단계로”가 아니라 “실패하면 이전 단계들을 보상할 수 있는가”를 기준으로 정해야 합니다.
예:
- 배송 예약은 취소 가능하지만, 이미 출고되면 취소 불가
- 쿠폰 사용은 취소 가능하나, 사용 이력 정합성 보장이 필요
따라서 보상 불가능 구간이 있다면:
- 해당 단계 이전에 최종 확정(예: 결제 매입) 같은 비가역 행위를 늦추거나
- 보상 대신 “사후 정산(Adjustment)” 프로세스를 별도로 두는 설계가 필요합니다.
4) 보상 순서는 “정방향의 역순”이 기본, 예외를 문서화
일반적으로 A -> B -> C로 실행됐다면 보상은 C' -> B' -> A' 역순입니다.
다만 예외가 생깁니다:
- B가 C의 입력을 변경해버리면 C 보상만으로 충분할 수 있음
- 특정 단계는 “보상 불필요” (예: 조회/검증)
이 예외는 코드가 아니라 설계 문서와 상태 머신 정의에 남겨야 운영 시 혼선을 줄입니다.
5) 재시도는 “안전한 재시도”부터
보상은 실패할 수 있습니다. 실패 시 무조건 즉시 재시도하면 외부 시스템을 더 망가뜨리거나, 스로틀링을 유발합니다.
권장:
- 지수 백오프 + 지터
- 최대 재시도 횟수 이후 DLQ로 보내고 수동 개입
- 재시도 가능한 오류와 불가능한 오류(검증 실패, 권한 문제)를 분리
6) 보상 결과는 이벤트로 남기고, 조회 가능한 스냅샷을 유지
운영에서 필요한 질문:
- 어떤 주문의 Saga가 어디서 멈췄나
- 보상이 몇 번 실패했나
- 현재 일관성은 어떤 상태인가
따라서:
- Saga 실행 로그(단계, 시각, 상태, 에러)를 저장
- 최종 사용자/CS가 볼 수 있는 “주문 상태”를 별도 스냅샷으로 제공
7) 타임아웃과 데드라인을 Saga에 포함
결제는 5분 내 승인 취소 가능, 배송은 30분 내 취소 가능 같은 제약이 있다면, Saga 오케스트레이터는 각 단계에 데드라인을 넣고 초과 시 다른 경로(부분 환불, 수동 처리)로 전환해야 합니다.
오케스트레이션 기반 Saga: 보상 설계 예시
오케스트레이션은 중앙 오케스트레이터가 각 서비스를 호출하고 상태를 관리합니다. 보상 설계가 명확해지고, 운영 관점에서 추적이 쉽습니다.
시나리오: 주문 생성 -> 결제 승인 -> 재고 예약 -> 배송 예약
- Step1: OrderService
CreateOrder - Step2: PaymentService
Authorize - Step3: InventoryService
Reserve - Step4: ShippingService
CreateShipment
실패 시 보상:
- Step4 실패: Step3
ReleaseReserve, Step2CancelAuthorization, Step1CancelOrder - Step3 실패: Step2
CancelAuthorization, Step1CancelOrder
데이터 모델 (예시)
아래 예시는 PostgreSQL 기준입니다.
CREATE TABLE saga_instance (
saga_id TEXT PRIMARY KEY,
saga_type TEXT NOT NULL,
status TEXT NOT NULL, -- RUNNING, COMPENSATING, COMPLETED, FAILED
current_step TEXT,
last_error TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE saga_step_log (
saga_id TEXT NOT NULL,
step_name TEXT NOT NULL,
direction TEXT NOT NULL, -- FORWARD, COMPENSATE
status TEXT NOT NULL, -- STARTED, SUCCEEDED, FAILED
idempotency_key TEXT NOT NULL,
attempt INT NOT NULL,
error TEXT,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (saga_id, step_name, direction, attempt)
);
핵심은 idempotency_key를 로그/상태와 함께 저장해 중복 실행을 흡수하는 것입니다.
오케스트레이터 의사코드
type StepResult = { ok: true } | { ok: false; error: string; retryable: boolean };
interface SagaStep {
name: string;
forward: (ctx: SagaContext) => Promise<StepResult>;
compensate: (ctx: SagaContext) => Promise<StepResult>;
}
async function runSaga(sagaId: string, steps: SagaStep[], ctx: SagaContext) {
// 1) forward
const completed: SagaStep[] = [];
for (const step of steps) {
const res = await step.forward(ctx);
if (res.ok) {
completed.push(step);
continue;
}
// 2) compensate in reverse order
for (const done of completed.reverse()) {
await compensateWithRetry(sagaId, done, ctx);
}
throw new Error(`saga failed at ${step.name}: ${res.error}`);
}
}
async function compensateWithRetry(sagaId: string, step: SagaStep, ctx: SagaContext) {
const key = `${sagaId}:${step.name}:COMPENSATE`;
for (let attempt = 1; attempt <= 10; attempt++) {
// 멱등 처리: 이미 성공했으면 스킵
if (await alreadySucceeded(key)) return;
const res = await step.compensate({ ...ctx, idempotencyKey: key });
if (res.ok) {
await markSucceeded(key);
return;
}
if (!res.retryable) {
await markFailedPermanently(key, res.error);
throw new Error(`compensation non-retryable: ${step.name}`);
}
await sleep(backoffWithJitter(attempt));
}
throw new Error(`compensation retries exceeded: ${step.name}`);
}
여기서 중요한 점:
- 보상도 재시도 대상이며, 재시도 가능/불가능 오류를 구분
alreadySucceeded로 중복 실행 방지- “최종 실패”는 DLQ 또는 운영자 개입 플로우로 연결
보상 로직 구현 패턴 4가지
1) 예약(Reserve) 후 확정(Commit)으로 비가역 작업을 늦추기
가장 강력한 패턴은 “확정”을 최대한 뒤로 미루는 것입니다.
- 재고:
Reserve(가용 재고 홀드) -> 주문 확정 시CommitReservation - 결제:
Authorize(승인) -> 배송 확정 시Capture(매입)
이렇게 하면 보상은 “확정 취소”가 아니라 “예약 해제”로 단순해집니다.
2) 보상 대신 정정(Adjustment) 이벤트를 발행
완전한 원복이 불가능한 경우, 회계/정산 관점에서 정정 이벤트를 남겨 일관성을 맞춥니다.
예:
- 포인트 적립이 이미 외부 제휴사로 전송됨
- 배송이 이미 출고됨
이때 보상은 CancelShipment이 아니라 IssueReturn 또는 CreateRefundCase 같은 후속 프로세스가 됩니다.
3) Outbox 패턴으로 “로컬 커밋 + 이벤트 발행” 원자성 확보
Saga 단계는 대개 “DB 업데이트 후 이벤트 발행”이 필요합니다. 이때 메시지 발행 실패로 유실이 생기면 보상/진행이 꼬입니다.
Outbox 테이블에 이벤트를 함께 기록하고, 별도 릴레이가 브로커로 전송하면 안정성이 올라갑니다.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
published_at TIMESTAMP
);
4) “보상 가능한 최소 단위”로 서비스 API를 쪼개기
서비스 API가 너무 큰 단위면 보상도 복잡해집니다.
- 나쁜 예:
PlaceOrder한 번에 결제/재고/배송까지 다 처리 - 좋은 예:
AuthorizePayment,ReserveInventory,CreateShipment처럼 보상 단위가 명확
코레오그래피 기반 Saga에서 보상 설계 시 주의점
코레오그래피는 서비스들이 이벤트를 구독/발행하며 진행합니다. 중앙 오케스트레이터가 없으므로 보상 설계가 더 어렵습니다.
주의점:
- “누가 보상을 트리거하는가”가 불명확해지기 쉬움
- 이벤트 순서 역전(out-of-order)과 중복 처리에 더 민감
- 전체 진행률을 한눈에 보기 어려움
권장 보완:
sagaId,causationId,correlationId를 모든 이벤트에 포함- 각 서비스는 자신의 상태 머신을 엄격히 관리
- 별도의 Saga 뷰(Projection) 서비스를 두어 관측 가능성 확보
운영 중 컨슈머 재시작/크래시로 인해 처리 중복이 생길 수 있으니, 쿠버네티스 환경이라면 장애 시그널을 로그로 빠르게 추적하는 습관이 중요합니다: K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기
실전 체크리스트: 보상 로직 설계 리뷰 항목
API/도메인
- 각 단계가 보상 가능한가(시간 제한, 정책 제한 포함)
- 보상은 “상태 전이”로 정의되어 있는가
- 부분 성공/부분 실패 시 사용자에게 노출할 상태가 정의되어 있는가
데이터/멱등성
- 모든 커맨드/이벤트에
idempotencyKey또는 유사 키가 있는가 - 중복 메시지 처리 시 DB 제약(유니크 키) 또는 처리 로그로 방어하는가
- 재시도 시 동일 요청으로 안전한가
재시도/백오프
- 재시도 가능한 오류와 불가능한 오류가 분리되어 있는가
- 백오프/지터/최대 횟수/DLQ가 정의되어 있는가
관측 가능성
- Saga 단위의 트레이싱(
correlationId)이 가능한가 - 단계별 성공/실패/보상 상태가 저장되는가
- 수동 개입(runbook) 절차가 있는가
마무리
Saga 패턴은 분산 트랜잭션을 “마법처럼 ACID로” 만들어주지 않습니다. 대신 실패를 전제로, 보상 가능한 단계로 쪼개고, 멱등성과 재시도, 관측 가능성을 갖춘 상태 머신으로 일관성을 달성하는 접근입니다.
보상 로직 설계가 탄탄하면, MSA에서도 장애가 곧 데이터 정합성 붕괴로 이어지지 않습니다. 반대로 보상이 애매하면 트랜잭션보다 더 큰 운영 비용(수동 정정, CS 폭증, 정산 오류)을 치르게 됩니다.
다음에 Saga를 설계할 때는 오케스트레이션/코레오그래피 선택보다 먼저, 각 단계의 “보상 가능성”과 “멱등성”을 API 계약 수준에서 확정하는 것부터 시작해보세요.