이 글은 누구를 위한 것인가
- 구독형 서비스의 결제 실패율을 낮추려는 팀
- 던닝 재시도 스케줄과 구독 정지/복구 로직을 설계하는 개발자
- Stripe 웹훅으로 결제 이벤트를 처리하려는 팀
들어가며
구독 서비스에서 결제 실패는 불가피하다. 카드 한도 초과, 만료, 분실 신고 등 다양한 원인이 있다. 던닝(Dunning)은 실패한 결제를 자동으로 재시도하고, 고객에게 알림을 보내 이탈을 방지하는 프로세스다.
이 글은 bluefoxdev.kr의 구독 던닝 전략 가이드 를 참고하여 작성했습니다.
1. 던닝 전략 설계
[결제 실패 유형]
소프트 실패 (재시도 가능):
- insufficient_funds: 잔액 부족 → 월말 재시도
- do_not_honor: 일시적 거부 → 24시간 후 재시도
- card_velocity_exceeded: 한도 초과 → 3일 후 재시도
하드 실패 (재시도 불가):
- card_not_supported: 카드 기능 미지원
- expired_card: 카드 만료 → 카드 업데이트 요청
- do_not_honor_declined: 영구 거부 → 즉시 알림
[던닝 스케줄 (일반적)]
Day 0: 최초 실패 → 즉시 알림 이메일
Day 3: 1차 재시도 + 알림
Day 7: 2차 재시도 + 카드 업데이트 유도
Day 14: 3차 재시도 + 구독 일시정지 예고
Day 21: 구독 일시정지 + 최종 안내
Day 30: 구독 해지 (선택)
[Smart Retry (Stripe 지원)]
카드 네트워크 데이터 기반 최적 재시도 시점 선택
수동 스케줄 대비 성공률 +11%
2. 던닝 구현
// Stripe 웹훅 처리
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
switch (event.type) {
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
}
return Response.json({ received: true });
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const subscription = await db.subscription.findFirst({
where: { stripeSubscriptionId: invoice.subscription as string },
include: { user: true },
});
if (!subscription) return;
const failureCount = subscription.paymentFailureCount + 1;
await db.subscription.update({
where: { id: subscription.id },
data: {
paymentFailureCount: failureCount,
lastPaymentFailedAt: new Date(),
status: failureCount >= 3 ? 'PAST_DUE' : subscription.status,
},
});
// 단계별 이메일 발송
const emailType = failureCount === 1 ? 'payment_failed_first'
: failureCount === 2 ? 'payment_failed_second'
: 'payment_failed_final';
await emailQueue.add('dunning', {
to: subscription.user.email,
type: emailType,
subscriptionId: subscription.id,
invoiceUrl: invoice.hosted_invoice_url,
});
// 3회 실패 시 구독 일시정지
if (failureCount >= 3) {
await stripe.subscriptions.update(invoice.subscription as string, {
pause_collection: { behavior: 'mark_uncollectible' },
});
}
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
// 결제 성공 시 실패 카운트 초기화 및 구독 복구
await db.subscription.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: {
paymentFailureCount: 0,
status: 'ACTIVE',
lastPaymentFailedAt: null,
},
});
// 일시정지 해제
await stripe.subscriptions.update(invoice.subscription as string, {
pause_collection: '',
} as any);
}
// 수동 던닝 재시도 스케줄러 (Stripe Smart Retry 미사용 시)
async function scheduleDunningRetries() {
const failedSubscriptions = await db.subscription.findMany({
where: {
status: 'PAST_DUE',
paymentFailureCount: { lt: 4 },
nextRetryAt: { lte: new Date() },
},
});
for (const sub of failedSubscriptions) {
const retryIntervals = [3, 7, 14]; // 일 단위
const daysUntilNext = retryIntervals[sub.paymentFailureCount - 1] ?? 0;
// 미결제 인보이스 재시도
const invoices = await stripe.invoices.list({
subscription: sub.stripeSubscriptionId,
status: 'open',
});
for (const invoice of invoices.data) {
try {
await stripe.invoices.pay(invoice.id);
} catch {
await db.subscription.update({
where: { id: sub.id },
data: { nextRetryAt: new Date(Date.now() + daysUntilNext * 86400_000) },
});
}
}
}
}
// 카드 업데이트 포털 URL 생성
async function createBillingPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return session.url;
}
const db = {} as any;
const emailQueue = { add: async (...args: any[]) => {} };
마무리
던닝의 핵심은 "재시도 타이밍"과 "커뮤니케이션"이다. 소프트 실패는 스케줄에 따라 재시도하고, 하드 실패는 즉시 카드 업데이트를 유도한다. Stripe Smart Retry를 활성화하면 카드 네트워크 데이터 기반으로 최적 시점을 자동 선택한다. 구독 일시정지는 해지보다 가역적이라 이탈률을 낮추는 데 효과적이다.