이 글은 누구를 위한 것인가
- 구독 상품을 런칭했거나 준비 중인 커머스 백엔드 엔지니어
- 결제 실패율이 높아 매출 누수가 생기는 팀
- 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_error | PG 내부 오류 |
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 전환 + 서비스 차단 알림
- 메시지: "구독이 일시 정지되었습니다. 결제 수단을 업데이트하면 즉시 재개됩니다."
메시지 설계 원칙
- blame하지 않는다: "결제에 실패했습니다"가 "고객님의 카드가 거절되었습니다"보다 이탈률이 낮다
- 서비스 중단 날짜를 명시한다: 모호한 경고보다 구체적인 날짜가 행동을 유도한다
- CTA는 하나만: 카드 업데이트 링크를 유일한 행동으로 제시한다
- 채널을 다각화한다: 이메일 + 앱 인앱 배너 + 푸시 알림을 조합하되, 과다 발송은 금지
5. 카드 자동 업데이트 (Account Updater)
만료 카드 문제는 고객이 직접 업데이트하지 않아도 해결할 수 있다. Visa, Mastercard는 Account Updater 서비스를 통해 카드 번호나 만료일이 변경되면 PG에 자동으로 통보한다.
Stripe 기준으로는 automatic_payment_methods를 활성화하면 카드 갱신이 자동으로 적용된다. 토스페이먼츠·KG이니시스 등 국내 PG는 별도 계약이 필요한 경우가 있으므로 사전 확인이 필요하다.
Account Updater 없이 직접 처리할 경우:
- 결제 실패 후
expired_card에러를 감지 - 이메일/앱 푸시로 카드 업데이트 요청
- 고객이 새 카드 등록 → 빌링키 재발급
- 미결 청구 즉시 재시도
6. 웹훅 처리: 이벤트 기반 상태 동기화
PG는 결제 결과를 동기 응답과 비동기 웹훅 두 가지로 전달한다. 구독 빌링에서는 웹훅이 단일 진실의 원천(source of truth) 이 되어야 한다.
처리해야 할 핵심 웹훅 이벤트
| 이벤트 | 처리 |
|---|---|
invoice.payment_succeeded | active 전환, 다음 결제일 갱신 |
invoice.payment_failed | past_due 전환, retry 스케줄 생성 |
invoice.payment_action_required | 3DS 인증 요청 이메일 발송 |
customer.subscription.deleted | canceled 전환 |
customer.updated | 카드 정보 갱신 반영 |
payment_method.automatically_updated | Account 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% 줄일 수 있다.
상태 머신을 먼저 정의하고, 웹훅 처리부터 구현을 시작하는 것을 권장한다. 나머지는 그 위에 쌓인다.