- Published on
MSA 사가 패턴 보상 트랜잭션 실패 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
사가 패턴은 MSA에서 분산 트랜잭션을 대체하는 대표적인 접근입니다. 하지만 운영에서 더 자주 마주치는 문제는 “정방향 트랜잭션 실패”가 아니라 “보상 트랜잭션이 실패하거나, 성공했는데도 원복이 덜 되거나, 여러 번 실행되는” 케이스입니다. 보상은 본질적으로 비즈니스 로직이며, 네트워크·메시징·데이터 정합성·락·재시도 정책이 얽혀 장애가 복합적으로 나타납니다.
이 글은 보상 실패를 디버깅할 때의 실전 순서를 제시합니다. 핵심은 감이 아니라 증거로 좁히는 것입니다. 로그 한 줄 더 찍는 수준이 아니라, 상관관계 ID, 상태 머신, 아웃박스, 멱등성 키, 재시도와 DLQ, 트랜잭션 전파까지 한 번에 점검해야 합니다.
관련해서 로컬 트랜잭션 경계 문제는 Spring 기반에서 특히 자주 터집니다. 트랜잭션 전파·롤백 함정은 아래 글도 함께 보면 좋습니다.
또한 사가가 외부 API 호출을 포함한다면, 재시도·폴백·서킷브레이커 설계가 보상 실패율을 크게 좌우합니다.
보상 트랜잭션 실패의 전형적인 증상 6가지
1) 보상이 아예 실행되지 않는다
- 사가 오케스트레이터가 실패 이벤트를 못 받음
- 메시지가 유실되거나, 컨슈머가 죽었거나, DLQ로 빠짐
- 오케스트레이터 상태가 잘못되어 보상 분기 자체가 안 탐
2) 보상이 실행되지만 일부만 원복된다
- 원복에 필요한 식별자(예약 ID, 결제 ID 등)가 이벤트에 누락
- 보상 로직이 “현재 상태”를 잘못 가정해 조건 분기에서 스킵
- 여러 리소스를 원복해야 하는데 중간 단계에서 실패하고 중단
3) 보상이 여러 번 실행된다
- at-least-once 전달로 동일 이벤트가 중복 소비
- 타임아웃 후 재시도와 실제 성공이 경합
- 컨슈머 리밸런싱 중 중복 처리
4) 보상 순서가 뒤바뀐다
- 병렬 보상 실행
- 이벤트 순서 보장이 없는 토픽/파티션 설계
- 사가 단계별로 서로 다른 토픽을 쓰며 순서 상실
5) 보상 자체가 실패한다
- 이미 취소된 리소스에 대해 또 취소 시도(멱등성 미구현)
- 락 경합, 데드락, 타임아웃
- 외부 시스템 장애로 보상 API 실패
6) 보상은 성공했는데도 최종 상태가 실패로 남는다
- 오케스트레이터 상태 업데이트가 별도 트랜잭션으로 커밋되지 않음
- 상태 업데이트 이벤트가 유실
- “보상 성공” 이벤트를 발행했는데 소비 측이 반영 실패
디버깅의 출발점: 사가를 상태 머신으로 강제하기
보상 디버깅이 어려운 가장 큰 이유는 “현재 이 사가 인스턴스가 어디까지 갔는지”를 아무도 확실히 말하지 못하기 때문입니다. 따라서 사가를 반드시 상태 머신으로 모델링하고, 모든 전이를 저장해야 합니다.
권장 최소 필드
sagaId: 전 구간 상관관계 IDstate: 예:STARTED,PAYMENT_RESERVED,INVENTORY_ALLOCATED,COMPENSATING,COMPENSATED,COMPLETED,FAILEDstep: 현재 단계 인덱스 또는 이름lastEventId: 마지막 처리 이벤트 IDversion: 낙관적 락updatedAt
예시: 사가 상태 테이블 스키마(개념)
create table saga_instance (
saga_id varchar(64) primary key,
state varchar(32) not null,
step varchar(64) not null,
last_event_id varchar(64),
version bigint not null,
updated_at timestamp not null
);
create table saga_log (
id bigserial primary key,
saga_id varchar(64) not null,
event_type varchar(64) not null,
event_id varchar(64) not null,
payload jsonb,
created_at timestamp not null
);
여기서 중요한 점은 saga_log입니다. 운영 장애에서 “이벤트가 왔는지, 왔다면 어떤 순서로, 어떤 페이로드로 왔는지”가 없으면 재현이 불가능합니다.
1단계: 상관관계 ID가 끝까지 살아있는지 확인
보상 트랜잭션 디버깅은 결국 “한 사가 인스턴스”를 추적하는 게임입니다. 아래 3가지는 무조건 통일해야 합니다.
- HTTP 요청 헤더:
X-Correlation-Id또는traceparent - 메시지 헤더:
correlationId,causationId,eventId - DB 레코드:
sagaId또는correlationId
예시: 이벤트 envelope 표준화
{
"eventId": "evt_01J...",
"correlationId": "saga_9f2b...",
"causationId": "evt_01J...",
"type": "PaymentReserved",
"occurredAt": "2026-02-24T12:34:56Z",
"data": {
"orderId": "ord_123",
"paymentId": "pay_456"
}
}
디버깅 시나리오에서 가장 흔한 실수는 “보상에 필요한 키가 이벤트에 없다”입니다. 예를 들어 취소하려면 paymentId가 필요한데, 정방향에서는 결제 서비스가 내부적으로만 알고 이벤트에는 orderId만 실어 보내는 경우가 있습니다. 그럼 보상 단계에서 매핑을 위해 추가 조회가 필요해지고, 조회 실패나 레이스 컨디션이 생깁니다.
2단계: 전달 보장과 중복을 전제로 설계했는지 점검
사가에서 보상 실패가 자주 보이는 이유는 대부분 메시징이 at-least-once이기 때문입니다. 즉 중복은 정상이며, 멱등성이 없으면 보상은 언젠가 반드시 깨집니다.
컨슈머 멱등성의 최소 구현
eventId를 처리 이력 테이블에 저장- 동일
eventId가 다시 오면 즉시 ack
create table consumed_event (
consumer_name varchar(64) not null,
event_id varchar(64) not null,
consumed_at timestamp not null,
primary key (consumer_name, event_id)
);
@Transactional
public void onMessage(EventEnvelope evt) {
boolean first = consumedEventRepository.tryInsert("payment-service", evt.eventId());
if (!first) {
return; // duplicate
}
// handle event
}
여기서 tryInsert는 유니크 키 충돌을 이용해 원자적으로 중복을 걸러야 합니다. 메모리 캐시로만 막으면 재시작 시 중복이 다시 들어옵니다.
3단계: 보상 로직은 반드시 멱등하게 만들기
보상은 “취소”라는 이름을 달고 있지만, 실제로는 상태 전이입니다. 따라서 보상 API는 다음 중 하나를 만족해야 합니다.
- 동일 요청을 여러 번 받아도 결과가 동일
- 이미 취소된 대상에 대해
200또는 비즈니스적으로 허용 가능한 응답
예시: 결제 취소 API의 멱등성 키
POST /payments/cancel
Idempotency-Key: cancel-pay_456
Content-Type: application/json
{"paymentId":"pay_456","reason":"SAGA_COMPENSATION"}
서버는 Idempotency-Key를 저장하고 동일 키 재요청 시 동일 결과를 반환합니다. 특히 타임아웃이 잦은 환경에서는 “서버는 성공했는데 클라이언트는 실패로 보고 재시도”가 흔합니다. 이때 멱등성 키가 없으면 이중 취소, 이중 환불 같은 치명적인 장애로 이어집니다.
4단계: 로컬 트랜잭션과 이벤트 발행을 분리하지 말기(Outbox)
보상 실패 디버깅에서 자주 나오는 패턴은 다음입니다.
- DB 업데이트는 커밋됨
- 이벤트 발행은 실패함
- 결과적으로 다음 서비스가 그 사실을 몰라 보상을 못 함
해결은 아웃박스 패턴입니다. “상태 변경”과 “이벤트 기록”을 같은 로컬 트랜잭션으로 묶고, 별도 퍼블리셔가 outbox를 읽어 브로커로 발행합니다.
Outbox 테이블 예시
create table outbox (
id bigserial primary key,
aggregate_type varchar(64) not null,
aggregate_id varchar(64) not null,
event_type varchar(64) not null,
payload jsonb not null,
published_at timestamp null,
created_at timestamp not null
);
Spring 예시: 상태 변경과 outbox 적재를 한 트랜잭션으로
@Transactional
public void cancelPaymentAsCompensation(String paymentId, String sagaId) {
Payment p = paymentRepository.findById(paymentId)
.orElseThrow();
if (p.isCancelled()) {
return; // idempotent
}
p.cancel("SAGA_COMPENSATION", sagaId);
paymentRepository.save(p);
OutboxEvent evt = OutboxEvent.of(
"Payment", paymentId,
"PaymentCancelled",
Map.of("paymentId", paymentId, "sagaId", sagaId)
);
outboxRepository.save(evt);
}
이 구조로 바꾸면 “보상은 성공했는데 오케스트레이터가 모른다” 같은 유령 장애가 크게 줄어듭니다.
5단계: 보상 실행 순서를 강제하거나, 순서 무관하게 만들기
보상은 보통 역순으로 실행해야 합니다. 예를 들어
- 재고 할당
- 결제 승인 순으로 진행했다면 보상은
- 결제 취소
- 재고 해제 가 아니라, 보통 “재고 해제 후 결제 취소” 또는 도메인 정책에 맞는 역순을 요구합니다.
문제는 이벤트 기반으로 각 서비스가 알아서 보상하면 순서가 깨질 수 있다는 점입니다.
대응 전략
- 오케스트레이터가 보상 커맨드를 단계별로 순차 발행
- 또는 각 보상이 선행 조건을 확인하고 조건 불만족이면 재시도(상태 기반)
예시: 오케스트레이터가 보상 커맨드를 단계별 발행
state=COMPENSATING
1) send InventoryReleaseCommand
2) wait InventoryReleased
3) send PaymentCancelCommand
4) wait PaymentCancelled
5) state=COMPENSATED
이때 “wait”는 동기 호출이 아니라, 이벤트 수신으로 상태 전이를 진행하는 의미입니다.
6단계: 재시도 정책이 보상을 더 망치고 있지 않은지 확인
보상 실패를 키우는 대표적인 설정 실수
- 무제한 재시도
- 짧은 고정 간격 재시도
- 모든 오류를 동일하게 재시도
권장
- 지수 백오프 + 지터
- 오류 분류:
4xx성격(비즈니스 불가)은 즉시 실패 처리,5xx성격은 재시도 - 최대 시도 횟수 초과 시 DLQ로 보내고 수동/반자동 처리
예시: 재시도와 DLQ 흐름(개념)
retry:
maxAttempts: 10
backoff: exponential
initialDelayMs: 200
maxDelayMs: 30000
jitter: true
onFailure:
sendTo: dlq.payment-cancel
DLQ에 쌓인 메시지는 “재처리 버튼”이 아니라 “원인 분석의 증거”입니다. DLQ 페이로드에는 반드시 eventId, correlationId, 마지막 에러, 시도 횟수, 마지막 시각이 포함되어야 합니다.
7단계: 타임아웃과 동시성으로 인한 유령 실패 잡기
보상은 외부 호출이 많아 타임아웃이 흔합니다. 이때 아래 상황이 자주 발생합니다.
- A가 보상 요청을 보냄
- 서버는 처리 완료
- 응답이 타임아웃
- A는 실패로 간주하고 재시도
- 서버는 “이미 취소됨”을 반환하거나, 멱등성 없으면 이중 처리
따라서 디버깅 시에는 “클라이언트 관점 실패”와 “서버 관점 성공”을 분리해서 봐야 합니다.
필수 체크
- 서버 로그에 실제 처리 완료 로그가 있는지
- APM 트레이스에서 서버 span이 정상 종료인지
- 서버 DB 상태가 바뀌었는지
- 멱등성 키가 적용되었는지
8단계: 트랜잭션 전파로 보상이 롤백되는 함정
Spring에서 자주 나오는 보상 실패 원인
- 보상 처리 메서드가 상위 트랜잭션에 묶여 예외로 전체 롤백
REQUIRES_NEW가 필요하지만 기본 전파로 처리됨- 체크 예외 처리로 롤백이 안 됨 또는 반대로 너무 광범위하게 롤백
예를 들어 “사가 로그 기록”은 실패해도 보상 자체를 롤백시키면 안 되는 경우가 많습니다. 반대로 “멱등성 처리 이력 기록”은 반드시 보상 처리와 원자적으로 묶여야 중복을 막습니다.
이 주제는 케이스가 다양하므로 아래 글을 함께 참고해 트랜잭션 전파/롤백 규칙을 다시 점검하는 것을 권합니다.
재현이 안 될 때: 결정적 리플레이를 위한 이벤트 로그
운영에서 보상 실패는 “그때만” 발생합니다. 따라서 사가 디버깅 체계의 끝은 리플레이 가능성입니다.
권장 접근
- 모든 도메인 이벤트를 저장(최소한 사가 관련 이벤트)
- 같은
correlationId로 묶어서 타임라인 조회 - 스테이징에서 동일 이벤트 스트림을 리플레이
예시: 사가 타임라인 쿼리
select created_at, event_type, event_id, payload
from saga_log
where saga_id = 'saga_9f2b...'
order by created_at asc;
이 결과가 있으면 “보상 이벤트가 안 왔다”인지, “왔는데 컨슈머가 죽었다”인지, “보상이 실패했는데 재시도가 이상했다”인지가 매우 빠르게 갈립니다.
실전 체크리스트: 보상 실패를 30분 안에 좁히기
A. 사가 인스턴스 관측
correlationId로 오케스트레이터 상태 조회 가능- 현재
state,step, 마지막 이벤트와 시간 확인 가능 - 사가 타임라인이 이벤트 단위로 남아 있음
B. 메시징
- 동일
eventId중복 소비 방지(영속 테이블) - DLQ 유무, DLQ 페이로드에 메타데이터 포함
- 파티션 키가
correlationId인지 확인(순서 필요 시)
C. 보상 API
- 멱등성 키 지원
- 이미 취소됨에 대한 응답 정책 합의
- 타임아웃과 재시도 정책 분리(네트워크 실패와 비즈니스 실패)
D. 데이터
- 보상에 필요한 식별자가 이벤트에 포함됨
- outbox로 상태 변경과 이벤트 발행의 원자성 확보
- 낙관적 락 또는 버전 필드로 동시 업데이트 감지
E. 트랜잭션
- 멱등성 처리 이력 저장은 보상 처리와 한 트랜잭션
- 로그/메트릭은 실패해도 보상 본처리를 깨지 않게 분리 고려
마무리: “보상 실패”는 시스템 설계의 거울
보상 트랜잭션 실패는 단순 버그가 아니라, 관측성 부족, 메시지 전달 가정 오류, 멱등성 부재, 트랜잭션 경계 실수 같은 구조적 문제가 한꺼번에 드러나는 지점입니다. 디버깅을 잘하려면 먼저 사가를 상태 머신으로 고정하고, 이벤트를 리플레이 가능한 형태로 남기며, 중복과 순서 뒤바뀜을 정상으로 받아들이는 설계를 해야 합니다.
다음 장애 때는 “왜 보상이 실패했지”에서 시작하지 말고, correlationId 하나로 사가 타임라인을 뽑고, outbox와 consumed event 테이블로 전달 보장과 중복 여부를 먼저 판별해 보세요. 원인 좁히는 속도가 체감될 정도로 빨라질 것입니다.