- Published on
MSA 트랜잭션 Saga 보상 실패·중복처리 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 각자 DB를 갖는 순간, 전통적인 2PC(분산 트랜잭션)는 현실적으로 선택지에서 빠집니다. 대신 Saga로 “로컬 트랜잭션의 연쇄 + 실패 시 보상 트랜잭션”을 구현합니다. 문제는 운영에서입니다. 실패는 반드시 발생하고, 메시지는 중복되며, 보상은 더 자주 실패합니다. 이 글은 보상 실패와 중복 처리를 ‘원인→증상→해결 패턴’으로 정리하고, Spring 기반 예제 코드까지 포함해 실제로 안정화하는 방법을 제시합니다.
Saga에서 가장 위험한 두 가지: 보상 실패와 중복 처리
1) 보상(Compensation) 실패가 치명적인 이유
보상은 “실패를 되돌리는 함수”처럼 보이지만, 실제로는 다음 특성을 가집니다.
- 보상도 외부 의존성(다른 서비스/DB/결제사)을 타므로 실패할 수 있음
- 원 트랜잭션과 동일한 경로가 아님: 데이터가 이미 변했거나, 리소스가 사라졌거나, 정책이 바뀌었을 수 있음
- 시간이 지나서 실행될 수 있음(지연/재시도/운영자 개입)
결과적으로 “실패하면 롤백하면 되지”가 아니라, 실패한 보상 자체를 추적하고, 재시도하고, 결국 사람이 개입할 수 있게 만들어야 합니다.
2) 중복 처리(duplicate)가 기본값인 이유
메시지 브로커(Kafka/RabbitMQ/SQS 등) 기반 비동기에서는 보통 at-least-once 전달이 기본입니다.
- 생산자 재시도
- 컨슈머 ack 타이밍 이슈
- 네트워크 분할
- 컨슈머 재시작
이 모든 것이 “같은 이벤트가 2번 이상 처리됨”을 정상 시나리오로 만듭니다. 따라서 Saga 설계에서 멱등성(idempotency) 은 옵션이 아니라 필수입니다.
전제: 로컬 트랜잭션부터 제대로 (Spring @Transactional 함정)
Saga는 “로컬 트랜잭션이 정확히 커밋/롤백 된다”는 가정 위에 서 있습니다. 그런데 Spring에서 @Transactional이 의도대로 동작하지 않으면, Saga는 시작부터 무너집니다(이벤트는 발행됐는데 DB는 롤백되거나, 반대로 DB는 커밋됐는데 이벤트가 누락되는 등).
Spring에서 트랜잭션이 무시되는 대표 케이스는 별도로 정리해 둔 글을 참고하세요: Spring Boot 3에서 @Transactional 무시되는 7가지
이 글의 나머지 내용은 “각 서비스의 로컬 트랜잭션이 정상”이라는 가정에서 진행합니다.
해결 전략 개요: ‘복구 가능성’을 설계한다
보상 실패·중복 처리를 동시에 잡는 실전 전략은 보통 아래 4개를 조합합니다.
- Outbox 패턴: DB 커밋과 이벤트 발행의 원자성 확보
- 멱등성 키 + 처리 로그: 중복 메시지 무해화
- Saga 상태머신 + 재시도 정책: 보상 실패를 “상태”로 관리
- DLQ/수동 복구 플로우: 자동으로 못 고치는 건 사람이 고친다
이제 각각을 구체적으로 구현해 봅니다.
1) Outbox로 “커밋된 사실만” 이벤트로 만든다
문제
서비스 A에서 주문 생성 후 OrderCreated 이벤트를 발행한다고 합시다.
- DB 커밋 성공
- 이벤트 발행 실패(네트워크/브로커 장애)
이 경우 다운스트림 서비스는 주문 생성 사실을 모릅니다. 반대로,
- 이벤트 발행 성공
- DB 커밋 실패
이면 “존재하지 않는 주문”을 기반으로 Saga가 진행됩니다.
해결: Transactional Outbox
로컬 DB에 outbox 테이블을 두고, 업무 데이터 변경과 outbox insert를 같은 트랜잭션으로 묶습니다. 별도 퍼블리셔가 outbox를 폴링/CDC로 읽어 브로커에 발행합니다.
-- outbox 테이블 예시 (PostgreSQL)
create table outbox_event (
id uuid primary key,
aggregate_type varchar(50) not null,
aggregate_id varchar(100) not null,
event_type varchar(100) not null,
payload jsonb not null,
status varchar(20) not null default 'NEW',
created_at timestamptz not null default now(),
published_at timestamptz null
);
create index idx_outbox_new on outbox_event(status, created_at);
// Spring 예시: 주문 생성 + outbox 기록을 같은 트랜잭션으로
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public UUID createOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(Order.create(cmd));
OutboxEvent evt = OutboxEvent.newEvent(
"Order", order.getId().toString(),
"OrderCreated",
Map.of("orderId", order.getId(), "amount", order.getAmount())
);
outboxRepository.save(evt);
return order.getId();
}
}
퍼블리셔는 status=NEW를 가져가 발행 후 PUBLISHED로 바꿉니다. 여기서도 중복 발행 가능성이 있으므로, 이벤트 id를 메시지 키로 사용하거나 컨슈머 측 멱등성으로 흡수해야 합니다.
2) 중복 처리는 멱등성으로 ‘무력화’한다
핵심 원칙
- 메시지를 “한 번만 처리”하려고 애쓰지 말고
- 두 번 처리해도 결과가 같게 만들 것
패턴 A: Idempotency Key + processed_message 테이블
컨슈머는 메시지의 고유 키(예: eventId)를 processed_message에 기록하고, 이미 처리된 키면 바로 ack 합니다.
create table processed_message (
consumer varchar(100) not null,
message_id uuid not null,
processed_at timestamptz not null default now(),
primary key (consumer, message_id)
);
@Service
@RequiredArgsConstructor
public class PaymentConsumer {
private final ProcessedMessageRepository processedRepo;
private final PaymentRepository paymentRepo;
@Transactional
public void onMessage(PaymentRequestedEvent event) {
// 1) 멱등성 체크
if (processedRepo.existsByConsumerAndMessageId("payment", event.eventId())) {
return;
}
// 2) 실제 처리(로컬 트랜잭션)
paymentRepo.save(Payment.request(event.orderId(), event.amount()));
// 3) 처리 완료 마킹 (같은 트랜잭션)
processedRepo.save(new ProcessedMessage("payment", event.eventId()));
}
}
포인트는 2) 비즈니스 처리와 3) 처리 마킹이 같은 트랜잭션이어야 한다는 점입니다. 그렇지 않으면 “처리는 됐는데 마킹 실패”로 다시 중복 처리됩니다.
패턴 B: Upsert로 멱등성 보장
특정 리소스를 생성하는 이벤트라면 insert ... on conflict do nothing 같은 DB upsert로 멱등성을 확보할 수 있습니다.
insert into payment(order_id, amount, status)
values (:orderId, :amount, 'REQUESTED')
on conflict (order_id) do nothing;
이 방식은 별도의 processed 테이블 없이도 “결과가 한 번만 생기게” 만들 수 있지만, 이벤트별로 유연성이 떨어질 수 있습니다.
3) 보상 실패는 ‘상태’로 만들고 재시도·관측 가능하게 한다
보상 실패의 전형적인 시나리오
예: 주문 Saga
- OrderCreated
- 재고 예약 성공
- 결제 승인 실패
- 재고 보상(예약 취소) 호출
여기서 4가 실패하면 주문은 “결제 실패인데 재고는 잡혀있는” 이상 상태가 됩니다.
해결: Saga 인스턴스 상태머신
각 Saga 인스턴스를 DB에 저장하고, 단계별 상태를 기록합니다.
- 현재 단계
- 마지막 에러
- 재시도 횟수/다음 재시도 시간
- 보상 필요 여부
간단한 모델 예시:
create table saga_instance (
saga_id uuid primary key,
saga_type varchar(50) not null,
status varchar(30) not null, -- RUNNING, COMPENSATING, FAILED, COMPLETED
step varchar(50) not null,
retry_count int not null default 0,
next_retry_at timestamptz null,
last_error text null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
보상 실패 시 FAILED로 끝내는 게 아니라, 보통은 COMPENSATING 상태에서 재시도 가능한 작업 큐로 넘깁니다.
재시도 정책(권장)
- 지수 백오프 + 지터: 1m, 5m, 15m, 1h…
- 최대 횟수 제한 후 DLQ/수동 처리
- 비재시도 에러 분리(예: 4xx 정책 위반, 이미 취소됨 등)
public record RetryPlan(int retryCount, Duration delay) {
static RetryPlan next(int current) {
int next = current + 1;
long seconds = switch (next) {
case 1 -> 30;
case 2 -> 120;
case 3 -> 600;
default -> 3600;
};
return new RetryPlan(next, Duration.ofSeconds(seconds));
}
}
보상 로직은 “역연산”이 아니라 “정상화”로 작성
보상 함수가 단순히 -1 같은 역연산이면 좋겠지만, 현실에서는 다음을 고려해야 합니다.
- 이미 다른 프로세스가 상태를 바꿨을 수 있음
- 보상 대상이 존재하지 않을 수 있음
- 부분 성공이 발생할 수 있음
따라서 보상은 “원래대로 되돌리기”보다 목표 상태로 수렴시키는 정상화(convergent) 작업으로 작성하는 편이 안전합니다.
예: 재고 예약 취소는 reservationId가 없으면 “이미 취소/만료됨”으로 간주하고 성공 처리.
4) 보상/중복을 더 악화시키는 동시성 문제: 락과 데드락
Saga는 여러 서비스가 동시에 같은 리소스를 만지기 때문에 DB 락 경합이 쉽게 생깁니다. 특히 “처리 로그 테이블 + 비즈니스 테이블”을 같은 트랜잭션에서 갱신하면 데드락 가능성이 올라갑니다.
- 항상 같은 순서로 테이블/로우를 잠그기
- 짧은 트랜잭션 유지
- 필요한 인덱스 구성
PostgreSQL 데드락을 재현하고 탐지하는 방법은 아래 글이 도움이 됩니다: PostgreSQL 데드락 40P01 재현·탐지·해결
5) “정확히 한 번(exactly-once)” 환상을 버리고, 경계를 정한다
MSA 전체에서 exactly-once를 달성하려면 비용이 급격히 증가합니다. 실무에서는 다음 경계가 합리적입니다.
- 서비스 내부: 로컬 트랜잭션 + 멱등성으로 사실상 exactly-once에 가깝게
- 서비스 간: at-least-once를 받아들이고, 중복 무해화로 안정화
- 보상: 자동 복구(재시도) + 수동 복구(runbook)로 운영 가능하게
6) 엔드투엔드 예시: 주문 Saga(오케스트레이션)에서 보상 실패 처리
아래는 오케스트레이터가 Saga 상태를 저장하고, 단계별 커맨드를 발행하며, 실패 시 보상 커맨드를 발행하는 흐름의 축약 예시입니다.
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final SagaRepository sagaRepository;
private final CommandPublisher commandPublisher;
@Transactional
public void start(UUID sagaId, UUID orderId, long amount) {
sagaRepository.save(SagaInstance.start(sagaId, "OrderSaga", "RESERVE_STOCK"));
commandPublisher.publishReserveStock(sagaId, orderId);
}
@Transactional
public void onStockReserved(UUID sagaId, UUID orderId) {
SagaInstance saga = sagaRepository.getForUpdate(sagaId);
saga.moveTo("REQUEST_PAYMENT");
commandPublisher.publishRequestPayment(sagaId, orderId);
}
@Transactional
public void onPaymentFailed(UUID sagaId, UUID orderId, String reason) {
SagaInstance saga = sagaRepository.getForUpdate(sagaId);
saga.startCompensating("CANCEL_STOCK", reason);
commandPublisher.publishCancelStock(sagaId, orderId);
}
@Transactional
public void onCancelStockFailed(UUID sagaId, String error) {
SagaInstance saga = sagaRepository.getForUpdate(sagaId);
saga.markFailedAndScheduleRetry(error, RetryPlan.next(saga.getRetryCount()));
// 별도 리트라이 워커가 next_retry_at 이후 재시도 커맨드 발행
}
}
여기서 중요한 점:
getForUpdate같은 방식으로 Saga 인스턴스를 직렬화(같은 sagaId에 대한 동시 이벤트 충돌 방지)- 보상 실패는
FAILED로 끝내지 않고 재시도 스케줄링 - 커맨드 발행도 Outbox로 묶어 “상태 변경 + 발행”을 일관되게 유지
7) 운영 관점 체크리스트: 장애를 ‘보이게’ 해야 고친다
보상 실패·중복 처리는 코드만으로 끝나지 않습니다. 최소한 아래를 갖추면 MTTR이 급감합니다.
- Saga 상태 대시보드:
RUNNING/COMPENSATING/FAILED카운트 - DLQ 모니터링 + 알람
- 보상 실패 사유(HTTP status, error code) 집계
- 특정 sagaId로 전 구간 트레이싱(traceId 상속)
- 수동 복구 API/스크립트: “이 sagaId 재시도”, “강제 완료 처리”
결론: 보상 실패와 중복은 ‘예외’가 아니라 ‘기본 시나리오’다
Saga를 도입하면 분산 트랜잭션 문제를 피할 수 있지만, 그 대가로 보상 실패와 중복 처리를 설계로 해결해야 합니다. 정리하면:
- Outbox로 이벤트/커맨드 발행의 일관성을 확보하고
- 컨슈머는 멱등성을 기본값으로 두며
- Saga 상태머신으로 보상 실패를 상태로 관리하고 재시도/수동복구 경로를 만든다
이 3가지만 제대로 갖추면, “가끔 터지는 분산 장애”가 “복구 가능한 운영 이벤트”로 바뀝니다.