결제 실패 · Dunning 시나리오 전수조사
beta 실데이터 기준 — 미래 결제 스케줄링(payment_retries) 전 경로 분석 · unpaid 상태 정의 · 최적방법 제안
요약
recurring 결제 실패
6건
payment_retries (Dunning)
0건 (영구)
unpaid 전이
0건
적용한 하드닝 PR
2건(#8838/#8839)
핵심 결론: Dunning(재시도) 상태머신은 프로덕션에서 한 번도 발현된 적이 없다(retry 0건). 발견된 결함은 전부 잠재(latent)이며 현재 피해자 0. 잠재 이중청구·고아 retry 3건은 단일 가드+retry 정리로 수정 완료. unpaid 자동정리 워커는 데이터상 불필요(0건) → 추가 안 함(김치볶음밥). 진짜 우선순위는 "미검증 경로의 통합 테스트".
1. beta 실데이터 (시나리오 모집단)
| 지표 | 값 | 해석 |
|---|---|---|
| 구독 status (toss) | expired 901 · trialing 88 · active 38 · canceled 32 | active 중 카드보유 8건뿐 |
| recurring 결제 | 성공 18 · 실패 6 · 환불 1 | 실패 6건 전부 2026-03월 |
| payment_retries | 0건 (테이블 영구 비어있음) | Dunning 미발현 |
| past_due 전이 이력 | 2건 | unpaid 전이 0건 |
| grace 진행중 | 5건 | 카드없음 유예 |
실패 6건 상세 (결정적 증거)
| 에러코드 | 건수 | 재시도가능? | 구독상태 | retry 생성 |
|---|---|---|---|---|
| INVALID_STOPPED_CARD | 3 | YES (retryable) | active | 0 |
| ALL_CARDS_FAILED | 3 | NO | active | 0 |
재시도 가능 코드(INVALID_STOPPED_CARD)인데도
past_due 전이·retry 생성이 안 됐고 구독은 여전히 active. 현재 코드 handlePaymentFailure는 active→past_due + (retryable이면) schedulePaymentRetries를 확실히 수행한다. 따라서 이 6건은 Dunning 상태머신 도입(#2098 결제 안정성 강화) 이전의 구(舊) 경로에서 발생했고, 그 이후 실패가 0건이라 Dunning이 프로덕션에서 검증된 적이 없다는 의미.
2. 결제 실패 → 미래 결제 스케줄링 흐름
결제 시도 (processSubscriptionPayment)
├─ [가드] getChargeBlockReason → 해지예약·종료상태면 차단 (신규)
├─ [성공] handlePaymentSuccess → status=active, 기간연장
│ + pending retry 전부 취소 (신규: 이중청구 방지)
└─ [실패] handlePaymentFailure
├─ active → past_due
└─ retryable 코드면 schedulePaymentRetries (D+1/D+3/D+7 3행)
매일 09:00 KST 워커 (cron "0 0 * * *", UTC)
├─ getSubscriptionsDueForPayment (active/past_due, cancel 제외)
├─ processPendingRetries (scheduled_at<=now pending, limit 50)
│ └─ 모두 소진 시 past_due → unpaid
└─ processScheduledCancellations (해지예약 정리)
탈출: 결제수단 추가 → retryUnpaidSubscriptions (unpaid/past_due 복구)
3. unpaid 상태 — 기준과 위험
| 항목 | past_due | unpaid |
|---|---|---|
| 진입 | 결제 실패 직후(active→past_due) | Dunning D+1/3/7 전부 소진 |
| 자동 결제 대상 | 포함 | 제외 |
| IAM 권한 | 활성 | 차단(비활성) |
| 탈출 경로 | 재시도/갱신 | 결제수단 추가 時에만 |
unpaid = 준-terminal stranded 상태: 정기 워커 어디도 unpaid를 재시도·취소·복구하지 않는다. 결제수단을 등록해야만(
retryUnpaidSubscriptions) 탈출. 사용자가 카드를 안 넣으면 영구 잔류 가능. 단, beta 실데이터상 unpaid 진입 0건 → 현재 실질 위험 없음.
4. 적용 완료 — 하드닝 3건 #8838 alpha#8839 beta
① 청구 가드 종료상태 확대 (순수함수화)
getChargeBlockReason 분리(SSOT·테스트가능). 차단: 해지예약 + 종료상태(canceled/expired/incomplete_expired) → 고아 retry가 종료 구독을 청구하는 것 차단. unpaid·past_due는 허용 유지(복구·재시도 보호).
② handlePaymentSuccess가 pending retry 취소
성공⟹잔여 retry 없음(SSOT). 누락 시 갱신경로로 성공한 past_due 구독의 잔여 pending이 다음 워커에서 또 청구(이중청구) → 방지.③ cancelSubscription이 pending retry 취소
해지(즉시·예약) 시 고아 retry 발생원 제거.
테스트:
tests/unit/subscription-chargeable.test.ts 7건 pass (차단/허용 매트릭스 전수). send-ci lint+type+bundle 통과.
5. beta 실데이터 기준 최적방법 제안
① 적용 완료 (유지)
잠재 이중청구·고아 retry·종료구독 청구 차단 3건 + 가드 단일화. 저위험, 현재 피해자 0.② unpaid 자동정리 워커 — 만들지 않음
unpaid 진입 0건(영구) → 발생하지 않는 케이스에 코드 추가 거부(김치볶음밥). 대신 모니터링: unpaid 진입 시 알림.③ 최우선 — Dunning 통합 테스트 (미검증 경로 검증)
Dunning은 프로덕션 0회 발현 = 미검증. 추가 코드보다tests/integration에서 실패→past_due→retry 스케줄→D+1/3/7 처리→unpaid 전 경로를 테스트 DB로 검증하는 것이 ROI 최고. 위험은 "알려진 버그"가 아니라 "한 번도 안 돌아본 경로".