Recurring Payment¶
정기 결제 주기 도달 시 자동으로 Toss 에 charge 요청을 보내고, 성공 시 License 의 만료 시각을 연장하며 Subscription 의 다음 주기를 갱신하는 흐름. 매 정각 cron + ±15분 jitter 로 분산 실행된다.
Scenario¶
Subscription 의 next_billing_at 이 도달하면 Worker 의 asynq cron 이 결제 대상을 조회하고 길드별 결제 job 을 enqueue. 워커가 Toss Billing API 를 호출, 성공 시 PaymentAttempt 성공 기록 + Subscription 주기 advance + Outbox 이벤트 발행. Licensing 이 이벤트를 수신해 License 의 expires_at 연장.
Actors¶
- asynq cron (worker) — 매 정각 due 조회
- charge worker (asynq) — 길드별 결제 실행
- Billing domain — Subscription 상태 갱신, Toss 호출
- Toss Server — 결제 승인
- Outbox poller — 이벤트 dispatch
- Licensing domain — License 만료 연장
- Notification — (옵션) 결제 성공 DM
Preconditions¶
- Subscription 상태가
active또는past_due next_billing_at <= now + lead_time(cron scan 포함 범위)- 유효한 BillingKey 존재 (
deleted_at IS NULL) - Guild 상태가 active (bot_removed_at NULL)
- License 상태가
active또는suspended아닌 상태 (canceled 면 결제 대상 아님)
Postconditions¶
billing.payment_attempts에 성공 레코드 (cycle=N, retry=0)billing.subscriptions의cycle_count증가,current_period_start/end이동,next_billing_at갱신 (jitter 유지)billing.subscriptions.retry_count는 0 으로 초기화events.outbox에PaymentSucceeded이벤트licensing.licenses.expires_at이GREATEST(current, new_period_end)로 업데이트- (사용자 preference 에 따라) 결제 성공 DM
Sequence¶
sequenceDiagram
participant Cron as asynq cron
(hourly)
participant Repo as Subscription Repo
participant Queue as asynq queue
participant Worker as charge worker
participant Billing
participant Toss
participant Outbox
participant Poller
participant Licensing
participant Notification
Cron->>Repo: ListDueForCharge(before=now+1h)
Repo-->>Cron: [sub1, sub2, ...]
loop each subscription
Cron->>Queue: Enqueue ProcessRecurringCharge(sub_id)
(scheduled at next_billing_at)
end
Note over Queue,Worker: next_billing_at 도달
Queue->>Worker: ProcessRecurringCharge(sub_id)
Worker->>Billing: Charge(subscription_id)
Billing->>Billing: Load subscription + billing_key
Billing->>Billing: Decrypt billingKey (AES-256-GCM)
Billing->>Billing: Compute orderId = sub_{id}_{cycle+1:03d}_r0
Billing->>Billing: INSERT payment_attempts (status=pending)
Billing->>Toss: POST /v1/billing/{billingKey}
{ orderId, amount, customerKey, orderName }
alt Success
Toss-->>Billing: { paymentKey, approvedAt }
Billing->>Billing: BEGIN TX
Billing->>Billing: UPDATE payment_attempts
status=succeeded, toss_payment_key, toss_approved_at
Billing->>Billing: UPDATE subscriptions
cycle_count++, current_period rollover,
next_billing_at=+1month±jitter,
retry_count=0, status=active
Billing->>Outbox: INSERT PaymentSucceeded event
Billing->>Billing: COMMIT
Note over Poller: ~2s later
Poller->>Outbox: Fetch unpublished
Poller->>Licensing: OnPaymentSucceeded
Licensing->>Licensing: UPDATE licenses
SET expires_at = GREATEST(expires_at, new_period_end)
Poller->>Notification: OnPaymentSucceeded
Notification->>Notification: INSERT notification request
(preference 체크)
else Failure
Note over Worker: flows/payment-failure.md 참조
end
Step-by-step¶
1. Cron 실행 (매 정각)¶
Worker 의 asynq cron 스케줄:
// apps/worker/internal/cron/billing.go
func ScheduleRecurringChargeCron(mux *asynq.ServeMux, svc billing.Service) {
// 매시 정각
asynq.RegisterCronFunc("0 * * * *", func(ctx context.Context, t *asynq.Task) error {
leadTime := time.Hour // 앞으로 1시간 내 due 수집
subs, err := svc.ListDueForCharge(ctx, time.Now().Add(leadTime))
if err != nil { return err }
for _, s := range subs {
// next_billing_at 시점에 실행되도록 delayed task
delay := time.Until(s.NextBillingAt)
task := asynq.NewTask("billing:charge", []byte(s.ID.String()))
_, _ = client.Enqueue(task, asynq.ProcessIn(delay), asynq.MaxRetry(0))
}
return nil
})
}
ListDueForCharge 쿼리:
SELECT *
FROM billing.subscriptions
WHERE status IN ('active', 'past_due')
AND next_billing_at <= $1
AND canceled_at IS NULL
ORDER BY next_billing_at ASC
LIMIT 1000;
2. Delayed task 실행¶
next_billing_at 도달 시 worker 가 task 수령:
func HandleChargeTask(ctx context.Context, t *asynq.Task) error {
subID, _ := uuid.Parse(string(t.Payload()))
return billingSvc.ProcessRecurringCharge(ctx, subID)
}
3. Billing.ProcessRecurringCharge¶
func (s *serviceImpl) ProcessRecurringCharge(ctx, subID) error {
sub, err := s.subs.GetByID(ctx, subID)
if err != nil { return err }
// 상태 재확인 (이미 canceled 되었거나 suspended 면 skip)
if sub.Status != "active" && sub.Status != "past_due" {
return nil
}
if sub.CanceledAt != nil && time.Now().After(*sub.CanceledAt) {
return nil // 이미 해지됨
}
// Billing key 유효성 확인
key, err := s.keys.GetByID(ctx, sub.BillingKeyID)
if err != nil { return err }
if key.DeletedAt != nil {
return s.handleMissingBillingKey(ctx, sub)
}
plan, _ := s.plans.GetByID(ctx, sub.PlanID)
// orderId 계산
nextCycle := sub.CycleCount + 1
orderID := fmt.Sprintf("sub_%s_%03d_r0", sub.ID, nextCycle)
// PaymentAttempt INSERT (pending)
attempt := PaymentAttempt{
ID: uuid.NewV7(),
SubscriptionID: sub.ID,
OrderID: orderID,
AmountKRW: plan.PriceKRW,
Status: "pending",
Cycle: nextCycle,
RetryNumber: 0,
}
if err := s.attempts.Insert(ctx, attempt); err != nil { return err }
// Toss charge
plaintext, _ := s.crypto.Decrypt(ctx, key.EncryptedKey, key.KeyNonce, []byte(key.CustomerKey))
result, err := s.toss.Charge(ctx, TossChargeInput{
BillingKey: string(plaintext),
OrderID: orderID,
Amount: plan.PriceKRW,
CustomerKey: key.CustomerKey,
OrderName: fmt.Sprintf("Umbra %s 구독", plan.Name),
})
if err != nil {
return s.handleChargeFailure(ctx, sub, attempt, err)
}
return s.handleChargeSuccess(ctx, sub, attempt, result)
}
4. 성공 처리¶
func (s *serviceImpl) handleChargeSuccess(ctx, sub, attempt, result) error {
return s.tx.WithTx(ctx, func(tx TxRepos) error {
// Attempt 성공 기록
attempt.Status = "succeeded"
attempt.TossPaymentKey = &result.PaymentKey
attempt.TossApprovedAt = &result.ApprovedAt
attempt.CompletedAt = ptr(time.Now())
if err := tx.Attempts.Update(ctx, attempt); err != nil { return err }
// Subscription 주기 advance
newPeriodStart := sub.CurrentPeriodEnd
newPeriodEnd := addCyclePeriod(newPeriodStart, plan.BillingCycle)
newNextBilling := applyJitter(newPeriodEnd)
sub.Status = "active"
sub.CycleCount = attempt.Cycle
sub.CurrentPeriodStart = ptr(newPeriodStart)
sub.CurrentPeriodEnd = ptr(newPeriodEnd)
sub.NextBillingAt = ptr(newNextBilling)
sub.RetryCount = 0
if err := tx.Subs.Update(ctx, sub); err != nil { return err }
// Outbox 이벤트
evt := PaymentSucceededEvent{
SubscriptionID: sub.ID,
AttemptID: attempt.ID,
LicenseID: sub.LicenseID,
NewPeriodEnd: newPeriodEnd,
CycleCount: attempt.Cycle,
}
return tx.Events.Publish(ctx, evt)
})
}
5. License 연장 (Outbox 구독자)¶
PaymentSucceeded 이벤트 수신 → Licensing:
func (h *LicensingHandler) OnPaymentSucceeded(ctx, event) error {
// Idempotent: GREATEST 로 멱등성
_, err := h.repo.ExtendExpiresAt(ctx, event.LicenseID, event.NewPeriodEnd)
if err != nil { return err }
// LicenseExtended 이벤트 발행
return h.events.Publish(ctx, LicenseExtendedEvent{
LicenseID: event.LicenseID,
NewExpiresAt: event.NewPeriodEnd,
})
}
SQL:
UPDATE licensing.licenses
SET expires_at = GREATEST(expires_at, $1),
updated_at = NOW()
WHERE id = $2;
GREATEST 덕분에 중복 이벤트가 와도 안전 (이미 연장된 날짜는 유지).
6. 사용자 알림 (옵션)¶
Notification consumer:
func (c *Consumer) OnPaymentSucceeded(ctx, event) error {
pref := c.prefs.Get(event.PayerUserID)
if !pref.DMEnabled || !pref.PaymentNotifications {
return nil // suppressed 로 기록
}
return c.svc.Enqueue(NotificationRequest{
RecipientType: "user_dm",
TemplateKey: "payment.succeeded",
Payload: event.ToPayload(),
})
}
Failure cases¶
Toss charge 실패¶
다음 문서의 메인 흐름: flows/payment-failure.md
요약: - handleChargeFailure 가 attempt 를 failed 로 기록 - Subscription status='past_due', retry_count++, next_billing_at = now + retry_delay - PaymentFailed Outbox 이벤트 - 다음 재시도 task 가 delayed 로 enqueue
BillingKey 삭제됨 (race)¶
- When — 사용자가 결제 직전에 빌링키 삭제
- Detection —
key.DeletedAt != nil - Response — Subscription
status='suspended', Notification "결제 수단이 없어 구독이 일시 중지되었습니다" - User experience — 대시보드에서 새 빌링키 등록 유도
Cron 이 중복 실행¶
- When — Worker 수평 확장 시 두 프로세스가 같은 시점에 cron 실행
- Detection — asynq cron 은 distributed lock 가지므로 일반적으로 1회만
- Response — 만약 중복 enqueue 되어도 delayed task 의 실행 시점에서
orderId유일성으로 Toss 가 하나만 승인 - User experience — 투명
Worker 프로세스 장애 (charge 실행 중)¶
- When — Toss 호출 중 워커 크래시
- Detection — asynq retry (max_retry 설정에 따라)
- Response — 같은 task 가 재실행되지만 orderId 가
sub_{id}_{cycle}_r0으로 동일 → Toss 가 중복 거부 가능성 - Mitigation — Task 재실행 시 우선 DB 에서
pendingattempt 가 있는지 확인, 있으면 Toss 의 orderId 조회 API 로 상태 확인 후 적절히 처리 (Phase 2 에서 상세화)
암호화 키 만료 (rotation)¶
- When — MEK rotation 시점에 과거 키로 암호화된 빌링키
- Detection — decrypt 실패
- Response — Phase 2 에서 dual-key decryption 지원 예정. MVP 는 동일 키 유지 전제.
Toss API rate limit¶
- When — 같은 초에 수천 건의 charge 호출
- Detection — Toss 429 응답
- Response — asynq retry (exponential backoff). jitter (±15분) 가 근본 완화 수단.
- User experience — 몇 분 지연 후 결제 완료
License status = 'canceled' 인데 next_billing_at 남아있음¶
- When — 사용자가 해지 요청 후 period_end 도달 전에 cron 돌음
- Detection — License 가 cancel 되면 Subscription 도
cancel_at_period_end = true로 표시됨.canceled_at <= NOW()이면 skip. - Response — Subscription 도
status='canceled'로 전환되어 있어야 정상. 방어 코드로 상태 재확인.
Edge cases¶
첫 결제 직후 cron (동일 cycle 중복 방지)¶
첫 결제(ProcessFirstCharge)는 flows/first-subscription.md 에서 처리되어 cycle_count=1 로 설정됨. cron 이 즉시 돌아도 next_billing_at 이 1개월 후라 pickup 안 됨.
주기 단위 변경 (monthly → yearly)¶
Phase 2 의 연간 결제 도입 시 plan.billing_cycle 에 따라 addCyclePeriod 함수가 달라짐. +1 month vs +1 year.
Grace period 중 결제 성공¶
- Subscription 이
past_due상태에서 재시도 charge 가 성공 handleChargeSuccess에서status='active',retry_count=0으로 복귀- 별도 이벤트 불필요 (
PaymentSucceeded면 충분)
결제 성공 후 즉시 Outbox 전달 전에 장애¶
- Subscription 업데이트 커밋됨, Outbox 이벤트도 같은 트랜잭션이라 함께 커밋
- 이후 Outbox poller 가 나중에 pickup → Licensing 갱신 지연 최대 수 초
- License
expires_at이 잠시 tight 하지만 권한 체크 캐시 TTL(60s) 내에 해결
Toss 의 부분 실패 응답¶
- Toss 가
FAILED_INTERNAL_SYSTEM_PROCESSING같은 애매한 코드 반환 - 재시도 안전성: orderId 유지하고 다음 재시도 (retry_number++) 로 진행 — 단 Toss orderId 중복 거부 가능성 있어 retry_number 반드시 증가
Involved domains¶
| Domain | Role in this flow |
|---|---|
| Billing | Charge 실행, Subscription 갱신 (writer) |
| Licensing | License expires_at 연장 |
| Notification | 결제 성공 DM |
| Audit | 이벤트 기록 |
See also¶
domain/billing.mdflows/first-subscription.md— 첫 결제flows/payment-failure.md— 실패 처리 (이 흐름의 대안 경로)flows/subscription-change.md— Plan 변경adr/0013-payment-toss-billing.mdadr/0022-jitter-payment-time.mdadr/0015-asynq-vs-temporal-split.md— asynq cron 역할