보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계
Payment Platform Project
- 1 결제 정보 검증을 통한 안전한 결제 연동 시스템 구현
- 2 트랜잭션 범위 최소화를 통한 성능 및 안정성 향상
- 3 결제 상태 전환 관리와 재시도 로직을 통한 결제 복구 시스템 구축
- 4 외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증
- 5 Logger 성능 저하 방지와 구조화된 로깅 설계
- 6 결제 이력 추적 및 핵심 지표 모니터링 시스템 구현
- 7 보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계 OPEN
- 8 전략 패턴을 통한 PG 독립성 확보 및 확장 가능한 결제 시스템 설계
- 9 Checkout API 멱등성 보장 — Caffeine 캐시와 TOCTOU 경쟁 조건 해결
- 10 비동기 결제 처리 플로우 구현 — Outbox 패턴부터 LinkedBlockingQueue Worker까지
- 11 결제 복구 상태 모델 설계 — 장애 내성을 갖춘 상태 전이
실행 환경: Java 21, Spring Boot 3.3.3
[!CAUTION] 이 글은 작성 시점의 구현을 기준으로 하며, 이후 보상 트랜잭션 실행 전 이중 조건 가드(Outbox IN_FLIGHT + Event 비종결 검사)가 추가되었고, 격리 전 최종 확인 단계가 도입되었다. 현재 설계는 결제 복구 상태 전이 설계를 참고한다.
배경 및 문제 정의
Section titled “배경 및 문제 정의”결제 로직은 내부 DB 자원(재고 차감)과 외부 API 자원(PG사 결제 요청)이라는 서로 다른 리소스를 하나의 비즈니스 프로세스로 처리한다.
기존에는 로직 수행 중 실패에 대해 보상 트랜잭션 방식을 채택했다.
- 재고 차감 먼저 수행
- 외부 결제가 실패 시 차감된 재고 복구
보상 트랜잭션의 한계
Section titled “보상 트랜잭션의 한계”문제는 ‘차감된 재고를 복구하는 보상 트랜잭션’ 자체가 실패할 수 있다는 점이다.
- PG사 결제 실패: 잔액 부족 등으로 인해 외부 결제 승인 API가 실패 응답 반환
- 보상 로직 가동: 시스템은 차감했던 재고를 복구하기 위해 DB 업데이트 시도
- 이차 장애 발생: 이 시점에 DB 데드락, 네트워크 타임아웃, 혹은 인스턴스 셧다운이 발생하여 복구 쿼리 유실
이러한 이중 장애가 발생하면 시스템은 아래와 같이 비즈니스 정합성이 깨지는 데이터 불일치 상태에 빠진다.
graph TD subgraph Problem ["보상 트랜잭션 실패 상황"] A[PG 결제 실패] -- " 1. 보상 트랜잭션 트리거 " --> B{재고 복구 시도} B -- " 2. DB 장애 / 서버 다운 " --> C((실패)) end
C -.-> D{{"데이터 불일치 발생"}} D --- E["결제 상태: <b>실패</b> (고객 미결제)"] D --- F["재고 상태: <b>차감됨</b> (복구 누락)"] style D fill: #ffcccc, stroke: #ff0000, stroke-width: 2px, color: #000 style C fill: #000, color: #fff결국, 단순한 보상 트랜잭션 로직만으로는 인프라 장애 시 발생하는 데이터 정합성 문제를 해결할 수 없어, 이를 극복하기 위해 시스템이 스스로 상태를 추적하고 회복할 수 있는 구조가 필요했다.
개발 목표 및 설계 원칙
Section titled “개발 목표 및 설계 원칙”이 실패 처리의 실패(Double Fault) 문제를 해결하기 위해, 다음과 같은 설계 원칙을 수립했다.
- 상태 추적성 (Traceability): 모든 결제 과정을 ‘작업’으로 정의하고 상태를 별도 테이블에 기록하여, 장애 발생 시에도 중단된 지점을 명확히 식별
- 최종적 일관성 (Eventual Consistency): 장애가 발생하더라도, 시스템 스스로 데이터 정합성 회복
기술적 결정 및 Trade-off
Section titled “기술적 결정 및 Trade-off”문제 해결을 위해 다음 세 가지 해결 방법을 검토했다.
- Two-Phase Commit (2PC)
- 모든 리소스(재고 DB, PG사)가 커밋에 동의해야 하는 강력한 일관성 모델
- 한계: 외부 PG사가 2PC를 지원하지 않으며, 전체 시스템의 성능 저하와 블로킹(Blocking)을 유발
- 메시지 큐와 Saga Pattern
- 재고 차감, 결제 요청, 결과 처리를 별도의 트랜잭션으로 분리하고 메시지 큐(Kafka, RabbitMQ)로 연결하는 방식
- 한계: 결합도는 낮지만 Kafka/RabbitMQ 도입이 필요해 현 규모에서는 오버엔지니어링으로 판단하여 제외
- 선택: 작업 테이블 + Scheduler
- ‘작업 테이블(
payment_process)‘을 DB 내의 ‘메시지 큐(Outbox)‘처럼 활용하는 방식 - 장점:
- 단순성: 외부 인프라 의존성 없이, Spring 스케줄러와 DB만으로 ‘최종적 일관성’을 구현 가능
- 트랜잭션 보장: 데이터 처리를 DB 트랜잭션 내에서 처리하여, 복잡한 트랜잭션 관리 불필요
- ‘작업 테이블(
아키텍처 - 3단계 트랜잭션 분리와 작업 테이블
Section titled “아키텍처 - 3단계 트랜잭션 분리와 작업 테이블”기존 결제 로직을 3단계로 분리하고, 모든 과정을 PaymentProcess 작업 테이블에 기록하도록 설계했다.
작업 테이블 (payment_process) 스키마
Section titled “작업 테이블 (payment_process) 스키마”| 컬럼명 | 타입 | 제약조건 | 설명 |
|---|---|---|---|
id | BigInt | PK, Auto-Increment | 작업 고유 ID |
order_id | String | Not Null | 주문 고유 ID |
status | Enum / Varchar | Not Null | 작업 상태 (PROCESSING, COMPLETED, FAILED) |
created_at | Timestamp | Not Null | 생성 시각 |
updated_at | Timestamp | Not Null | 수정 시각 |
이 테이블을 기반으로 결제 흐름은 다음과 같이 분리된다.
- 1단계 (Tx 1): 재고 차감 + 작업 생성 (
PROCESSING) - 2단계 (Non-Tx): 외부 PG사 결제 API 호출
- 3단계 (Tx 2): 결제 결과 반영 + 작업 상태 변경 (
COMPLETED/FAILED)
이 구조의 핵심은 장애 발생 시 PROCESSING 상태가 DB에 남는다는 것이다.
graph TD%% 클래스 정의 (가독성 및 테마 대응) classDef standard fill: #F5F5F5, stroke: #333, color: #000 classDef process fill: #E1F5FF, stroke: #0078D4, color: #000 classDef decision fill: #FFF2CC, stroke: #D79B00, color: #000 classDef success fill: #E8F5E9, stroke: #2E7D32, color: #000 classDef fail fill: #F8CECC, stroke: #B85450, color: #000
subgraph sg1 ["1단계: 작업 생성 및 재고 차감 (Transaction 1)"] A["요청 시작"]:::standard --> B{"Tx START"}:::decision B --> C["1. payment_process 테이블에<br/>'PROCESSING' 상태로 작업(Job) INSERT"]:::process C --> D["2. stock 테이블 재고 차감"]:::process D --> E{"Tx COMMIT"}:::decision end
E -- " 성공 " --> F["2단계: 외부 PG사 결제 처리"]:::standard F --> G{결제 성공?}:::decision
subgraph sg3 ["3단계: 작업 완료 (Transaction 2)"] G -- " Yes " --> H_Success["Tx START"]:::decision H_Success --> I_Success["payment_process 상태 'COMPLETED'로 UPDATE"]:::success I_Success --> J_Success["orders 테이블 상태 '결제 완료'로 변경"]:::success J_Success --> K_Success["Tx COMMIT"]:::decision K_Success --> L_End["종료"]:::standard G -- " No " --> H_Fail["Tx START"]:::decision H_Fail --> I_Fail["payment_process 상태 'FAILED'로 UPDATE"]:::fail I_Fail --> J_Fail["[보상 트랜잭션]<br/>차감했던 재고 복구"]:::fail J_Fail --> K_Fail["Tx COMMIT"]:::decision K_Fail --> L_End end
subgraph sg_recovery ["장애 복구 로직"] Z["복구 로직 시작"]:::standard --> Y["payment_process 테이블에서<br/>'PROCESSING' 상태인 작업 조회"]:::process Y --> X{"PG사에 실제 결제 성공 여부 조회 or 승인 요청"}:::decision %% 복구 로직 연동 X -- " 결제 성공 상태 " --> H_Success X -- " 결제 실패 상태 " --> H_Fail end
%% 스타일 보정 (연결선 텍스트 강조) linkStyle 4,14,15 stroke: #333, stroke-width: 2px, color: #000보상 트랜잭션 실패 흐름 시나리오
Section titled “보상 트랜잭션 실패 흐름 시나리오”sequenceDiagram participant Client as 사용자 participant Service as PaymentCoordinator participant DB as 데이터베이스 (JPA) participant Toss as Toss PG API Client ->> Service: 1. 결제 승인 요청%% --- 1단계 (Tx 1) --- Service ->> DB: 2. [Tx 1 시작] Service ->> DB: 3. 재고(Stock) 차감 Service ->> DB: 4. 작업(PaymentProcess) 생성 (상태: PROCESSING) Service ->> DB: 5. [Tx 1 커밋]%% --- 2단계 (Non-Tx) --- Service ->> Toss: 6. 외부 결제 승인 API 호출 Toss -->> Service: 7. 결제 실패 응답 (예: 한도 초과)%% --- 3단계 (Tx 2) - 보상 트랜잭션 --- Service ->> DB: 8. [Tx 2 (보상) 시작] Service ->> DB: 9. 재고(Stock) 복구 시도 Service ->> DB: 10. 작업(PaymentProcess) 상태 'FAILED' 변경 시도
critical DB 장애 발생 Service ->> DB: 11. [Tx 2 (보상) 롤백] (예: DB 데드락) end
Service -->> Client: 12. 결제 실패 응답 Note right of DB: [데이터 불일치 상태!]<br/>- 재고: 차감됨 (롤백 실패)<br/>- 작업 상태: PROCESSING (롤백됨)11번 단계가 핵심인데, 보상 트랜잭션이 롤백되면서, 재고는 차감되었지만 작업 상태는 PROCESSING으로 남는 데이터 불일치 상태가 된다.
스케줄러를 통한 최종적 일관성 확보
Section titled “스케줄러를 통한 최종적 일관성 확보”문제를 해결하기 위해 PaymentRecoveryUseCase라는 스케줄러를 구현했다.
- 동작:
PROCESSING상태로 일정 시간 이상 방치된 작업을 조회 - 로직: 조회된 작업의
orderId로 Toss API에 실제 결제 상태를 다시 조회
sequenceDiagram participant Scheduler as PaymentRecoveryUseCase participant DB as 데이터베이스 (JPA) participant Toss as Toss PG API participant Service as PaymentCoordinator Scheduler ->> Scheduler: 1. 스케줄러 실행 Scheduler ->> DB: 2. 'PROCESSING' 상태 작업 조회 DB -->> Scheduler: 3. [Job (orderId-123)] 반환 Scheduler ->> Toss: 4. Toss API로 결제 상태 재확인 (orderId-123) Toss -->> Scheduler: 5. 최종 상태 응답: "CANCELED" (실패) Scheduler ->> Service: 6. '결제 실패 보상 로직' 재실행%% --- 3단계 (Tx 2) - 보상 트랜잭션 "재시도" --- Service ->> DB: 7. [Tx 2 (보상) 시작] Service ->> DB: 8. 재고(Stock) 복구 Service ->> DB: 9. 작업(PaymentProcess) 상태 'FAILED' 변경 Service ->> DB: 10. [Tx 2 (보상) 커밋] Note right of DB: [데이터 정합성 복구 완료!]<br/>- 재고: 정상 복구<br/>- 작업 상태: FAILED- 실패 처리의 실패(Double Fault) 해결: 데이터 불일치 시나리오에 대한 자동 복구 메커니즘 구축
- 최종적 일관성 확보: 스케줄러와 작업 테이블로 장애 발생 시에도 시스템이 스스로 정합성 회복