결제 복구 상태 모델 설계 — 장애 내성을 갖춘 상태 전이
Payment Platform Project
- 1 결제 정보 검증을 통한 안전한 결제 연동 시스템 구현
- 2 트랜잭션 범위 최소화를 통한 성능 및 안정성 향상
- 3 결제 상태 전환 관리와 재시도 로직을 통한 결제 복구 시스템 구축
- 4 외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증
- 5 Logger 성능 저하 방지와 구조화된 로깅 설계
- 6 결제 이력 추적 및 핵심 지표 모니터링 시스템 구현
- 7 보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계
- 8 전략 패턴을 통한 PG 독립성 확보 및 확장 가능한 결제 시스템 설계
- 9 Checkout API 멱등성 보장 — Caffeine 캐시와 TOCTOU 경쟁 조건 해결
- 10 비동기 결제 처리 플로우 구현 — Outbox 패턴부터 LinkedBlockingQueue Worker까지
- 11 결제 복구 상태 모델 설계 — 장애 내성을 갖춘 상태 전이 OPEN
실행 환경: Java 21, Spring Boot 3.4.4, MySQL 8.0
최근 비동기 결제 처리 아키텍처를 도입하면서, 기존 상태 모델이 새 아키텍처를 충분히 표현하지 못하는 문제가 드러났다.
- 재시도 대상과 완전 실패 건의 구분이 모호하여 복구 대상을 정확히 추적의 어려움
- 고정 간격 재시도로 인해 PG 장애 시 부하가 집중 위험 가능
이 글에서는 이러한 상태 모델의 구조적 한계를 분석하고, 장애 내성을 갖춘 상태 전이 체계를 재설계한 과정을 다룬다.
** 비동기 결제 아키텍처 자체의 도입 배경과 구현은 이전 글에서 다루므로, 여기서는 상태 모델과 복구 로직에 집중한다.
비동기 결제 플로우 요약
Section titled “비동기 결제 플로우 요약”본론에 앞서, 이 글의 전제가 되는 비동기 결제 구조를 간략히 정리한다.
- 클라이언트의 결제 승인 요청(confirm)과 실제 PG(Payment Gateway, 결제 대행사) 승인 API 호출을 기술적으로 분리
- 서버는 PG 응답을 기다리지 않고 즉시 202 Accepted를 반환
- 실제 승인은 백그라운드에서 처리하며, 클라이언트는 승인 상태를 조회하여 최종 결과를 확인
sequenceDiagram participant C as 클라이언트 participant S as 서버 participant PG as PG사 C ->> S: 결제 승인 요청 S -->> C: 202 Accepted Note over S, PG: 백그라운드 처리 S ->> PG: PG 승인 요청 PG -->> S: 승인 결과 C ->> S: 승인 상태 조회 S -->> C: 최종 결과이 구조에서 핵심이 되는 세 가지 개념이 있다.
PaymentEvent: 결제 1건의 생명주기를 추적하는 도메인 엔티티- READY → IN_PROGRESS → DONE/FAILED 등 상태 전이를 관리
PaymentOutbox: Outbox 패턴을 적용한 작업 큐 역할의 테이블- PENDING(대기) → IN_FLIGHT(처리 중) → DONE/FAILED(종결) 상태 전이를 관리
- Outbox 패턴: 비동기로 처리할 작업을 같은 DB 트랜잭션(TX) 안에 기록하여, 서버가 중간에 죽더라도 처리 대상이 유실되지 않도록 보장하는 기법
- Worker: Outbox 레코드를 읽어 실제 PG 승인을 수행하는 백그라운드 처리기
- 즉시 처리기(TX 커밋 직후 즉시 처리)와 폴링 복구기(주기적 폴링으로 누락 건 재처리) 두 트랙으로 구성
기존 설계의 한계
Section titled “기존 설계의 한계”위 비동기 구조에서 기존 상태 모델에 네 가지 구조적 한계가 있었다.
한계 1 - 불명확한 UNKNOWN 상태와 분산된 스케줄러
Section titled “한계 1 - 불명확한 UNKNOWN 상태와 분산된 스케줄러”기존 상태 머신의 IN_PROGRESS와 UNKNOWN 두 상태 모두 재시도 대상이었고, 실패가 반복되면 FAILED로 전이되었다.
stateDiagram-v2 [*] --> READY: checkout 완료 READY --> IN_PROGRESS: PG 승인 요청 수신 READY --> EXPIRED: 만료 (30분) IN_PROGRESS --> DONE: PG 승인 성공 IN_PROGRESS --> FAILED: 실패 (non-retryable 또는 재시도 소진) IN_PROGRESS --> UNKNOWN: 재시도 가능 에러 발생 UNKNOWN --> DONE: 재시도 성공 UNKNOWN --> FAILED: 재시도 소진 UNKNOWN --> UNKNOWN: 재시도 계속또한 여러 스케줄러가 돌아가고 있고, 이를 관리하는 복구 사이클이 분산되어 있었다.
한계 2 - 하드코딩된 재시도 정책 및 부하 집중 위험
Section titled “한계 2 - 하드코딩된 재시도 정책 및 부하 집중 위험”재시도 한도가 PaymentEvent와 PaymentOutbox 양쪽에 RETRYABLE_LIMIT = 5로 중복 하드코딩되어 있었다.
- 고정된 폴링 간격마다 모든 건에 요청하면서 일시적 장애 시 PG에 부하 집중될 수 있음
PaymentOutbox에nextRetryAt개념이 없어 고정 간격(FIXED) 이외의 Backoff 전략을 지원할 수 없음- 한도를 변경하려면 두 클래스를 동시에 수정해야 하는 구조
한계 3 - 예외 기반 실패 분류의 이중 구조
Section titled “한계 3 - 예외 기반 실패 분류의 이중 구조”도메인 결과값(PaymentConfirmResult)이 이미 실패 유형을 분류하고 있음에도, 예외 타입으로 변환하여 catch 블록에서 분기하는 이중 구조가 존재했다.
한계 4 - PG 상태 미조회와 무조건 재승인
Section titled “한계 4 - PG 상태 미조회와 무조건 재승인”복구 사이클이 PG 상태를 조회하지 않고 무조건 승인 요청을 재발행하는 구조였다.
- 멱등성 키(동일 요청을 여러 번 보내도 결과가 한 번만 적용되도록 보장하는 고유 키)로 중복 결제라는 심각한 문제는 방지
- 재승인 요청 직전 상태를 알 수 없는 구조
위 네 가지 한계를 해결하기 위해, 상태 모델 재정의 → 재시도 정책 도메인 객체화 → 복구 결정 로직 집중 → 동시성 안전장치 순서로 재설계를 진행했다.
| 솔루션 | 대응하는 한계 |
|---|---|
| 상태 모델 재정의 | 한계 1 - 불명확한 UNKNOWN, 분산 스케줄러 |
| RetryPolicy | 한계 2 - 하드코딩된 재시도 정책, 부하 집중 |
| RecoveryDecision | 한계 3, 4 - 실패 분류 이중 구조, PG 미조회 |
| 원자적 선점 / 재고 복구 가드 | 동시성 안전장치 (신규) |
상태 모델 재정의 - RETRYING, QUARANTINED 도입과 UNKNOWN 제거
Section titled “상태 모델 재정의 - RETRYING, QUARANTINED 도입과 UNKNOWN 제거”한계 1 해결: 불명확한 UNKNOWN 상태 제거, 분산 스케줄러 통합
기존 UNKNOWN 상태를 RETRYING으로 변경하여 “재시도 대기 중”이라는 의미를 명확히 부여하고, 한도 소진 시 복구 사이클에서 영구 이탈시키는 QUARANTINED(격리) 상태를 도입했다.
stateDiagram-v2 classDef ready fill: #D6EAF8, color: #1B4F72, stroke: #2E86C1 classDef inprogress fill: #FEF9E7, color: #7D6608, stroke: #F1C40F classDef retrying fill: #FEF5E7, color: #7E5109, stroke: #F39C12 classDef done fill: #D5F5E3, color: #0E6251, stroke: #28B463 classDef failed fill: #FADBD8, color: #7B241C, stroke: #E74C3C classDef quarantined fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2 [*] --> READY: checkout 완료 READY --> IN_PROGRESS: PG 승인 요청 수신 READY --> EXPIRED: 만료 (30분) IN_PROGRESS --> DONE: PG 승인 성공 IN_PROGRESS --> FAILED: non-retryable 오류 IN_PROGRESS --> RETRYING: retryable 오류 (첫 실패) IN_PROGRESS --> QUARANTINED: 판단 불가 + 한도 소진 RETRYING --> DONE: 재시도 성공 RETRYING --> FAILED: non-retryable 오류 또는 재시도 소진 RETRYING --> RETRYING: 재시도 또 실패 (한도 내) RETRYING --> QUARANTINED: 판단 불가 + 한도 소진 class READY ready class IN_PROGRESS inprogress class RETRYING retrying class DONE done class FAILED,EXPIRED failed class QUARANTINED quarantinedUNKNOWN → RETRYING: “알 수 없는 상태”가 아니라 “재시도 대기 중”이라는 의미를 명확히 부여RETRYING → RETRYING: 한도 내에서 반복 실패 시 self-loopRETRYING → DONE / FAILED: 재시도 성공 또는 소진 시 종결IN_PROGRESS / RETRYING → QUARANTINED: 한도 소진 후에도 PG 상태를 판단할 수 없으면 격리 (관리자 개입 전까지 자동 복구 사이클에서 영구 이탈)- PG 측에서 이미 승인이 완료되었을 가능성 고려
이를 위해 기존 상태 전이 가드도 업데이트했다.
| 전이 | 기존 허용 source | 변경 후 허용 source |
|---|---|---|
| 승인 완료 전이 | IN_PROGRESS, DONE | IN_PROGRESS, RETRYING, DONE |
| 실패 처리 전이 | READY, IN_PROGRESS | READY, IN_PROGRESS, RETRYING |
| 재시도 전환 | (신규) | READY, IN_PROGRESS, RETRYING |
| 격리 | (신규) | READY, IN_PROGRESS, RETRYING |
RetryPolicy 도메인 객체 추가 및 nextRetryAt 필드 추가로 재시도 시점 제어
Section titled “RetryPolicy 도메인 객체 추가 및 nextRetryAt 필드 추가로 재시도 시점 제어”한계 2 해결: 하드코딩된 재시도 정책, 부하 집중 위험
중복 하드코딩된 재시도 한도를 RetryPolicy 도메인 객체로 분리했다.(설정은 application.yml로 외부화)
public record RetryPolicy( int maxAttempts, BackoffType backoffType, long baseDelayMs, long maxDelayMs) {
public boolean isExhausted(int retryCount) { return retryCount >= maxAttempts; }
public Duration nextDelay(int retryCount) { return switch (backoffType) { case FIXED -> Duration.ofMillis(baseDelayMs); case EXPONENTIAL -> Duration.ofMillis( Math.min(baseDelayMs * (1L << retryCount), maxDelayMs) ); }; }}maxAttempts: 최대 재시도 횟수BackoffType: FIXED(고정 간격) 또는 EXPONENTIAL(지수 증가)baseDelayMs/maxDelayMs: Backoff 계산의 기본값과 상한
추가적으로 PaymentOutbox에 nextRetryAt 필드를 도입하여, 재시도 시점을 동적으로 제어할 수 있도록 했다.
RecoveryDecision 값 객체
Section titled “RecoveryDecision 값 객체”한계 3, 4 해결: 예외 기반 실패 분류 → 도메인 결과값 기반, PG 상태 선행 조회 도입
기존의 예외 타입을 통한 실패 분류 대신, 복구 결정 로직을 순수 도메인 값 객체에 집중시켰다.
| Decision | 의미 | 판단 기준 | 후속 처리 |
|---|---|---|---|
| COMPLETE_SUCCESS | PG 승인 완료 | PG 조회 결과 = DONE | 로컬 DONE 전이 |
| COMPLETE_FAILURE | PG 종결 실패 (취소/중단/만료 등) | PG 조회 결과 = CANCELED/ABORTED/EXPIRED 등 | 보상 TX (재고 복원 + FAILED) |
| ATTEMPT_CONFIRM | PG에 기록 없음 | PG 조회 결과 = NOT_FOUND | PG 승인 요청 후 결과 재평가 |
| RETRY_LATER | PG 조회 오류 또는 진행 중 (한도 내) | PG 조회 실패(타임아웃/5xx) 또는 PG 처리 중 + 한도 미소진 | RETRYING + nextRetryAt 설정 |
| QUARANTINE | 한도 소진 + 판단 불가 | 한도 소진 + PG 상태 판단 불가(타임아웃/매핑 불가) | QUARANTINED (자동 복구 이탈) |
| REJECT_REENTRY | 로컬이 이미 종결 상태 | 로컬 PaymentEvent = DONE/FAILED/CANCELED 등 | Outbox만 멱등 종결 |
RecoveryDecision decision = RecoveryDecision.decide( pgStatus, // PG 상태 조회 결과 localEventStatus, // 로컬 PaymentEvent 상태 retryCount, // 현재까지 재시도 횟수 retryPolicy // RetryPolicy 설정);Spring 의존 없이 순수하게 테스트할 수 있으며, 모든 복구 분기가 한 곳에 집중된다.
원자적 선점
Section titled “원자적 선점”같은 건을 여러 Worker가 동시에 처리하는 것을 막기 위해, Outbox 상태를 PENDING(대기)에서 IN_FLIGHT(Worker가 선점하여 처리 중)로 원자적으로 전환하는 선점 메커니즘을 도입했다.
UPDATE payment_outboxSET status = 'IN_FLIGHT', in_flight_at = :nowWHERE order_id = :orderId AND status = 'PENDING' AND (next_retry_at IS NULL OR next_retry_at <= :now)- WHERE 절에
status = 'PENDING'조건이 포함되어 있으므로, 두 Worker가 동시에 같은 건을 선점하려 해도 하나만 UPDATE에 성공 - 실패한 Worker는 영향 행 수가 0이므로 즉시 포기하고, 성공한 Worker만 이후 복구 로직 진행
재고 복구 가드
Section titled “재고 복구 가드”보상 트랜잭션은 재고를 복원하는 되돌릴 수 없는 작업이므로, 실행 전에 데이터가 정상 상태인지 이중 조건 가드로 확인한다.
- 정상 흐름에서는 원자적 선점이 중복 진입을 원천 차단
- 비정상 데이터(타임아웃 복구 후 상태 불일치 등)가 보상 TX까지 도달했을 때 재고를 함부로 건드리지 않기 위한 안전망
flowchart TD classDef check fill: #FEF9E7, color: #7D6608, stroke: #F1C40F classDef skip fill: #F5F5F5, color: #616161, stroke: #9E9E9E classDef exec fill: #FADBD8, color: #7B241C, stroke: #E74C3C A["보상 트랜잭션 진입"] --> C{"1. 대기열이 처리 중인가?\n(현재 사이클에서 선점한 건)"}:::check C -- " NO " --> D["재고 복원 건너뜀\n대기열/결제 종결만 수행"]:::skip C -- " YES " --> E{"2. 결제가 아직 미종결인가?\n(아직 실패/완료 아닌 건)"}:::check E -- " NO " --> D E -- " YES " --> F["재고 복원 수행\n+ 대기열 → 실패\n+ 결제 → 실패"]:::execTX 시작 시점에 Outbox와 PaymentEvent를 DB에서 다시 조회하여, 두 조건 모두 충족할 때만 재고 복원을 실행한다.
- Outbox가 IN_FLIGHT가 아닌 경우: 다음 두 가지 경로로 이 상태에 도달
- 다른 Worker가 복구를 먼저 완료하여 Outbox가 DONE 또는 FAILED로 전이된 경우
- IN_FLIGHT 타임아웃 복구에 의해 Outbox가 PENDING으로 초기화된 뒤, 다른 Worker가 재선점하여 현재 Worker의 IN_FLIGHT가 무효화된 경우
- 어느 쪽이든 현재 Worker의 선점이 유효하지 않으므로 재고 복원 skip
- PaymentEvent가 이미 종결 상태(DONE/FAILED/CANCELED 등)인 경우: 다른 경로에서 보상 TX가 이미 커밋되었으므로 재고 복원을 skip
격리 전 최종 확인
Section titled “격리 전 최종 확인”재시도 중 마지막 요청이 성공했을 수도 있으므로, 격리 직전에 PG 상태를 한 번 더 조회하여 최종 확인한다.
flowchart TD classDef success fill: #D5F5E3, color: #0E6251, stroke: #28B463 classDef failure fill: #FADBD8, color: #7B241C, stroke: #E74C3C classDef quarantine fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2 classDef action fill: #EBF5FB, color: #21618C, stroke: #3498DB A["한도 소진 시점"] --> B["PG 상태 1회 최종 재확인"]:::action B -->|" 승인 완료 "| C["성공 확정 (승격)"]:::success B -->|" PG 종결 실패\n(취소/중단/만료 등) "| D["실패 확정 (재고 복원)"]:::failure B -->|" PG 기록 없음/타임아웃/매핑 불가\n(판단 불가) "| F["격리 (관리자 개입 대기)"]:::quarantine복구 처리기 구성
Section titled “복구 처리기 구성”시나리오에 앞서, 복구 처리를 담당하는 세 구성 요소의 역할을 정리한다.
| 구성 요소 | 트리거 | 역할 |
|---|---|---|
| 즉시 처리기 | 채널 수신 즉시 | PG 승인 요청 직후 Outbox에 발행된 건을 즉시 처리 |
| 폴링 복구기 | 5초 주기 폴링 | IN_FLIGHT 타임아웃 복구 / PENDING 건 배치 조회 후 복구 처리 위임 |
| 만료 스케줄러 | 5분 주기 | 30분 이상 READY 상태인 만료 건을 EXPIRED로 전이 |
폴링 복구기는 매 틱마다 두 단계를 순서대로 수행한다.
- IN_FLIGHT 상태가 일정 시간(기본 5분) 이상 지속된 건을 PENDING으로 복구: Worker가 처리 중 비정상 종료하거나 응답이 느려진 경우를 대비한 안전장치
- 5분은 PG 승인 API의 최대 응답 시간(통상 수십 초)에 충분한 여유를 두되, 복구 지연이 과도하게 길어지지 않도록 설정한 값으로 설정
- PENDING 건을 배치 조회하고, 각 건에 대해 PENDING → IN_FLIGHT 원자적 선점 후 복구 처리 시작
타임아웃 복구와 배치 조회를 별도 단계로 분리한 이유
Section titled “타임아웃 복구와 배치 조회를 별도 단계로 분리한 이유”한 쿼리로 합치면 “정상 처리 중인 IN_FLIGHT”와 “죽은 IN_FLIGHT”를 구분할 수 없기 때문이다.
- 먼저 시간 기준으로 죽은 건만 PENDING으로 확정
- 이후 원자적 선점이 PENDING → IN_FLIGHT 단일 전이만 경쟁하도록 하여 선점 보장을 단순하게 유지
엣지 케이스 검증
Section titled “엣지 케이스 검증”위 설계가 실제 장애 상황을 어떻게 방어하는지, 구체적인 시나리오로 확인한다.
1. PG 승인 완료인데 로컬이 모르는 경우
Section titled “1. PG 승인 완료인데 로컬이 모르는 경우”서버가 PG 승인 응답을 받은 직후, DB에 반영하기 전에 죽는 상황이다.
sequenceDiagram participant S as 서버 participant DB as DB participant PG as PG사 S ->> PG: PG 승인 요청 PG -->> S: 승인 완료 Note over S: 서버 장애 발생 (DB 커밋 전) Note over DB: 결제 = 진행 중, 대기열 = 처리 중 Note over S: 5분 후 타임아웃 복구 → 대기 S ->> PG: PG 상태 조회 PG -->> S: 승인 완료 S ->> DB: [TX] 결제 완료 + 대기열 종결PG 상태를 먼저 조회함으로써 승인 재요청 없이 로컬에 바로 동기화하게 된다.
2. Worker 타임아웃으로 동시 처리 발생
Section titled “2. Worker 타임아웃으로 동시 처리 발생”W1이 처리 중 느려져서 타임아웃이 발생하고, W2가 같은 건을 다시 선점하는 상황이다.
sequenceDiagram participant W1 as 워커 1 (느림) participant DB as DB participant W2 as 워커 2 participant PG as PG사 W1 ->> DB: 원자적 선점 (대기 → 처리 중) Note over W1: 처리 지연... Note over DB: 타임아웃 복구 DB ->> DB: 대기열 → 대기 복원 W2 ->> DB: 원자적 재선점 (대기 → 처리 중) W2 ->> PG: PG 상태 조회 PG -->> W2: 취소됨 (PG 종결 실패) W2 ->> DB: [보상 TX] 재고 복원 + 결제 실패 + 대기열 실패 Note over W1: 뒤늦게 보상 TX 시도 W1 ->> DB: [보상 TX] 가드 검사 Note over DB: 대기열 = 실패 (처리 중 아님) → 가드 차단 W1 ->> DB: 재고 복원 건너뜀W2가 먼저 보상 TX를 커밋한 뒤, W1이 뒤늦게 같은 건에 보상 TX를 시도했지만, 재고 복구 가드가 “Outbox가 IN_FLIGHT가 아님”을 감지하여 재고 이중 복원을 차단한다.
- Worker가 IN_FLIGHT 타임아웃(5분)을 초과하는 현실적인 시나리오
- Worker 스레드 혹은 프로세스가 비정상 종료(OOM, 예기치 않은 예외 등)되어 응답 자체를 반환하지 못하는 경우를 가정
- IN_FLIGHT 타임아웃은 이처럼 Worker가 처리 도중 사라진 건을 복구하기 위한 안전장치
3. 한도 소진 직전 PG 승인 완료
Section titled “3. 한도 소진 직전 PG 승인 완료”4번째 재시도까지 타임아웃이 반복되다가, 5번째(마지막) 시도 직전에 PG가 승인을 완료한 경우다.
sequenceDiagram participant W as 워커 participant DB as DB participant PG as PG사 Note over DB: 결제 = 재시도 중 (4회 실패) Note over DB: 대기열 = 대기 (재시도 시점 도달) W ->> DB: 원자적 선점 W ->> PG: PG 상태 조회 PG -->> W: 타임아웃 (5번째 실패) Note over W: 한도 소진 → 격리 전 최종 확인 W ->> PG: PG 상태 1회 최종 재확인 PG -->> W: 승인 완료 W ->> DB: [TX] 결제 완료 + 대기열 종결이 확인이 없었다면 5회 소진 시점에 QUARANTINED 또는 FAILED로 처리되어, 이미 승인된 결제의 재고가 복원될 수 있었지만, 마지막 getStatus 조회가 성공으로 승격시켜준다.
최종 상태 다이어그램
Section titled “최종 상태 다이어그램”모든 개선을 반영한 PaymentEventStatus의 최종 상태 전이는 다음과 같다.
stateDiagram-v2 classDef ready fill: #D6EAF8, color: #1B4F72, stroke: #2E86C1 classDef inprogress fill: #FEF9E7, color: #7D6608, stroke: #F1C40F classDef retrying fill: #FEF5E7, color: #7E5109, stroke: #F39C12 classDef done fill: #D5F5E3, color: #0E6251, stroke: #28B463 classDef failed fill: #FADBD8, color: #7B241C, stroke: #E74C3C classDef quarantined fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2 [*] --> READY: checkout 완료 READY --> IN_PROGRESS: PG 승인 요청 수신 READY --> EXPIRED: 만료 (30분) READY --> RETRYING: 복구 사이클 재진입 IN_PROGRESS --> DONE: PG 승인 성공 IN_PROGRESS --> FAILED: non-retryable 오류 IN_PROGRESS --> RETRYING: retryable 오류 IN_PROGRESS --> QUARANTINED: 한도 소진 + 판단 불가 RETRYING --> DONE: 재시도 성공 RETRYING --> FAILED: non-retryable 또는 한도 소진 + 확정 실패 RETRYING --> RETRYING: 한도 내 재시도 RETRYING --> QUARANTINED: 한도 소진 + 판단 불가 class READY ready class IN_PROGRESS inprogress class RETRYING retrying class DONE done class FAILED,EXPIRED failed class QUARANTINED quarantined복구 사이클 전체 플로우
Section titled “복구 사이클 전체 플로우”복구 사이클의 최종 구조를 플로우차트로 정리하면 다음과 같다.
- 원자적 선점 성공 → 로컬 종결 여부 확인 → PG 상태 선행 조회 순서로 진입
- PG 조회 결과에 따라 즉시 종결(DONE/FAILED), 재시도(RETRY_LATER), 승인 요청(ATTEMPT_CONFIRM) 중 하나로 분기
- 한도를 소진하면 격리 전 최종 확인을 거쳐 QUARANTINE 또는 종결로 분기
- 보상 TX 실행 전에는 항상 재고 복구 가드가 이중 조건을 검증
flowchart TD classDef success fill: #D5F5E3, color: #0E6251, stroke: #28B463 classDef retryable fill: #FEF5E7, color: #7E5109, stroke: #F39C12 classDef failure fill: #FADBD8, color: #7B241C, stroke: #E74C3C classDef quarantine fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2 classDef action fill: #EBF5FB, color: #21618C, stroke: #3498DB classDef check fill: #FEF9E7, color: #7D6608, stroke: #F1C40F classDef skip fill: #F5F5F5, color: #616161, stroke: #9E9E9E A["복구 틱"] --> CL["원자적 선점\n대기 → 처리 중"]:::action CL -->|" 선점 실패 "| SKIP["다른 워커가 처리 중 → 포기"]:::skip CL -->|" 선점 성공 "| LC{"결제 종결 여부 검사\n완료/실패/취소 등"}:::check LC -->|" 이미 종결\n(완료/실패/취소 등) "| REENTRY["재진입 거부\n대기열만 멱등 종결"]:::skip LC -->|" 비종결\n(대기/진행 중/재시도 중) "| GS["PG 상태 선행 조회"]:::action GS -->|" 승인 완료 "| SUCCESS["성공 확정"]:::success GS -->|" 취소/중단/만료 등\nPG 종결 실패 "| FAILURE["실패 확정"]:::failure GS -->|" PG 기록 없음 "| CONFIRM["PG 승인 요청"]:::action GS -->|" 타임아웃/5xx/매핑 불가\n+ 한도 미소진 "| RETRY["재시도 대기"]:::retryable SUCCESS --> TX_DONE["결제 완료\n대기열 종결"]:::success FAILURE --> GUARD{"재고 복구 가드\n대기열 처리 중?\n결제 비종결?"}:::check CONFIRM --> CF["PG 승인 요청"]:::action RETRY --> TX_RETRY["결제 → 재시도 중\n재시도 시점 설정\n대기열 → 대기"]:::retryable CF -->|" 승인 성공 "| TX_DONE CF -->|" 재시도 가능 + 한도 미소진 "| TX_RETRY CF -->|" 재시도 불가 "| GUARD CF -->|" 재시도 가능 + 한도 소진 "| FINAL TX_RETRY -->|" 한도 소진 "| FINAL FINAL["격리 전 최종 확인\nPG 상태 1회 재확인"]:::action FINAL -->|" 승인 완료 확인 "| TX_DONE FINAL -->|" PG 종결 실패 "| GUARD FINAL -->|" 판단 불가 "| QU["격리\n결제 → 격리 상태\n대기열 → 실패\n재고 복원 금지"]:::quarantine GUARD -->|" 조건 충족 "| TX_COMP["보상 트랜잭션 실행\n재고 복원\n결제 → 실패\n대기열 → 실패"]:::failure GUARD -->|" 조건 미충족 "| TX_SKIP["대기열 → 실패만\n재고 복원 건너뜀"]:::skip상태 모델을 재정의하고 복구 결정 로직을 도메인 값 객체에 집중시킨 결과, 복구 사이클이 대응하는 전체 케이스를 다음과 같이 정리할 수 있다.
복구 사이클 내 케이스
Section titled “복구 사이클 내 케이스”복구 사이클은 PG 상태 조회 결과를 기준으로 종결 / 재시도 / 격리 중 하나를 결정하여 처리한다.
| 상황 | Event | Outbox | 재고 |
|---|---|---|---|
| PG에서 승인이 정상 완료된 경우 | → DONE | → DONE | 점유 유지 |
| PG에서 결제가 취소/중단/만료된 경우 | → FAILED | → FAILED | 복원 |
| PG에 결제 기록 자체가 없는 경우 | 승인 결과에 따라 결정 | 승인 결과에 따라 결정 | 점유 유지 |
| PG 조회가 일시적으로 실패한 경우 (한도 내) | → RETRYING | → PENDING (nextRetryAt) | 점유 유지 |
| PG에서 아직 처리 중인 경우 (한도 내) | → RETRYING | → PENDING (nextRetryAt) | 점유 유지 |
| 위 재시도 케이스에서 한도를 소진한 경우 | 격리 전 최종 확인 결과에 따라 결정 | 격리 전 최종 확인 결과에 따라 결정 | 결과에 따라 결정 |
| 이미 로컬에서 종결 처리가 완료된 경우 | 변경 없음 | → DONE | 변경 없음 |
스케줄러 및 선점
Section titled “스케줄러 및 선점”복구 사이클 뿐만 아니라, 동시성과 장애 상황에 대응하는 스케줄러와 선점 구조도 다음과 같이 정리할 수 있다.
| 상황 | Event | Outbox | 재고 |
|---|---|---|---|
| Worker가 느려져서 다른 Worker가 먼저 처리를 완료한 경우 | 변경 없음 | 변경 없음 | 가드가 이중 복원 차단 |
| Worker가 비정상 종료하여 처리가 중단된 경우 | 변경 없음 | → PENDING | 점유 유지 |
| 다른 Worker가 이미 선점한 경우 | 변경 없음 | 변경 없음 | 변경 없음 |