🎟️ 결제 실패 보상 이벤트 유실 방지하기 - Outbox 패턴 적용기

2026. 6. 1. 19:47·프로젝트/공연 티켓팅 프로젝트

배경

콘서트 티켓 예매 시스템 TicketFlow는 대기열 기반의 예매 흐름을 갖습니다.

사용자 진입 → Redis 대기열(Queue) → 입장권 발급 → 예매(Booking) 생성 → 결제(Payment)

초기 구현에서 결제는 항상 성공하는 단순한 구조였습니다. 그러다 결제 실패 시나리오를 추가하면서 자연스럽게 설계 질문이 생겼습니다.

결제가 실패하면 앞 단계에서 점유한 자원을 어떻게 되돌릴 것인가?

결제 실패 시 되돌려야 할 자원은 두 가지였습니다.

  • Booking 상태: PENDING_PAYMENT -> CANCELLED
  • 좌석 복구: 차감된 bookedCount 원상복귀, SOLD_OUT이었다면 OPEN으로 전환

가장 단순한 방법은 결제 실패 시점에 바로 보상 메서드를 호출하는 것이었습니다.

if (shouldFail) {
    payment.fail("PG 실패");
    booking.cancel();
    queueService.restoreSeat(concertId);
}

사용자 경험 관점에서는 이 방식이 가장 좋았습니다. 결제 실패와 보상 처리가 동시에 일어나 지연이 없었습니다.

그러나 설계를 계속 들여다보다가 한 가지 전제를 발견했습니다.

이 코드는 JVM 프로세스가 살아있을 때만 실행된다.


서버 재시작 시 보상 이벤트는 어떻게 될까

설계 리뷰에서 발견

운영 장애로 발견한 것이 아니었습니다. 설계 단계에서 스스로 물었습니다.

결제 실패 직후 서버가 재시작되면 이 보상 코드는 어떻게 되는가?

즉시 보상의 구조적 한계

보상 로직을 코드 안에서 직접 호출하는 방식은 JVM 스레드 위에서 실행되고 어디에도 흔적을 남기지 않았습니다. 서버가 보상 도중 종료되면 실행 사실 자체가 사라졌습니다. 보상 처리를 빠르게 할수록 JVM에 의존하는 영역이 커지고 JVM이 죽는 순간 보상 의도도 함께 소멸했습니다.

실제 구현에서 결제 실패와 Outbox INSERT는 같은 트랜잭션 안에 묶여 있었습니다. 이 구조에서 서버가 강제 종료되면 다음과 같은 일이 벌어졌습니다.

T+0  결제 실패 감지
T+1  Payment 상태 FAILED 변경
T+2  Outbox INSERT
T+3  트랜잭션 커밋
T+4  서버 강제 종료 (kill -9)

결과:
  Payment  = FAILED   (커밋됨)
  Outbox   = PENDING  (커밋됨, 스케줄러 미실행)
  Booking  = PENDING_PAYMENT (아직 보상 미완료)

→ 재기동 후 스케줄러가 PENDING Outbox를 발견해 재처리 가능

여기서 핵심은 T+3과 T+4 사이였습니다.

트랜잭션이 이미 커밋됐기 때문에 서버가 종료되더라도 Outbox 레코드는 DB에 남았습니다.

반면 즉시 보상 방식이었다면 T+4 시점에 보상 자체가 진행 중이었을 수도 있고 그 사실은 어디에도 기록되지 않았습니다.

 

중요한 것은 보상 작업 자체가 아니라 '보상해야 한다는 사실'이 사라지지 않는 것이었습니다.

BookingExpiryScheduler가 있으면 되는 거 아닌가?

이 시스템에는 BookingExpiryScheduler가 있었습니다.

PENDING_PAYMENT 상태로 30분 이상 방치된 예약을 주기적으로 정리하는 스케줄러였습니다.

이 사실을 알면 자연스럽게 이런 생각이 들었습니다.

어차피 30분 뒤에 정리되면, Outbox가 없어도 결국 복구되는 거 아닌가?

결론부터 말하면 두 컴포넌트의 책임이 달랐습니다.

  BookingExpiryScheduler PaymentCompensationOutbox
대상 결제가 일어나지 않은 예약 결제 실패가 확정된 예약
목적 타임아웃 정리 실패 보상 복구
처리시점 30분 후 10초 이내
트리거 시간 경과 결제 실패 이벤트

만료 스케줄러에 보상을 위임하면 두 가지 문제가 생겼습니다.

첫째, 결제 실패 후 최대 30분 동안 좌석이 점유됐습니다. 인기 공연이라면 치명적인 시간이었습니다.

둘째, 두 경로가 같은 자원을 건드리게 됐습니다. 보상이 언제, 어느 경로로 처리될지 예측할 수 없었고 각 경로는 서로의 존재를 몰랐습니다.

만료 스케줄러는 "결제가 일어나지 않은 예약"을 위한 안전망이었고 결제 실패 보상은 별도의 경로가 필요하다고 판단했습니다.

At-least-once 요구사항 도출

즉시 보상과 신뢰성 사이의 트레이드오프는 명확했습니다.

  • 즉시 보상: 사용자 경험 최적. 단, JVM이 살아있다는 가정 위에서만 성립
  • 지연 보상(Outbox): 최대 10초 지연 허용. 대신 서버 재시작 이후에도 복구 가능

이번 시스템에서 우선순위는 보상 누락 방지였습니다.

결제 실패 후 10초 이내에 좌석이 복구되면 충분하고 신뢰성을 잃는 것은 허용할 수 없다고 판단했습니다.


해결 방안 검토

세 가지 방안을 검토했습니다.

방안 At-least-once 보장 복잡도 인프라 추가
Application 레벨 즉시 보상 불가 (JVM 종속) 낮음 없음
Message Queue (Kafka 등) 가능 높음 필요
Transactional Outbox 패턴 가능 중간 없음

왜 DB를 신뢰 저장소로 선택했는가

보장해야 하는 것은 "보상 작업이 존재한다는 사실"이었습니다.

이미 Payment와 Booking 데이터가 저장되는 저장소는 MySQL이었습니다.

 

새로운 인프라를 추가하기보다 동일한 트랜잭션 안에서 Payment와 Outbox를 함께 커밋하는 것이 가장 단순하면서도 신뢰성 있는 방법이라고 판단했습니다. DB가 살아있는 한 기록은 남고 스케줄러가 그것을 폴링했습니다.

 

MQ 도입은 강력하지만 현재 문제의 규모를 넘는다고 생각했습니다. Kafka 클러스터 운영 비용과 복잡도를 추가하기에는 요구사항이 충분히 단순했습니다. Outbox 패턴으로 동일한 신뢰성을 달성할 수 있다면 그것으로 충분하다고 판단했습니다.


Outbox 패턴이란

본격적인 설계에 앞서 Outbox 패턴이 무엇인지 간단히 정리했습니다.

 

Outbox 패턴은 "해야 할 일을 메모리가 아닌 DB에 먼저 기록하고, 별도의 프로세스가 그것을 꺼내 실행하는" 구조입니다.

 

이름의 유래는 물리적인 편지함에서 왔습니다. 편지를 바로 부치는 대신 먼저 발신함(Outbox)에 넣어두고 배달부가 주기적으로 꺼내 배송하는 방식입니다. 편지가 발신함에 들어간 순간부터 배달은 보장됩니다.

이 패턴이 해결하는 핵심 문제는 하나입니다.

비즈니스 로직과 그 부수 작업을 원자적으로 처리하면서도, 부수 작업의 실행을 프로세스 생존과 분리하는 것

일반적으로는 MSA 환경에서 서비스 간 이벤트 발행의 신뢰성을 보장하기 위해 많이 쓰였습니다. 이번 구현에서는 Message Broker 없이 스케줄러가 outbox table을 직접 폴링해 보상 로직을 실행하는 방식으로 단순화해 적용했습니다.

인프라를 추가하지 않고도 동일한 신뢰성을 달성하는 것이 목표였습니다.


Outbox 패턴 설계

핵심: 트랜잭션 원자성 활용

결제 실패 처리
    + Outbox INSERT
    ↓ 같은 @Transactional
DB에 함께 커밋

세 가지 상황이 모두 안전해졌습니다.

  • 트랜잭션 커밋: 결제는 FAILED, Outbox 레코드는 DB에 존재 -> 스케줄러가 재처리
  • 트랜잭션 롤백: 결제도, Outbox도 없던 일 -> 일관성 유지
  • 커밋 후 서버 재시작: Outbox 레코드가 DB에 남아있음 -> 재기동 후 스케줄러가 재처리

트랜잭션 경계

@Transactional  // Payment + Outbox가 하나의 트랜잭션으로 커밋
private PaymentResponse doProcess(...) {

    payment.fail("Mock PG 실패");

    // 결제 실패와 Outbox INSERT를 원자적으로 저장
    String payload = buildPayload(booking.getId(), concertId, userId, payment.getId());
    outboxRepository.save(PaymentCompensationOutbox.create(payload));

    // 서버가 커밋 후 죽어도: Outbox 레코드가 DB에 남아 스케줄러가 재처리
    // 커밋 전에 죽으면: 롤백 → 둘 다 없던 일
}

Outbox 테이블 스키마

@Entity
@Table(
    name = "payment_compensation_outbox",
    indexes = {@Index(name = "idx_outbox_status", columnList = "status")}
)
public class PaymentCompensationOutbox {
    private Long id;
    private String eventType;          // "payment.compensation" — 향후 다른 보상 이벤트 확장 대비
    private String payload;            // JSON: bookingId, concertId, userId, paymentId
    private OutboxStatus status;       // PENDING → PUBLISHED / FAILED
    private int retryCount;            // 3회 초과 시 FAILED 전환 기준
    private LocalDateTime createdAt;
    private LocalDateTime processedAt; // 처리 완료 시각, 운영 모니터링 및 SLA 측정용
}

status 컬럼에 인덱스를 걸었습니다. 스케줄러는 항상 WHERE status = 'PENDING'으로 조회하기 때문이었습니다.

상태 전이

PENDING ──(처리 성공)──→ PUBLISHED
        ──(3회 실패)──→ FAILED

FAILED는 자동 복구를 포기하고 운영자 개입이 필요한 상태였습니다.

멱등성 처리

스케줄러는 같은 이벤트를 두 번 처리할 수 있었습니다. 재기동 직후 이전 실행이 완료됐는지 알 수 없기 때문이었습니다.

Booking 상태로 방어했습니다.

// 이미 취소된 예약이면 좌석 반납 없이 Outbox만 완료 처리
if (booking.getStatus() == BookingStatus.CANCELLED) {
    outbox.markPublished();
    return;  // 좌석 이중 반납 없음
}

검증

시나리오: 서버 강제 종료 후 보상 복구

Transactional Outbox의 핵심은 "서버가 종료되어도 보상 이벤트가 사라지지 않는가"였습니다.

이를 확인하기 위해 실제로 결제 실패 직후 애플리케이션을 강제 종료한 뒤 재기동하여 상태 변화를 DB에서 직접 검증했습니다.

 

fail-rate=100 설정 후 결제 실패를 유도했고, 스케줄러가 실행되기 전(fixedDelay=10s 이내)에 서버를 kill -9로 강제 종료했습니다.

그림 1. 결제 실패가 DB에 저장된 상태

status=FAILED로 커밋됐습니다. 결제 실패 사실이 DB에 기록됐습니다.

그림 2. 보상 이벤트가 Outbox에 PENDING 상태로 저장된 모습

결제 실패 처리 직후 Outbox 레코드가 PENDING 상태로 저장된 것을 확인했습니다.

 

이후 스케줄러가 실행되기 전에 서버를 kill -9로 강제 종료했습니다. 재기동 전 DB를 조회해보니 Outbox 레코드는 여전히 PENDING 상태로 남아 있었습니다. 프로세스는 종료됐지만 보상 이벤트 자체는 DB에 보존된 것이었습니다.

그림 3. 재기동 후 스케줄러가 처리하여 PUBLISHED로 변경된 모습

 

재기동 후 10초 이내에 스케줄러가 실행됐습니다.

  • id=4: PUBLISHED, processed_at=2026-06-01 18:13:58 — 정상 복구 완료
  • id=3: FAILED, retry_count=3 — 존재하지 않는 bookingId를 가진 테스트 데이터로 재시도 정책 검증을 위해 의도적으로 생성한 이벤트였습니다. 3회 재시도 소진 후 FAILED로 전환되어 운영자 개입이 필요한 상태로 분류됐습니다.

두 케이스 모두 의도한 동작이었습니다. 정상 케이스는 PUBLISHED, 복구 불가 케이스는 FAILED로 명확히 구분됐습니다.

그림 4. Booking 상태가 CANCELLED로 전환된 모습

Outbox 처리 과정에서 booking.cancel()이 호출됐고 예매 상태가 CANCELLED로 전환됐습니다. 좌석 수도 함께 복구됐습니다.


결과와 한계

도입 효과

  도입 전 도입 후
보상 처리 주체 JVM (프로세스 종속) DB + 스케줄러 (프로세스 독립)
서버 재시작 시 보상 유실 가능 불가 (DB 레코드 보존)
보상 처리 지연 없음 (즉시) 최대 10초
중복 실행 방어 없음 Booking 상태 체크 (멱등)
다중 인스턴스 환경 미고려 ShedLock 분산 락

신뢰성과 즉시성을 맞바꾼 트레이드오프였습니다.

결제 실패 후 최대 10초 이내 보상 처리가 허용 범위라고 판단했고 그 대신 보상 누락 가능성을 제거했습니다.

잔존 트레이드오프

DB 폴링 방식이기 때문에 미처리 Outbox가 누적되면 스케줄러 부하가 증가했습니다. 현재 구조에서 Outbox는 결제 실패 시에만 생성되므로 일반적인 트래픽에서는 문제가 없었습니다. 다만 대규모 장애 상황에서 Outbox가 폭발적으로 누적되면 폴링 성능이 저하될 수 있다고 판단했습니다.

확장 방향

폴링 방식의 한계를 넘으려면 CDC(Change Data Capture) 기반으로 전환할 수 있다고 생각했습니다. Debezium 같은 도구로 DB 변경 로그(binlog)를 직접 구독하면 폴링 없이 이벤트를 감지할 수 있었습니다. 현재 구조는 스케줄러를 CDC 컨슈머로 교체하기 쉽게 설계해두었습니다.


마치며

처음에는 결제 실패 시 바로 booking.cancel()을 호출하면 충분하다고 생각했습니다.

 

그러나 설계를 검토하면서 보상 로직이 JVM 프로세스의 생존에 의존한다는 사실을 발견했습니다. 서버가 종료되는 순간 보상 작업 자체가 사라질 수 있었고 이는 좌석 점유 상태가 영구적으로 남는 문제로 이어질 수 있었습니다.

 

Transactional Outbox를 도입한 뒤에는 "보상해야 한다"는 사실 자체를 DB에 저장하게 됐습니다.

서버가 종료되더라도 이벤트는 남고 재기동 후 스케줄러가 복구를 이어서 수행할 수 있었습니다.

 

이번 작업을 통해 신뢰성은 재시도 로직의 문제가 아니라 상태 보존의 문제라는 점을 배웠습니다. 보상 로직을 JVM 메모리 위에 두면 프로세스 종료와 함께 사라질 수 있었습니다. 반면 Outbox를 통해 보상 이벤트를 영속 저장하면 서버 재시작 이후에도 복구를 이어갈 수 있었습니다.

 

결제 실패 보상이라는 비교적 단순한 요구사항이었지만 그 과정에서 At-least-once 처리, 멱등성, 트랜잭션 경계, 분산 스케줄링과 같은 운영 환경의 문제를 함께 고민할 수 있었습니다.

'프로젝트 > 공연 티켓팅 프로젝트' 카테고리의 다른 글

🎟️ 결제 확정 API 중복 호출 방지 - 3계층 멱등성으로 중복 결제 막기  (0) 2026.06.01
🎟️ 대기열 시스템에서 WebSocket 대신 Polling을 선택한 이유  (0) 2026.06.01
🎟️ Redis로 좌석 차감을 옮겼는데도 Lock Wait이 사라지지 않았다.  (0) 2026.05.31
🎟️ Docker-compose up 한 번으로 실행되는 백엔드 만들기  (1) 2026.05.18
🎟️ MySQL 인덱스 실험: 예약 만료 배치 쿼리 성능 개선 (10만 건 실험)  (1) 2026.05.13
'프로젝트/공연 티켓팅 프로젝트' 카테고리의 다른 글
  • 🎟️ 결제 확정 API 중복 호출 방지 - 3계층 멱등성으로 중복 결제 막기
  • 🎟️ 대기열 시스템에서 WebSocket 대신 Polling을 선택한 이유
  • 🎟️ Redis로 좌석 차감을 옮겼는데도 Lock Wait이 사라지지 않았다.
  • 🎟️ Docker-compose up 한 번으로 실행되는 백엔드 만들기
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (81)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (45)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (35)
        • 공연 티켓팅 프로젝트 (10)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
🎟️ 결제 실패 보상 이벤트 유실 방지하기 - Outbox 패턴 적용기
상단으로

티스토리툴바