분산 트랜잭션을 복구하는 Saga 보상 아키텍처
- 1 보상 작업(compensation)
- 2 보상 가능성, 멱등성, 운영 복구
1. 서론 및 문제 정의: 부분 성공이 만드는 진짜 운영 부채
모놀리스 안에서 주문을 생성하고 결제를 승인하며 재고를 차감하는 일은 하나의 데이터베이스 트랜잭션으로 묶을 수 있다. 실패하면 롤백하면 된다. 그러나 주문, 결제, 재고, 배송이 독립 서비스와 독립 저장소로 분리되는 순간 이 직관은 깨진다. 결제 승인은 끝났는데 재고 차감 이벤트가 유실될 수 있고, 재고는 확보됐는데 배송 접수가 시간 초과될 수 있다. 소비자는 결제 문자를 받았지만 주문 화면에는 ‘처리 중’만 남는다. 운영자는 어느 시스템을 진실로 믿어야 할지 모른 채 수동 정산을 시작한다.
이 문제를 “메시지 브로커를 넣으면 해결된다”라고 이해하면 위험하다. 브로커는 전달과 재시도를 제공하지만, 각 서비스의 사업 상태를 하나로 되돌려 주지는 않는다. 반대로 모든 서비스에 분산 2단계 커밋을 강제하면 잠금 대기, 장애 전파, 운영권한 결합이 커진다. 특히 서로 다른 클라우드 서비스나 외부 결제사까지 묶인 환경에서는 단일 커밋 조정자에게 모든 자원을 붙드는 방식이 실용적이지 않을 때가 많다.
Saga는 이 간극에서 출발한다. 하나의 긴 트랜잭션을 여러 개의 로컬 트랜잭션으로 쪼개고, 이미 끝난 단계를 취소할 일이 생기면 그 단계에 대응하는 보상 작업(compensation)을 실행한다. 다만 보상은 데이터베이스 ROLLBACK이 아니다. 결제 취소에는 카드사 승인 상태와 환불 규정이 걸리고, 재고 복구에는 다른 주문이 이미 들어왔을 수 있으며, 고객 알림은 이미 발송됐을 수 있다. 따라서 Saga의 품질은 성공 메시지를 얼마나 빠르게 흘리느냐보다, 실패한 뒤 어떤 상태를 고객·운영자·정산 시스템에 남길지 얼마나 명확히 정의하느냐에 달려 있다.
이 글은 단순한 이벤트 체이닝이나 Kafka 설정을 설명하지 않는다. 기존의 고처리량 메시지·LLM 서빙·서비스 메시 글과 달리, 독립 데이터 소스를 가진 업무 서비스에서 보상 가능성, 멱등성, 운영 복구를 어떻게 하나의 실행 경계로 묶을지 다룬다. 대상은 강한 일관성을 포기하자는 조직이 아니라, 이미 부분 성공을 경험했고 수동 수습 비용을 줄여야 하는 팀이다.
2. 핵심 메커니즘 심층 해부: 명령과 사실, 보상과 재시도의 경계
Saga에는 크게 두 가지 형태가 있다. 코레오그래피(choreography)는 각 서비스가 이벤트를 받아 다음 이벤트를 발행한다. 서비스 수가 적고 흐름이 단순할 때는 자연스럽지만, 시간이 지나면 어느 이벤트가 전체 흐름을 전진시키는지 추적하기 어렵다. 오케스트레이션(orchestration)은 전용 조정자가 다음 명령과 보상 명령을 보낸다. 실패 경로와 타임아웃 정책을 한곳에서 읽기 쉽지만, 조정자 자체의 상태 저장·가용성·버전 관리가 중요한 운영 대상이 된다.
둘 중 하나가 언제나 우월한 것은 아니다. 주문처럼 보상 순서가 중요하고 여러 팀이 같은 흐름을 바꾸는 경로에는 오케스트레이터가 대체로 안전하다. 반면 결제 승인 완료를 분석·알림·추천 시스템이 독립적으로 구독하는 부수 효과에는 코레오그래피가 결합도를 낮춘다. 실무의 좋은 분리는 ‘핵심 금전·재고 흐름은 명령 중심’, ‘파생 반응은 이벤트 중심’이다. 이 구분이 없으면 모든 이벤트가 사실인지 명령인지 모호해지고 소비자는 의도치 않게 핵심 상태를 바꾼다.
text
+-------------+ ReserveStock +-------------+ Authorize +-------------+
| Saga | ------------------> | Inventory | ---------------> | Payment |
| Orchestrator| <------------------ | reserved | <--------------- | authorized |
+-------------+ result +-------------+ result +-------------+
| |
| CreateShipment | failure / timeout
v v
+-------------+ result +-------------+ ReleaseStock +-------------+
| Shipping | <----------------- | Saga | ----------------> | Inventory |
| requested | | state log | | compensated |
+-------------+ +-------------+ +-------------+
|
| VoidAuthorization / Refund
v
+-------------+
| Payment |
| compensated |
+-------------+
여기서 가장 중요한 설계 단위는 ‘단계’가 아니라 상태 전이다. PAYMENTAUTHORIZED는 결제 서비스가 관찰한 사실이고, VOIDPAYMENT는 조정자가 실행을 요청하는 명령이다. 이 둘을 같은 이벤트 이름으로 섞으면 재시도 때 결제를 다시 승인하거나, 이미 취소된 결제를 다시 취소하는 혼란이 생긴다. 각 명령에는 Saga 인스턴스 ID, 단계 ID, 멱등 키, 만료 시각을 넣고, 각 사실에는 원인 명령 ID와 처리 결과를 넣어야 한다. 그래야 운영자가 “이 주문은 왜 보상 중인가”를 로그 하나가 아니라 연결된 전이 기록으로 답할 수 있다.
타임아웃도 오류가 아니라 정상 경로 중 하나로 설계한다. 배송 서비스가 응답하지 않는다고 해서 배송 요청이 반드시 실패한 것은 아니다. 네트워크 응답만 잃었을 수 있다. 그래서 ‘재시도 가능’, ‘상태 조회 후 결정’, ‘사람의 확인이 필요한 보류’ 상태를 구분한다. 결제처럼 외부 부작용이 큰 단계는 무한 재시도보다 제공사의 상태 조회 API와 원장 대사를 우선해야 한다. 반면 재고 예약처럼 내부에서 멱등하게 처리할 수 있는 단계는 제한된 지수 백오프 재시도가 적합하다.
3. 실무 구현 및 벤치마크: 아웃박스와 멱등 키로 재현 가능한 실행 만들기
로컬 트랜잭션이 성공했는데 이벤트 발행 전에 프로세스가 죽는 ‘이중 쓰기’는 Saga에서 가장 흔한 균열이다. 주문 상태를 PAYMENT_PENDING으로 저장하고 별도의 브로커 발행을 호출하는 두 작업은 하나의 원자 작업이 아니다. 이를 줄이는 기본 패턴이 트랜잭션 아웃박스다. 업무 상태 변경과 발행할 메시지를 같은 데이터베이스 트랜잭션으로 기록하고, 별도 릴레이가 아직 발행되지 않은 레코드를 브로커로 전달한다. 릴레이가 중복 전달할 수 있다는 사실을 전제로, 소비자는 멱등하게 처리해야 한다.
typescript
type SagaState = 'STARTED' | 'STOCK_RESERVED' | 'PAYMENT_AUTHORIZED' | 'COMPENSATING' | 'COMPLETED' | 'FAILED';
interface OutboxMessage {
id: string;
topic: 'inventory.command' | 'payment.command';
key: string; // sagaId:step, consumer-side deduplication key
payload: Record<string, unknown>;
}
export async function reserveStock(sagaId: string, orderId: string) {
await db.transaction(async (tx) => {
const saga = await tx.get(`sagas/${sagaId}`);
if (saga.state !== 'STARTED') return; // repeated command: no state regression
tx.update(`sagas/${sagaId}`, { state: 'STOCK_RESERVING', updatedAt: Date.now() });
tx.insert('outbox', {
id: crypto.randomUUID(),
topic: 'inventory.command',
key: `${sagaId}:reserve-stock`,
payload: { command: 'ReserveStock', sagaId, orderId }
} satisfies OutboxMessage);
});
}
위 코드에서 return은 실패를 숨기는 장치가 아니다. 이미 같은 명령을 처리했거나 더 앞선 상태라면 상태를 뒤로 돌리지 않겠다는 계약이다. 실제 구현에서는 허용 전이표를 명시하고, 예상하지 못한 상태 요청은 무시하지 말고 진단 이벤트와 함께 격리해야 한다. 보상 명령에도 같은 원칙이 적용된다. ‘재고 복구’가 두 번 도착하더라도 예약 수량을 두 번 더하지 않도록, 원래의 예약 레코드를 기준으로 상태를 RELEASED로 전환해야 한다.
성능 평가는 평균 처리량 하나로 끝내면 안 된다. 운영 대시보드에는 최소한 단계별 명령 대기 시간, 재시도 횟수, 보상 진입률, 보류 상태 체류 시간, 동일 멱등 키의 중복 수를 둔다. 특히 p99 단계 지연이 늘어나면 성공률이 유지돼도 고객 화면의 ‘처리 중’ 체류가 길어진다. 보상 진입률의 급등은 코드 결함뿐 아니라 외부 결제사 장애나 이벤트 스키마 변경을 가리킬 수 있다. 팀의 기준선은 시스템의 평소 부하와 업무 SLA에 맞춰 정해야 하며, 일반화된 성공 수치를 억지로 가져와서는 안 된다.
▲ 아웃박스 릴레이, 메시지 재전송, 보상 명령의 안정적 전달을 받쳐 주는 프로덕션 인프라의 미시적 장면
장애 복구 절차 역시 코드와 별개로 남겨야 한다. 조정자 재시작 후에는 상태 로그에서 COMPENSATING과 장시간 PENDING 상태를 우선 조회한다. 다음으로 각 외부 시스템에 대해 명령의 최종 상태를 확인하고, 동일 명령을 안전하게 재발행할 수 있는지 판단한다. 마지막으로 자동 보상이 불가능한 사례를 별도 큐로 보내 담당자가 고객 안내·정산·재처리를 결정하게 한다. 이 수동 경로는 실패가 아니라 안전 장치다. 복구 도구가 없는 자동화는 장애 시점에 사람에게 가장 비싼 추론을 요구한다.
스키마 진화도 별도 운영 항목이다. 보상 명령의 필드 이름이나 상태 값이 배포 중에 바뀌면, 며칠 전에 저장된 아웃박스 레코드를 새 소비자가 읽지 못할 수 있다. 메시지에 버전을 넣고, 소비자는 한동안 이전 버전과 새 버전을 함께 해석해야 한다. 처리 불가 메시지를 단순히 폐기하지 말고 원본 페이로드·실패 사유·재처리 횟수를 보관하는 격리 큐로 옮긴다. 격리 큐가 커지는 현상은 브로커의 문제가 아니라 호환성 계약이 깨졌다는 조기 경보다. 운영자가 메시지를 수정해 재발행할 수 있는 권한과 감사 기록도 이 시점에 함께 설계해야 한다.
4. 엔지니어링 트레이드오프 및 안티패턴: 보상으로 지울 수 없는 비용
💡 실무 원칙: 각 단계에 ‘자동 보상 가능’, ‘상태 조회 후 보상’, ‘수동 처리 필요’를 표시한다. 보상 불가능한 단계를 뒤쪽에 배치하거나 사전 승인 흐름으로 바꾼다.
💡 실무 원칙: 전달 보장은 적어도 한 번으로 가정하고, 업무 키를 기준으로 중복을 무해하게 만든다. 멱등 키의 보존 기간은 지연 전달과 감사 요구사항을 고려해 정한다.
💡 실무 원칙: 독립 소유권, 외부 부작용, 장기 실행이라는 세 조건이 함께 있을 때 Saga를 검토한다. 그렇지 않으면 먼저 데이터 모델과 로컬 트랜잭션 경계를 단순화한다.
5. 에디토리얼 결론 및 실무 권고사항: 한 경로부터 시작하는 복구 설계
Saga의 목적은 모든 실패를 자동으로 없애는 것이 아니다. 실패가 생겼을 때 시스템이 어떤 상태인지, 누가 무엇을 재시도할 수 있는지, 고객에게 무엇을 약속할 수 있는지를 분명하게 만드는 것이다. 그래서 도입의 첫 단계는 거대한 범용 워크플로 엔진이 아니라, 실제로 수동 정산이 발생했던 단 하나의 업무 경로를 고르는 일이다. 팀은 그 경로의 성공 메시지보다 실패·중복·지연 경로를 먼저 그려야 한다.
| 주차 | 핵심 실행 과제 (Actionable Milestone) | 산출물 및 성공 지표 |
|---|---|---|
| 1주차 | 최근 부분 성공 또는 수동 정산 사례 하나를 골라 서비스 소유권, 외부 부작용, 보상 가능성을 흐름도로 정리한다. | 성공·실패·보류 상태를 포함한 상태 전이표. 자동 보상 불가 단계를 합의하면 통과. |
| 2주차 | 핵심 로컬 트랜잭션에 아웃박스와 멱등 키를 추가하고, 소비자 한 곳에서 중복 명령 재처리를 시험한다. | 중복 전달 테스트와 이벤트 발행 지연 대시보드. 상태가 뒤로 가지 않으면 통과. |
| 3주차 | 낮은 비율의 실제 요청으로 오케스트레이터 또는 명시적 워크플로를 실행하고, 타임아웃·재시작·외부 조회를 모의한다. | 재시작 후 상태 복원 기록과 제한된 재시도 정책. 보류 사례가 식별되면 통과. |
| 4주차 | 자동 보상과 수동 처리의 경계를 운영 문서·알림·담당자 체계에 연결하고, 실제 장애 가정으로 롤백 훈련을 한다. | 실행 가능한 복구 런북과 감사 가능한 Saga 타임라인. 담당자가 다음 행동을 즉시 알 수 있으면 통과. |
분산 시스템의 신뢰성은 실패하지 않는 흐름에서 드러나지 않는다. 이미 일부가 성공한 뒤 계획이 틀어졌을 때, 그 성공을 어떻게 기록하고 누구의 권한으로 어떤 비용을 감수하며 되돌릴지 설명할 수 있을 때 비로소 아키텍처는 운영 가능한 시스템이 된다.
댓글 0