- Published on
MSA 사가 보상 트랜잭션 설계 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모놀리식에서는 데이터베이스 트랜잭션 하나로 원자성을 얻기 쉬웠지만, MSA에서는 서비스와 데이터 저장소가 분리되면서 같은 방식이 통하지 않습니다. 이때 자주 선택하는 접근이 사가(Saga) 패턴이고, 사가의 핵심은 실패했을 때 되돌리는 방법 즉 보상 트랜잭션(compensating transaction)을 명확히 설계하는 것입니다.
보상 트랜잭션은 단순히 반대 연산을 수행하는 함수가 아닙니다. 네트워크 재시도, 중복 메시지, 순서 뒤바뀜, 부분 실패, 장시간 지연 같은 분산 시스템의 현실을 전제로 해야 합니다. 이 글은 “보상 트랜잭션을 설계할 때 무엇을 반드시 확인해야 하는가”를 체크리스트 형태로 정리합니다.
참고로 사가를 이벤트 소싱과 함께 쓰는 경우도 많은데, 스냅샷이나 이벤트 중복/유실이 보상 로직과 충돌하면 장애가 커집니다. 관련해서는 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략도 함께 보면 좋습니다.
1) 먼저 결정해야 할 것: 오케스트레이션 vs 코레오그래피
사가 구현은 크게 두 가지로 나뉩니다.
- 오케스트레이션(Orchestration): 중앙 오케스트레이터가 각 서비스에 커맨드를 보내고 성공/실패에 따라 다음 단계 또는 보상을 지시
- 코레오그래피(Choreography): 각 서비스가 이벤트를 발행하고 다른 서비스가 이를 구독하여 다음 동작을 수행
보상 트랜잭션 관점에서의 차이는 다음과 같습니다.
- 오케스트레이션: 보상 실행의
순서와조건을 중앙에서 통제하기 쉬움. 대신 오케스트레이터가 단일 장애 지점이 될 수 있어 HA 설계가 필요. - 코레오그래피: 서비스 결합이 느슨하지만, 보상 실행의
전파,순서,중복통제가 어려워지고 디버깅 난이도가 상승.
체크포인트:
- 보상 실행 순서를 강하게 보장해야 하는 비즈니스인가
- 장애 시 운영자가 중앙에서 “현재 사가가 어디까지 갔는지”를 확인해야 하는가
- 팀/조직 구조상 중앙 오케스트레이터를 운영할 역량이 있는가
2) 보상은 “되돌리기”가 아니라 “의미를 복구”하는 것
보상 트랜잭션은 수학적 역함수처럼 깔끔하지 않습니다. 예를 들어 결제 승인 후 재고 차감이 실패했다면, 결제를 취소(환불)하는 것이 일반적인 보상인데, 실제로는 다음 같은 변수가 있습니다.
- 부분 환불만 가능한 결제 수단
- 취소 가능 시간이 지난 경우
- 결제 취소가 비동기 처리되고 완료까지 시간이 걸리는 경우
- 이미 고객에게 알림이 나간 경우
따라서 보상 트랜잭션은 원상복구가 아니라 비즈니스적으로 일관된 상태로 수렴시키는 작업입니다.
체크포인트:
- 보상이 불가능한 경우(irreversible)를 정의했는가
- 보상이 불가능할 때의 대체 플로우(수동 처리, CS 티켓, 크레딧 지급 등)가 있는가
- “완전 취소”가 아닌 “정정 이벤트”로 처리해야 하는 단계가 있는가
3) 사가 상태 모델링: 상태 전이표를 먼저 그려라
보상 설계의 시작은 상태 모델입니다. 최소한 아래를 사가 단위로 정의해야 합니다.
- 단계 목록(예:
ReserveInventory,AuthorizePayment,CreateOrder) - 각 단계의 성공/실패 상태
- 보상 단계(예:
ReleaseInventory,VoidPayment,CancelOrder) - 최종 상태(성공, 실패-보상완료, 실패-보상부분실패, 수동조치필요)
체크포인트:
- 사가 인스턴스의 상태가 저장되는가(메모리 금지)
- 상태 전이가
단조롭게진행되도록 설계했는가(되돌아가는 전이를 최소화) - 운영자가 상태만 보고 다음 액션을 판단할 수 있는가
4) 멱등성(idempotency)은 보상이 아니라 “모든 단계”의 기본값
분산 환경에서 메시지는 중복될 수 있고, API 호출은 재시도됩니다. 따라서 커맨드와 보상 모두 멱등해야 합니다.
- 같은
sagaId와step으로 같은 요청이 여러 번 와도 결과가 동일해야 함 - 보상도 마찬가지로 “이미 보상된 상태”면 성공으로 처리해야 함
체크포인트:
- 각 단계에
idempotencyKey를 설계했는가(보통sagaId-stepName) - 서비스가 멱등 키를 저장하고 중복 요청을 무해화하는가
- 멱등 처리의 저장소가 TTL로 날아가면서 장기 재시도에 문제를 만들지 않는가
예시: 결제 취소 보상 API의 멱등 처리(의사 코드)
// TypeScript-like pseudocode
async function voidPayment(req) {
const { sagaId, step, paymentId } = req;
const key = `${sagaId}:${step}:VOID_PAYMENT`;
const existing = await idempotencyStore.get(key);
if (existing) return existing.response; // already processed
// 실제 취소 시도
const result = await paymentProvider.void(paymentId);
const response = { status: "OK", providerResult: result };
await idempotencyStore.put(key, { response }, { ttlSeconds: 60 * 60 * 24 * 30 });
return response;
}
5) 보상 순서: “역순”이 기본이지만, 항상 맞지는 않다
일반적으로는 실행의 역순으로 보상합니다.
- 재고 예약
성공→ 결제 승인성공→ 주문 생성실패 - 보상: 주문 생성(없음) → 결제 취소 → 재고 해제
하지만 역순이 항상 정답은 아닙니다.
- 외부 시스템(배송, 쿠폰, 포인트)에서 취소/정정의 선행 조건이 있을 수 있음
- “취소 전에 정산 데이터가 생성되면 안 된다” 같은 규칙이 있을 수 있음
체크포인트:
- 보상 순서가 비즈니스 규칙을 만족하는가
- 보상 순서가 바뀌어도 안전한가(순서 무관하게 수렴하는가)
- 보상 중 일부가 실패했을 때 다음 보상을 진행할지 중단할지 정책이 있는가
6) 보상 실패는 반드시 발생한다: 재시도, DLQ, 수동조치 설계
보상은 실패할 수 있습니다. 특히 외부 결제/배송 같은 의존성이 있으면 더 그렇습니다. 따라서 “보상 실패”를 예외가 아니라 정상 시나리오로 취급해야 합니다.
체크포인트:
- 보상 커맨드 재시도 정책이 있는가(지수 백오프, 최대 횟수)
- 재시도 중복이 안전한가(멱등)
- 최종 실패 시 DLQ로 보내고 운영자가 처리할 수 있는가
- 수동조치 플레이북이 있는가(어떤 화면/쿼리로 무엇을 확인하는지)
운영 관점에서 로그가 핵심인데, 노이즈가 폭증하면 장애 시점에 필요한 신호를 놓칩니다. 로그 보관/압축 정책은 journalctl 로그 폭증? systemd 압축·보관 최적화 같은 관점으로 점검해두는 것이 좋습니다.
7) 데이터 정합성 기준을 명시하라: 강한 정합성 대신 “수렴 조건”
사가에서는 강한 정합성 대신 최종적 정합성을 택합니다. 그렇다면 무엇이 “정상”인지 수치화/명문화해야 합니다.
예:
- 주문 상태가
CANCELING이면 최대 5분 내CANCELED또는MANUAL_REVIEW로 수렴 - 결제 승인 후 10분 내 주문이 확정되지 않으면 자동 취소
체크포인트:
- 각 중간 상태의 최대 체류 시간(SLA)이 정의되어 있는가
- 타임아웃이 발생하면 어떤 보상이 실행되는가
- 타임아웃 이후에도 늦게 도착한 성공 이벤트를 어떻게 무해화하는가
8) Outbox/Inbox 패턴으로 “저장과 발행”을 분리하지 마라
보상 설계가 어려운 이유 중 하나는 메시지 발행의 신뢰성입니다. DB 업데이트는 성공했는데 이벤트 발행이 실패하면, 다음 단계가 진행되지 않아 보상이 꼬입니다.
해법으로 흔히 쓰는 것이 Outbox/Inbox 입니다.
- Outbox: 로컬 트랜잭션에서 비즈니스 데이터 변경과 함께
outbox 테이블에 이벤트를 기록하고, 별도 릴레이가 이벤트를 브로커로 발행 - Inbox: 소비 측에서 메시지 중복을 방지하기 위해 수신 메시지 ID를 저장
체크포인트:
- 각 서비스가 Outbox를 통해 이벤트 발행을 보장하는가
- 소비 측이 Inbox로 중복 소비를 막는가
- 릴레이 장애 시 적체가 쌓일 때 모니터링/알람이 있는가
9) 보상 트랜잭션의 부작용을 통제하라
보상은 “되돌리기”지만, 실제로는 또 다른 부작용을 만들 수 있습니다.
- 취소 알림이 중복 발송
- 포인트 복구가 중복 적립
- 재고 해제가 이미 판매된 재고를 음수로 만들기
체크포인트:
- 사용자 커뮤니케이션(알림/메일/SMS)의 멱등성이 확보되어 있는가
- 회계/정산 데이터는 취소가 아니라 정정 분개로 처리해야 하는가
- 보상 실행으로 인해 다른 사가가 시작되는 연쇄 효과를 고려했는가
10) 관측성: 사가 단위 트레이싱과 상관관계 ID는 필수
보상 설계가 제대로 되었는지 확인하려면, 장애 시 “한 사가 인스턴스의 타임라인”을 재구성할 수 있어야 합니다.
체크포인트:
- 모든 로그/이벤트/메트릭에
sagaId,orderId같은 상관관계 ID가 포함되는가 - 분산 트레이싱에서 각 단계가 하나의 trace로 묶이는가
- 보상 실행 횟수, 보상 실패율, DLQ 적재량이 대시보드로 보이는가
쿠버네티스 환경이라면 장애가 보상 실패로만 보이고 실제 원인은 파드 OOM 같은 경우도 흔합니다. 인프라 레벨 진단은 K8s CrashLoopBackOff - OOMKilled·Probe 실패 진단 같은 체크리스트와 함께 보는 것이 실전에서 도움이 됩니다.
11) 체크리스트: 보상 트랜잭션 설계 시 꼭 묻는 질문 20개
아래는 설계 리뷰 때 그대로 사용할 수 있는 질문 목록입니다.
비즈니스/도메인
- 이 단계는 보상이 가능한가, 불가능한가
- 불가능하다면 대체 플로우는 무엇인가
- 보상 시 고객에게 어떤 상태를 보여줄 것인가(
CANCELING같은 중간 상태 포함) - 보상으로 인해 회계/정산 데이터가 틀어지지 않는가
상태/흐름
- 사가 상태 머신이 문서화되어 있는가
- 각 단계의 성공/실패/타임아웃이 정의되어 있는가
- 보상 순서가 역순인지, 예외가 있는지 근거가 있는가
- 부분 보상 성공 시 최종 상태는 무엇인가
기술/신뢰성
- 모든 단계가 멱등한가
- 보상도 멱등한가
- 중복 메시지, 순서 뒤바뀜(out-of-order)을 허용하는가
- Outbox/Inbox로 저장과 발행/소비 중복을 통제하는가
- 재시도 정책(백오프, 최대 횟수, 서킷 브레이커)이 있는가
- 최종 실패 시 DLQ 및 수동조치 경로가 있는가
운영/관측
sagaId로 전체 타임라인을 추적할 수 있는가- 보상 실패율과 DLQ 적재량 알람이 있는가
- 운영자가 “지금 이 주문은 왜 취소가 안 됐는지”를 UI나 쿼리로 확인 가능한가
- 데이터 정합성 검증 배치(리컨실리에이션)가 있는가
성능/확장
- 보상 폭주 시(외부 장애로 대량 취소) 큐 적체와 처리량을 감당할 수 있는가
- 보상 처리의 우선순위(고객 영향 큰 건 먼저)가 필요한가
12) 예시 시나리오: 주문 사가와 보상 설계(간단 모델)
주문 생성 사가를 예로 들면 단계와 보상은 다음처럼 잡을 수 있습니다.
ReserveInventory(재고 예약)- 보상:
ReleaseInventory
- 보상:
AuthorizePayment(결제 승인)- 보상:
VoidPayment또는RefundPayment
- 보상:
CreateOrder(주문 레코드 확정)- 보상:
CancelOrder(상태 전이)
- 보상:
오케스트레이터 기반으로 의사 코드를 쓰면 아래와 같습니다.
# Python-like pseudocode
def run_order_saga(saga_id, order_id):
steps_done = []
try:
reserve_inventory(saga_id, order_id)
steps_done.append("ReserveInventory")
authorize_payment(saga_id, order_id)
steps_done.append("AuthorizePayment")
create_order(saga_id, order_id)
steps_done.append("CreateOrder")
mark_saga_success(saga_id)
except Exception as e:
mark_saga_failed(saga_id, reason=str(e))
# compensate in reverse order
for step in reversed(steps_done):
try:
if step == "CreateOrder":
cancel_order(saga_id, order_id)
elif step == "AuthorizePayment":
void_payment(saga_id, order_id)
elif step == "ReserveInventory":
release_inventory(saga_id, order_id)
except Exception as ce:
record_compensation_failure(saga_id, step, str(ce))
# 정책에 따라 계속 진행하거나 중단
finalize_saga_as_compensated_or_manual(saga_id)
여기서 중요한 포인트는 보상 실패를 기록하고, 사가의 최종 상태가 COMPENSATED인지 MANUAL_REVIEW인지 명확히 남기는 것입니다. 그래야 운영이 가능합니다.
마무리
MSA에서 사가는 “분산 트랜잭션의 대안”이라기보다, 실패를 전제로 한 일관성 수렴 메커니즘입니다. 보상 트랜잭션을 잘 설계하려면 역연산을 구현하는 수준을 넘어, 멱등성, 메시징 신뢰성(Outbox/Inbox), 타임아웃과 수동조치, 관측성까지 함께 묶어야 합니다.
위 체크리스트를 설계 리뷰 템플릿으로 고정해두면, 사가가 커질수록 더 자주 터지는 부분 실패와 중복/재시도 문제를 초기에 잡아낼 수 있습니다.