토스페이먼츠 자동결제(빌링) 연동 가이드

최종 업데이트: 2026년 4월 23일

토스페이먼츠 자동결제(빌링)를 Next.js(App Router) 기반 웹사이트에 붙이는 최소 예시입니다. Vue 3 혼용 프로젝트에도 그대로 쓸 수 있으며, 구독 서비스·정기결제·월정액 멤버십에 필요한 흐름을 전부 다룹니다.

토스페이먼츠 심사 조건 안내

아래 항목은 심사 통과를 위해 상점 웹사이트에 미리 갖춰둬야 할 요건입니다. 심사 제출 전에 체크리스트처럼 훑어보세요.

자동결제 별도 계약 필수 — 일반 결제 심사와 별개로, 자동결제 승인 API(POST /v1/billing/{billingKey})는 추가 계약이 필요합니다. 빌링키 발급까지는 일반 계약으로 가능하지만 실제 주기 청구는 계약 승인 후에만 동작합니다.

전체 흐름

[사용자 페이지]          [상점 서버]              [Toss 서버]
   ① 카드 등록 버튼
         │
         │  requestBillingAuth({method:'CARD'})
         ├────────────────────────────────────▶  결제창 UI
         │                                        (고객 카드 입력)
         │
         │  ◀────── successUrl?authKey=...&customerKey=...
         │
         │  ② POST /api/billing/issue
         │     { authKey, customerKey }
         ├──────────────────▶  POST /v1/billing/authorizations/issue
         │                     ◀── { billingKey, card, ... }
         │                     (billingKey를 DB에 저장)
         │  ◀── { ok, card, ... }
         │
   ③ 카드 등록 완료 ✓

   ...시간이 흘러 정기 청구 시점에...

   cron / 스케줄러 트리거
         │
         │  ④ POST /v1/billing/{billingKey}
         │     { customerKey, amount, orderId }
         ├──────────────────▶ Toss 결제 승인
         │                     ◀── { status: 'DONE', paymentKey, ... }
         │
         └ 구독 갱신 처리

1. 환경변수

자동결제는 링크페이와 달리 두 개의 키가 필요합니다. 브라우저 SDK(requestBillingAuth)가 실행돼야 하므로 클라이언트 키도 필요합니다.

# 클라이언트 SDK 초기화용 (브라우저에 노출됨)
NEXT_PUBLIC_TOSS_BILLING_CLIENT_KEY=test_ck_XXXX...

# 서버 측 Basic 인증용 (절대 브라우저에 노출 금지)
TOSS_BILLING_SECRET_KEY=test_sk_XXXX...

키 단계별 구분

상황사용 키비고
개발 초기 — 플로우 확인test_ck_... / test_sk_...자사 MID 샌드박스 키 발급 후 사용
프로덕션live_ck_... / live_sk_...라이브 계약 + 자동결제 승인 API 계약 완료 후

공용 샌드박스 키는 자동결제 MID가 아니라서 대부분 NOT_SUPPORTED_METHOD 또는 NOT_SUPPORTED_CARD_TYPE가 발생합니다. 빌링 전용 MID 키를 상점관리자에서 발급받아 사용하세요.

2. 서버: 빌링키 발급 프록시

app/api/billing/issue/route.ts — SDK에서 받은 authKey를 Toss에 보내 실제 billingKey로 교환합니다. 시크릿 키가 필요하니 반드시 서버에서 호출.

import { NextRequest } from 'next/server';

export const runtime = 'nodejs';

const TOSS_BASE = 'https://api.tosspayments.com';

function authHeader() {
  const key = process.env.TOSS_BILLING_SECRET_KEY;
  if (!key) throw new Error('TOSS_BILLING_SECRET_KEY is not set');
  return 'Basic ' + Buffer.from(`${key}:`).toString('base64');
}

export async function POST(req: NextRequest) {
  try {
    const { authKey, customerKey, plan } = (await req.json()) as {
      authKey?: string;
      customerKey?: string;
      plan?: string;
    };
    if (!authKey || !customerKey) {
      return Response.json(
        { error: 'authKey, customerKey are required' },
        { status: 400 },
      );
    }

    const upstream = await fetch(
      `${TOSS_BASE}/v1/billing/authorizations/issue`,
      {
        method: 'POST',
        headers: {
          Authorization: authHeader(),
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ authKey, customerKey }),
      },
    );

    const text = await upstream.text();
    let data: unknown;
    try {
      data = text ? JSON.parse(text) : {};
    } catch {
      data = { error: 'Upstream returned non-JSON', raw: text.slice(0, 500) };
    }
    if (!upstream.ok) {
      return Response.json(data, { status: upstream.status });
    }

    // TODO: 실제 구독 서비스에서는 d.billingKey를 DB에 저장해야
    // 추후 주기 결제가 가능합니다. (아래 5번 참고)
    const d = data as {
      billingKey: string;
      customerKey: string;
      method: string;
      card?: {
        issuerCode?: string;
        number?: string;
        cardType?: string;
        ownerType?: string;
      } | null;
      authenticatedAt: string;
    };

    return Response.json({
      ok: true,
      customerKey: d.customerKey,
      method: d.method,
      card: d.card ?? null,
      authenticatedAt: d.authenticatedAt,
      plan,
    });
  } catch (e) {
    return Response.json(
      { error: e instanceof Error ? e.message : 'unknown error' },
      { status: 500 },
    );
  }
}

3. 클라이언트: Next.js 버전

SDK 스크립트 로드 → requestBillingAuth 호출 → successUrl로 리다이렉트.

'use client';

import { useEffect, useRef, useState } from 'react';

const CLIENT_KEY = process.env.NEXT_PUBLIC_TOSS_BILLING_CLIENT_KEY!;

function generateCustomerKey(): string {
  return typeof crypto !== 'undefined' && 'randomUUID' in crypto
    ? crypto.randomUUID()
    : `cust-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}

export default function SubscribePage() {
  const [sdkReady, setSdkReady] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const tpRef = useRef<any>(null);

  useEffect(() => {
    const script = document.createElement('script');
    script.src = 'https://js.tosspayments.com/v2/standard';
    script.async = true;
    script.onload = () => {
      tpRef.current = (window as any).TossPayments(CLIENT_KEY);
      setSdkReady(true);
    };
    script.onerror = () => setError('Toss SDK 로드 실패');
    document.head.appendChild(script);
  }, []);

  async function handleStart() {
    if (!tpRef.current) return;
    setLoading(true);
    setError(null);
    try {
      const customerKey = generateCustomerKey();
      const payment = tpRef.current.payment({ customerKey });
      const origin = window.location.origin;
      await payment.requestBillingAuth({
        method: 'CARD',
        successUrl: `${origin}/billing/success?plan=monthly`,
        failUrl: `${origin}/billing/fail`,
      });
    } catch (e: any) {
      if (e?.code === 'USER_CANCEL') { setLoading(false); return; }
      setError(e?.message ?? '카드 등록 창 호출 실패');
      setLoading(false);
    }
  }

  return (
    <div>
      <h1>프리미엄 월간 구독</h1>
      <button onClick={handleStart} disabled={!sdkReady || loading}>
        {!sdkReady ? 'SDK 준비 중...' : loading ? '결제창 여는 중...' : '카드 등록하고 시작하기'}
      </button>
      {error && <div>{error}</div>}
    </div>
  );
}

successUrl 콜백 페이지

Toss가 authKeycustomerKey를 query에 담아 리다이렉트합니다. 이 페이지에서 서버로 POST해서 빌링키 발급을 트리거합니다.

'use client';

import { Suspense, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';

function SuccessInner() {
  const sp = useSearchParams();
  const authKey = sp.get('authKey');
  const customerKey = sp.get('customerKey');
  const plan = sp.get('plan') ?? undefined;

  const [result, setResult] = useState<any>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!authKey || !customerKey) {
      setError('authKey / customerKey 누락');
      return;
    }
    fetch('/api/billing/issue', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ authKey, customerKey, plan }),
    })
      .then((r) => r.json())
      .then((d) => {
        if (!d?.ok) throw new Error(d?.message || d?.error || '발급 실패');
        setResult(d);
      })
      .catch((e) => setError(e.message));
  }, [authKey, customerKey, plan]);

  if (error) return <div>카드 등록 실패: {error}</div>;
  if (!result) return <div>빌링키 발급 중...</div>;
  return (
    <div>
      <h1>카드 등록 완료</h1>
      <p>{result.card?.number} ({result.card?.cardType})</p>
    </div>
  );
}

export default function BillingSuccessPage() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <SuccessInner />
    </Suspense>
  );
}

4. 클라이언트: Vue 3 버전

서버 라우트는 동일(Next.js API 사용 시). Vue 전용 백엔드라면 Express 등에 서버 코드를 옮기면 됩니다.

<script setup lang="ts">
import { onMounted, ref } from 'vue';

const CLIENT_KEY = import.meta.env.VITE_TOSS_BILLING_CLIENT_KEY as string;

const sdkReady = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);
let tp: any = null;

function generateCustomerKey() {
  return typeof crypto !== 'undefined' && 'randomUUID' in crypto
    ? crypto.randomUUID()
    : `cust-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}

onMounted(() => {
  const script = document.createElement('script');
  script.src = 'https://js.tosspayments.com/v2/standard';
  script.async = true;
  script.onload = () => {
    tp = (window as any).TossPayments(CLIENT_KEY);
    sdkReady.value = true;
  };
  document.head.appendChild(script);
});

async function handleStart() {
  if (!tp) return;
  loading.value = true;
  error.value = null;
  try {
    const customerKey = generateCustomerKey();
    const payment = tp.payment({ customerKey });
    const origin = window.location.origin;
    await payment.requestBillingAuth({
      method: 'CARD',
      successUrl: `${origin}/billing/success?plan=monthly`,
      failUrl: `${origin}/billing/fail`,
    });
  } catch (e: any) {
    if (e?.code === 'USER_CANCEL') { loading.value = false; return; }
    error.value = e?.message ?? '카드 등록 창 호출 실패';
    loading.value = false;
  }
}
</script>

<template>
  <div>
    <h1>프리미엄 월간 구독</h1>
    <button :disabled="!sdkReady || loading" @click="handleStart">
      {{ !sdkReady ? 'SDK 준비 중...' : loading ? '결제창 여는 중...' : '카드 등록하고 시작하기' }}
    </button>
    <div v-if="error">{{ error }}</div>
  </div>
</template>

5. 주기 청구 (계약 승인 후)

발급받은 billingKeyDB에 영구 저장해야 반복 결제가 가능합니다. (customerKey ↔ billingKey 매핑) 이후 cron 또는 스케줄러에서 아래 API를 호출하면 실제 청구가 일어납니다.

import { NextRequest } from 'next/server';

export const runtime = 'nodejs';

const TOSS_BASE = 'https://api.tosspayments.com';

function authHeader() {
  const key = process.env.TOSS_BILLING_SECRET_KEY;
  if (!key) throw new Error('TOSS_BILLING_SECRET_KEY is not set');
  return 'Basic ' + Buffer.from(`${key}:`).toString('base64');
}

export async function POST(req: NextRequest) {
  const { billingKey, customerKey, amount, orderId, orderName } =
    await req.json();

  const res = await fetch(`${TOSS_BASE}/v1/billing/${billingKey}`, {
    method: 'POST',
    headers: {
      Authorization: authHeader(),
      'Content-Type': 'application/json',
      // 멱등성 보장 (네트워크 재시도 시 중복 청구 방지)
      'Idempotency-Key': `charge-${orderId}`,
    },
    body: JSON.stringify({
      customerKey,
      amount,
      orderId,
      orderName,
    }),
  });
  const data = await res.json();
  return Response.json(data, { status: res.status });
}

Idempotency-Key 헤더를 함께 보내면 같은 요청이 두 번 들어와도 중복 청구되지 않습니다. 구독 서비스에는 필수.

billingKey 보관 시 유의 — billingKey는 실제 카드에 대한 결제 권한을 의미합니다. DB 유출 시 재청구가 가능하므로 at-rest 암호화와 접근 권한 최소화가 필요합니다.

6. 주의사항 체크리스트

7. 발급된 빌링키 관리

삭제(해지) API는 DELETE 한 번이면 됩니다:

curl -X DELETE \
  -u "test_sk_...:" \
  https://api.tosspayments.com/v1/billing/{billingKey}

구독 해지 시 해당 billingKey를 삭제하고 DB 매핑도 함께 지우세요.

자동결제 연동, 공식 파트너 PayFree가 계약부터 구현까지 진행해드립니다

연동문의

구독 결제, 직접 구현할 필요 없습니다

공식 파트너 PayFree가 심사 준비 · 자동결제 계약 · 실제 코드 연동 까지 직접 처리해드립니다.