- Published on
MSA Saga 보상 트랜잭션 설계 실수 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 각자 로컬 트랜잭션을 커밋하는 MSA 환경에서는, 실패 시점에 전체를 되돌리는 전통적인 2PC(분산 트랜잭션)가 현실적으로 어렵습니다. 그래서 많이 선택하는 것이 **사가(Saga)**이고, 그 핵심이 바로 보상 트랜잭션(Compensating Transaction) 입니다.
문제는 “실패하면 보상하면 되지”라는 단순한 접근이 실제 운영에서 거의 항상 깨진다는 점입니다. 보상은 Undo가 아니라 새로운 비즈니스 트랜잭션이며, 네트워크/재시도/중복 실행/시간 지연/부분 성공 같은 분산 시스템의 모든 어려움을 그대로 안고 갑니다. 이 글에서는 현장에서 자주 터지는 보상 트랜잭션 설계 실수 7가지를 짚고, 각 실수별로 예방 설계와 구현 팁을 제공합니다.
사가(Saga)와 보상 트랜잭션을 다시 정의하기
사가에는 크게 두 형태가 있습니다.
- 오케스트레이션(Orchestration): 중앙 오케스트레이터가 단계별 커맨드를 호출하고, 실패 시 보상 커맨드를 호출
- 코레오그래피(Choreography): 서비스들이 이벤트를 발행/구독하며 흐름이 구성되고, 실패 이벤트에 반응해 보상 진행
보상 트랜잭션은 다음 특징을 가집니다.
- 원 트랜잭션을 “정확히” 되돌리는 것이 아니라, 비즈니스적으로 동등한 상태로 되돌리는 것
- 항상 즉시 실행되지 않을 수 있으며, 지연과 재시도가 전제
- 중복 실행될 수 있으므로 멱등성(idempotency) 이 필수
이 전제를 놓치면 아래 실수들로 직행합니다.
실수 1) 보상을 ‘DB 롤백’처럼 설계한다
가장 흔한 오해는 “결제 승인했으면 승인 취소 API 호출하면 끝”처럼 보상을 단순 롤백으로 보는 것입니다. 하지만 현실에서는:
- 결제 취소 가능 시간/부분취소/취소 수수료
- 재고는 이미 다른 주문에 재할당
- 쿠폰/포인트는 이미 사용 처리 후 만료
등으로 인해 완전한 역연산이 불가능한 경우가 많습니다.
예방 설계
- 보상은 역연산(undo) 이 아니라 상태 전이(state transition) 로 모델링
- “원상복구” 대신 정의 가능한 목표 상태를 둠
- 예:
PAID -> REFUND_PENDING -> REFUNDED - 예:
RESERVED -> RELEASE_REQUESTED -> RELEASED
- 예:
코드 예시(상태 전이 기반 도메인 모델)
public enum PaymentStatus {
AUTHORIZED,
CAPTURED,
REFUND_PENDING,
REFUNDED,
REFUND_FAILED
}
public class Payment {
private PaymentStatus status;
public void requestRefund() {
if (status != PaymentStatus.CAPTURED) {
throw new IllegalStateException("Only captured payments can be refunded");
}
status = PaymentStatus.REFUND_PENDING;
}
public void markRefunded() {
if (status != PaymentStatus.REFUND_PENDING) {
throw new IllegalStateException("Refund must be pending");
}
status = PaymentStatus.REFUNDED;
}
}
실수 2) 보상 트랜잭션에 멱등성이 없다
분산 환경에서 재시도는 기본값입니다. HTTP 타임아웃, 메시지 중복 전달, 컨슈머 재처리 등으로 같은 보상 요청이 여러 번 실행될 수 있습니다. 멱등성이 없으면:
- 환불이 2번 나감
- 재고가 음수로 풀림
- 포인트가 중복 복구됨
이런 사고는 “정합성”이 아니라 “돈” 문제로 번집니다.
참고로 타임아웃/재시도는 애플리케이션 로직이 아니라 인프라와 클라이언트 정책에서도 쉽게 발생합니다. 운영에서 타임아웃을 다루는 감각은 필수인데, 유사한 장애 감각은 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드 같은 글에서 다루는 접근(재현→관측→정책 조정)과도 닮아 있습니다.
예방 설계
- 모든 커맨드/보상 커맨드에 Idempotency Key(=Command ID) 를 부여
- 처리 결과를 저장하고, 같은 키로 재요청 시 동일 결과를 반환
- 가능하면 외부 결제/배송 같은 연동도 idempotency 지원 여부를 확인
코드 예시(멱등 처리 테이블)
CREATE TABLE saga_command_log (
command_id VARCHAR(64) PRIMARY KEY,
command_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
result_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@Transactional
public CommandResult handleRefund(String commandId, RefundRequest req) {
var existing = commandLogRepository.findById(commandId);
if (existing.isPresent()) {
return CommandResult.fromJson(existing.get().getResultJson());
}
// 실제 환불 로직
payment.requestRefund();
paymentRepository.save(payment);
var result = CommandResult.success();
commandLogRepository.save(new CommandLog(commandId, "REFUND", "DONE", result.toJson()));
return result;
}
실수 3) 보상 순서를 “원 트랜잭션 역순”으로만 고정한다
교과서적으로는 정방향 실행의 역순으로 보상합니다. 하지만 실제로는 역순이 항상 안전하지 않습니다.
예시:
재고 예약 -> 결제 캡처 -> 배송 생성- 실패 시 역순 보상:
배송 취소 -> 결제 환불 -> 재고 해제
여기서 배송 취소가 실패하거나 지연되면 환불을 늦춰야 할까? 반대로 환불을 먼저 하면 고객 경험은 좋아지지만 배송이 이미 출고되면 손실이 커질 수 있습니다.
예방 설계
- 보상 순서를 “역순”이 아니라 리스크/비용/고객경험 기준으로 재정의
- 보상 간 의존성을 명시하고, 필요하면 병렬 보상(독립 단계)을 허용
- 보상 불가 단계(배송 출고 등)는 “보상”이 아니라 후속 처리(Claim/CS workflow) 로 분리
코드 예시(의존성 기반 보상 플로우)
compensationPlan:
- step: cancelShipment
dependsOn: []
timeout: 2m
- step: refundPayment
dependsOn: [cancelShipment]
timeout: 30s
- step: releaseInventory
dependsOn: [refundPayment]
timeout: 30s
실수 4) 보상을 “즉시 동기 호출”로만 처리한다
오케스트레이터가 실패를 감지하자마자 각 서비스 보상을 동기 HTTP 체인으로 호출하면, 장애 시 다음이 터집니다.
- 한 서비스 지연이 전체 사가를 블로킹
- 재시도 폭증으로 연쇄 부하
- 타임아웃이 늘어나며 실패가 더 늘어나는 악순환
이건 특히 인그레스/로드밸런서 레벨에서 5xx/504로 관측되며, 원인은 애플리케이션이 아니라 “동기 체인 설계”인 경우가 많습니다. 비슷한 운영 관점은 EKS ALB Ingress 504(5xx) 간헐 발생 원인·해결 같은 케이스에서 크게 체감합니다.
예방 설계
- 보상은 기본적으로 비동기(메시지/잡) 로 전환
- 오케스트레이터는 “호출”이 아니라 보상 작업을 스케줄
- 각 보상 단계는 독립적으로 재시도/백오프/서킷브레이커 적용
코드 예시(Outbox로 보상 이벤트 발행)
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload_json TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@Transactional
public void scheduleCompensation(String sagaId, String type, String payloadJson) {
outboxRepository.save(new OutboxEvent(sagaId, type, payloadJson));
// 같은 트랜잭션에서 커밋되어야 "저장했는데 발행 못함"을 줄일 수 있음
}
실수 5) “부분 성공”을 상태 모델에 넣지 않는다
사가의 실패는 보통 완전 실패가 아니라 부분 성공입니다.
- 결제는 성공했는데 재고 예약이 실패
- 재고는 됐는데 쿠폰 적용이 실패
- 보상 중 환불은 성공했는데 재고 해제가 실패
부분 성공을 모델링하지 않으면 운영자는 “지금 이 주문이 어떤 상태인지” 알 수 없고, 자동 복구도 불가능해집니다.
예방 설계
- 사가 인스턴스에 대해 명시적 상태 머신을 둠
- 단계별 상태:
PENDING / DONE / COMPENSATING / COMPENSATED / FAILED - UI/CS/운영툴에서 조회 가능한 단일 진실 소스(Single Source of Truth) 를 마련
코드 예시(사가 상태 테이블)
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),
last_error TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE saga_step (
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
attempt INT NOT NULL DEFAULT 0,
last_error TEXT,
PRIMARY KEY (saga_id, step_name)
);
실수 6) 보상에 “관측 가능성(Observability)”이 없다
보상은 보통 장애 상황에서 실행됩니다. 그런데 로그에 refund failed 한 줄만 남으면, 원인 분석은 지옥이 됩니다.
필수로 있어야 할 것:
- 사가 ID(상관관계 ID), 커맨드 ID
- 단계별 시작/종료 시간, 시도 횟수
- 외부 연동 호출의 응답 코드/에러 분류(재시도 가능 vs 불가)
- 대시보드에서 “보상 대기/실패 적체”를 보는 큐/잡 지표
특히 스레드/동시성 모델 변화(예: 가상 스레드 도입) 이후 지연이 늘어나면 보상 큐가 뒤늦게 폭발하기도 합니다. 이런 진단 감각은 Spring Boot 3 가상스레드 적용 후 지연·데드락 진단처럼 “지연이 어떻게 증폭되는지”를 파고드는 방식이 도움이 됩니다.
코드 예시(구조화 로깅 + 상관관계 ID)
public void compensateRefund(String sagaId, String commandId) {
MDC.put("sagaId", sagaId);
MDC.put("commandId", commandId);
long start = System.currentTimeMillis();
try {
log.info("compensation.start step=refund");
refundService.refund(commandId);
log.info("compensation.success step=refund elapsedMs={}", System.currentTimeMillis() - start);
} catch (RetryableException e) {
log.warn("compensation.retryable step=refund elapsedMs={} err={}",
System.currentTimeMillis() - start, e.toString());
throw e;
} finally {
MDC.clear();
}
}
실수 7) “보상 불가능” 케이스를 설계에서 배제한다
현실에는 보상이 불가능하거나, 보상 비용이 너무 큰 케이스가 존재합니다.
- 외부 시스템이 취소를 지원하지 않음
- 취소 가능 시간 초과
- 이미 출고/사용/소비됨
- 법/정책상 환불이 제한됨
이런 케이스를 무시하면 시스템은 결국 영구 실패 상태를 쌓아두고, 운영자는 수동 처리로 소진됩니다.
예방 설계
- 각 단계에 대해 보상 가능 여부를 사전에 분류
COMPENSATABLE,REQUIRES_MANUAL,NON_COMPENSATABLE
- 보상 실패가 일정 횟수/시간을 넘으면 Dead Letter + 티켓/알림으로 전환
- 고객 커뮤니케이션(환불 지연 안내 등)을 자동화할 이벤트도 함께 설계
코드 예시(재시도 한도 초과 시 DLQ 전송)
public void runCompensationStep(SagaStep step) {
try {
execute(step);
markDone(step);
} catch (Exception e) {
step.incrementAttempt();
if (step.getAttempt() >= 5) {
dlqPublisher.publish("COMPENSATION_FAILED", step.toJson());
markFailed(step, e);
} else {
scheduleRetry(step, backoff(step.getAttempt()));
}
}
}
설계 체크리스트(실전용)
아래 항목을 “예/아니오”로 점검해보면, 보상 설계의 구멍이 빠르게 드러납니다.
- 보상은 역연산이 아닌 도메인 상태 전이로 정의되어 있는가?
- 모든 커맨드/보상 커맨드가 멱등인가(저장소 기반 중복 방지 포함)?
- 보상 순서가 단순 역순이 아니라 리스크 기반으로 조정 가능한가?
- 보상이 비동기로 실행되어 장애 전파를 줄이는가?
- 부분 성공/보상 진행 중 상태가 사가 상태 머신에 반영되는가?
- 사가 ID로 end-to-end 추적 가능한 관측성(로그/메트릭/트레이스)이 있는가?
- 보상 불가능 케이스가 프로세스(수동 처리/알림/DLQ) 로 설계되어 있는가?
마무리: “정합성”보다 “운영 가능성”이 먼저다
사가에서 보상 트랜잭션은 정합성을 위한 장치이지만, 실제로는 운영 가능성을 결정하는 설계 요소입니다. 멱등성, 상태 모델, 비동기 실행, 관측성, 보상 불가능 케이스까지 포함해 설계하지 않으면 시스템은 언젠가 “되돌릴 수 없는 중간 상태”를 쌓고 멈춥니다.
보상 설계가 어렵게 느껴진다면, 먼저 각 단계에 대해 다음 한 문장으로 정의해보세요.
- “이 단계가 성공한 뒤 실패하면, 우리는 무엇을 어떤 상태로 만들면 비즈니스적으로 수용 가능한가?”
이 문장에 답이 명확해질수록, 사가는 더 단단해집니다.