- Published on
MSA 사가 패턴 - 보상 트랜잭션 누락 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 각자 DB를 갖는 MSA 환경에서 분산 트랜잭션을 구현할 때, 사가(Saga) 패턴은 사실상 표준 선택지입니다. 그런데 운영에서 가장 곤란한 장애 중 하나가 보상 트랜잭션(compensation) 이 “실행되어야 하는데 실행되지 않는” 상황입니다. 주문은 실패했는데 재고가 그대로 예약되어 있거나, 결제는 취소되어야 하는데 승인 상태로 남아 정산이 꼬이는 식이죠.
이 글은 “왜 보상이 누락되는가”를 원인 분류로 정리하고, 재현 가능한 디버깅 루틴(로그 상관관계, 상태머신 검증, 메시징/Outbox 점검, 멱등성/중복 처리, 재처리 전략)을 단계별로 제시합니다.
또한 이벤트 기반 사가에서 흔히 함께 엮이는 exactly-once 착각과 Outbox 현실론은 아래 글도 같이 보면 좋습니다.
1) 증상 정의: “보상 누락”을 먼저 정확히 쪼개기
보상 트랜잭션 누락은 하나의 현상이 아니라 여러 실패 모드의 묶음입니다. 디버깅 시작 전에 아래 중 어디에 해당하는지 분류해야 합니다.
A. 보상 이벤트가 아예 발행되지 않음
- 오케스트레이터가 실패를 감지하지 못함
- 실패는 감지했지만 보상 커맨드/이벤트 발행 전에 프로세스가 죽음
- DB 커밋은 됐는데 메시지 발행이 안 됨(Outbox 미사용 또는 깨짐)
B. 보상 이벤트는 발행됐지만 소비자가 처리하지 않음
- 컨슈머 다운/리밸런스/오프셋 문제
- DLQ로 빠졌는데 모니터링이 없음
- 스키마 변경으로 역직렬화 실패
C. 소비자는 처리했지만 “보상 로직이 무효”
- 멱등성 키 설계 미흡으로 중복/역순서에 취약
- 보상 조건이 잘못되어 조용히 스킵됨(예: 상태가 기대값이 아니라서
return) - 보상 트랜잭션이 롤백/데드락으로 실패했는데 재시도가 없음
D. 보상은 실행됐지만 관찰이 안 됨
- 로그 상관관계(correlation)가 없어 찾지 못함
- 트레이싱이 끊겨 “안 된 것처럼” 보임
이 글의 나머지는 위 네 가지를 빠르게 가르는 방법에 집중합니다.
2) 사가 유형별로 “누락 지점”이 다르다
사가 구현은 크게 오케스트레이션과 코레오그래피로 나뉘고, 누락이 발생하는 지점도 달라집니다.
오케스트레이션(Orchestration)
중앙 오케스트레이터가 상태를 갖고 단계별 커맨드를 날립니다.
- 장점: 상태 추적이 쉬움(단일 진실 소스)
- 단점: 오케스트레이터 장애 시 보상 누락 가능
코레오그래피(Choreography)
서비스들이 이벤트를 주고받으며 다음 행동을 유도합니다.
- 장점: 결합도 낮고 확장성 좋음
- 단점: “어디서 끊겼는지” 찾기 어려움, 이벤트 유실/중복에 더 민감
보상 누락 디버깅은 결국 “어느 서비스의 어떤 상태에서 어떤 메시지가 사라졌는가”를 찾는 작업입니다. 따라서 관측 가능성(Observability)과 상태머신이 핵심입니다.
3) 1차 점검: 상관관계 키가 없으면 디버깅이 불가능하다
보상 누락의 80%는 “실제로는 어딘가에서 실패했는데, 연결해서 보지 못하는 것”에서 시작합니다.
필수로 넣어야 할 식별자는 아래 3개입니다.
sagaId: 사가 인스턴스 식별자(주문 단위 등)correlationId: 요청 흐름 식별자(게이트웨이에서 생성)idempotencyKey: 커맨드/이벤트 단위 멱등 처리 키
이 값들은 로그, 메시지 헤더, DB 레코드(사가 상태 테이블/Outbox)에 모두 동일하게 찍혀야 합니다.
예시: 이벤트 헤더 표준화(JSON)
아래 예시는 본문이 아니라 “메시지 공통 헤더”를 표준화한 형태입니다.
{
"eventId": "01HZY...",
"eventType": "PaymentFailed",
"occurredAt": "2026-02-24T10:15:30Z",
"sagaId": "order-9f3c...",
"correlationId": "req-2a1b...",
"idempotencyKey": "payment-cancel-order-9f3c...",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
이 중 하나라도 빠지면 “보상 이벤트는 발행됐는데 못 찾는” 문제가 즉시 발생합니다.
4) 2차 점검: 사가 상태머신이 실제로 상태를 기록하는가
보상 누락은 종종 “보상 로직이 실행될 조건이 충족되지 않음”에서 발생합니다. 즉 코드 버그라기보다 상태 전이 정의가 현실과 불일치하는 문제입니다.
체크리스트
- 실패 상태가 반드시 기록되는가(예:
FAILED,COMPENSATING) - 각 단계의 완료/실패 이벤트를 저장하는가
- 재시작 시 상태를 복원하여 이어서 보상할 수 있는가
예시: 사가 상태 테이블(간단 스키마)
CREATE TABLE saga_instance (
saga_id VARCHAR(64) PRIMARY KEY,
saga_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
current_step VARCHAR(64) NOT NULL,
last_error_code VARCHAR(64),
last_error_msg TEXT,
updated_at TIMESTAMP NOT NULL
);
오케스트레이터가 있다면, 보상 누락 사건이 터졌을 때 가장 먼저 해야 할 일은 아래 질의입니다.
SELECT saga_id, status, current_step, last_error_code, updated_at
FROM saga_instance
WHERE saga_id = 'order-9f3c...';
- 상태가
COMPLETED인데 보상이 필요하다면: 성공/실패 판정 로직이 잘못됐을 가능성 - 상태가
FAILED인데 보상이 진행되지 않는다면: 보상 트리거(스케줄러/워커)가 멈췄거나 메시지 발행이 끊겼을 가능성 - 상태가
COMPENSATING에서 멈췄다면: 특정 보상 단계에서 재시도/멱등성이 깨졌을 가능성
5) 3차 점검: Outbox 없으면 “DB 커밋과 메시지 발행”이 분리된다
보상 이벤트 누락의 대표 원인은 아래 시나리오입니다.
- 로컬 DB 트랜잭션 커밋 성공
- 그 다음 메시지 브로커 발행 시도
- 발행 전에 프로세스 크래시 또는 네트워크 오류
- 결과: DB에는 실패/보상 필요 상태가 남았지만, 보상 이벤트는 존재하지 않음
이 문제를 줄이는 정석이 Transactional Outbox 입니다.
예시: Outbox 테이블
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
saga_id VARCHAR(64),
correlation_id VARCHAR(64),
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP
);
CREATE INDEX idx_outbox_unpublished
ON outbox (published_at)
WHERE published_at IS NULL;
예시: 비즈니스 트랜잭션과 Outbox를 같은 트랜잭션으로
아래는 Java/Spring 스타일 의사코드입니다.
@Transactional
public void failPaymentAndTriggerCompensation(String sagaId, String orderId) {
paymentRepository.markFailed(orderId);
OutboxEvent evt = OutboxEvent.of(
"PaymentFailed",
Map.of("orderId", orderId),
sagaId,
MDC.get("correlationId")
);
outboxRepository.insert(evt);
}
그리고 별도 퍼블리셔가 published_at IS NULL을 읽어 브로커에 발행합니다.
@Scheduled(fixedDelay = 500)
public void publishOutbox() {
List<OutboxEvent> batch = outboxRepository.findUnpublished(100);
for (OutboxEvent evt : batch) {
broker.publish(evt.eventType(), evt.payload(), evt.headers());
outboxRepository.markPublished(evt.id());
}
}
디버깅 포인트: 보상 누락이 의심되면 Outbox에 이벤트가 남아있는지부터 확인하세요.
SELECT id, event_type, saga_id, created_at
FROM outbox
WHERE saga_id = 'order-9f3c...'
ORDER BY id;
- Outbox에 있는데 브로커에 없다: 퍼블리셔 장애/권한/네트워크
- Outbox에도 없다: 애초에 이벤트를 기록하지 않음(코드 경로 누락)
6) 4차 점검: 컨슈머는 “적어도 한 번”을 전제로 멱등해야 한다
대부분의 메시징은 실무에서 at-least-once로 동작합니다. 즉 보상 이벤트가 중복 전달될 수 있고, 심지어 역순서로 도착할 수 있습니다.
보상 누락처럼 보이는 케이스 중 하나는 다음입니다.
- 보상 이벤트는 도착했지만
- 컨슈머가 이전에 같은 이벤트를 처리했다고 판단하고 스킵
- 그런데 그 “처리했다고 판단하는 기준”이 잘못되어 실제로는 반영이 안 됨
예시: 멱등 처리 테이블
CREATE TABLE processed_message (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL,
handler VARCHAR(128) NOT NULL
);
예시: 컨슈머 처리 흐름(의사코드)
@Transactional
public void onCompensateInventory(CompensateInventory msg) {
if (processedMessageRepository.exists(msg.messageId())) {
return; // 중복 방지
}
inventoryRepository.releaseReservation(msg.orderId());
processedMessageRepository.insert(msg.messageId(), "CompensateInventoryHandler");
}
디버깅 포인트
processed_message에는 기록됐는데 실제 보상 데이터 변경이 없다면: 트랜잭션 경계/예외 처리/부분 실패 가능성- 반대로 데이터는 변경됐는데 기록이 없다면: 중복 처리로 재실행될 수 있음
7) 5차 점검: 실패는 DLQ로 갔는데 아무도 안 본다
보상 이벤트는 보통 “실패 시 더 많이 발생”합니다. 즉 장애 시점에 트래픽과 오류가 몰려 DLQ(Dead Letter Queue) 로 빠질 확률도 커집니다.
DLQ 점검 루틴
- 특정
sagaId로 DLQ 검색이 가능한가 - DLQ 적재 시 알람이 뜨는가
- DLQ 재처리 도구가 있는가(재발행, 스키마 변환 후 재처리)
DLQ가 있는데도 보상 누락이 장기간 방치되는 팀의 공통점은 “DLQ는 만들었지만 운영 루프가 없다”입니다.
8) 6차 점검: 시간/타임아웃/재시도 정책이 보상을 ‘영원히 미룸’
오케스트레이터든 코레오그래피든, 아래 설정이 결합되면 보상은 누락이 아니라 지연 후 유실처럼 관측됩니다.
- 재시도 백오프가 과도하게 큼
- 타임아웃이 너무 짧아 정상 응답도 실패로 판정
- 서킷 브레이커가 열려 보상 호출이 계속 차단
- 스케줄러 워커가 한 대뿐이고 병목
디버깅 팁
- 사가 상태가
COMPENSATING에서 오래 멈추면 “보상 워커 큐 적체”를 의심 - 쿠버네티스라면 워커 파드가 재시작/메모리 부족인지 확인
운영에서 파드가 불안정하면 보상 워커가 자주 죽어 “보상 이벤트 발행/소비”가 끊깁니다. 이 경우 아래 글의 방법론(원인 추적 루틴)을 그대로 적용할 수 있습니다.
9) 실전 디버깅 플레이북: 10분 안에 범인 좁히기
아래는 실제 장애 대응에서 효과적인 순서입니다.
1단계: “정답 데이터”부터 확정
- 주문/결제/재고 중 무엇이 최종적으로 어떤 상태여야 하는지 합의
sagaId또는orderId를 기준으로 관련 레코드를 모두 조회
2단계: 상태머신 조회
- 오케스트레이터가 있으면
saga_instance확인 - 코레오그래피면 각 서비스의 “로컬 상태”와 “마지막 처리 이벤트”를 확인
3단계: Outbox 조회
- 보상 이벤트 타입이 Outbox에 존재하는지
published_at이 비어 있는지(발행 실패)
4단계: 브로커에서 이벤트 존재 확인
- 토픽/파티션/오프셋에서 해당
sagaId이벤트가 있는지 - 컨슈머 그룹 lag 확인
5단계: 컨슈머 처리 확인
- 컨슈머 로그에서
correlationId로 검색 processed_message같은 멱등 테이블로 처리 여부 확인- DLQ 확인
이 루틴을 문서화해두면 “보상 누락”이 들어왔을 때 사람마다 다른 감으로 파지 않고, 같은 순서로 같은 근거를 남길 수 있습니다.
10) 재현 테스트: ‘보상 누락’을 의도적으로 만들어보기
운영에서만 터지는 문제는 대부분 “레이스/부분 실패”입니다. 아래 시나리오를 로컬이나 스테이징에서 강제로 만들어야 합니다.
시나리오 A: DB 커밋 후 프로세스 강제 종료
- 결제 실패 처리 직후
SIGKILL - Outbox 사용 여부에 따라 이벤트 유실 여부가 갈림
시나리오 B: 컨슈머 역직렬화 실패
- 이벤트 스키마에 필드 추가/삭제 후 구버전 컨슈머로 소비
- DLQ로 빠지는지, 무한 리트라이로 막히는지 확인
시나리오 C: 중복/역순서 이벤트
- 같은
messageId를 두 번 발행 PaymentSucceeded다음에PaymentFailed가 도착하는 비정상 순서 주입
이 재현 테스트를 자동화하면, 배포 전에 보상 누락을 상당수 차단할 수 있습니다.
11) 복구 전략: 이미 누락된 보상은 어떻게 되돌릴까
장애가 났을 때 중요한 건 “원인 분석”과 별개로 “데이터를 어떻게 정상화할지”입니다.
A. 재처리(replay) 가능한 구조 만들기
- Outbox에 남아있는 미발행 이벤트를 재발행
- DLQ에서 사유를 제거한 뒤 재처리
B. 사가 재구동(job) 제공
오케스트레이터가 있다면 다음 같은 운영용 API나 배치를 두는 것이 좋습니다.
POST /ops/saga/retrywithsagaIdPOST /ops/saga/compensatewithsagaId
단, 운영 API는 반드시 권한/감사 로그가 필요합니다.
C. 최후의 수단: 정합성 리컨실리에이션(batch)
- “주문 실패인데 재고 예약이 남아있는 건” 같은 규칙을 정의
- 주기적으로 스캔해서 보정
이 방식은 근본 해결은 아니지만, 금융/정산처럼 정합성이 중요한 도메인에서 안전망 역할을 합니다.
12) 결론: 보상 누락은 ‘관측+상태+메시징’의 합성 문제다
보상 트랜잭션 누락은 단순히 보상 함수 하나의 버그라기보다,
- 상관관계 키 설계
- 상태머신의 전이/영속
- Outbox를 포함한 발행 보장
- 컨슈머 멱등성과 DLQ 운영
- 재시도/타임아웃/워커 안정성
이 다섯 축이 동시에 맞물려야 사라집니다.
특히 “DB와 메시지 발행의 원자성”과 “멱등 처리”는 사가의 생명줄입니다. 아직 Outbox/DLQ/리플레이 체계가 없다면, 보상 누락 디버깅은 매번 포렌식이 되고, 해결도 사람의 기억에 의존하게 됩니다.
운영에서 한 번이라도 보상 누락을 겪었다면, 이번 기회에 사가 상태 테이블 + Outbox + 멱등 테이블 + DLQ 운영 루프를 최소 단위로 갖추는 것을 권합니다.