- Published on
MSA Saga 패턴 - 보상 트랜잭션 설계·디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스가 각자 DB를 소유하는 MSA에서 2PC 같은 강결합 분산 트랜잭션은 대개 피합니다. 대신 Saga로 “로컬 트랜잭션들의 연쇄”를 만들고, 실패 시 보상 트랜잭션으로 되돌리는 방식이 현실적인 선택이 됩니다. 문제는, Saga를 도입했다고 끝이 아니라는 점입니다. 실제 운영에서는 중복 메시지, 순서 역전, 부분 실패, 장기 실행, 재시도 폭주, 데이터 불일치 탐지가 끊임없이 발생하고, 보상 트랜잭션 설계가 허술하면 디버깅이 지옥이 됩니다.
이 글은 “보상 트랜잭션을 어떻게 설계해야 안전한가”와 “어떻게 관측하고 재현해서 고칠 것인가”를 중심으로 정리합니다. 도메인 경계가 흐려지면 Saga가 더 어려워지므로, 애그리게이트 경계 관점은 함께 참고할 만합니다. DDD 애그리게이트 경계 깨짐 - 해결 7가지
Saga 패턴을 보상 트랜잭션 관점에서 다시 정의하기
Saga는 보통 두 형태로 분류합니다.
- Choreography(이벤트 기반): 각 서비스가 이벤트를 구독하고 다음 단계를 수행
- Orchestration(오케스트레이터 기반): 중앙 오케스트레이터가 각 서비스를 호출하고 상태를 관리
보상 트랜잭션 관점에서 중요한 건 형태보다도 아래 3가지입니다.
- 각 단계는 로컬 트랜잭션으로 커밋된다
- 실패하면 이미 커밋된 것들을 “되돌리는” 별도 트랜잭션이 필요하다
- 되돌리기는 완전한 롤백이 아니라 “비즈니스적으로 상쇄”하는 작업이다
예를 들어 결제 승인 취소는 DB 롤백이 아니라 PG에 void 요청을 보내고, 내부 상태를 CANCELED로 전이시키는 식입니다.
보상 트랜잭션 설계 원칙 7가지
1) 보상은 “역연산”이 아니라 “상태 전이”로 설계
보상은 원자적 롤백이 아닙니다. 따라서 각 단계는 명시적 상태 머신으로 모델링하는 편이 안전합니다.
- 주문:
CREATED→PAID→FULFILLING→COMPLETED - 보상:
PAID상태에서 실패 시CANCELING→CANCELED
상태 전이 기반이면 디버깅 시 “어디까지 갔는지”가 명확해집니다.
2) 보상은 반드시 멱등(idempotent)해야 한다
MSA에서 이벤트/메시지는 중복될 수 있습니다. 보상 또한 중복 실행될 수 있으므로 다음이 필요합니다.
sagaId또는commandId를 키로 중복 처리 방지 테이블- 상태 전이를
WHERE status = ...조건으로 제한
예: 이미 CANCELED면 보상은 OK로 종료.
3) 보상은 “항상 성공”이 아니라 “재시도 가능한 실패”를 표현해야 한다
보상 호출이 실패할 수 있습니다(외부 PG 장애, 재고 시스템 타임아웃 등). 따라서 보상도 다음을 가져야 합니다.
- 재시도 정책(지수 백오프)
- 최대 재시도 이후의 격리(
DEAD_LETTER,NEEDS_MANUAL_REVIEW) - 운영자가 개입 가능한 재처리 버튼/스크립트
4) 보상 순서는 원 트랜잭션의 역순(LIFO)이 기본
A → B → C 순으로 커밋되었다면 보상은 보통 C → B → A 순입니다.
다만 “역순”이 항상 정답은 아닙니다. 예를 들어 배송이 이미 출고되었다면 결제 취소보다 “반품/회수” 프로세스가 먼저일 수 있습니다. 즉, 보상 순서는 기술적 역순이 아니라 비즈니스 규칙 기반으로 확정해야 합니다.
5) 보상 불가능한 단계는 미리 격리(예약/홀드)로 바꾸기
대표적으로 “외부에 이미 전파된 변경”은 되돌리기 어렵습니다.
- 쿠폰 소진, 포인트 차감, 재고 차감 같은 것은
- 즉시 확정(commit) 대신 예약(hold) 후 최종 확정하는 2단계로 바꾸면 보상이 쉬워집니다.
예: 재고 RESERVED 후 결제 완료 시 CONFIRMED, 실패 시 RELEASED.
6) 보상은 “부작용(side effect)”을 최소화
보상 트랜잭션이 또 다른 이벤트를 발행해 새로운 연쇄를 만들면 장애 전파가 커집니다.
- 보상 이벤트는 가능한 “내부 정합성 회복”에 집중
- 외부 알림(메일, 푸시)은 보상 완료 후 별도 워크플로로 분리
7) 관측 가능성(Observability)을 보상 설계의 일부로 포함
Saga는 디버깅이 곧 설계입니다. 최소한 아래를 표준으로 잡는 것이 좋습니다.
- 모든 로그에
sagaId,orderId,step,attempt포함 - 분산 트레이싱에서
sagaId를 baggage 또는 attribute로 전파 - 상태 저장소(오케스트레이터 DB 또는 Saga 상태 테이블)에서 “현재 단계”가 조회 가능
구현 예시: 오케스트레이션 기반 Saga 스켈레톤
아래 예시는 Spring Boot 스타일의 오케스트레이터 의사코드입니다. 핵심은 sagaId 기반 상태 저장, 단계별 커맨드 발행, 실패 시 보상 커맨드 발행입니다.
// 주의: 예시 코드(의사코드). 실제로는 메시지 브로커, outbox, 재시도 정책이 필요
enum Step { CREATE_ORDER, RESERVE_STOCK, AUTHORIZE_PAYMENT, CONFIRM_ORDER }
enum SagaStatus { RUNNING, COMPENSATING, COMPLETED, FAILED }
data class SagaState(
String sagaId,
String orderId,
Step currentStep,
SagaStatus status,
int attempt
)
class OrderSagaOrchestrator {
void start(String orderId) {
String sagaId = UUID.randomUUID().toString();
save(new SagaState(sagaId, orderId, Step.CREATE_ORDER, SagaStatus.RUNNING, 0));
sendCreateOrder(sagaId, orderId);
}
void onCreateOrderSucceeded(String sagaId) {
advance(sagaId, Step.RESERVE_STOCK);
sendReserveStock(sagaId);
}
void onReserveStockFailed(String sagaId, String reason) {
beginCompensation(sagaId, reason);
// CREATE_ORDER 보상
sendCancelOrder(sagaId);
}
void onAuthorizePaymentFailed(String sagaId, String reason) {
beginCompensation(sagaId, reason);
// RESERVE_STOCK 보상
sendReleaseStock(sagaId);
// CREATE_ORDER 보상은 재고 해제 성공 후 수행하도록 설계할 수도 있음
}
void onAllCompensationsDone(String sagaId) {
markFailed(sagaId);
}
// 멱등 처리: 이미 상태가 진행/종료된 경우 무시
private void advance(String sagaId, Step next) {
// UPDATE saga_state SET current_step = ? WHERE saga_id = ? AND status = 'RUNNING'
}
}
이 구조의 장점은 “현재 단계와 상태”가 한 곳에 모여 디버깅이 쉽다는 점입니다. 단점은 오케스트레이터가 단일 장애 지점이 될 수 있으므로, 이중화/재처리/락 설계가 필요합니다.
이벤트 기반(Choreography)에서 보상이 더 어려운 이유
이벤트 기반은 서비스 간 결합이 낮아 보이지만, 보상은 오히려 어려워질 수 있습니다.
- “어디까지 진행되었는지”를 한눈에 보기 어렵다
- 실패 이벤트가 유실되면 보상이 시작되지 않는다
- 보상 이벤트가 또 다른 이벤트를 촉발해 연쇄가 길어진다
그래서 이벤트 기반을 택한다면 최소한 아래를 권장합니다.
- Saga 상태를 저장하는 프로세스 매니저(Process Manager) 컴포넌트 도입
- 이벤트는 Outbox로 발행(로컬 트랜잭션과 발행의 원자성 확보)
- 각 컨슈머는 멱등 처리 필수
Outbox + 멱등 컨슈머: 중복/유실 디버깅의 핵심
Saga 디버깅에서 가장 흔한 원인은 “DB는 커밋됐는데 이벤트가 안 나갔다” 또는 “이벤트는 나갔는데 컨슈머가 중복 처리했다”입니다.
- Outbox 패턴: 로컬 트랜잭션에서 비즈니스 변경과 Outbox 레코드를 함께 커밋
- Relay(퍼블리셔): Outbox 테이블을 폴링하거나 CDC로 브로커에 발행
- Idempotent Consumer:
messageId또는sagaId + step조합으로 중복 처리 방지
간단한 Outbox 테이블 예시는 아래처럼 잡습니다.
CREATE TABLE outbox_event (
id BIGSERIAL PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP NULL
);
CREATE INDEX idx_outbox_unpublished ON outbox_event(published_at) WHERE published_at IS NULL;
Relay는 published_at IS NULL만 가져가 발행 후 published_at을 채웁니다. 이때도 중복 발행 가능성을 염두에 두고, 컨슈머는 반드시 멱등해야 합니다.
보상 트랜잭션 디버깅: 증상별 체크리스트
1) “주문이 취소됐는데 재고가 그대로 묶여 있음”
가능한 원인
release-stock보상 메시지 유실- 보상 컨슈머 장애로 재시도 큐에만 쌓임
- 재고 서비스에서 멱등 키 충돌로 무시됨
확인 순서
sagaId로 오케스트레이터 상태 조회: 보상 단계가 어디까지 갔는지- 브로커에서 해당
sagaId메시지 존재 여부 확인 - 재고 서비스 로그에서
sagaId,commandId로 처리 결과 확인 - 재고 DB에서 예약 레코드 상태가
RESERVED로 남아 있는지 확인
해결 팁
- 보상 단계별로 “완료 이벤트”를 반드시 남겨 오케스트레이터가 다음 보상으로 진행하도록 구성
- 예약 레코드에 TTL을 두고, 만료 시 자동 해제(단, 이중 해제를 멱등으로 방지)
2) “결제가 두 번 취소되거나, 취소 실패가 간헐적으로 발생”
가능한 원인
- 결제 취소 API가 비멱등
- 네트워크 타임아웃으로 동일 보상 요청이 재시도됨
해결 팁
- 외부 결제사에
idempotency-key가 있으면sagaId를 그대로 사용 - 없다면 내부적으로 “취소 요청 기록 테이블”을 두고, 동일 키면 재호출하지 않기
3) “순서가 뒤집혀서 보상 전에 확정이 먼저 처리됨”
가능한 원인
- 브로커 파티션 키가 단계별로 달라 순서 보장이 깨짐
- 동일 주문의 이벤트가 여러 파티션으로 분산
해결 팁
- 파티션 키를
orderId로 고정해 같은 주문은 같은 파티션으로 흐르게 하기 - 컨슈머에서 상태 머신으로 가드:
WHERE status = ...조건을 둬 순서가 틀리면 무시 또는 지연 재시도
4) “재시도 폭주로 DB 커넥션이 고갈됨”
보상/재처리 루프가 폭주하면 DB 커넥션이 먼저 터집니다. HikariCP 튜닝 자체도 필요하지만, 근본적으로는 재시도 정책을 손봐야 합니다. 관련해서는 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드도 함께 보면 좋습니다.
해결 팁
- 즉시 재시도 금지: 지수 백오프 + 지터
- 동일
sagaId에 대한 동시 재시도 제한(락 또는 단일 플라이트) - 외부 의존성 장애 시 서킷 브레이커로 빠르게 실패시키고 큐에서 늦게 재처리
테스트 전략: “보상 시나리오”를 자동화하는 방법
Saga는 성공 경로보다 실패 경로가 더 중요합니다. 아래를 자동화하면 운영 장애가 크게 줄어듭니다.
- 각 단계별 실패 주입(fault injection)
- 메시지 중복 전달 시나리오(같은
commandId2번) - 순서 역전 시나리오(보상 메시지가 먼저 도착)
- 외부 API 타임아웃 후 성공(가장 흔한 유령 케이스)
예: 테스트에서 결제 승인 API를 “첫 호출은 타임아웃, 두 번째 호출은 성공”으로 만들고, 오케스트레이터가 어떤 상태로 수렴하는지 검증합니다.
운영 도구화: 사람이 살기 위한 최소 기능
보상 트랜잭션은 결국 운영자가 만지게 됩니다. 최소한 아래 4가지는 제품 일부로 넣는 편이 좋습니다.
sagaId로 전체 타임라인 조회(각 단계 요청/응답/이벤트)- 특정 단계부터 재시작(예: 재고 예약부터 다시)
- 특정 보상만 수동 실행(예: 결제 취소 재시도)
NEEDS_MANUAL_REVIEW큐/테이블과 대시보드
이때도 수동 실행은 멱등 키를 강제해 “사람이 버튼을 두 번 눌러도 안전”해야 합니다.
흔한 설계 실수 5가지
- 보상을 “원복 SQL”로만 생각하고 외부 부작용을 무시함
- 멱등 키 없이 재시도부터 붙임(중복 처리로 더 망가짐)
- 상태 저장 없이 이벤트만 흘려보냄(진행 상황을 아무도 모름)
- 보상 실패를 무시하고 성공으로 간주함(잠복 불일치)
- 도메인 경계가 불명확한데 Saga로 억지로 엮음(서비스 간 상호 침범)
특히 마지막 항목은 Saga 자체 문제가 아니라 모델링 문제인 경우가 많습니다. 애그리게이트 경계를 다시 정리한 뒤 Saga를 설계하면 보상 복잡도가 눈에 띄게 줄어듭니다. DDD 애그리게이트 경계 깨짐 - 해결 7가지
결론: “보상”은 기능이 아니라 시스템의 안전장치
Saga는 분산 트랜잭션의 대체재가 아니라, 실패를 전제로 한 일관성 전략입니다. 보상 트랜잭션을 잘 설계하려면
- 상태 머신 기반 모델링
- 멱등성, 재시도, 순서 보장(또는 가드)
- Outbox 기반 발행 신뢰성
- 관측 가능성과 운영 도구화
가 한 묶음으로 들어가야 합니다. 이 중 하나라도 빠지면, 장애는 “언젠가”가 아니라 “곧” 터집니다.