보상 트랜잭션이 실패해도 Saga가 끝까지 살아남는 법 — 멱등성과 상태 머신으로 분산 롤백 완료시키기
Saga 패턴을 처음 도입했을 때 저도 착각했습니다. "보상 트랜잭션만 구현해두면 실패해도 괜찮겠지." 그런데 프로덕션에 올린 지 얼마 안 돼서 이런 상황을 마주쳤습니다. 결제 서비스가 실패해서 재고 릴리즈 보상을 실행하는 도중에, 하필 재고 서비스 자체가 다운돼버린 거죠. Saga는 중간 어딘가에서 멈췄고, 시스템 재시작 후엔 어디까지 진행됐는지조차 알 수 없었습니다.
보상 트랜잭션이 실패하는 것 자체가 진짜 문제의 시작입니다. 일반적인 ACID 트랜잭션은 DB 엔진이 자동으로 롤백해주지만, 분산 환경에서는 그런 보호막이 없습니다. 보상이 실패하면 데이터 불일치가 그대로 굳어버리고, 이걸 나중에 수동으로 복구하는 건 정말 고통스럽습니다.
이 글에서는 보상 트랜잭션이 실패하더라도 분산 롤백을 끝까지 완료시킬 수 있는 두 가지 핵심 메커니즘 — 멱등성(Idempotency) 과 상태 머신(State Machine) — 을 어떻게 설계하고 조합하는지 살펴봅니다. 개념 설명보다는 실제로 어떤 코드를 작성해야 하는지에 집중합니다.
왜 보상 트랜잭션은 그냥 재시도로는 안 될까
Saga의 본질적 한계
Chris Richardson의 microservices.io에서 정의하듯, Saga는 각 서비스가 자체 로컬 트랜잭션을 실행하고 실패 시 이미 완료된 단계를 역순으로 되돌리는 방식입니다. 핵심은 보상 트랜잭션이 데이터를 '이전 상태로 복원'하는 게 아니라 비즈니스 의미론적으로 이전 작업을 논리적으로 되돌리는 별도의 연산이라는 점입니다.
'결제 취소'는 결제 레코드를 DELETE하는 게 아니라 상태를 '환불 완료'로 전이시키는 새로운 비즈니스 연산입니다. 이 말은 보상 자체도 실패할 수 있고, 심지어 같은 보상이 두 번 실행될 수도 있다는 뜻입니다.
아래는 이커머스 주문 Saga에서 발생하는 전형적인 실패 시나리오입니다.
보상 실패의 두 축과 재시도의 함정
보상 실패는 크게 두 종류로 나뉩니다. 그리고 이와 별개로, 재시도 자체를 잘못 설계하면 원래 없던 문제가 새로 생깁니다.
| 실패 유형 | 원인 | 대응 전략 |
|---|---|---|
| 일시적 실패 | 네트워크 타임아웃, 서비스 재시작 중 | 지수 백오프로 재시도 |
| 영구적 실패 | 외부 API 거부, 비즈니스 규칙 위반 | 보상 포기 후 DLQ, 수동 개입 |
두 유형을 구분하지 못하면 재시도를 해야 할 때 포기하거나, 포기해야 할 때 무한 재시도 루프에 빠집니다. 그리고 이 구분과는 별개로, 보상 액션이 멱등하지 않으면 재시도 자체가 새로운 버그를 만들어냅니다. 재시도 폭풍으로 인한 이중 환불이 대표적입니다. 그래서 재시도 정책보다 먼저 멱등성을 확보해야 합니다.
멱등성 — 보상을 몇 번 실행해도 안전하게 만들기
왜 멱등성이 필수인가
솔직히 이 부분을 처음엔 대수롭지 않게 여겼습니다. "어차피 보상은 한 번만 실행되겠지." 그런데 네트워크 응답이 유실되면 조율자는 실패로 판단하고 재시도합니다. 보상이 멱등하지 않으면 '환불 $50'이 두 번 실행되어 '환불 $100'이 되는 상황이 생깁니다.
멱등성 키(Idempotency Key) 는 각 보상 실행을 유일하게 식별하는 값입니다. saga_id + step_id 조합을 키로 사용하면, 같은 보상 요청이 N번 들어와도 처음 한 번만 실제로 실행됩니다.
구현 예시 (개념적 예시 — PostgreSQL 기반)
CREATE TABLE idempotency_records (
idempotency_key VARCHAR(255) PRIMARY KEY,
saga_id UUID NOT NULL,
step_id VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL, -- 'PROCESSING', 'COMPLETED', 'FAILED'
result JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);# 개념적 예시 (Python pseudo-code)
def execute_compensating_action(saga_id: str, step_id: str, action_fn):
idempotency_key = f"{saga_id}:{step_id}"
# 1) 짧은 트랜잭션: 상태를 PROCESSING으로 선점 (유니크 제약이 경합을 막음)
with db.transaction():
row = db.execute(
"""
INSERT INTO idempotency_records (idempotency_key, saga_id, step_id, status)
VALUES (%s, %s, %s, 'PROCESSING')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING status
""",
[idempotency_key, saga_id, step_id]
).fetchone()
if row is None:
existing = db.execute(
"SELECT status, result FROM idempotency_records WHERE idempotency_key = %s",
[idempotency_key]
).fetchone()
if existing["status"] == "COMPLETED":
return existing["result"]
if existing["status"] == "PROCESSING":
raise AlreadyInProgressError(idempotency_key)
# FAILED면 아래로 진행해 재시도 허용 (별도 상태 전이 로직)
# 2) 트랜잭션 밖에서 외부 호출 — DB 커넥션/락을 잡지 않음
try:
result = action_fn()
except Exception:
with db.transaction():
db.execute(
"UPDATE idempotency_records SET status='FAILED', updated_at=NOW() WHERE idempotency_key=%s",
[idempotency_key]
)
raise
# 3) 짧은 트랜잭션: 결과 확정
with db.transaction():
db.execute(
"UPDATE idempotency_records SET status='COMPLETED', result=%s, updated_at=NOW() WHERE idempotency_key=%s",
[result, idempotency_key]
)
return result여기엔 실전에서 자주 놓치는 긴장관계가 있습니다. "멱등성 확인과 보상 실행이 하나의 원자적 단위여야 한다"는 원칙은 맞지만, 외부 HTTP 호출을 DB 트랜잭션 안에서 실행하면 커넥션과 락이 응답 대기 시간만큼 유지되어 커넥션 풀이 금방 고갈됩니다. 그래서 유니크 제약으로 경합을 막고 PROCESSING 중간 상태를 먼저 선점한 뒤, 외부 호출은 트랜잭션 밖에서 수행하는 3단계 구조가 안전합니다. PROCESSING 레코드가 오래 남아있으면 죽은 워커의 잔재이므로, 별도 스캐너가 타임아웃 기준으로 회수·재분배합니다.
Inbox 패턴으로 수신 측 멱등성 보장
멱등성 키는 발신 측뿐 아니라 수신 측에서도 필요합니다. Inbox 패턴을 사용하면 메시지 브로커에서 같은 이벤트가 두 번 전달되더라도 한 번만 처리할 수 있습니다.
// 개념적 예시 (Java — Inbox 패턴)
@Transactional
public void handleCompensationEvent(CompensationEvent event) {
String messageId = event.getMessageId();
// 경쟁 조건을 피하려면 존재 확인 후 INSERT가 아니라
// messageId 유니크 제약 + 중복 키 예외 처리로 판정한다.
try {
inboxRepository.saveNew(new InboxRecord(messageId, event.getSagaId()));
} catch (DuplicateKeyException e) {
log.info("Duplicate message ignored: {}", messageId);
return;
}
// 실제 보상 로직은 같은 트랜잭션 안에서 수행 (외부 호출은 별도 워커로 분리하는 것을 권장)
inventoryService.releaseReservation(event.getOrderId(), event.getQuantity());
}existsBy... 후 save는 두 스레드가 동시에 통과할 수 있는 전형적인 TOCTOU 경합입니다. @Transactional만으로는 막히지 않으니, 반드시 유니크 제약과 중복 키 예외 처리를 조합해야 합니다.
상태 머신 — Saga가 어디에 있는지 항상 알고 있기
상태를 퍼시스턴트하게 저장하는 이유
멱등성만으로는 부족합니다. 시스템이 재시작되면 "어느 단계까지 완료했고, 어디서 보상을 시작해야 하는지" 알 수가 없습니다. 이것이 상태 머신이 필요한 이유입니다.
Dorin Baba의 프로덕션 사례에서 언급되듯, 상태 머신이 단계별 상태를 퍼시스턴트하게 저장하면 어떤 단계에서도 N번 재시도가 가능하며, N-1번 실패해도 N번째에 성공하면 Saga가 정상 완료됩니다.
이커머스 주문 Saga의 상태 전이를 모델링하면 이렇습니다. 여기서 중요한 점 하나는 보상이 완료된 종단 상태와 진짜 실패 상태를 분리하는 것입니다. 둘 다 FAILED로 뭉뚱그리면 운영 대시보드에서 '보상이 잘 마무리된 케이스'와 '데이터 불일치가 남은 케이스'를 구분할 수 없게 됩니다.
상태 테이블 설계
CREATE TABLE saga_instances (
saga_id UUID PRIMARY KEY,
saga_type VARCHAR(100) NOT NULL,
current_state VARCHAR(100) NOT NULL,
saga_data JSONB NOT NULL, -- Saga 컨텍스트 (주문 ID, 금액 등)
retry_count INTEGER DEFAULT 0,
last_updated_at TIMESTAMPTZ DEFAULT NOW(),
timeout_at TIMESTAMPTZ -- 타임아웃 전이를 위한 데드라인
);
CREATE TABLE saga_state_history (
id BIGSERIAL PRIMARY KEY,
saga_id UUID NOT NULL REFERENCES saga_instances(saga_id),
from_state VARCHAR(100),
to_state VARCHAR(100) NOT NULL,
event_type VARCHAR(100),
occurred_at TIMESTAMPTZ DEFAULT NOW()
);상태 전이 로직 구현 (개념적 예시 — Java)
전이 규칙이 10쌍을 넘길 수 있으므로 Map.of() 대신 Map.ofEntries()로 초기화합니다(Map.of는 최대 10쌍까지만 허용).
// 개념적 예시 (Java)
@Service
public class OrderSagaOrchestrator {
private static final Map<String, List<String>> VALID_TRANSITIONS = Map.ofEntries(
Map.entry("STARTED", List.of("INVENTORY_RESERVING")),
Map.entry("INVENTORY_RESERVING", List.of("PAYMENT_PROCESSING", "INVENTORY_COMPENSATING", "FAILED")),
Map.entry("PAYMENT_PROCESSING", List.of("DELIVERY_SCHEDULING", "INVENTORY_COMPENSATING")),
Map.entry("DELIVERY_SCHEDULING", List.of("COMPLETED", "PAYMENT_COMPENSATING")),
Map.entry("PAYMENT_COMPENSATING", List.of("INVENTORY_COMPENSATING", "COMPENSATION_FAILED")),
Map.entry("INVENTORY_COMPENSATING", List.of("COMPENSATED", "COMPENSATION_FAILED"))
);
@Transactional
public void transition(UUID sagaId, String toState, String eventType) {
SagaInstance saga = sagaRepository.findById(sagaId)
.orElseThrow(() -> new SagaNotFoundException(sagaId));
String fromState = saga.getCurrentState();
if (!VALID_TRANSITIONS.getOrDefault(fromState, List.of()).contains(toState)) {
throw new InvalidStateTransitionException(fromState, toState);
}
saga.setCurrentState(toState);
saga.setLastUpdatedAt(Instant.now());
sagaRepository.save(saga);
stateHistoryRepository.save(StateHistory.of(sagaId, fromState, toState, eventType));
triggerNextAction(saga, toState);
}
}타임아웃 전이로 무한 대기 방지
상태 머신의 또 다른 역할은 Saga가 무한 대기에 빠지지 않도록 가드레일을 제공하는 겁니다. 결제 서비스 응답을 기다리다 정해진 시간이 지나면 자동으로 보상 흐름으로 전이시키는 것이죠.
# 개념적 예시 — 타임아웃 스캐너 (Python)
import asyncio
async def timeout_scanner():
while True:
stale_sagas = db.query("""
SELECT saga_id, current_state, saga_data
FROM saga_instances
WHERE timeout_at < NOW()
AND current_state NOT IN ('COMPLETED', 'COMPENSATED', 'FAILED', 'COMPENSATION_FAILED')
""").fetchall()
for saga in stale_sagas:
await publish_event(
topic="saga.timeout",
payload={
"saga_id": str(saga["saga_id"]),
"timed_out_state": saga["current_state"]
}
)
await asyncio.sleep(30)하이브리드 복구 전략 — 재시도, 보상, DLQ를 언제 쓸지
실무에서 자리 잡은 흐름
재시도 → DLQ → 수동 개입이라는 흐름 자체는 오래된 관행입니다. Saga의 맥락에서 정리하면 대체로 다음과 같은 구조로 수렴합니다.
- 일시적 실패 → 지수 백오프 + 지터(Jitter)로 재시도. 지터 없이 일괄 재시도하면 다운스트림 서비스에 재시도 폭풍이 발생합니다.
- 재시도 한도 초과 또는 영구 실패 → DLQ로 전송하고 운영팀에 알림.
- DLQ 깊이(depth) > 0 → 데이터 불일치가 누적 중이라는 경보 신호.
DLQ 깊이는 "보상이 끝까지 도달하지 못한 사건 수"를 그대로 보여주는 지표이므로, 운영 대시보드에서 눈에 잘 띄는 자리에 두는 게 좋습니다. 이걸 SLO에 넣을지 여부는 팀마다 다르지만, 최소한 알림 임계치는 반드시 걸어두어야 합니다.
지수 백오프 + 지터 구현
Go 1.21부터 min이 내장 함수이므로 별도로 정의하지 않습니다.
// 개념적 예시 (Go 1.21+)
package saga
import (
"context"
"math"
"math/rand"
"time"
)
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
func RetryWithBackoff(ctx context.Context, cfg RetryConfig, fn func() error) error {
var lastErr error
for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
if err := fn(); err == nil {
return nil
} else {
lastErr = err
}
delay := time.Duration(math.Pow(2, float64(attempt))) * cfg.BaseDelay
jitter := time.Duration(rand.Int63n(int64(delay/2) + 1))
sleep := min(delay+jitter, cfg.MaxDelay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(sleep):
}
}
return lastErr
}Transactional Outbox로 트리거 신뢰성 높이기
보상 이벤트를 발행했지만 로컬 DB 커밋이 실패하는 상황(또는 그 반대)을 방지하려면 Transactional Outbox 패턴이 필요합니다.
CREATE TABLE saga_outbox (
id BIGSERIAL PRIMARY KEY,
saga_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
published_at TIMESTAMPTZ, -- NULL이면 미발행
created_at TIMESTAMPTZ DEFAULT NOW()
);# 보상 이벤트를 같은 로컬 트랜잭션에 기록 (개념적 예시)
with db.transaction():
db.execute(
"UPDATE saga_instances SET current_state = 'INVENTORY_COMPENSATING' WHERE saga_id = %s",
[saga_id]
)
db.execute("""
INSERT INTO saga_outbox (saga_id, event_type, payload)
VALUES (%s, 'COMPENSATE_INVENTORY', %s)
""", [saga_id, {"order_id": order_id, "quantity": quantity}])
# 트랜잭션 커밋 후 Outbox 폴러가 메시지 브로커로 발행.
# 프로세스가 죽어도 DB에 이벤트가 남아 있으므로 재시작 후 발행 가능.실전 도구 선택
Temporal — Saga를 코드로 작성하는 대표적 접근
Temporal은 Saga를 장기 실행 워크플로우로 취급해, 워커 장애 이후에도 매우 높은 신뢰도로 보상이 실행되도록 해줍니다(Namespace 삭제·영속 스토어 손실·워크플로우 코드의 비결정적 변경 같은 예외는 존재하므로 "무조건 보장"은 아닙니다). 자동 재시도, 타임아웃 관리, 버저닝을 플랫폼 수준에서 제공하기 때문에 상태 머신과 재시도 로직을 직접 처음부터 만들지 않아도 됩니다.
Temporal에서는 activity를 반드시 @activity.defn으로 등록된 최상위 함수로 전달해야 합니다. 워크플로우는 결정적 재실행(deterministic replay)이 전제라 lambda나 클로저는 직렬화되지 않고 런타임에서 실패합니다.
# Temporal Python SDK를 사용한 Saga 워크플로우 (개념적 예시)
from dataclasses import dataclass
from datetime import timedelta
from temporalio import workflow, activity
from temporalio.common import RetryPolicy
@dataclass
class OrderRequest:
order_id: str
quantity: int
amount: int
@activity.defn
async def reserve_inventory(order: OrderRequest) -> None: ...
@activity.defn
async def release_inventory(order: OrderRequest) -> None: ...
@activity.defn
async def process_payment(order: OrderRequest) -> None: ...
@activity.defn
async def cancel_payment(order: OrderRequest) -> None: ...
@activity.defn
async def schedule_delivery(order: OrderRequest) -> None: ...
@workflow.defn
class OrderSagaWorkflow:
@workflow.run
async def run(self, order: OrderRequest) -> str:
# 보상 스택: (activity 함수, 인자) 튜플로 보관해야 직렬화·재실행이 안전하다.
compensations: list[tuple] = []
try:
await workflow.execute_activity(
reserve_inventory, order,
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3),
)
compensations.append((release_inventory, order))
await workflow.execute_activity(
process_payment, order,
start_to_close_timeout=timedelta(seconds=60),
retry_policy=RetryPolicy(maximum_attempts=3),
)
compensations.append((cancel_payment, order))
await workflow.execute_activity(
schedule_delivery, order,
start_to_close_timeout=timedelta(seconds=30),
)
return "ORDER_COMPLETED"
except Exception:
for comp_fn, comp_arg in reversed(compensations):
await workflow.execute_activity(
comp_fn, comp_arg,
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=10),
)
return "ORDER_COMPENSATED"프레임워크별 선택 기준
| 플랫폼/프레임워크 | 언어 | 언제 선택하면 좋을까 |
|---|---|---|
| Temporal | 다중 언어 | 복잡한 워크플로우, 장기 실행 Saga, 운영 가시성이 중요할 때 |
| Axon Framework | Java/Kotlin | Spring Boot 생태계, 이벤트 소싱과 함께 사용할 때 |
| Eventuate Tram Saga | Java | Chris Richardson의 선언적 Saga 정의가 필요할 때 |
| MassTransit | C#/.NET | .NET 생태계, 상태 머신 DSL이 필요할 때 |
| AWS Step Functions | 다중 언어 | 서버리스 환경, AWS 인프라와 강결합된 경우 |
트레이드오프 — 솔직한 이야기
Saga가 해결하지 못하는 것
Saga를 도입하면서 팀이 기대하는 것 중 현실에서 맞지 않는 게 있습니다.
더티 리드(Dirty Read) 문제. Saga가 진행 중인 중간 상태를 다른 서비스가 읽을 수 있습니다. 결제 처리 중에 주문이 '결제 완료' 상태로 잠깐 보일 수 있다는 뜻입니다. 임시 격리 전략(예: '예약 중' 상태 명시)이 별도로 필요합니다.
수동 개입 제로는 없습니다. Saga는 결과적 일관성을 보장하지만, DLQ로 흘러간 보상 실패는 운영팀이 손으로 처리해야 합니다. 여행 예약 시스템에서 외부 항공사 API 보상이 실패하면, 내부 시스템이 아무리 잘 만들어져 있어도 항공사 측 프로세스와 조율해야 합니다.
상태 폭발(State Explosion). 상태 머신의 상태 수가 늘어날수록 설계와 테스트가 복잡해집니다. 특히 보상 순서 의존성이 있는 경우(선행 보상이 완료돼야 후행 보상 가능)를 모두 모델링하면 상태 다이어그램이 금방 복잡해집니다.
Saga가 만능은 아닙니다. 강결합 레거시 위에 올린 서비스, 규제상 2PC를 유지해야 하는 일부 금융/지불 정산 파이프라인, 초저지연 강일관성이 요구되는 경우처럼 Saga가 어울리지 않는 자리도 여전히 많습니다. Saga는 "많은 경우에 합리적인 기본값"일 뿐 자동 선택지가 아닙니다.
| 설계 결정 | 이점 | 비용 |
|---|---|---|
| 멱등성 키 도입 | 재시도 안전, 데이터 오염 방지 | DB 조회 오버헤드, 키 관리 복잡도 |
| 상태 머신 퍼시스턴스 | 재시작 후 복구 가능, 디버깅 용이 | 추가 DB 쓰기, 스키마 관리 |
| Transactional Outbox | 이벤트 발행 신뢰성 보장 | Outbox 폴러 운영, 지연 발생 가능 |
| DLQ + 수동 개입 | 보상 불가 상황의 안전망 | 운영 대시보드 필요, 운영팀 교육 |
마무리 — 어디서부터 손대면 좋을까
Saga 패턴이 분산 트랜잭션 문제를 '해결'한다는 말은 절반만 맞습니다. 정확히는, 자동 롤백이 없는 환경에서 일관성을 달성하는 복잡성을 애플리케이션 코드와 인프라로 이전시키는 것입니다.
이미 Saga가 돌아가는 시스템이 있다면, 다음 순서로 뜯어보는 걸 권합니다.
- 보상 액션 하나를 골라 두 번 호출해 보세요. 결과가 달라진다면 멱등성 키부터 도입해야 합니다. 이걸 안 갖춰두면 뒤에 뭘 붙여도 재시도가 곧 버그가 됩니다.
- Saga 인스턴스 테이블이 없다면 그다음이 상태 머신입니다. 재시작 후 "지금 어디에 있는 saga인가"를 SQL 한 줄로 답할 수 없다면 복구 자동화는 시작할 수 없습니다.
- 그다음이 관측 지표입니다. 특히 DLQ 깊이,
PROCESSING상태에서 일정 시간 이상 머무는 saga 수, 보상 재시도 횟수 분포 — 이 세 신호가 대시보드에 없으면, 데이터 불일치는 사용자 컴플레인이 올라와서야 발견됩니다.
세 가지를 순서대로 갖추면 Saga는 '평시엔 잘 되지만 장애 때 예측 불가능한 시스템'에서 '장애가 나도 결과적으로 스스로 봉합되는 시스템'으로 바뀝니다. 프로덕션에서 이 차이는 새벽에 울리는 페이지 수로 꽤 정직하게 드러납니다.
참고 자료
- Microsoft Azure Architecture Center — Compensating Transaction Pattern
- Microsoft Azure Architecture Center — Saga Design Pattern
- microservices.io — Saga Pattern (Chris Richardson)
- microservices.io — Transactional Outbox Pattern
- Temporal — Mastering Saga Patterns for Distributed Transactions in Microservices
- Temporal — Saga Pattern Documentation
- Medium / Dorin Baba — How we used SAGA and State Machine for distributed transactions
- Orkes.io — Compensation Transaction Patterns