- Published on
MSA 사가 패턴 - 보상 트랜잭션 실패 복구 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 사가(Saga) 패턴을 도입하면 분산 트랜잭션의 강한 일관성 대신 최종 일관성을 선택하게 됩니다. 문제는 여기서 끝나지 않습니다. 대부분의 팀이 실제 장애를 겪는 지점은 “정방향 트랜잭션”이 아니라 보상 트랜잭션(Compensation) 이 실패했을 때입니다.
보상 실패는 단순 오류가 아니라, 이미 일부 서비스에 반영된 상태를 되돌리지 못해 데이터 불일치가 장기화되고, 고객 경험과 정산/재고/과금 같은 핵심 도메인에 직접 영향을 줍니다. 이 글에서는 사가에서 보상 실패를 어떻게 설계하고, 어떻게 복구하며, 운영에서 어떤 장치를 두어야 하는지 실전 관점으로 정리합니다.
사가에서 “보상 실패”가 더 위험한 이유
사가의 기본 흐름은 다음과 같습니다.
- 로컬 트랜잭션을 서비스별로 순차(또는 이벤트 기반) 실행
- 중간에 실패하면 이미 성공한 단계에 대해 보상 트랜잭션 실행
여기서 보상 실패가 위험한 이유는 다음과 같습니다.
- 보상은 종종 “완전한 롤백”이 아니다: 예를 들어 결제 승인 취소는 가능하지만, 이미 발송된 상품은 취소가 아니라 “반품 프로세스”로 바뀝니다.
- 보상은 더 많은 외부 의존성을 갖는다: 결제사, 재고 시스템, 배송사 등.
- 시간이 지날수록 보상 난이도가 상승: 재고가 다른 주문에 재할당되거나, 정산 배치가 지나가면 단순 취소가 불가능해집니다.
따라서 사가에서 핵심은 “보상 로직을 구현했다”가 아니라 보상 실패를 전제로 복구 체계를 갖췄는가입니다.
보상 트랜잭션 설계 원칙 6가지
1) 보상은 “반대 연산”이 아니라 “의미 있는 상태 전이”
보상은 create의 반대가 항상 delete가 아닙니다. 도메인적으로 안전한 상태 전이를 정의해야 합니다.
- 예약 생성 보상: 예약
CANCELLED - 결제 승인 보상: 결제
VOIDED또는REFUNDED - 배송 요청 보상: 배송
CANCEL_REQUESTED(즉시 취소 불가 가능)
상태 전이를 명확히 하면, 보상 실패 시에도 “어느 상태에서 멈췄는지”가 남아 복구가 쉬워집니다.
2) 보상은 반드시 멱등성(idempotency)을 가져야 한다
보상 실패 복구의 1순위는 재시도입니다. 재시도가 안전하려면 멱등성이 필요합니다.
- 같은 사가 인스턴스에서 동일 단계 보상을 여러 번 호출해도 결과가 동일해야 함
- 외부 API 호출 시
idempotencyKey를 전달하거나, 내부적으로 “이미 처리됨”을 기록
3) 사가 오케스트레이터는 상태머신으로 구현한다
보상 실패를 다루려면 “지금 어느 단계인지”가 데이터로 남아야 합니다.
- 단계별
PENDING,DONE,COMPENSATING,COMPENSATED,FAILED - 실패 원인(에러 코드, 응답 바디 요약, 마지막 시도 시간)
코드에서 if-else로 분기하는 것보다, 상태머신 테이블을 두고 전이를 관리하면 운영 복구가 훨씬 단단해집니다.
4) 재시도 정책은 지수 백오프 + 한도 + 서킷 브레이커
보상은 외부 의존성이 많아 일시 장애가 흔합니다. 아래 조합이 실전에서 가장 안정적입니다.
- 지수 백오프(예: 1s, 2s, 4s, 8s...)
- 최대 시도 횟수(예: 10회)
- 최대 경과 시간(예: 24시간)
- 특정 에러는 즉시 중단(예: “취소 불가” 같은 비재시도 오류)
외부 호출이 반복 실패할 때는 서킷 브레이커로 불필요한 폭주를 막고, DLQ로 격리해 운영자가 확인할 수 있게 해야 합니다.
5) “자동 복구 불가”를 인정하고 수동 복구 경로를 만든다
보상 트랜잭션은 현실적으로 100% 자동 복구가 불가능합니다.
- 배송이 이미 출고됨
- 결제사가 승인 취소를 거절(시간 초과)
- 재고가 이미 다른 주문에 할당됨
이 경우를 위해 “수동 처리 큐”와 “운영자 도구”를 설계해야 합니다.
- 사가 인스턴스 상세 조회
- 단계별 재시도 버튼
- 강제 상태 전이(승인된 운영자만)
- 고객 커뮤니케이션 템플릿(환불 지연 안내 등)
6) 관측 가능성(Observability)을 보상 흐름에 맞춰 설계한다
보상 실패는 로그 한 줄로 끝나면 안 됩니다.
- 사가 ID 기반 분산 트레이싱
- 단계별 성공/실패 메트릭
- DLQ 적재량 알람
- “보상 지연 시간” SLO
특히 쿠버네티스 환경에서는 장애가 애플리케이션 로직인지, 인프라인지 빠르게 분리해야 합니다. 예를 들어 파드가 반복 재시작되면 보상 워커가 진행되지 않아 불일치가 장기화될 수 있습니다. 이런 경우는 EKS Pod 1분마다 재시작? livenessProbe 실패 해결 같은 체크리스트로 먼저 런타임 안정성을 확보하는 게 우선입니다.
보상 실패 복구 아키텍처: DLQ + 재처리 워커 + 운영 콘솔
보상 실패 복구를 시스템으로 만들 때 가장 실용적인 구성은 다음입니다.
- 오케스트레이터(또는 코레오그래피 소비자)가 보상 이벤트 발행
- 보상 핸들러가 처리하다 실패하면 재시도
- 재시도 한도를 넘기면 DLQ(Dead Letter Queue)로 이동
- 별도 재처리 워커가 DLQ를 폴링하거나 운영자 승인 후 재처리
이때 “왜 실패했는지”를 DLQ 메시지에 충분히 담아야 운영자가 판단할 수 있습니다.
- 사가 ID
- 단계명
- 요청 파라미터 요약
- 마지막 오류(HTTP 상태/에러 코드)
- 시도 횟수/마지막 시도 시간
외부 API 연동이라면 409 같은 충돌이나 499 같은 클라이언트 취소 유형도 보상 흐름을 흔듭니다. 이런 오류는 단순 재시도만으로 해결되지 않는 경우가 있어, 원인을 분류해 정책화해야 합니다. 관련해서는 OpenAI Responses API 409 499 충돌 취소 오류 해결 글의 “충돌/취소를 재시도 정책으로 어떻게 다룰지” 접근이 유사한 인사이트를 줍니다.
구현 예시 1: 사가 상태 테이블과 전이
아래는 오케스트레이션 기반 사가에서 상태를 저장하는 간단한 예시입니다. 핵심은 “성공/실패”만 기록하지 말고 보상 진행 상태까지 관리하는 것입니다.
-- saga_instance
create table saga_instance (
saga_id varchar(64) primary key,
saga_type varchar(64) not null,
status varchar(32) not null, -- RUNNING, COMPENSATING, COMPLETED, FAILED
current_step varchar(64),
created_at timestamp not null,
updated_at timestamp not null
);
-- saga_step
create table saga_step (
saga_id varchar(64) not null,
step_name varchar(64) not null,
status varchar(32) not null, -- PENDING, DONE, COMPENSATING, COMPENSATED, FAILED
try_count int not null default 0,
last_error varchar(1024),
updated_at timestamp not null,
primary key (saga_id, step_name)
);
이 구조를 두면 운영에서 다음 질문에 바로 답할 수 있습니다.
- 어떤 단계까지 성공했고 어디서 실패했나?
- 보상은 어느 단계까지 진행됐나?
- 특정 단계의 보상이 반복 실패하는가?
구현 예시 2: 보상 핸들러 멱등성 (TypeScript)
보상 로직은 재시도를 전제로 하므로, “이미 보상 처리됨”을 빠르게 판정할 수 있어야 합니다. 아래는 주문 취소 보상의 단순 예시입니다.
type CompensationResult =
| { status: "COMPENSATED" }
| { status: "SKIPPED_ALREADY_DONE" }
| { status: "RETRYABLE_FAILURE"; reason: string }
| { status: "NON_RETRYABLE_FAILURE"; reason: string };
interface CancelPaymentCommand {
sagaId: string;
paymentId: string;
idempotencyKey: string; // sagaId + stepName 등으로 구성
}
export async function cancelPayment(cmd: CancelPaymentCommand): Promise<CompensationResult> {
// 1) 로컬 멱등성 체크: 이미 VOIDED/REFUNDED면 스킵
const payment = await db.payment.findUnique({ where: { id: cmd.paymentId } });
if (!payment) return { status: "NON_RETRYABLE_FAILURE", reason: "payment_not_found" };
if (payment.status === "VOIDED" || payment.status === "REFUNDED") {
return { status: "SKIPPED_ALREADY_DONE" };
}
// 2) 외부 결제사 호출 (idempotency key 전달)
try {
const res = await paymentGateway.void({
paymentId: cmd.paymentId,
idempotencyKey: cmd.idempotencyKey,
});
if (res.ok) {
await db.payment.update({
where: { id: cmd.paymentId },
data: { status: "VOIDED" },
});
return { status: "COMPENSATED" };
}
// 비즈니스적으로 취소 불가라면 재시도해도 소용이 없음
if (res.errorCode === "VOID_NOT_ALLOWED") {
return { status: "NON_RETRYABLE_FAILURE", reason: "void_not_allowed" };
}
return { status: "RETRYABLE_FAILURE", reason: `gateway_error_${res.errorCode}` };
} catch (e) {
return { status: "RETRYABLE_FAILURE", reason: "network_or_timeout" };
}
}
포인트는 다음입니다.
- 로컬 상태로 1차 멱등성 보장
- 외부 호출에도
idempotencyKey를 전달 - 실패를 재시도 가능/불가능으로 분류
보상 실패를 줄이는 “사가 설계” 팁
보상 자체가 필요 없도록 만들기: 예약(hold)과 확정(confirm) 분리
결제/재고/좌석 같은 도메인은 “바로 확정”하지 말고 다음처럼 나누면 보상 실패 리스크가 크게 줄어듭니다.
reserve(만료 가능한 임시 상태)confirmrelease(만료 또는 실패 시 자동 해제)
이러면 보상은 “취소”가 아니라 “만료/해제”로 단순화될 수 있습니다.
타임아웃과 데드라인을 사가에 포함하기
보상은 시간이 지날수록 어려워지므로, 사가 인스턴스에 데드라인을 두고 데드라인 이후에는 자동 보상 대신 “수동 처리”로 전환하는 정책이 필요합니다.
- 예: 결제 승인 취소는 10분 내 자동, 이후는 환불 프로세스 티켓 생성
이벤트 중복/순서 뒤바뀜을 전제로 하라
메시지 브로커 환경에서는 중복과 순서 뒤바뀜이 흔합니다.
- 소비자 측 멱등성 키
- 사가 단계별 버전(예:
stepVersion) - “이미 다음 단계로 진행했으면 무시” 같은 가드
운영 체크리스트: 보상 실패가 터졌을 때
- 사가 ID로 전체 단계 상태를 조회
- 실패 유형 분류
- 네트워크/타임아웃: 재시도 우선
- 4xx 비즈니스 거절: 수동 처리로 전환
- 충돌(409)류: 상태 동기화 후 재시도 또는 스킵
- DLQ 적재량과 증가 속도 확인
- 워커/컨슈머 런타임 상태 확인
- 파드 재시작, OOM, livenessProbe 실패
- 고객 영향 여부 판단
- 결제는 됐는데 주문이 취소됨 같은 케이스는 최우선
쿠버네티스에서 레지스트리 인증 문제로 이미지 풀 실패가 나면 보상 워커가 아예 뜨지 않아 장애가 장기화됩니다. 이런 유형은 애플리케이션 이전에 배포/런타임부터 복구해야 하므로, K8s ImagePullBackOff 401·429 - ECR·레지스트리 제한 해결 같은 관점의 인프라 점검도 함께 가져가면 좋습니다.
결론: “보상 로직”이 아니라 “보상 실패 시스템”을 만들어라
사가 패턴의 성패는 보상 트랜잭션을 구현했는지가 아니라, 보상이 실패했을 때 어떻게 복구되는지에 달려 있습니다.
- 보상을 상태 전이로 정의하고
- 멱등성을 보장하며
- 재시도 정책과 DLQ를 갖추고
- 자동 복구 불가 케이스를 위한 수동 처리 경로를 만들고
- 관측 가능성을 보상 흐름 중심으로 설계
이 다섯 가지가 갖춰지면, 사가는 “언젠가 터질 폭탄”이 아니라 운영 가능한 분산 트랜잭션 패턴이 됩니다.