- Published on
MSA 사가 실패로 중복결제 터질 때 Outbox로 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 결제·주문·정산을 나눠 갖는 MSA에서 사가(Saga) 패턴은 자주 “정답처럼” 소개되지만, 운영에서 가장 무서운 실패 모드는 따로 있습니다. 바로 재시도(리트라이)와 타임아웃이 결합된 상태에서 결제 요청이 중복 처리되는 것입니다.
"결제 API는 한 번만 호출했는데 카드가 두 번 승인됐다" 같은 사고는 대개 (1) 서비스 간 이벤트 발행 유실, (2) 컨슈머 중복 처리, (3) 네트워크 타임아웃으로 인한 클라이언트 재시도가 겹치며 발생합니다. 이 글은 사가가 실패하는 현실적인 지점을 먼저 해부하고, 그 대응으로 가장 실전적인 해법인 Transactional Outbox + Idempotency(멱등성) 조합을 구현 관점에서 설명합니다.
> 운영 환경에서 네트워크·DNS·NAT 이슈로 타임아웃이 늘어나면 재시도가 폭증하면서 중복 처리 확률이 급격히 상승합니다. EKS에서 이런 “겉으로는 정상인데 실제 트래픽이 꼬이는” 상황은 아래 글의 진단 흐름이 도움이 됩니다: EKS Pod→S3 504 타임아웃 - VPC 엔드포인트·NAT·DNS 진단
사가 패턴이 중복결제를 만드는 전형적인 실패 시나리오
1) 오케스트레이션 사가에서의 “결제 성공 → 이벤트 유실”
결제 서비스가 DB에 PAYMENT_APPROVED를 커밋한 뒤, Kafka/RabbitMQ로 PaymentApproved 이벤트를 발행합니다. 그런데 발행 직후 네트워크 장애로 프로듀서 ACK를 못 받거나, 브로커에 쓰기 실패가 발생하면 어떻게 될까요?
- DB에는 결제 성공이 저장됨
- 주문 서비스는
PaymentApproved이벤트를 못 받아 주문 상태를 갱신하지 못함 - 오케스트레이터(또는 주문 서비스)는 타임아웃 후 “결제 실패로 간주”하고 재시도
- 결제 서비스는 같은 결제 요청을 또 처리 → 중복 승인
핵심은 DB 커밋과 메시지 발행이 원자적으로 묶이지 않는다는 점입니다. 이 간극이 바로 Outbox가 해결하는 영역입니다.
2) 코레오그래피 사가에서의 “컨슈머 중복 처리”
브로커가 최소 한 번(at-least-once) 전달을 보장하는 경우(대부분 그렇습니다), 컨슈머는 동일 이벤트를 두 번 받을 수 있습니다.
- 컨슈머가 처리 후 ACK를 보내기 전에 크래시
- 재밸런싱/리밋/네트워크 이슈로 재전달
컨슈머가 멱등하지 않으면, 주문 상태가 두 번 바뀌거나, 정산이 두 번 이뤄지거나, 환불/취소 보상 트랜잭션이 꼬이는 문제가 생깁니다.
3) 클라이언트 재시도 + 결제 PG 중복 승인
외부 결제 PG는 종종 “요청이 도착했는지”를 즉시 확답 못 하는 상황이 있습니다. 이때 클라이언트(또는 API Gateway)가 타임아웃으로 재시도하면, PG가 요청을 두 번 처리할 수 있습니다.
따라서 내부 이벤트 발행의 정확성뿐 아니라, **결제 요청 자체의 멱등키(idempotency key)**가 필수입니다.
Outbox 패턴이 해결하는 것과 해결하지 못하는 것
Outbox가 해결하는 것
DB 트랜잭션 커밋과 이벤트 기록을 하나의 원자적 작업으로 묶어 “결제는 성공했는데 이벤트는 유실” 같은 상태를 제거합니다.
- 결제 승인 저장
- 동일 트랜잭션에서 outbox 테이블에 이벤트 레코드 저장
이후 별도 프로세스(퍼블리셔)가 outbox를 읽어 브로커로 발행하고, 성공하면 outbox 상태를 SENT로 바꿉니다.
Outbox가 단독으로 해결하지 못하는 것
- 컨슈머 중복 처리(컨슈머 멱등 필요)
- 결제 PG 중복 승인(요청 멱등키 필요)
- 브로커의 exactly-once 보장(현실적으로 “effectively once”를 목표)
따라서 실무에서는 다음 3종 세트를 같이 둡니다.
- 결제 요청 멱등키(API 레벨)
- Transactional Outbox(프로듀서 레벨)
- 컨슈머 멱등 처리(컨슈머 레벨)
구현 설계: 결제 중복을 막는 데이터 모델
1) 결제 테이블: 멱등키로 유니크 보장
클라이언트가 Idempotency-Key를 보내고, 결제 서비스는 이를 payments.idempotency_key로 저장하며 UNIQUE 제약을 겁니다.
- 같은 키로 결제 요청이 두 번 와도 DB 레벨에서 중복 insert를 막고
- 이미 존재하면 기존 결과를 반환합니다.
2) Outbox 테이블
아래는 PostgreSQL 기준 예시입니다.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL, -- 예: 'payment'
aggregate_id TEXT NOT NULL, -- payment_id
event_type TEXT NOT NULL, -- 예: 'PaymentApproved'
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING, SENT, FAILED
idempotency_key TEXT, -- 선택: 이벤트 멱등
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
);
CREATE INDEX outbox_pending_idx ON outbox (status, created_at);
이벤트 멱등키를 왜 또 두나?
프로듀서가 같은 이벤트를 중복 발행할 수 있는 상황(퍼블리셔 크래시, 재시도 등)에 대비해, 이벤트 자체에도 event_id 또는 idempotency_key를 두고 컨슈머가 중복 제거할 수 있게 합니다.
핵심 트랜잭션: 결제 승인 + Outbox 기록을 한 번에
아래는 Python(FastAPI + SQLAlchemy) 스타일의 의사 코드입니다. 포인트는 결제 row 생성/갱신과 outbox insert가 같은 트랜잭션이라는 점입니다.
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
def approve_payment(session, order_id: str, amount: int, idem_key: str):
try:
with session.begin():
# 1) 멱등키로 결제 레코드 생성 (중복이면 기존 반환)
payment = Payment(order_id=order_id, amount=amount, idempotency_key=idem_key)
session.add(payment)
session.flush() # payment.id 확보
# 2) 결제 승인 처리 (실제로는 PG 승인 결과 반영)
payment.status = "APPROVED"
# 3) Outbox에 이벤트 적재
evt = Outbox(
aggregate_type="payment",
aggregate_id=str(payment.id),
event_type="PaymentApproved",
payload={
"paymentId": str(payment.id),
"orderId": order_id,
"amount": amount,
"idempotencyKey": idem_key,
},
status="PENDING",
idempotency_key=f"PaymentApproved:{payment.id}",
)
session.add(evt)
return payment
except IntegrityError:
# idem_key UNIQUE 충돌: 기존 결제 결과를 조회해 반환
existing = session.execute(
select(Payment).where(Payment.idempotency_key == idem_key)
).scalar_one()
return existing
이 구조의 장점은 명확합니다.
- 결제가 승인되어 커밋되었다면 outbox에도 반드시 이벤트가 남습니다.
- 이벤트 발행은 “트랜잭션 밖”에서 재시도 가능해집니다.
Outbox 퍼블리셔(릴레이) 구현: 안전한 폴링과 락
퍼블리셔는 보통 두 방식 중 하나입니다.
- 폴링(polling): 일정 주기로
PENDING을 가져와 발행 - CDC(Change Data Capture): Debezium 같은 도구로 outbox insert를 스트리밍
여기서는 구현 난이도가 낮고 많이 쓰는 폴링을 예시로 듭니다.
1) 동시 실행 안전성: SKIP LOCKED
여러 퍼블리셔 인스턴스가 떠도 동일 이벤트를 중복 집지 않도록 FOR UPDATE SKIP LOCKED를 씁니다.
WITH picked AS (
SELECT id
FROM outbox
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
)
SELECT o.*
FROM outbox o
JOIN picked p ON o.id = p.id;
2) 발행 후 상태 변경
발행 성공 시 SENT로 업데이트합니다.
def publish_loop(session, broker):
while True:
with session.begin():
rows = session.execute(text(SQL_PICK)).mappings().all()
for row in rows:
broker.publish(
topic=row["event_type"],
key=row["aggregate_id"],
value=row["payload"],
headers={"eventId": row["idempotency_key"] or str(row["id"])},
)
session.execute(
text("UPDATE outbox SET status='SENT', sent_at=now() WHERE id=:id"),
{"id": row["id"]},
)
sleep(0.2)
“브로커 publish가 성공했는데 DB 업데이트 전에 죽으면?”
이 경우 동일 outbox row가 다시 PENDING으로 남아 재발행될 수 있습니다. 그래서 컨슈머는 eventId 기반 중복 제거를 해야 합니다. 즉, Outbox는 “유실 방지”에 강하고, 중복은 “컨슈머 멱등”으로 마무리하는 설계가 일반적입니다.
컨슈머 멱등 처리: 중복 이벤트를 무해하게 만들기
주문 서비스가 PaymentApproved를 받아 주문 상태를 변경한다고 합시다. 아래 중 하나를 선택합니다.
- Processed Events 테이블:
eventId를 UNIQUE로 저장 - 주문 상태 전이 자체를 조건부 업데이트: 이미 PAID면 무시
둘 다 하는 경우도 많습니다(특히 금전 이벤트).
Processed Events 테이블 예시
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
def handle_payment_approved(session, event_id: str, payload: dict):
with session.begin():
# 1) 이벤트 중복 체크 (UNIQUE)
session.execute(
text("INSERT INTO processed_events(event_id) VALUES (:eid)"),
{"eid": event_id},
)
# 2) 주문 상태 갱신 (멱등)
session.execute(
text("""
UPDATE orders
SET status='PAID'
WHERE id=:order_id AND status <> 'PAID'
"""),
{"order_id": payload["orderId"]},
)
이 방식은 컨슈머가 크래시/재시도/재전달을 겪어도 결과가 안전합니다.
사가 보상 트랜잭션과 Outbox의 결합 포인트
사가가 실패하면 보상 트랜잭션(예: PaymentCancelRequested)을 발행해야 합니다. 이때도 동일한 원칙을 적용합니다.
- “보상 상태 저장”과 “보상 이벤트 outbox 적재”를 한 트랜잭션으로
- 보상 이벤트도 eventId를 부여해 컨슈머 멱등 처리
특히 결제 취소는 외부 PG와 연동되므로, 취소 요청에도 멱등키를 적용해야 합니다(동일 cancelId로 중복 취소 호출 방지).
운영 관점 체크리스트: 중복결제를 ‘기술적으로’ 줄이는 방법
1) 타임아웃과 재시도 정책을 “금전 트랜잭션”에 맞게
- API Gateway/클라이언트 재시도는 무조건 좋은 게 아닙니다.
- 결제 승인 API는 짧은 타임아웃 + 제한된 재시도 + idem_key 강제가 기본입니다.
2) 장애 시나리오를 주기적으로 리허설
- 브로커 장애
- DB failover
- 퍼블리셔 프로세스 강제 종료
- 컨슈머 크래시 후 재시작
이런 상황에서 outbox 적재/재발행/중복 제거가 의도대로 동작하는지 확인해야 합니다.
3) EKS에서 “겉보기 정상” 함정 줄이기
서비스는 살아 있는데 네트워크 정책/보안그룹/CNI 문제로 특정 방향 트래픽만 막히면, 결제 이벤트가 한쪽으로만 유실되는 형태가 나옵니다. 이런 경우 빠른 진단 루틴이 중요합니다: EKS Pod는 뜨는데 트래픽 0 - NetPol·SG·CNI 10분 진단
또한 readiness가 정상처럼 보여도 실제 의존성(브로커, DB) 연결이 실패하는 케이스가 있어, 프로브 설계를 재점검해야 합니다: EKS에서 Readiness 실패인데 로그는 정상일 때
결론: “사가만으로는 부족”하고, Outbox가 간극을 메운다
사가 패턴은 분산 트랜잭션을 다루는 좋은 도구지만, 운영에서 중복결제를 막으려면 다음을 분리해서 설계해야 합니다.
- 요청 멱등성: 같은 결제 요청은 한 번만 승인
- 이벤트 유실 방지: 승인 결과는 반드시 이벤트로 이어짐(Transactional Outbox)
- 중복 전달 무해화: 컨슈머 멱등 처리로 효과적 exactly-once 달성
즉, “결제 승인과 이벤트 발행 사이의 틈”에서 사고가 나고, Outbox는 그 틈을 구조적으로 제거합니다. 여기에 멱등키와 컨슈머 중복 제거까지 갖추면, 사가가 실패하거나 재시도가 난무하는 상황에서도 중복결제는 ‘발생하기 어렵고, 발생해도 자동으로 무해화되는’ 수준까지 끌어내릴 수 있습니다.