구독 결제 설계 — Billing Retry와 Dunning으로 매출 누수 막기

커머스

구독결제BillingDunning정기결제PG

이 글은 누구를 위한 것인가

  • 구독 상품을 런칭했거나 준비 중인 커머스 백엔드 엔지니어
  • 결제 실패율이 높아 매출 누수가 생기는 팀
  • PG사 웹훅 처리와 재시도 로직을 직접 설계해야 하는 개발자
  • Stripe·토스페이먼츠 등 외부 빌링 시스템과 연동하는 프로젝트

들어가며

구독 결제는 일반 단건 결제와 다르다. 고객이 결제 버튼을 누르는 순간이 없고, 플랫폼이 자동으로 청구한다. 이 자동화된 흐름에서 카드 만료, 한도 초과, 은행 거절은 언제든 발생한다.

Recurly의 2024년 보고서에 따르면 구독 결제 실패의 48%는 소프트 디클라인(soft decline) — 즉, 재시도하면 성공할 수 있는 일시적 실패다. 재시도 전략 없이 즉시 구독을 취소하면 회수할 수 있었던 매출의 절반을 버리는 것이다.

이 글은 구독 상태 머신 설계부터 retry 전략, dunning 타이밍, 카드 업데이트 자동화까지 실무에서 바로 적용할 수 있는 패턴을 다룬다.


1. 구독 상태 머신 설계

구독의 모든 상태 전이를 명확히 정의하지 않으면 "구독 중인데 서비스를 못 쓴다", "이미 취소했는데 결제가 나갔다" 같은 사고가 생긴다.

핵심 상태

상태의미서비스 접근
trialing무료 체험 기간허용
active결제 성공, 구독 유지허용
past_due결제 실패, 재시도 대기유예 허용 (grace period)
unpaid재시도 모두 실패차단
canceled사용자 또는 시스템이 취소차단 (기간 종료 시)
paused사용자 일시정지 요청차단

상태 전이 규칙

trialing ──결제 성공──▶ active
trialing ──결제 실패──▶ past_due
active   ──결제 실패──▶ past_due
past_due ──재시도 성공──▶ active
past_due ──재시도 모두 실패──▶ unpaid
past_due ──사용자 카드 업데이트 후 성공──▶ active
unpaid   ──수동 결제 성공──▶ active
active   ──사용자 취소 요청──▶ canceled (기간 말 effective)
canceled ──재구독──▶ trialing or active

설계 원칙:

  • 상태는 DB 컬럼 하나로 관리하되, 상태 전이 로직은 반드시 도메인 서비스 레이어에서만 수행
  • past_due 상태에서 서비스 차단 여부는 비즈니스 정책이므로 grace_period_days 설정값으로 분리
  • 상태 전이 이벤트는 모두 이벤트 로그 테이블에 기록 (감사 추적)

2. 결제 실패 분류: Hard vs Soft Decline

모든 실패를 동일하게 처리하면 안 된다. 카드 분실 신고(hard decline)에 3번 더 시도하는 것은 시간 낭비이고, 잔액 부족(soft decline)을 한 번 실패로 포기하면 매출 누수다.

Hard Decline (재시도 불필요)

PG 에러 코드 (예시)원인
card_declined:stolen도난 카드
card_declined:lost분실 카드
do_not_honor영구 거절
invalid_account유효하지 않은 계좌
fraudulent사기 탐지

즉시 unpaid 전환, 재시도 없음. 재시도 자체가 카드사 블랙리스트 등록 위험을 높인다.

Soft Decline (재시도 가능)

PG 에러 코드 (예시)원인
insufficient_funds잔액 부족
card_velocity_exceeded사용 한도 초과
expired_card카드 만료
processing_errorPG 내부 오류
network_error네트워크 타임아웃

재시도 전략 적용. 재시도 일정, 횟수, 알림을 설계에 포함해야 한다.


3. Retry 전략 설계

3-1. Smart Retry: 언제 재시도할 것인가

단순 exponential backoff는 구독 빌링에 맞지 않는다. 고객의 월급날, 카드 결제일 등 현금 흐름 패턴을 고려한 Smart Retry가 더 효과적이다.

권장 재시도 일정 (soft decline 기준):

시도실패 후 경과비고
1차 재시도D+1 (다음날)일시적 오류 회수
2차 재시도D+3주말 이후 복구
3차 재시도D+7급여일 전후
4차 재시도D+14최후 시도
포기D+15 이후unpaid 전환

실패 유형별 차별화:

  • insufficient_funds: 월말/월초 급여일에 맞춰 재시도 (D+5, D+10)
  • expired_card: 재시도보다 카드 업데이트 요청이 우선
  • processing_error: 1시간 후 즉시 재시도 1회 추가
  • network_error: 30분 내 즉시 재시도

3-2. 재시도 구현 시 주의사항

멱등성 키 필수: 동일한 결제 주기에 대한 재시도는 반드시 같은 idempotency key를 사용해야 이중 청구를 방지할 수 있다.

idempotency_key = sha256(subscription_id + billing_period_start + attempt_number)

attempt_number를 포함해 시도 횟수마다 키를 갱신해야 한다. 같은 키로 재시도하면 PG가 이전 결과를 그대로 반환한다.

분산 환경에서의 재시도 잠금: 여러 서버 인스턴스에서 같은 구독에 대해 동시에 재시도하면 이중 청구가 발생한다. Redis 기반 distributed lock 또는 DB row-level lock으로 동시 실행을 막아야 한다.

-- 재시도 시작 시 원자적 lock 획득
UPDATE subscriptions
SET retry_locked_until = NOW() + INTERVAL '10 minutes',
    retry_locked_by = :worker_id
WHERE id = :subscription_id
  AND (retry_locked_until IS NULL OR retry_locked_until < NOW())
  AND status = 'past_due'
RETURNING id

반환된 row가 없으면 다른 워커가 이미 처리 중이므로 skip한다.


4. Dunning 전략: 고객 커뮤니케이션

재시도 로직만으로는 부족하다. 고객에게 적시에 올바른 메시지를 보내야 카드 업데이트와 결제 재개를 유도할 수 있다.

Dunning 타임라인

결제 실패 발생 (D+0)
  │
  ├─ [즉시] 결제 실패 알림 이메일/앱푸시
  │    - 메시지: "결제에 실패했습니다. 서비스는 N일간 유지됩니다."
  │    - CTA: 카드 정보 업데이트 링크
  │
  ├─ [D+1] 1차 재시도
  │    - 성공 → active 전환, 완료 알림
  │    - 실패 → 2차 안내 이메일
  │
  ├─ [D+3] 2차 재시도
  │    - 실패 → "서비스 중단 예정" 긴급 알림
  │
  ├─ [D+7] 3차 재시도 + "D+3 후 서비스 중단" 최후 경고
  │
  ├─ [D+10] Grace period 종료 예정 알림
  │    - 메시지: "48시간 후 서비스 이용이 중단됩니다"
  │
  ├─ [D+14] 4차 재시도 (최후)
  │
  └─ [D+15] unpaid 전환 + 서비스 차단 알림
       - 메시지: "구독이 일시 정지되었습니다. 결제 수단을 업데이트하면 즉시 재개됩니다."

메시지 설계 원칙

  1. blame하지 않는다: "결제에 실패했습니다"가 "고객님의 카드가 거절되었습니다"보다 이탈률이 낮다
  2. 서비스 중단 날짜를 명시한다: 모호한 경고보다 구체적인 날짜가 행동을 유도한다
  3. CTA는 하나만: 카드 업데이트 링크를 유일한 행동으로 제시한다
  4. 채널을 다각화한다: 이메일 + 앱 인앱 배너 + 푸시 알림을 조합하되, 과다 발송은 금지

5. 카드 자동 업데이트 (Account Updater)

만료 카드 문제는 고객이 직접 업데이트하지 않아도 해결할 수 있다. Visa, Mastercard는 Account Updater 서비스를 통해 카드 번호나 만료일이 변경되면 PG에 자동으로 통보한다.

Stripe 기준으로는 automatic_payment_methods를 활성화하면 카드 갱신이 자동으로 적용된다. 토스페이먼츠·KG이니시스 등 국내 PG는 별도 계약이 필요한 경우가 있으므로 사전 확인이 필요하다.

Account Updater 없이 직접 처리할 경우:

  1. 결제 실패 후 expired_card 에러를 감지
  2. 이메일/앱 푸시로 카드 업데이트 요청
  3. 고객이 새 카드 등록 → 빌링키 재발급
  4. 미결 청구 즉시 재시도

6. 웹훅 처리: 이벤트 기반 상태 동기화

PG는 결제 결과를 동기 응답과 비동기 웹훅 두 가지로 전달한다. 구독 빌링에서는 웹훅이 단일 진실의 원천(source of truth) 이 되어야 한다.

처리해야 할 핵심 웹훅 이벤트

이벤트처리
invoice.payment_succeededactive 전환, 다음 결제일 갱신
invoice.payment_failedpast_due 전환, retry 스케줄 생성
invoice.payment_action_required3DS 인증 요청 이메일 발송
customer.subscription.deletedcanceled 전환
customer.updated카드 정보 갱신 반영
payment_method.automatically_updatedAccount Updater 적용 로그 기록

웹훅 처리 패턴

1. 수신 즉시 HTTP 200 응답 (PG 재전송 방지)
2. 이벤트를 webhook_events 큐 테이블에 저장
3. 비동기 워커가 큐를 소비
4. event_id 기준 중복 처리 방지 (UNIQUE 제약)
5. 처리 실패 시 DLQ(Dead Letter Queue)로 이동

중복 웹훅 방지:

INSERT INTO webhook_events (event_id, event_type, payload, processed_at)
VALUES (:event_id, :type, :payload, NULL)
ON CONFLICT (event_id) DO NOTHING
RETURNING id

반환된 row가 없으면 이미 처리된 이벤트이므로 200만 응답하고 처리를 스킵한다.


7. 프로레이션(Proration): 업그레이드/다운그레이드 처리

구독 중간에 플랜을 변경하면 이미 결제된 금액을 어떻게 처리할지 정책이 필요하다.

옵션 비교

방식설명적합한 경우
즉시 프로레이션변경 시점부터 차액 즉시 청구/환불높은 ARPU 구독
다음 주기 반영현재 주기는 유지, 다음 청구부터 변경월정액 SaaS
크레딧 적립차액을 크레딧으로 적립고객 만족 우선

계산 공식 (즉시 프로레이션):

일할 환불액 = (기존 플랜 가격 / 청구 주기 일수) × 남은 일수
추가 청구액 = (신규 플랜 가격 / 청구 주기 일수) × 남은 일수
최종 청구 = 추가 청구액 - 일할 환불액

음수가 나오면 크레딧으로 적립하거나 즉시 환불 처리한다.


8. 구독 빌링 실무 체크리스트

시스템 설계

  • 구독 상태 머신의 모든 상태와 전이를 문서화했는가
  • hard decline과 soft decline을 PG 에러 코드로 분류했는가
  • 재시도 잠금(distributed lock)을 구현했는가
  • 웹훅 중복 처리 방지 로직이 있는가
  • 상태 전이 이벤트 로그를 보관하는가

운영 지표

  • 결제 실패율 (월 기준, 플랜별 분리)
  • Soft decline 회수율 (재시도 후 성공 비율)
  • Dunning 이탈률 (unpaid 전환 후 카드 업데이트 없이 이탈)
  • 평균 재시도 횟수 (성공까지)
  • 구독 취소 사유 분포 (voluntary vs involuntary churn)

알림 시스템

  • 결제 실패 즉시 알림을 발송하는가
  • 서비스 차단 예정 날짜를 명시하는가
  • 카드 업데이트 링크가 이메일/앱 모두에 포함되는가
  • Dunning 이메일의 발송 빈도 상한이 설정되어 있는가

맺으며

구독 빌링의 복잡성은 "결제가 실패했을 때 무엇을 할 것인가"에서 온다. 재시도 전략 없이 즉시 취소하면 회수 가능한 매출의 절반을 잃고, 재시도를 무분별하게 반복하면 카드사 블랙리스트에 오를 수 있다.

핵심은 세 가지다: 실패를 분류하고, 재시도 타이밍을 설계하고, 고객과 올바른 타이밍에 소통한다. 이 세 가지가 갖춰진 빌링 시스템은 involuntary churn을 30~40% 줄일 수 있다.

상태 머신을 먼저 정의하고, 웹훅 처리부터 구현을 시작하는 것을 권장한다. 나머지는 그 위에 쌓인다.