- Published on
MSA 사가 패턴 중복결제 버그, Outbox로 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 결제 플로우를 나눠 갖는 MSA에서 사가(Saga) 패턴을 도입하면, 분산 트랜잭션 없이도 비즈니스 프로세스를 이어갈 수 있습니다. 하지만 운영에서 가장 자주 터지는 사고 중 하나가 바로 중복결제입니다. 특히 네트워크 타임아웃, 메시지 재전송, 컨슈머 재시작 같은 “정상적인 장애”가 겹치면, 결제 서비스가 같은 주문을 두 번 결제해버리는 상황이 쉽게 만들어집니다.
이 글에서는 사가 패턴에서 중복결제가 발생하는 전형적인 원인을 짚고, Outbox 패턴을 중심으로 “한 번만 결제되도록” 만드는 설계를 단계적으로 정리합니다. 예시는 Spring Boot + PostgreSQL + Kafka 조합으로 설명하지만, 핵심은 기술보다 원자성(atomicity)과 멱등성(idempotency)을 어디에 두느냐입니다.
사가에서 중복결제가 생기는 전형적인 시나리오
1) 최소 한 번(at-least-once) 전달의 함정
대부분의 메시지 브로커/컨슈머는 기본적으로 at-least-once 전달을 제공합니다. 즉, 컨슈머가 메시지를 처리했지만 ack 이전에 죽으면 브로커는 같은 메시지를 다시 보냅니다. 이때 결제 API가 “같은 주문인데 또 결제”를 허용하면 바로 중복결제로 이어집니다.
2) 오케스트레이터 재시도와 타임아웃
오케스트레이션 사가에서 오케스트레이터가 결제 서비스 호출 후 타임아웃이 나면 재시도합니다. 실제로 결제는 성공했지만 응답이 늦었을 뿐이라면, 재시도 호출이 두 번째 결제를 만들 수 있습니다.
3) DB 커밋과 이벤트 발행의 불일치(dual write)
가장 위험한 케이스는 이겁니다.
- 결제 서비스가 DB에
PAYMENT_APPROVED를 저장 - 그 다음 Kafka에
PaymentApproved이벤트 발행 - 그런데 이벤트 발행 직전에 프로세스가 죽음
그러면 DB는 승인됐는데 이벤트가 안 나가서 다운스트림(주문/배송)이 멈춥니다. 반대로 이벤트를 먼저 발행하고 DB 커밋이 실패하면 “승인 이벤트는 나갔는데 DB에는 결제 없음” 같은 유령 상태가 생깁니다.
이 dual write 문제는 중복결제의 직접 원인이라기보다, 재처리/보정 작업을 유발해서 중복을 더 쉽게 만듭니다.
단순한 해결책이 왜 자주 실패하는가
“컨슈머에서 중복 체크하면 되지 않나?”
가능하지만 조건이 있습니다.
- 중복을 판단할 수 있는 고유 키(idempotency key) 가 있어야 함
- 그 키에 대해 DB 수준 유니크 제약 같은 강한 보장이 있어야 함
- 이벤트 처리와 중복 체크가 분리되면 레이스 컨디션이 생김
즉, 애플리케이션 코드에서 existsBy...로 확인하고 insert하는 식은 동시성에서 깨집니다.
“결제 PG가 멱등을 지원하니 괜찮다?”
PG가 idempotency를 지원해도 내부적으로는
- 우리 서비스 DB 상태
- PG 결제 상태
- 메시지 이벤트 상태
가 서로 엇갈릴 수 있습니다. 결국 우리 시스템의 상태 전파(이벤트 발행) 까지 포함해 멱등을 설계해야 합니다.
Outbox 패턴이 중복결제에 강한 이유
Outbox 패턴의 핵심은 간단합니다.
- 비즈니스 상태 변경(예: 결제 승인 저장)
- 이벤트 발행 대상 기록(Outbox 테이블 insert)
을 같은 DB 트랜잭션으로 묶습니다.
그 다음 별도 퍼블리셔가 Outbox 테이블을 읽어 브로커로 발행하고, 발행 완료를 마킹합니다. 이렇게 하면
- DB 커밋이 되면 이벤트도 “발행될 예정”으로 반드시 남고
- 프로세스가 죽어도 Outbox가 남아 재발행 가능하며
- 발행 중복이 발생해도 이벤트에 고유 ID를 부여해 다운스트림에서 멱등 처리 가능
이 구조가 됩니다.
설계 목표: 결제는 한 번만, 이벤트는 결국 발행
중복결제를 막으려면 크게 두 축이 필요합니다.
- 결제 자체의 멱등성: 같은 주문/시도에 대해 결제 승인 레코드가 1개만 생기게
- 이벤트 발행의 신뢰성: 승인 레코드가 생기면 승인 이벤트가 결국 발행되게
Outbox는 (2)를 강하게 보장하고, (1)은 DB 유니크 제약 + idempotency key로 보완합니다.
데이터 모델: Payment와 Outbox
PostgreSQL 기준 예시입니다.
-- 결제는 idempotency_key 단위로 1번만 승인되게
create table payments (
id bigserial primary key,
order_id varchar(64) not null,
idempotency_key varchar(128) not null,
amount numeric(18,2) not null,
status varchar(32) not null,
pg_tx_id varchar(128),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (idempotency_key)
);
-- Outbox는 이벤트 단위 고유 ID를 갖고 발행 상태를 관리
create table outbox_events (
event_id uuid primary key,
aggregate_type varchar(64) not null,
aggregate_id varchar(64) not null,
event_type varchar(128) not null,
payload jsonb not null,
status varchar(32) not null, -- PENDING, PUBLISHED, FAILED
created_at timestamptz not null default now(),
published_at timestamptz
);
create index idx_outbox_pending on outbox_events (status, created_at);
여기서 포인트는 다음입니다.
payments.idempotency_key유니크로 “결제 승인 레코드 중복 생성”을 원천 차단- Outbox는
event_id로 이벤트 중복을 식별 가능
결제 승인 처리: 하나의 트랜잭션으로 Payment + Outbox 저장
Spring Boot(JPA) 예시입니다.
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final OutboxRepository outboxRepository;
private final PgClient pgClient;
@Transactional
public PaymentResult approve(PaymentCommand cmd) {
// 1) 멱등 키로 기존 결제 확인 (유니크 제약이 최종 안전장치)
var existing = paymentRepository.findByIdempotencyKey(cmd.idempotencyKey());
if (existing.isPresent()) {
return PaymentResult.alreadyProcessed(existing.get().getId());
}
// 2) PG 결제 승인 호출 (여기서도 idempotency_key를 PG에 전달하는 편이 좋음)
var pg = pgClient.approve(cmd.orderId(), cmd.amount(), cmd.idempotencyKey());
// 3) 결제 레코드 저장
var payment = new Payment(cmd.orderId(), cmd.idempotencyKey(), cmd.amount());
payment.approve(pg.transactionId());
paymentRepository.save(payment);
// 4) Outbox 이벤트 저장 (같은 트랜잭션)
var event = OutboxEvent.pending(
java.util.UUID.randomUUID(),
"Payment",
payment.getId().toString(),
"PaymentApproved",
java.util.Map.of(
"paymentId", payment.getId(),
"orderId", cmd.orderId(),
"amount", cmd.amount(),
"pgTxId", pg.transactionId()
)
);
outboxRepository.save(event);
return PaymentResult.approved(payment.getId());
}
}
동시성 레이스는 어떻게 막나
위 코드만으로는 동시에 같은 idempotency_key로 요청이 2개 들어오면 둘 다 find에서 빈 값 보고 진행할 수 있습니다. 하지만 마지막에 unique (idempotency_key)가 걸려 있어서 둘 중 하나는 insert에서 실패합니다.
이때 중요한 것은 유니크 위반을 “정상 멱등 결과”로 처리하는 것입니다.
try {
paymentRepository.save(payment);
} catch (DataIntegrityViolationException e) {
var p = paymentRepository.findByIdempotencyKey(cmd.idempotencyKey())
.orElseThrow();
return PaymentResult.alreadyProcessed(p.getId());
}
Outbox 퍼블리셔: 폴링 + 배치 발행 + 상태 전이
Outbox 발행은 크게 두 방식이 있습니다.
- 폴링 방식: 주기적으로 DB에서
PENDING을 읽어 발행 - CDC 방식: Debezium 같은 CDC로 Outbox insert를 스트리밍
여기서는 구현 난이도가 낮은 폴링을 예로 듭니다.
@Component
public class OutboxPublisher {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Scheduled(fixedDelayString = "${outbox.publish.delay-ms:1000}")
@Transactional
public void publishBatch() {
// SKIP LOCKED로 다중 인스턴스 경쟁 시 중복 발행을 줄임
var events = outboxRepository.findPendingForUpdateSkipLocked(100);
for (var e : events) {
try {
var payload = objectMapper.writeValueAsString(e.getPayload());
// key를 aggregateId로 두면 파티션 순서 보장에 유리
kafkaTemplate.send("payment-events", e.getAggregateId(), payload);
e.markPublished();
} catch (Exception ex) {
e.markFailed(ex.getMessage());
}
}
}
}
리포지토리 쿼리 예시입니다.
select *
from outbox_events
where status = 'PENDING'
order by created_at
for update skip locked
limit 100;
“발행 성공”의 기준을 어디까지 볼 것인가
Kafka send는 비동기이며, 진짜 브로커 ack까지 기다리려면 get()을 호출하거나 producer 설정을 조정해야 합니다. 운영에서는
acks=allenable.idempotence=true
같은 프로듀서 설정을 권장합니다. 그래도 애플리케이션 장애로 “발행했는데 마킹 못함”이 발생할 수 있어, 중복 발행 가능성은 남습니다.
따라서 다운스트림은 반드시 event_id 또는 paymentId 기준 멱등 처리를 해야 합니다.
다운스트림(주문 서비스) 멱등 처리: Inbox 또는 Processed Event 테이블
주문 서비스가 PaymentApproved를 받아 주문 상태를 PAID로 바꾼다고 합시다. 중복 이벤트가 오면 업데이트가 여러 번 일어나도 결과는 같을 수 있지만, 부수효과(쿠폰 사용 처리, 포인트 적립 등)가 있으면 문제가 됩니다.
가장 흔한 패턴은 “처리한 이벤트 ID를 저장”하는 것입니다.
create table processed_events (
event_id uuid primary key,
processed_at timestamptz not null default now()
);
컨슈머 처리 예시입니다.
@Transactional
public void onMessage(PaymentApprovedEvent evt) {
if (processedEventRepository.existsById(evt.eventId())) {
return; // 이미 처리됨
}
orderRepository.markPaid(evt.orderId(), evt.paymentId());
processedEventRepository.save(new ProcessedEvent(evt.eventId()));
}
여기서도 exists 후 insert 레이스가 있으니, processed_events.event_id를 PK로 두고 유니크 위반을 정상 처리로 보는 방식이 더 안전합니다.
사가 관점에서의 정리: 보상 트랜잭션과 Outbox의 역할
사가에서 결제는 보통 “되돌리기 어려운” 단계입니다. 결제가 승인된 뒤 다음 단계(재고 차감, 배송 생성)가 실패하면 보상 트랜잭션으로 환불을 실행해야 합니다.
Outbox는 이때도 중요합니다.
- 결제 승인 이벤트가 유실되면 오케스트레이터는 결제가 안 된 줄 알고 재시도하며 중복 위험이 커짐
- 결제 승인 이벤트가 반드시 발행되면, 오케스트레이터는 그 이벤트를 기준으로 다음 단계를 진행하거나 실패 시 환불로 넘어갈 수 있음
즉 Outbox는 “중복결제 방지”뿐 아니라 사가의 상태 전파 신뢰성을 만들어줍니다.
운영에서 자주 놓치는 체크리스트
1) DB 성능이 Outbox를 망친다
Outbox는 테이블에 쓰기/읽기가 꾸준히 발생합니다. 인덱스가 부실하거나 autovacuum이 밀리면 지연이 커지고, 지연은 재시도 폭증으로 이어져 중복/부하 문제가 커집니다.
PostgreSQL 운영 이슈가 의심되면 아래 글도 함께 참고하는 게 좋습니다.
2) 컨슈머 장애가 “결제 중복”으로 관측된다
결제는 한 번만 됐는데 주문이 PAID로 안 바뀌면, 운영자는 결제가 실패한 줄 알고 수동 재처리/재시도를 합니다. 이때 idempotency key가 제대로 설계되지 않으면 진짜 중복결제가 발생합니다.
클러스터 환경에서 503이나 타임아웃이 자주 보이면 인프라 레벨 진단도 병행하세요.
3) 커넥션 풀 고갈이 재시도를 부른다
결제/주문 서비스가 DB 커넥션 풀 고갈로 지연되면 타임아웃과 재시도가 연쇄적으로 발생합니다. 그 결과 “같은 요청이 여러 번” 들어오고, 멱등이 약하면 중복결제로 직결됩니다.
결론: Outbox는 중복결제 방지의 ‘중심축’이다
사가 패턴에서 중복결제는 단순 버그가 아니라 전달 보장, 재시도, 타임아웃, dual write가 합쳐져 나타나는 구조적 문제입니다. 이를 해결하려면
- 결제 생성은
idempotency_key+ DB 유니크 제약으로 한 번만 - 결제 승인 이벤트는 Outbox로 “DB 커밋과 함께” 기록
- Outbox 퍼블리셔는 재시도 가능하게 만들고, 중복 발행 가능성은 전제로 둠
- 다운스트림은
event_id기반 멱등 처리로 부수효과 중복을 차단
이 네 가지가 세트로 들어가야 합니다.
Outbox를 도입하면 “결제는 됐는데 주문이 멈춤” 같은 상태 불일치를 크게 줄이고, 그 과정에서 발생하던 재처리/재시도로 인한 중복결제 위험도 함께 낮출 수 있습니다. 결국 목표는 하나입니다. 결제는 한 번만, 상태 전파는 결국 도달입니다.