Post

Transactional Outbox 패턴 도입기; 결제 후 스케줄 예약하기

외부 시스템 연동이 포함된 트랜잭션에서 데이터 정합성 확보하기

Transactional Outbox 패턴 도입기; 결제 후 스케줄 예약하기

개요

Mockly에서는 통합 결제 연동 플랫폼인 PortOne을 활용하여 구독 기반 결제 시스템을 구축하고 있다.

구독 결제는 일반 단건 결제와 달리, 최초 결제 이후 동일한 결제 수단으로 반복 결제가 발생한다. 이를 위해 PG사는 사용자의 카드 정보와 매핑되는 Billing Key를 발급하고, 서비스는 해당 Key를 기반으로 매달 자동 결제를 수행하게 된다. 즉 최초 결제 시에는 카드 인증과 Billing Key 발급이 이루어지고, 이후 결제부터는 Billing Key를 활용한 서버 간 결제 요청만으로 과금이 진행된다.

사용자가 구독을 활성화시키는 플로우는 다음과 같이 3단계로 이루어져 있다:

  1. [PortOne API 호출] Billing Key 기반 즉시 결제 요청
  2. 내부 데이터인 Payment와 Subscription의 상태를 각각 PAID와 ACTIVE로 변경
  3. [PortOne API 호출] 다음 달 결제를 위한 스케줄 예약 요청

흐름 자체는 단순해보였지만, 초기 구현 단계에서는 이 세 과정을 하나의 트랜잭션 안에서 처리하고 있었다. 결제와 스케줄 예약이 하나의 비즈니스 플로우로 이어진다고 판단했기 때문이다.

하지만 구조를 검토하는 과정에서 한 가지 문제점을 발견했다. 결제 승인은 외부 시스템에서 이미 확정되었는데, 그 이후의 내부 상태 변경과 또 다른 외부 시스템 호출(스케줄 예약 요청)이 동일한 트랜잭션 경계 안에 포함되어 있었다. 즉 되돌릴 수 없는 외부 상태 변화와 롤백 가능한 내부 트랜잭션이 하나의 흐름에 묶여 있던 것이다.

payment-fail-case-sequence-diagram

예를 들어 결제는 정상적으로 승인되었지만 이후 스케줄 예약이 실패한다면, 사용자의 돈은 이미 빠져나갔음에도 불구하고 트랜잭션이 롤백되어 DB에는 결제 기록조차 남지 않게 된다. 반대로 스케줄 예약 실패를 무시한다면, 다음 달 결제가 예약되지 않아 매출 누락이 발생할 수 있다.

요구사항을 다시 정리해보자

1
2
3
4
5
결제 성공 후,
다음 결제 스케줄이 예약되어야 한다.

BUT 스케줄 예약 실패가
결제 실패로 이어져서는 안된다.

정리하면 다음과 같았다.

  1. 결제와 스케줄 예약은 각각 독립적으로 성공해야 한다.
  2. 스케줄 예약은 즉시 실패해도 되지만, 최종적으로는 성공해야 한다.

즉 결제는 즉시 확정되어야 하는 강한 일관성이 요구되는 반면, 스케줄 예약은 최종적으로 성공하면 되는 작업이다. 이렇게 두 작업의 일관성 요구 수준이 달랐기 때문에 하나의 트랜잭션으로 묶어 처리할 수 없었다.


1️⃣ Webhook으로 스케줄 예약 처리하기

스케줄 예약 호출을 메인 트랜잭션에서 분리하기 위해 가장 먼저 고려한 방법은 Webhook이다.

PortOne은 안정적인 결제 처리를 위해 Webhook 연동을 강력히 권장하고 있다. 결제에 대한 결과를 이벤트 형태로 전달해주는데, 이러한 webhook을 수신하는 서비스를 이미 구축해두었다. 따라서 결제 트랜잭션에서 스케줄 예약을 수행하지 않고, 결제 성공 이벤트를 수신했을 때 스케줄 예약을 수행하도록 분리하는 구조로 변경해보았다.

흐름은 다음과 같다.

webhook-sequence-diagram

해당 방식은 결제와 스케줄 예약을 자연스럽게 분리할 수 있었다.

하지만 Webhook은 외부 시스템이 전달하는 이벤트이기 때문에 전달 자체를 완벽히 신뢰할 수 없었다. 네트워크 장애로 인해 이벤트가 유실될 가능성이 존재하고, 이벤트 처리 과정에서 예외가 발생하면 스케줄 예약이 수행되지 않을 수도 있다. 또한 이벤트 전달이 지연되는 상황도 고려해야 한다.

물론 PortOne은 SLA를 통해 Webhook 전달 안정성을 보장하고 있지만, 설계 관점에서 보면 Webhook만으로는 스케줄 예약의 완료를 보장하기 어렵다. Webhook이 보장하는 것은 이벤트 전달이지, 그 이후 작업의 성공은 아니다. 이벤트를 수신한 이후 처리 과정에서 예외가 발생하거나 서버가 다운된다면? 스케줄 예약은 수행되지 않을 수 있다. 즉 Webhook은 전달을 보장할 수는 있어도, 작업 완료까지 보장하는 구조는 아니었다.

결국 Webhook은 결제 상태를 보조적으로 동기화하거나 정합성을 검증하는 용도로는 적합하지만, 스케줄 예약을 처리하는 핵심 로직으로 두기에는 부족했다.


2️⃣ Event로 스케줄 예약 처리하기

Webhook처럼 외부 시스템이 전달하는 이벤트에 의존하는 대신, 내부에서 직접 이벤트를 발행하는 방식도 고려해보았다. 결제 트랜잭션이 커밋되는 시점에 이벤트를 발행하고, 해당 이벤트를 구독하는 리스너가 스케줄 예약을 수행하도록 분리하는 구조다.

흐름은 다음과 같다.

event-sequence-diagram

Spring에서는 @TransactionalEventListener(AFTER_COMMIT) 를 활용하면 트랜잭션이 커밋된 이후에 로직을 실행할 수 있다. 이렇게 하면 스케줄 예약을 결제 트랜잭션과 분리할 수 있고, 예약 실패가 결제 롤백으로 이어지는 문제를 피할 수 있다.

하지만 이 방식 역시 최종 성공을 보장할 수 없었다. 스케줄 예약은 한 번 시도해보는 작업이 아니라 반드시 성공해야 하는 작업이다. 즉 At Most Once가 아니라 At Least Once 보장이 필요했다.

예를 들어 스케줄 예약 호출 중 예외가 발생한다면? 혹은 일시적 네트워크 오류가 발생한다면 재시도 로직을 추가해볼 수 있다. 하지만 트랜잭션 커밋 직후 서버가 다운된다면 어떻게 될까? 이벤트는 메모리 상에서 발행되었을 뿐, 어디에도 영속적으로 기록되지 않았다. 이벤트가 저장되지 않는 구조에서는 실패 여부를 확인할 수도 없고, 재시도 대상이 무엇인지조차도 알 수 없다.


3️⃣ 이벤트를 메모리가 아니라 DB에 저장하자

실패를 감지하고 재시도를 할 수 있도록 하기 위해 이벤트를 영속적으로 기록하는 구조가 필요했다.

리스너 내부에서 스케줄 예약을 호출하는 구조에서는 다음을 구분할 수 없었다.

  • 스케줄 예약 호출이 실패했는지
  • 커밋 직후 서버가 다운되어 아예 실행되지 않았는지

이 둘을 구분할 수 없다면 재시도는 시작조차 할 수 없다. 재시도를 위해서는 수행해야 할 작업이 어딘가엔 기록되어 있어야 했다. 이를 위해 결제 트랜잭션에서 Payment와 Subscription의 상태를 변경할 때 스케줄 예약 요청도 함께 기록하기로 하였다.

transactional-outbox-pattern-polling-ver

트랜잭션은 이벤트를 기록하는 것까지만 책임지고, 실제 외부 호출은 트랜잭션 밖에서 수행하도록 분리하였다. 결제 트랜잭션이 커밋되면, 별도의 스케줄러가 처리되지 않은 이벤트를 주기적으로 조회한다. 스케줄러는 해당 이벤트를 기반으로 스케줄 예약 요청을 수행하고, 성공하면 처리 완료 상태로 변경한다. 실패할 경우에는 이벤트를 그대로 남겨두고 다음 주기에 다시 시도한다.

해당 방식은 At Least Once 전달을 전제로 하기 때문에 중복 호출 가능성이 존재한다. 따라서 외부 시스템 호출은 반드시 멱등하게 설계해야 한다.

메시지 전달 보장 수준

  • At Most Once: 최대 한 번만 전달된다. 실패 시 유실될 수 있다.
  • At Least Once: 최소 한 번 이상 전달된다. 대신 중복이 발생할 수 있다.
  • Exactly Once: 중복과 유실 없이 전달된다. 구현 비용이 매우 높다.


Dual Write

지금까지의 고민은 단순한 이벤트 유실 문제가 아니었다.

결제 흐름을 다시 살펴보면, Mockly DB의 상태 변경과 PortOne 외부 API 호출이 하나의 트랜잭션으로 묶여 있었다. 하지만 이 두 작업은 서로 다른 시스템에 걸쳐 수행되기 때문에 원자적으로 보장할 수 없는 구조였다.

이러한 구조에서 대표적으로 발생하는 문제가 Dual Write Problem이다.

Dual Write Problem

Dual Write란 하나의 비즈니스 작업서로 다른 여러 시스템(DB, 외부 API, 메시지 브로커 등)에 각각 쓰기 작업을 수행하는 상황를 말한다. 두 시스템으로의 쓰기 작업은 하나의 원자적 연산으로 보장되지 않기 때문에, 일부만 반영된 상태가 발생할 수 있다.

  • DB의 상태 변경에 성공하여 커밋되었지만, 메시지가 유실된 경우
  • DB의 상태 변경에 실패하여 롤백되었지만, 이미 이벤트가 소비된 경우

결제는 확정되었지만 스케줄 예약은 실패하거나, 반대로 스케줄 예약은 되었지만 내부 상태는 반영되지 않는 상황이 여기에 해당한다.

Transactional Outbox Pattern

Dual Write Problem을 해결하기 위한 대표적인 방법이 Transactional Outbox 패턴이다.

Outbox란 이메일 서비스에서의 전송 중이거나 전송에 실패한 메시지가 임시로 보관되는 ‘보낼 편지함’을 뜻한다.

  • 외부 호출을 트랜잭션 안에 포함시키지 않는다.
  • 대신, 외부로 전달해야 할 작업을 동일한 트랜잭션에서 DB에 기록한다.
  • 이후 별도의 프로세스가 해당 작업을 읽어 처리하도록 분리한다.

이렇게 하면 DB 상태 변경과 해야 할 작업에 대한 기록은 하나의 트랜잭션으로 묶여 원자적으로 처리된다. 따라서 외부 호출의 실패가 곧바로 데이터 정합성 문제로 이어지지 않고, 재시도를 통해 최종적으로 작업이 수행될 수 있다.


실패한 작업을 되돌리는 방법은 없을까

다음 흐름처럼 보상 트랜잭션을 통해 작업을 되돌리는 SAGA 패턴도 고려해볼 수 있다.

ex) 결제 성공 → 스케줄 예약 실패 → 환불(보상) 트랜잭션

하지만 이런 구조에서는 환불이 지연될 경우, 사용자는 돈이 빠져나간 상태로 대기해야 하는 상황이 발생할 수 있다. 또한 환불 API가 실패나 중복 보상 방지 로직까지 고려하려면 설계와 운영 복잡도가 크게 증가하게 된다. 따라서 Mockly에서는 보상보다는 재시도를 통한 완료를 보장하는 방식을 선택하였다.


🤨 불필요한 조회가 많은 것 같은데…

Polling 기반 Outbox는 단순하지만, 이벤트가 없더라도 일정 주기마다 테이블을 조회해야 한다는 점에서 비효율적으로 보였다.

polling-ver-flow-diagram

그렇다면 Polling을 줄이거나 없애는 방식은 없을까?

CDC; Change Data Capture

Polling 대신 CDC를 사용하는 방법도 있다. Debezium과 같은 CDC 툴은 데이터베이스의 변경 로그(MySQL의 binlog, Oracle의 redo log)를 읽어, 커밋된 변경 사항을 감지하고, 이를 외부 시스템으로 전달한다.

cdc-outbox-ver-flow-diagram

CDC를 적용하면 다음과 같은 차이가 있다.

  • 이벤트가 없을 때는 DB를 반복적으로 조회하지 않아도 된다.
  • 변경 감지 로직을 애플리케이션 코드에서 분리할 수 있다.
  • 이벤트 감지는 DB 변경 로그를 기준으로 이루어진다.

다만 CDC를 도입하기 위해서는 변경 로그를 수집하고 외부로 전달하는 파이프라인을 구성해야 하고, 장애 시 재처리 전략과 모니터링도 함께 설계해야 한다. 또한 대부분의 경우 메시지 브로커와 함께 사용하기 때문에 인프라 복잡도도 증가하게 된다.

현재 Mockly는 단일 애플리케이션과 하나의 데이터베이스를 사용하는 구조이며, 트래픽 규모도 크지 않을 것이라 판단하였다. 또한 다음 달 결제에 대한 스케줄 예약이라는 특성상 어느 정도의 지연이 허용되기 때문에 Polling 기반 Transactional Outbox 패턴을 적용하는 것으로 결정하였다.


✅ 정리

  • 결제와 스케줄 예약은 모두 외부 API 호출로 로컬 트랜잭션으로 원자성을 보장할 수 없었다.
  • 두 작업은 서로 다른 일관성을 요구했기 때문에 하나의 트랜잭션 경계로 묶는 것은 적절하지 않았다.
  • Dual Write 구조에서 일부만 반영되는 상태를 방지하기 위해 Transactional Outbox 패턴을 도입하였다.
  • 보상 트랜잭션으로 되돌리는 작업 대신 재시도를 통해 Eventually하게 성공하도록 만드는 전략을 선택하였다.
  • CDC를 통해 이벤트를 효율적으로 발행할 수 있지만, 현재 프로젝트 기준 Polling 기반 구조가 더 합리적이라고 판단하였다.


참고

This post is licensed under CC BY 4.0 by the author.