본문으로 건너뛰기
← 블로그로 돌아가기
튜토리얼 2026년 4월 3일

토스페이먼츠로 구독 결제 구현하기

빌링키 발급부터 자동 결제까지. Next.js + Supabase 환경에서 토스페이먼츠 정기결제 구현 과정.

토스페이먼츠 결제 구독 Next.js Supabase

구독 결제 구조

SaaS에서 구독 결제는 두 단계로 나뉜다.

  1. 빌링키 발급 — 사용자가 카드 정보를 입력하면 PG사가 빌링키를 발급
  2. 자동 결제 — 매월 빌링키로 결제 요청

토스페이먼츠는 이 흐름을 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%를 차지한다. 결제는 “동작하는 것”보다 “실패해도 안전한 것”이 더 중요하다.