- Published on
MSA SAGA 보상 트랜잭션 중복 실행 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 SAGA를 도입하면 분산 트랜잭션을 피해가면서도 “전체적으로는 일관성 있게” 비즈니스를 완결할 수 있습니다. 문제는 보상 트랜잭션(compensation)이 생각보다 자주 중복 실행된다는 점입니다.
- 메시지 브로커의 at-least-once 전달
- 컨슈머 재시작/리밸런스
- 네트워크 타임아웃으로 인한 재시도
- 오케스트레이터 장애 후 재구동
이런 환경에서 보상 트랜잭션이 2번 이상 실행되면, 환불이 중복되거나 재고가 음수로 내려가거나, 이미 취소된 주문이 또 취소되면서 장애로 이어집니다.
이 글에서는 “보상은 반드시 한 번만 실행되어야 한다”는 기대를 버리고, 중복 실행되어도 안전하도록 설계하면서 동시에 중복 실행 자체를 최대한 차단하는 방법을 단계적으로 정리합니다.
참고로 메시지 기반 처리에서 정확히 한 번을 보장하기 어려울 때 Outbox로 현실적인 해법을 구성하는 방식은 아래 글과 결이 같습니다.
왜 보상 트랜잭션이 중복 실행되는가
SAGA는 크게 오케스트레이션과 코레오그래피로 나뉘지만, 어떤 방식이든 보상은 결국 “어딘가에서 이벤트/커맨드를 받아 수행”하는 형태가 됩니다. 이때 중복이 생기는 대표 원인은 다음과 같습니다.
1) at-least-once 전달의 본질
대부분의 브로커(Kafka 포함)는 기본적으로 at-least-once를 현실적인 기본값으로 둡니다. 컨슈머가 처리 후 커밋하기 전에 장애가 나면 같은 메시지를 다시 받습니다.
2) 타임아웃과 재시도
오케스트레이터가 결제 취소 API를 호출했는데 응답이 늦어 타임아웃이 나면, “실패로 간주하고 재시도”합니다. 하지만 실제로는 결제 취소가 이미 성공했을 수 있습니다.
3) 분산 환경의 경합
동일한 보상 커맨드가 두 개의 워커에 의해 거의 동시에 처리되는 경우도 있습니다(리밸런스/파티션 이동/락 부재 등).
결론은 간단합니다.
- 보상 트랜잭션은 중복 실행을 전제로 설계해야 합니다.
- 동시에, 중복 실행을 DB 제약과 상태머신으로 강하게 차단해야 합니다.
목표 정의: “중복 보상 방지”를 3층으로 나누기
현장에서 가장 견고한 접근은 보통 아래 3층을 함께 적용하는 것입니다.
- 입력 중복 제거(Inbox / Dedup store): 같은 커맨드/이벤트를 두 번 처리하지 않기
- 도메인 레벨 멱등성: 설령 두 번 들어와도 결과가 한 번과 같게 만들기
- 상태 전이 제약(상태머신 + 조건부 업데이트): 잘못된 타이밍의 보상을 막기
이 중 하나만으로는 구멍이 생기기 쉽고, 2개 이상을 조합해야 “운영에서 버티는” 수준이 됩니다.
핵심 1: 보상 커맨드에 멱등성 키를 박아라
보상 트랜잭션을 막연히 “주문ID로 취소” 같은 형태로 만들면, 중복 여부를 판단하기가 어렵습니다. 보상에는 반드시 유일하게 식별 가능한 키가 있어야 합니다.
권장 키 구성 예시:
sagaId(한 SAGA 인스턴스 식별)stepName또는action(예:RESERVE_INVENTORY,CHARGE_PAYMENT)commandId(각 커맨드의 유일 ID, UUID)
보상 커맨드 예시(JSON):
{
"sagaId": "a3d1c9b0-4b8c-4b3b-9e2d-2dcb3f0f2e3a",
"commandId": "c7a0f6c4-0e5b-4b8d-9a3c-9d2d0a9b1e10",
"type": "CANCEL_PAYMENT",
"orderId": "ORD-20240224-0001",
"reason": "INVENTORY_RESERVATION_FAILED"
}
중요 포인트는 orderId만으로는 부족하다는 점입니다. 같은 주문에서도 여러 단계의 보상이 있을 수 있고, 동일 단계라도 재시도/부분 실패가 생깁니다. 따라서 “이 보상 요청 자체”를 대표하는 commandId가 필요합니다.
핵심 2: Inbox 테이블로 1차 중복 제거(유니크 제약)
메시지 소비 측(보상 수행 서비스)에서 가장 강력하고 단순한 방어는 Inbox 테이블 + 유니크 키입니다.
테이블 예시
CREATE TABLE inbox_message (
id BIGSERIAL PRIMARY KEY,
command_id UUID NOT NULL,
saga_id UUID NOT NULL,
message_type VARCHAR(64) NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
processed_at TIMESTAMP NULL,
status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED',
payload JSONB NOT NULL,
UNIQUE (command_id)
);
처리 흐름
- 메시지 수신
- 트랜잭션 시작
inbox_message에command_id로 insert 시도
- 성공하면 “처리 권한 획득”
- 유니크 충돌이면 이미 처리(또는 처리 중)이므로 ack 하고 종료
- 도메인 로직 수행
processed_at,status=PROCESSED업데이트- 커밋
의사 코드(예: Spring 스타일)
@Transactional
public void handleCancelPayment(CancelPaymentCommand cmd) {
boolean inserted = inboxRepository.tryInsert(cmd.commandId(), cmd.sagaId(), "CANCEL_PAYMENT", cmd);
if (!inserted) {
// 중복 메시지: 이미 처리했거나 누군가 처리 중
return;
}
paymentService.cancel(cmd.orderId(), cmd.reason());
inboxRepository.markProcessed(cmd.commandId());
}
tryInsert는 DB에서 유니크 제약을 이용해 원자적으로 중복을 걸러야 합니다. 애플리케이션 메모리 캐시로 dedup을 하면 재시작 시 무용지물이 됩니다.
핵심 3: 도메인 작업 자체를 “멱등하게” 만들어라
Inbox로 대부분 막을 수 있지만, 다음 상황이 남습니다.
- insert는 성공했는데 처리 도중 장애 발생
paymentService.cancel은 성공했는데markProcessed전에 장애 발생
이 경우 재처리되면 command_id가 이미 inbox에 있으니 “중복으로 건너뛴다”로 끝낼 수도 있지만, 문제는 처리 상태가 PROCESSING에서 멈춘 메시지를 어떻게 회복하느냐입니다.
현실적으로는 “PROCESSING이 일정 시간 이상이면 재처리” 같은 리커버리 정책이 들어가고, 그 순간 보상 로직이 다시 실행될 수 있습니다. 따라서 보상 API/도메인 변경은 멱등해야 합니다.
결제 취소를 멱등하게 만드는 방법
- 결제사에
idempotencyKey를 전달(가능한 경우) - 내부 DB에
refund레코드를command_id로 유니크하게 기록
예: refund 테이블 유니크 제약
CREATE TABLE payment_refund (
id BIGSERIAL PRIMARY KEY,
command_id UUID NOT NULL,
order_id VARCHAR(64) NOT NULL,
amount NUMERIC(18,2) NOT NULL,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (command_id)
);
환불 처리 의사 코드:
@Transactional
public void cancel(String orderId, String reason, UUID commandId) {
boolean created = refundRepository.tryCreate(commandId, orderId, computeAmount(orderId));
if (!created) {
// 이미 동일 commandId로 환불이 생성됨: 멱등 처리
return;
}
// 외부 결제사 호출(가능하면 idempotency key로 commandId 전달)
pgClient.refund(orderId, commandId.toString());
refundRepository.markSucceeded(commandId);
}
즉, “취소 요청이 중복될 수 있다”가 아니라 “중복되어도 결과가 같게” 만드는 것입니다.
핵심 4: 상태머신 + 조건부 업데이트로 잘못된 보상 자체를 차단
중복 실행뿐 아니라, 순서가 꼬인 보상도 문제입니다.
예:
- 결제 승인 이벤트가 늦게 도착
- 오케스트레이터가 실패로 판단해 결제 취소를 보냄
- 실제로는 결제 승인도 성공했고, 취소도 실행되어 이상 상태
이를 줄이려면 각 단계의 상태를 명시하고, 보상은 특정 상태에서만 허용해야 합니다.
주문/결제 상태 예시
PAYMENT_PENDINGPAYMENT_CHARGEDPAYMENT_CANCEL_REQUESTEDPAYMENT_CANCELLED
보상 커맨드 처리 시 조건부 업데이트를 사용합니다.
UPDATE payment
SET status = 'PAYMENT_CANCEL_REQUESTED', updated_at = NOW()
WHERE order_id = $1
AND status IN ('PAYMENT_CHARGED');
업데이트된 row 수가 0이면 다음 중 하나입니다.
- 이미 취소 요청/취소 완료
- 아직 결제 승인 전
- 다른 흐름이 상태를 바꿈
이 경우 보상 로직은 “아무 것도 하지 않음” 또는 “추가 확인 후 종료”로 가야 합니다. 핵심은 상태 전이의 단방향성과 조건을 DB가 강제하게 만드는 것입니다.
핵심 5: Outbox/Inbox 조합으로 “보상 이벤트 발행 중복”도 막기
보상 실행을 막는 것만큼, 보상 결과 이벤트(예: PaymentCancelled) 발행의 중복도 흔한 장애 원인입니다.
- 보상은 한 번만 됐는데 이벤트가 두 번 나가서 다운스트림이 두 번 처리
이때는 Outbox 패턴이 정석입니다.
- 보상 처리 트랜잭션 안에서
outbox_event에 insert - 별도 퍼블리셔가 outbox를 읽어 브로커로 발행
- 발행 성공 시 outbox 상태 업데이트
이 구조를 쓰면 “DB 반영은 됐는데 이벤트 발행이 안 됨” 또는 “이벤트는 나갔는데 DB 반영이 안 됨” 같은 분리 실패를 줄일 수 있습니다.
관련 구현 감각은 아래 글이 참고됩니다.
운영에서 자주 놓치는 디테일
1) Dedup 저장소의 TTL을 섣불리 짧게 잡지 말기
command_id 중복 제거를 7일만 유지하면, 8일 뒤 늦게 재전달된 메시지가 다시 처리될 수 있습니다. “이벤트가 얼마나 늦게 도착할 수 있는가”와 “재처리 윈도우”를 기준으로 TTL을 잡아야 합니다.
- 결제/정산처럼 돈이 걸리면 TTL을 매우 길게(또는 영구) 가져가는 경우가 많습니다.
2) PROCESSING stuck 메시지의 회복 전략
Inbox에 status를 두는 이유는 stuck를 다루기 위해서입니다.
RECEIVED또는PROCESSING이 N분 이상이면 재시도 대상으로 전환- 단, 재시도는 결국 중복 실행 가능성을 올리므로 도메인 멱등성이 필수
3) 컨슈머 동시성에서의 경합
여러 스레드/인스턴스가 동시에 같은 command_id를 insert하려고 할 때, 유니크 제약이 최종 방어선입니다. 애플리케이션 락은 보조 수단일 뿐입니다.
4) 재시도 정책과 백오프
보상 호출이 외부 API에 걸리면 재시도 설계가 중요합니다. 무한 재시도는 중복/부하/정합성 문제를 키웁니다.
- 지수 백오프
- 최대 재시도 횟수
- 재시도 가능한 오류/불가능한 오류 분리
재시도 설계 자체는 아래 글의 접근(429/과부하 대응)이 분산 시스템에서 그대로 응용됩니다.
실전 조합 레시피(권장 아키텍처)
보상 트랜잭션 중복 실행을 “현실적으로” 막는 조합을 정리하면 다음이 가장 많이 통합니다.
- 보상 커맨드에
command_id를 부여하고 전 구간 전달 - 소비 서비스는 Inbox 테이블에
UNIQUE(command_id)로 1차 중복 제거 - 보상 도메인 로직은
command_id기반 유니크 제약으로 멱등 처리(환불/재고복구 등) - 상태머신 + 조건부 업데이트로 “가능한 상태에서만 보상” 수행
- 보상 결과 이벤트는 Outbox로 발행하여 중복/누락을 최소화
이렇게 하면 “중복 메시지”와 “재시도”와 “부분 실패”가 동시에 있어도, 시스템이 망가지지 않고 수렴합니다.
간단 예시: 재고 예약 보상(재고 복구) 흐름
재고 서비스에서 RESTORE_INVENTORY 보상을 처리한다고 가정합니다.
1) Inbox로 중복 제거
INSERT INTO inbox_message(command_id, saga_id, message_type, payload)
VALUES ($1, $2, 'RESTORE_INVENTORY', $3)
ON CONFLICT (command_id) DO NOTHING;
2) 재고 복구를 멱등하게
재고 복구도 “한 번만 증가”해야 하므로, inventory_adjustment 로그를 command_id로 유니크하게 남깁니다.
CREATE TABLE inventory_adjustment (
id BIGSERIAL PRIMARY KEY,
command_id UUID NOT NULL,
sku VARCHAR(64) NOT NULL,
qty INT NOT NULL,
type VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(command_id)
);
처리 트랜잭션:
-- 1) adjustment 생성(중복이면 0 row)
INSERT INTO inventory_adjustment(command_id, sku, qty, type)
VALUES ($1, $2, $3, 'RESTORE')
ON CONFLICT (command_id) DO NOTHING;
-- 2) 실제 재고 증가(단, 위 insert가 성공했을 때만 수행되게 애플리케이션에서 분기)
UPDATE inventory
SET available = available + $3
WHERE sku = $2;
애플리케이션에서는 adjustment insert 성공 여부를 보고 재고 업데이트를 수행합니다. 이로써 동일 command_id의 보상이 두 번 들어와도 재고는 한 번만 복구됩니다.
마무리: “중복을 막는” 게 아니라 “중복에도 안전한” 구조로
MSA SAGA에서 보상 트랜잭션 중복 실행을 완벽히 0으로 만드는 것은 어렵습니다. 대신 다음 원칙을 지키면 운영에서 사고 확률이 급격히 내려갑니다.
- 보상 커맨드에 멱등성 키(
command_id)를 설계에 포함 - Inbox(유니크 제약)로 입력 중복 제거
- 도메인 변경도
command_id기반 유니크 제약으로 멱등화 - 상태머신 + 조건부 업데이트로 잘못된 보상 실행 차단
- Outbox로 결과 이벤트 중복/누락을 최소화
이 조합은 “장애가 나도 결국 수렴하는 시스템”을 만드는 가장 실용적인 방법입니다.