← 블로그로 돌아가기
튜토리얼 2026년 4월 3일
토스페이먼츠로 구독 결제 구현하기
빌링키 발급부터 자동 결제까지. Next.js + Supabase 환경에서 토스페이먼츠 정기결제 구현 과정.
토스페이먼츠 결제 구독 Next.js Supabase
구독 결제 구조
SaaS에서 구독 결제는 두 단계로 나뉜다.
- 빌링키 발급 — 사용자가 카드 정보를 입력하면 PG사가 빌링키를 발급
- 자동 결제 — 매월 빌링키로 결제 요청
토스페이먼츠는 이 흐름을 SDK + REST API로 제공한다.
빌링키 발급 (프론트엔드)
토스페이먼츠 SDK를 사용해 카드 등록 UI를 띄운다. 사용자가 카드 정보를 입력하면 토스페이먼츠 서버에서 빌링키를 발급하고, 콜백으로 authKey를 전달한다.
const tossPayments = await loadTossPayments(clientKey);
const widget = tossPayments.widgets({ customerKey: userId });
await widget.setPaymentMethod({
selector: '#payment-method',
variantKey: 'DEFAULT',
});
// 빌링키 발급 요청
await widget.requestBillingAuth({
successUrl: `${origin}/api/billing/success`,
failUrl: `${origin}/api/billing/fail`,
});
customerKey는 Supabase의 사용자 ID를 그대로 사용한다. 토스페이먼츠에서 고객을 식별하는 키로, 한 고객이 여러 빌링키를 가질 수 있다.
빌링키 확인 (백엔드)
콜백으로 받은 authKey를 서버에서 확인하고, 빌링키를 저장한다.
// /api/billing/success
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const authKey = searchParams.get('authKey');
const customerKey = searchParams.get('customerKey');
// 토스페이먼츠 API로 빌링키 확인
const res = await fetch(
'https://api.tosspayments.com/v1/billing/authorizations/issue',
{
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(secretKey + ':').toString('base64')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ authKey, customerKey }),
}
);
const { billingKey } = await res.json();
// Supabase에 빌링키 저장
await supabase
.from('subscriptions')
.upsert({ user_id: customerKey, billing_key: billingKey, plan: 'pro' });
return redirect('/billing/complete');
}
빌링키는 민감 정보다. DB에 저장하되, 클라이언트에는 절대 노출하지 않는다.
자동 결제
매월 결제일에 빌링키로 결제를 요청한다. Vercel Cron 또는 별도 스케줄러로 실행한다.
const chargeSubscription = async (subscription: Subscription) => {
const res = await fetch(
`https://api.tosspayments.com/v1/billing/${subscription.billing_key}`,
{
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(secretKey + ':').toString('base64')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerKey: subscription.user_id,
amount: subscription.plan === 'pro' ? 9900 : 4900,
orderId: `sub_${subscription.user_id}_${Date.now()}`,
orderName: `Repasta ${subscription.plan} 월간 구독`,
}),
}
);
const result = await res.json();
// 결제 로그 저장
await supabase.from('payment_logs').insert({
user_id: subscription.user_id,
amount: result.totalAmount,
status: result.status,
payment_key: result.paymentKey,
});
};
실패 처리
결제 실패는 반드시 발생한다. 카드 한도 초과, 분실 카드, 잔액 부족 등.
- 1차 실패: 3일 후 재시도
- 2차 실패: 7일 후 재시도 + 이메일 알림
- 3차 실패: 구독 일시 정지 + 플랜 Free로 다운그레이드
if (result.status === 'FAILED') {
const retryCount = subscription.retry_count + 1;
if (retryCount >= 3) {
await supabase
.from('subscriptions')
.update({ status: 'suspended', plan: 'free' })
.eq('user_id', subscription.user_id);
} else {
const nextRetry = retryCount === 1 ? 3 : 7; // days
await supabase
.from('subscriptions')
.update({ retry_count: retryCount, next_retry: addDays(new Date(), nextRetry) })
.eq('user_id', subscription.user_id);
}
}
환불 정책
자동 환불 API를 구현했다가 제거했다. 이유:
- 부분 환불 계산이 복잡 (일할 계산 vs 전액)
- 악용 가능성 (가입 → 사용 → 즉시 환불)
- 초기 단계에서 자동화보다 케이스별 판단이 나음
현재는 이메일로 환불 요청을 받고, 토스페이먼츠 대시보드에서 수동 처리한다. 사용자가 늘면 자동화를 재검토할 예정이다.
주의사항
- 테스트 키와 프로덕션 키 분리: 환경변수로 관리. 실수로 테스트 빌링키로 실결제 요청하면 실패한다
- 금액 하드코딩 금지: DB나 설정에서 가격을 가져와야 가격 변경이 쉽다
- 결제 로그 필수: 분쟁 시 증빙 자료. 모든 요청/응답을 기록한다
- 웹훅 검증: 토스페이먼츠 웹훅의 서명을 반드시 검증해야 위변조를 막을 수 있다
정리
토스페이먼츠 구독 결제는 SDK가 잘 되어 있어서 기본 구현은 하루면 된다. 하지만 실패 처리, 환불 정책, 로깅 같은 운영 코드가 전체의 70%를 차지한다. 결제는 “동작하는 것”보다 “실패해도 안전한 것”이 더 중요하다.