결제 실패 · Dunning 시나리오 전수조사

beta 실데이터 기준 — 미래 결제 스케줄링(payment_retries) 전 경로 분석 · unpaid 상태 정의 · 최적방법 제안

2026-06-18 · BUSEOT 인시던트 후속 심층 감사

요약

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 32active 중 카드보유 8건뿐
recurring 결제성공 18 · 실패 6 · 환불 1실패 6건 전부 2026-03월
payment_retries0건 (테이블 영구 비어있음)Dunning 미발현
past_due 전이 이력2건unpaid 전이 0건
grace 진행중5건카드없음 유예

실패 6건 상세 (결정적 증거)

에러코드건수재시도가능?구독상태retry 생성
INVALID_STOPPED_CARD3YES (retryable)active0
ALL_CARDS_FAILED3NOactive0
재시도 가능 코드(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_dueunpaid
진입결제 실패 직후(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 최고. 위험은 "알려진 버그"가 아니라 "한 번도 안 돌아본 경로".

④ 별개 follow-up — active·무카드 25건

active이면서 billing_key 없고 grace도 아닌 toss 구독 25건. 기간 종료 시 "No active billing key"로 실패할 운명. 레거시인지/기간 미도래인지 별도 감사 필요(이번 인시던트와 무관).