콘텐츠로 이동

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_canceledpreference 무시 (중요도 우선).

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: 즉시 재시도

  • "지금 재시도" 버튼 → 즉시 RetryCharge task 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 기간 중 봇 강퇴
  • DetectionBotKicked 이벤트
  • 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.md
  • flows/recurring-payment.md — 정상 경로 (이 흐름의 출발점)
  • flows/subscription-change.md — 수동 재시도 / 해지
  • adr/0012-license-subscription-separation.md — Grace period 표현 근거
  • adr/0013-payment-toss-billing.md — Retry 정책