Payment Failure & Retry¶
정기 결제가 실패했을 때의 재시도 체인과 최종 자동 해지 흐름. 24h → 48h → 72h 세 번 재시도 후 실패하면 Subscription 을 canceled 로 전환하고 License 를 Free 로 downgrade 한다. Grace period 동안은 License 가 active 로 유지된다.
Scenario¶
정기 결제 cron 이 trigger 된 Subscription 의 charge 가 Toss 에서 실패 응답을 받는다. Billing 이 PaymentAttempt 를 failed 로 기록하고 Subscription 을 past_due 로 전환. 다음 재시도 task 를 delayed 로 enqueue. 이 과정이 최대 3번 반복되며, 4번째(즉 3차 재시도) 도 실패하면 Subscription auto-cancel 및 License downgrade.
Actors¶
- charge worker (asynq) — 각 시도 실행 주체
- Billing domain — 실패 기록, 재시도 스케줄, 최종 해지
- Toss Server — 실패 응답
- Outbox poller — 이벤트 dispatch
- Licensing domain — 최종 실패 시 Free 로 downgrade
- Notification domain — 실패 알림 (각 단계별)
- Audit domain — 전 이력 기록
Preconditions¶
- 이전에 성공한 결제로 Subscription 이
active였거나 past_due 에서 재시도 중 - BillingKey 존재
- Guild active
Postconditions (branches)¶
Recovery success (재시도 중 성공)¶
- Subscription
status='active',retry_count=0 PaymentSucceeded이벤트 → License 연장 (normal path)
Final failure (모든 재시도 실패)¶
- Subscription
status='canceled',canceled_at=NOW() PaymentFailedFinal이벤트- License Free 로 downgrade (
LicenseDowngraded이벤트 연쇄) - 사용자에게 자동 해지 알림
- Guild 의 Pro/Enterprise 기능 비활성화 (AntiNuke workflow 중지 등)
Retry schedule¶
| Stage | retry_number | Delay from previous | orderId suffix |
|---|---|---|---|
| 1차 시도 (정기) | 0 | — | _r0 |
| 1차 재시도 | 1 | 24h | _r1 |
| 2차 재시도 | 2 | 48h | _r2 |
| 3차 재시도 | 3 | 72h | _r3 |
| 자동 해지 | — | — | — |
누적 grace 기간: 24 + 48 + 72 = 144시간 (6일).
Sequence¶
sequenceDiagram
participant Worker as charge worker
participant Billing
participant Toss
participant Outbox
participant Queue as asynq queue
participant Poller
participant Licensing
participant Notification
Worker->>Billing: ProcessRecurringCharge(sub)
Billing->>Toss: POST /v1/billing/{key} (orderId _r0)
Toss-->>Billing: 4xx FAILED
{ code: CARD_LIMIT_EXCEEDED }
Billing->>Billing: BEGIN TX
Billing->>Billing: UPDATE payment_attempts status=failed
failure_code, failure_message
Billing->>Billing: UPDATE subscriptions
status=past_due, retry_count=1,
next_billing_at = NOW() + 24h
Billing->>Outbox: INSERT PaymentFailed event
(retry_number=1)
Billing->>Billing: COMMIT
Billing->>Queue: Enqueue RetryCharge(sub_id, retry=1)
ProcessIn 24h
Note over Poller: ~2s later
Poller->>Notification: OnPaymentFailed
Notification->>Notification: DM "결제 실패, 24h 후 재시도합니다"
Note over Queue: 24h 후
Queue->>Worker: RetryCharge(sub_id, retry=1)
Worker->>Billing: Retry(sub, retry=1)
Billing->>Toss: POST ... (orderId _r1)
alt Success (grace recovery)
Toss-->>Billing: Success
Billing->>Billing: Advance subscription to next period
retry_count=0, status=active
Billing->>Outbox: PaymentSucceeded
Note over Licensing: normal extend path
else Still failing
Toss-->>Billing: 4xx
Billing->>Billing: retry_count=2, next_billing_at=+48h
Billing->>Queue: Enqueue RetryCharge(retry=2) in 48h
Billing->>Outbox: PaymentFailed (retry_number=2)
end
Note over Queue: ... similar for retry=2 (48h), retry=3 (72h)
Note over Worker: After 3rd retry fails
Worker->>Billing: Retry(sub, retry=3)
Billing->>Toss: POST ... (orderId _r3)
Toss-->>Billing: 4xx
Billing->>Billing: BEGIN TX
Billing->>Billing: UPDATE attempt failed
Billing->>Billing: UPDATE subscription
status=canceled, canceled_at=NOW()
Billing->>Outbox: INSERT PaymentFailedFinal event
Billing->>Billing: COMMIT
Poller->>Licensing: OnPaymentFailedFinal
Licensing->>Licensing: DOWNGRADE license to FREE
expires_at = NULL
Licensing->>Outbox: LicenseDowngraded event
Poller->>Notification: OnPaymentFailedFinal
Notification->>Notification: DM "구독이 자동 해지되었습니다"
Step-by-step¶
1. 첫 실패 처리¶
Billing 의 handleChargeFailure:
func (s *serviceImpl) handleChargeFailure(ctx, sub, attempt, chargeErr) error {
tossErr, _ := chargeErr.(*TossError) // code, message 추출
return s.tx.WithTx(ctx, func(tx TxRepos) error {
// Attempt 실패 기록
attempt.Status = "failed"
attempt.FailureCode = &tossErr.Code
attempt.FailureMessage = &tossErr.Message
attempt.CompletedAt = ptr(time.Now())
tx.Attempts.Update(ctx, attempt)
// Subscription 상태 업데이트
nextRetry := attempt.RetryNumber + 1
if nextRetry > 3 {
// 최종 실패 → 자동 해지
return s.finalizeCancellation(ctx, tx, sub, attempt)
}
// 재시도 스케줄
retryDelay := retryDelays[nextRetry] // 24h, 48h, 72h
sub.Status = "past_due"
sub.RetryCount = nextRetry
sub.NextBillingAt = ptr(time.Now().Add(retryDelay))
tx.Subs.Update(ctx, sub)
// Outbox 이벤트
evt := PaymentFailedEvent{
SubscriptionID: sub.ID,
AttemptID: attempt.ID,
RetryNumber: nextRetry,
NextRetryAt: *sub.NextBillingAt,
FailureCode: tossErr.Code,
}
tx.Events.Publish(ctx, evt)
// Delayed retry task enqueue (트랜잭션 밖에서)
s.enqueueRetryAfterCommit(sub.ID, nextRetry, retryDelay)
return nil
})
}
var retryDelays = map[int]time.Duration{
1: 24 * time.Hour,
2: 48 * time.Hour,
3: 72 * time.Hour,
}
2. Delayed retry task¶
func HandleRetryChargeTask(ctx context.Context, t *asynq.Task) error {
var payload RetryPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil { return err }
return billingSvc.RetryCharge(ctx, payload.SubscriptionID, payload.RetryNumber)
}
func (s *serviceImpl) RetryCharge(ctx, subID, retryNumber) error {
sub, _ := s.subs.GetByID(ctx, subID)
// 상태 재확인
if sub.Status != "past_due" {
return nil // 이미 다른 경로로 처리됨 (수동 해지 등)
}
if sub.RetryCount != retryNumber {
return nil // 상태 drift, skip
}
// Attempt 생성 (orderId = sub_{id}_{cycle}_r{retry})
nextCycle := sub.CycleCount + 1 // 여전히 같은 주기의 결제
orderID := fmt.Sprintf("sub_%s_%03d_r%d", sub.ID, nextCycle, retryNumber)
attempt := PaymentAttempt{
ID: uuid.NewV7(),
SubscriptionID: sub.ID,
OrderID: orderID,
AmountKRW: plan.PriceKRW,
Status: "pending",
Cycle: nextCycle,
RetryNumber: retryNumber,
}
s.attempts.Insert(ctx, attempt)
// Toss charge (decrypt billing key 등, 동일)
result, err := s.toss.Charge(ctx, ...)
if err != nil {
return s.handleChargeFailure(ctx, sub, attempt, err)
}
return s.handleChargeSuccess(ctx, sub, attempt, result)
}
3. 재시도 성공 시 (Grace recovery)¶
handleChargeSuccess 의 일반 경로가 그대로 적용. 주요 효과:
status='active'로 복귀retry_count=0초기화- Subscription 주기 advance (현재 주기 span + jitter)
PaymentSucceeded→ License extend (normal path)
사용자 알림: "결제가 정상 처리되었습니다" DM (payment.succeeded template).
4. 최종 실패 (3차 재시도 실패)¶
func (s *serviceImpl) finalizeCancellation(ctx, tx, sub, lastAttempt) error {
sub.Status = "canceled"
sub.CanceledAt = ptr(time.Now())
sub.NextBillingAt = nil
tx.Subs.Update(ctx, sub)
// 최종 실패 이벤트 (License downgrade 트리거)
evt := PaymentFailedFinalEvent{
SubscriptionID: sub.ID,
LicenseID: sub.LicenseID,
LastAttemptID: lastAttempt.ID,
LastFailureCode: *lastAttempt.FailureCode,
}
return tx.Events.Publish(ctx, evt)
}
5. License downgrade¶
Licensing consumer:
func (h *LicensingHandler) OnPaymentFailedFinal(ctx, event) error {
license, _ := h.repo.GetByID(ctx, event.LicenseID)
freePlan, _ := h.plans.GetByCode(ctx, "FREE")
return h.svc.Downgrade(ctx, DowngradeInput{
LicenseID: license.ID,
NewPlanID: freePlan.ID,
ExpiresAt: nil, // Free 는 무기한
EffectiveAt: time.Now(), // 즉시 (grace 이미 소진)
})
}
이때 LicenseDowngraded 이벤트 발행 → Recovery 가 AntiNuke workflow 중지.
6. 사용자 알림¶
각 실패 단계별 Notification:
| retry_number | Template | 메시지 요지 |
|---|---|---|
| 1 (1차 재시도 대기) | payment.failed | 결제 실패, 24h 후 재시도 |
| 2 (2차) | payment.failed | 2차 결제 실패, 48h 후 재시도 |
| 3 (3차) | payment.failed | 최종 재시도 예정, 실패 시 자동 해지 경고 |
| final | subscription.auto_canceled | 구독이 해지되었습니다. 카드 확인 후 대시보드에서 재구독 바랍니다 |
AntiNuke 긴급 알림과 달리 이 알림들은 사용자 preference (payment_notifications) 를 존중. 단 subscription.auto_canceled 는 preference 무시 (중요도 우선).
Grace period behavior¶
재시도 기간(~6일) 동안 Umbra 의 동작:
- License 상태 —
active유지 → 권한 체크 통과 - Subscription 상태 —
past_due→ 사용자에게 "결제 실패" 표시 - 기능 사용 — Pro/Enterprise 기능 정상 동작 (AntiNuke, Restore, Dashboard)
- 대시보드 — "결제 실패 중, {다음 시도 시각}" 배너 표시
- 빌링키 수정 — 즉시 적용 (다음 재시도 시 새 카드 사용)
이 정책의 근거:
- 사용자의 일시적 카드 문제 (해외 결제 한도, 야간 점검 등) 를 유예
- 강제 기능 중단은 서비스 불만 유발
Manual recovery during grace¶
사용자가 대시보드에서 직접 조치:
Option 1: 새 빌링키 등록¶
- "결제 수단 변경" → Toss widget → 새 BillingKey
- Subscription 의
billing_key_id업데이트 - 다음 재시도 시점에 새 키로 charge
Option 2: 즉시 재시도¶
- "지금 재시도" 버튼 → 즉시
RetryChargetask enqueue (scheduled delay 무시) - 성공 시 recovery, 실패 시 기존 retry_count 유지
Failure cases (within the retry flow)¶
Retry task 실행 실패 (asynq 레벨)¶
- When — 워커 장애로 retry task pickup 불가
- Detection — asynq 는 자동 재큐잉 (3회 기본)
- Response — 워커 복구 후 자동 실행. 시간 drift 는 허용 (24h 가 약간 지연됨)
next_billing_at 을 기다리는 중 사용자가 해지 요청¶
- When — past_due 상태에서 사용자 취소
- Detection — cancel 요청 시 현재 상태 확인
- Response — Subscription
status='canceled',canceled_at=NOW(). 남은 retry task 는 실행 시점에 상태 확인 후 skip - User experience — 해지 즉시 반영. License 는
current_period_end까지 유지 아닌 즉시 Free 로 downgrade (결제 미성공이므로)
재시도 중 Bot kicked¶
- When — grace 기간 중 봇 강퇴
- Detection —
BotKicked이벤트 - Response — Subscription
suspended(canceled 와 별개), 모든 retry task skip. 재설치 시active로 resume 하고 next_billing_at 재설정.
Toss 장애 지속¶
- When — 재시도 3회가 모두 Toss 측 장애로 실패
- Detection — failure_code 가
FAILED_EXTERNAL_SYSTEM류 - Response — 여전히 자동 해지. 정책적으로 카드 문제와 동일 취급.
- Mitigation (Phase 2) — 외부 시스템 장애 패턴 감지 시 재시도 연장 (운영자 검토 게이트)
License downgrade 이벤트 누락¶
- When — Outbox poller 가
PaymentFailedFinal처리 중 크래시 - Detection — 이벤트는 unpublished 로 남음
- Response — Poller 재시작 후 자동 재전달. Licensing handler 는 idempotent (
status='canceled'체크) - User experience — 최대 수 분 내 downgrade 반영
Edge cases¶
동일 retry_number 의 orderId 중복 (매우 드묾)¶
- Toss 가 과거 trial 의 orderId 를 기억하고 거부
- retry_number 를 매번 증가시키므로 orderId 는 매번 달라짐 → 실무상 발생 어려움
Retry 중간에 사용자가 빌링키 재등록¶
- Subscription 의
billing_key_id즉시 갱신 - 남은 retry task 가 새 키로 charge
- 성공 시 recovery 경로 동일
결제 실패 이력이 많은 사용자¶
- PaymentAttempt 가 누적되지만 문제 없음 (감사 목적)
- Phase 2 에서 분석 대시보드에 "결제 신뢰도" 지표 노출 검토
Grace period 중 Plan 변경 시도¶
- past_due 상태에서 Pro → Enterprise 요청
- 정책: grace 중 plan 변경 금지 (결제 먼저 해결해야 함)
- 사용자 응답: "결제 수단을 먼저 업데이트해주세요"
4회째 재시도가 있는가?¶
- 명시적으로 없음. 3차(retry=3) 실패 즉시
finalizeCancellation실행 - 운영자 수동 개입만 가능 (수동 재시도 API 는 제한적)
Involved domains¶
| Domain | Role in this flow |
|---|---|
| Billing | 재시도 스케줄, 상태 전이 (writer) |
| Licensing | 최종 실패 시 Free 로 downgrade |
| Notification | 각 단계별 알림 |
| Audit | 전 이력 기록 |
| Recovery | License downgrade 연쇄로 AntiNuke workflow 중지 |
See also¶
domain/billing.mdflows/recurring-payment.md— 정상 경로 (이 흐름의 출발점)flows/subscription-change.md— 수동 재시도 / 해지adr/0012-license-subscription-separation.md— Grace period 표현 근거adr/0013-payment-toss-billing.md— Retry 정책