토스페이먼츠 자동결제(빌링) 연동 가이드
토스페이먼츠 자동결제(빌링)를 Next.js(App Router) 기반 웹사이트에 붙이는 최소 예시입니다. Vue 3 혼용 프로젝트에도 그대로 쓸 수 있으며, 구독 서비스·정기결제·월정액 멤버십에 필요한 흐름을 전부 다룹니다.
토스페이먼츠 심사 조건 안내
아래 항목은 심사 통과를 위해 상점 웹사이트에 미리 갖춰둬야 할 요건입니다. 심사 제출 전에 체크리스트처럼 훑어보세요.
- 결제 플로우 완성 — 상품 → 주문 → 체크아웃 → 결제 전 단계 동작 확인
- 회원가입이 필요한 서비스일 경우, 심사자용 테스트 아이디/비밀번호 공유
- 사업자 정보 footer 전역 명시 — 사업자등록증 기준 상호명, 사업자등록번호, 대표자명, 사업장 주소, 유선번호와 이용약관·개인정보 처리방침·반품/환불 규정 링크
- 판매하는 서비스 1개 이상 실제 상품 등록
- 카테고리로 나뉘어질 경우 비어있는 카테고리 삭제
- 랜딩 페이지 구조보다는 개별 HTML로 페이지 구성 권장 (선택사항)
- 법인/개인사업자 공통 — 신용도 확인: 연체·미납 내역 없어야 통과 가능성 ↑
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가 authKey와 customerKey를 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. 주기 청구 (계약 승인 후)
발급받은 billingKey는 DB에 영구 저장해야 반복 결제가 가능합니다. (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 헤더를 함께 보내면 같은 요청이 두 번 들어와도 중복 청구되지 않습니다. 구독 서비스에는 필수.
6. 주의사항 체크리스트
- 서버 프록시 필수 — 시크릿 키는 절대 브라우저에 노출 금지
- customerKey는 UUID 권장 — 자동 증가 숫자나 이메일·전화번호 금지. 유추 불가능한 값으로
- Idempotency-Key 사용 — 주기 청구에서 중복 방지
- 타임아웃 60초 이상 — 자동결제 승인 API는 카드사 응답이 느릴 수 있음
- 웹훅 수신 — 카드 만료, 결제 실패 등 이벤트를 실시간으로 받으려면 webhook 등록 필수
- 재등록 플로우 — 카드 만료 시 고객에게 알림 → 카드 재등록 페이지로 유도 구현
- 환불 정책 명시 — 구독 서비스는 청약철회 및 환불 정책을 약관에 명확히 기재 (심사 대상)
7. 발급된 빌링키 관리
삭제(해지) API는 DELETE 한 번이면 됩니다:
curl -X DELETE \
-u "test_sk_...:" \
https://api.tosspayments.com/v1/billing/{billingKey}구독 해지 시 해당 billingKey를 삭제하고 DB 매핑도 함께 지우세요.
자동결제 연동, 공식 파트너 PayFree가 계약부터 구현까지 진행해드립니다
연동문의 →구독 결제, 직접 구현할 필요 없습니다
공식 파트너 PayFree가 심사 준비 · 자동결제 계약 · 실제 코드 연동 까지 직접 처리해드립니다.