결제 시스템 아웃박스 패턴 구현

김신영·2025년 8월 14일

문제 정의

기존 시스템의 문제점

모임 참가로직을 설계할 때, 결제 완료 시 모임 참가에 승인한다는 규칙을 정했습니다.

이에 프로젝트 초기에는 서비스 호출을 계획했습니다.
모임 참가 로직 안에서 결제 서비스 메서드를 호출하고,
내부적으로 사용한 toss api 자체는 webclient blocking 방식으로 처리하도록 했습니다.

다만, 초기에 부하 테스트를 할 때 이런 문제가 발생했습니다.

  1. 커넥션 풀 고갈로 인한 응답 지연

모임 테이블의 참가 인원수에만 행락을 짧게 걸어 락 자체가 길어지지는 않았지만, 결제 승인 대기로 인한 db 커넥션 점유 시간이 과다해졌고 결과적으로 커넥션 풀의 고갈로 응답이 지연됐습니다.

  1. 결제 시스템 장애가 주요 비즈니스 로직에 영향을 미침

또한 toss api 장애 시 전체 모임 참가 기능이 마비 될 가능성이 있었습니다.

이런 문제점으로 인해 이벤트 기반 설계로 전환하는 것을 결정했습니다.
저희 팀은 이벤트 기반 설계로 전환하는 과정에 메세지 큐를 프로젝트 전체에서 사용하기로 하였습니다. 이 과정에서의 발생할 수 있는 문제점은 이렇습니다.

  1. 메시지 유실 위험
    • 결제 완료 -> RabbitMQ 전송 실패 -> 참가자 등록 안됨
    • 고객 결제는 됐는데 서비스 이용 불가
  2. 트랜잭션 불일치
    • DB 커밋 v BUT 메시지 발행 x
    • 보상 트랜잭션 필요
  3. 중복 발행
    • 여러 인스턴스가 동시 처리 -> 중복 결제 이벤트

이에 아웃박스 패턴을 도입하기로 결정했습니다.

아웃박스 패턴을 택한 이유

  • 데이터 정합성 보장: DB 트랜잭션과 메시지 발행을 원자적으로 처리하여 중복 결제 문제 해결
  • 메시지 무손실 보장: 메시지를 DB에 저장하므로 시스템 장애 시에도 복구 가능
  • 재시도 및 추적 용이: DB에 저장된 메시지로 발행 이력 추적과 재처리가 간편
  • 단순한 트랜잭션 관리: 복잡한 분산 트랜잭션 없이 로컬 DB 트랜잭션만으로 일관성 확보

Outbox 패턴 아키텍처

초기 구현: Polling 방식

outbox + publisher(scheduler)로 “발행 <-> 확인(ACK/NACK)”을 관리하도록 구현했었습니다.

결제 서비스 안에서 결제 완료/실패/환불 로직을 수행

같은 트랜잭션으로 payment_outbox 레코드 (status = PENDING) 저장.

일정 주기(5~10 초)로 돌아가는 PaymentOutboxPublisher

PENDING 혹은 재시도 가능한 FAILED 행을 읽음.
RabbitTemplate.convertAndSend(… correlationData) 로 발행
브로커의 ACK/NACK 결과에 따라
markAsPublished() -> status=PUBLISHED
markAsFailed() -> status=FAILED, retryCount++
retryCount ≥ 3 -> markAsDeadLettered() (DLQ 테이블 개념)


두번째로 고려한 방식:Transactional Event Listener 직발행 + Confirm 실패 시(Publisher Confirm NACK/Timeout 이면) Outbox INSERT

이 구현은 예외가 발생 했을 시에만 아웃박스에 저장하는 fall back 방식이었습니다.

여기서는 3단계 재시도 전략을 사용했습니다

즉시 발행 (TransactionalEventListener)
트랜잭션 커밋 직후 시도
Publisher Confirm 3초 타임아웃
스케줄러 재시도 (10초 주기)
PENDING 상태 메시지 발행
FAILED 메시지 중 retryCount < 3 재시도
각 실패마다 retryCount++
DLQ 처리
retryCount >= 3이면 DEAD_LETTERED 상태로 변경
별도 관리자 개입 필요


최종 구현 방식

Outbox 저장 + 즉시 발행 + 스케줄러 백업

트랜잭션 커밋 직후 즉시 발행을 시도하되, 실패 시 스케줄러가 재시도하는 방식을 채택했습니다.

핵심 구현 포인트:

  1. 트랜잭션 내 Outbox 저장: 결제 로직과 같은 트랜잭션에서 PaymentOutbox 저장 (원자성 보장)
  2. Spring Event로 즉시 발행: @TransactionalEventListener(AFTER_COMMIT)로 커밋 직후 발행 시도
  3. 비동기 Publisher Confirm: 블로킹 없이 콜백으로 ACK/NACK 처리
  4. 원자적 선점 처리: tryMarkProcessing()으로 중복 발행 방지
  5. 스케줄러 재시도: PENDING(10초), FAILED(30초) 주기로 미발행/실패 메시지 처리

구현 흐름 상세

1. 결제 서비스 처리 (PaymentServiceImpl)
   - Payment 엔티티 생성/업데이트
   - PaymentOutbox 레코드 저장 (status=PENDING)
   - Spring 도메인 이벤트 발행 (PaymentEvents.Completed)
   - 모두 같은 트랜잭션으로 원자적 처리

2. 즉시 발행 시도 (PaymentEventHandler)
   - @TransactionalEventListener(AFTER_COMMIT)
   - PaymentEventProducer.publishOutboxEvent() 호출

3. Producer 발행 프로세스 (PaymentEventProducer)
   a. tryMarkProcessing() - 원자적 UPDATE로 PENDING→PROCESSING 전환
      (다른 인스턴스가 이미 선점했으면 중단)
   b. Outbox 조회 및 EventWrapper 역직렬화
   c. RabbitTemplate.convertAndSend() with CorrelationData
   d. 비동기 Confirm/Return 콜백 대기

4. Publisher Confirm 콜백 처리
   - ACK: markAsPublished() → status=PUBLISHED
   - NACK/Return: markAsFailed() → status=FAILED, next_retry_at 설정
   - 타임아웃: 스케줄러가 PROCESSING 상태 복구

5. 스케줄러 백업 처리 (PaymentOutboxScheduler)
   - PENDING 처리: 10초마다 실행
   - FAILED 재시도: 30초마다, next_retry_at 체크, 최대 3회
   - PROCESSING 복구: 10분마다, 5분 이상 방치된 건 FAILED로 전환
   - 각 메시지는 스레드풀에서 병렬 처리

6. 최종 상태
   - 성공: PUBLISHED (30일 후 자동 삭제)
   - 실패: DEAD_LETTERED (3회 초과, 90일 후 삭제)

동시성 제어 메커니즘

-- 원자적 선점 쿼리 (tryMarkProcessing)
UPDATE payment_outbox
SET status='PROCESSING', updated_at=NOW()
WHERE id=? AND status IN ('PENDING','FAILED')
  AND (status='PENDING' OR next_retry_at <= NOW())

이 쿼리가 affected rows = 1을 반환할 때만 발행을 진행하여, 여러 인스턴스가 동시에 같은 메시지를 처리하는 것을 방지합니다.

재시도 전략

  • 지수 백오프: 10초 → 20초 → 40초 (최대 300초)
  • 재시도 제한: 3회 실패 시 DEAD_LETTERED
  • 선택적 라우팅 실패 처리: 환불 이벤트의 라우팅 실패는 정책상 성공으로 처리

이 하이브리드 방식으로 평상시에는 즉시 발행의 낮은 지연시간을 유지하면서, 장애 상황에서는 스케줄러를 통한 안정적인 재시도를 보장합니다.


재시도 및 메세지 유실 해결

graceful shutdown 적용

또한 스케쥴러에서 사용하는 아웃 박스 실행 스레드 풀에서
setWaitForTasksToCompleteOnShutdown(true);
을 설정해 주어 종료 시 graceful shutdown을 적용 해 배포 중 메세지 유실을 막도록 했습니다.

PROCESSING 타임아웃 복구

// 10분마다 체크
// 5분 이상 PROCESSING 상태면 -> FAILED로 변경
// 서버 다운시 자동 복구

문제 상황 및 해결 요약

  1. 아웃박스 중복 발송: 여러 서버가 같은 이벤트 처리 -> 원자적 선점 쿼리로 해결
  2. 메시지 유실: 네트워크 오류 -> 재시도로 해결
  3. 무한 재시도: 계속 실패하는 이벤트 -> 3번 제한 + DLQ
  4. 성능 저하: 동기 처리로 느림 -> 비동기 + 병렬 처리
  5. DB 부하: 아웃박스에서 모든 FAILED 스캔 -> next_retry_at 인덱스 활용

0개의 댓글