2026 토스페이먼츠 Next.js 연동 완벽 가이드 (App Router)
Next.js로 한국 SaaS를 만들 때 가장 먼저 부딪히는 벽이 바로 결제 연동입니다. Stripe는 한국 사업자에게 제한적이고, 포트원은 래핑 레이어가 하나 더 생기죠. 그래서 많은 한국 개발자들이 토스페이먼츠를 직접 연동하는 방식을 선택합니다.
이 글에서는 Next.js App Router + 토스페이먼츠 API를 활용해서 빌링키 발급 → 정기결제 → 웹훅 처리까지 실제 프로덕션에서 쓸 수 있는 코드를 단계별로 보여드립니다.
2026년 기준 토스페이먼츠는 한국 온라인 결제 시장 점유율 약 40%로, PG사 중 가장 높은 점유율을 차지하고 있습니다. API 문서도 잘 정리되어 있고, 테스트 환경도 잘 갖춰져 있어서 개발자 경험이 좋은 편입니다.
1단계: 토스페이먼츠 API 키 발급
먼저 토스페이먼츠 개발자센터에서 계정을 만들고 API 키를 발급받습니다.
필요한 키는 두 가지입니다:
- 클라이언트 키 (Client Key) — 프론트엔드에서 결제창 호출 시 사용
- 시크릿 키 (Secret Key) — 서버에서 API 호출 시 사용 (절대 노출 금지!)
.env.local 파일에 다음과 같이 설정합니다:
# .env.local
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxx
TOSS_PAYMENTS_SECRET_KEY=test_sk_xxxxxxxxxxxxxxxx주의: 시크릿 키는 NEXT_PUBLIC_ 접두사를 붙이면 안 됩니다. 브라우저에 노출되면 큰 보안 사고로 이어집니다.
2단계: 토스 API 클라이언트 만들기
토스페이먼츠 API 호출을 위한 공통 클라이언트를 먼저 만듭니다. 모든 API 호출에서 재사용할 수 있는 타입 안전한 래퍼입니다.
const TOSS_API_BASE = "https://api.tosspayments.com/v1";
function getSecretKey(): string {
const key = process.env.TOSS_PAYMENTS_SECRET_KEY;
if (!key) {
throw new Error(
"TOSS_PAYMENTS_SECRET_KEY 환경변수가 설정되지 않았습니다."
);
}
return key;
}
function getAuthHeader(): string {
const encoded = Buffer.from(`${getSecretKey()}:`).toString("base64");
return `Basic ${encoded}`;
}
async function tossRequest<T>(
path: string,
options: {
method: "GET" | "POST";
body?: Record<string, unknown>;
}
): Promise<{ data: T | null; error: string | null }> {
try {
const response = await fetch(`${TOSS_API_BASE}${path}`, {
method: options.method,
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: options.body
? JSON.stringify(options.body)
: undefined,
});
if (!response.ok) {
const errorBody = await response.json()
.catch(() => ({}));
const message =
errorBody.message
?? `토스 API 오류 (${response.status})`;
return { data: null, error: message };
}
const data = (await response.json()) as T;
return { data, error: null };
} catch (err) {
const message =
err instanceof Error
? err.message
: "알 수 없는 오류";
return { data: null, error: message };
}
}핵심 포인트를 짚어보겠습니다:
- Basic 인증 — 토스 시크릿 키 뒤에 콜론(
:)을 붙이고 Base64 인코딩합니다. 콜론을 빠뜨리면 인증이 실패하니 주의하세요. - 에러 핸들링 —
{ data, error }패턴으로 반환합니다. try-catch 지옥을 피하고 호출부에서 깔끔하게 처리할 수 있습니다. - 타입 안전성 — 제네릭
<T>를 사용해서 각 API 응답 타입을 정확히 추론합니다.
3단계: 빌링키 발급 API Route 만들기
SaaS에서 정기결제(구독)를 구현하려면 빌링키가 필요합니다. 빌링키는 고객의 카드 정보를 대신하는 토큰으로, 이걸 저장해두면 매달 자동으로 결제할 수 있습니다.
먼저 타입 정의부터 보겠습니다:
// 빌링키 발급 응답 타입
export interface TossBillingKeyResponse {
billingKey: string;
customerKey: string;
authenticatedAt: string;
method: string;
card: {
issuerCode: string;
acquirerCode: string;
number: string;
cardType: string;
ownerType: string;
} | null;
}그리고 빌링키 발급 함수입니다:
/** authKey로 빌링키 발급 (카드 등록 후 서버에서 호출) */
export function requestBillingKey(params: {
authKey: string;
customerKey: string;
}) {
return tossRequest<TossBillingKeyResponse>(
"/billing/authorizations/issue",
{ method: "POST", body: params }
);
}이제 Next.js App Router의 Route Handler에서 이 함수를 호출합니다:
import { NextRequest, NextResponse } from "next/server";
import { requestBillingKey } from "@/lib/payments/toss";
export async function POST(req: NextRequest) {
const { authKey, customerKey } = await req.json();
// 입력 검증
if (!authKey || !customerKey) {
return NextResponse.json(
{ error: "authKey와 customerKey가 필요합니다." },
{ status: 400 }
);
}
const { data, error } = await requestBillingKey({
authKey,
customerKey,
});
if (error) {
return NextResponse.json(
{ error },
{ status: 500 }
);
}
// DB에 빌링키 저장 (예: Drizzle ORM)
// await db.insert(billingKeys).values({
// userId: session.user.id,
// billingKey: data.billingKey,
// cardNumber: data.card?.number,
// });
return NextResponse.json({
success: true,
billingKey: data.billingKey,
});
}프론트엔드 흐름은 이렇습니다:
- 토스 SDK로 카드 등록 팝업을 띄움
- 사용자가 카드 정보 입력 →
authKey발급됨 - 이
authKey를 위의 API Route로 전송 - 서버에서 빌링키를 발급받아 DB에 저장
4단계: 정기결제(자동결제) 구현
빌링키를 발급받았으면, 이제 매달 자동으로 결제를 실행할 수 있습니다. 아래는 빌링키로 결제를 승인하는 함수입니다:
/** 빌링키로 자동결제 승인 */
export function chargeBilling(params: {
billingKey: string;
customerKey: string;
amount: number;
orderId: string;
orderName: string;
}) {
return tossRequest<TossPaymentResponse>(
`/billing/${params.billingKey}`,
{
method: "POST",
body: {
customerKey: params.customerKey,
amount: params.amount,
orderId: params.orderId,
orderName: params.orderName,
},
}
);
}실제 정기결제 API Route는 이렇게 만듭니다:
import { NextRequest, NextResponse } from "next/server";
import { chargeBilling } from "@/lib/payments/toss";
import { nanoid } from "nanoid";
export async function POST(req: NextRequest) {
const { billingKey, customerKey, amount, planName } =
await req.json();
const orderId = `order_${nanoid()}`;
const { data, error } = await chargeBilling({
billingKey,
customerKey,
amount,
orderId,
orderName: `${planName} 월간 구독`,
});
if (error) {
console.error("결제 실패:", error);
return NextResponse.json(
{ error },
{ status: 500 }
);
}
// 결제 성공 → DB에 결제 기록 저장
// await db.insert(payments).values({
// paymentKey: data.paymentKey,
// orderId: data.orderId,
// amount: data.totalAmount,
// status: data.status,
// });
return NextResponse.json({
success: true,
payment: data,
});
}정기결제 스케줄링은 보통 다음 방법 중 하나를 사용합니다:
- Vercel Cron Jobs —
vercel.json에 크론 설정 추가 (가장 간편) - Supabase pg_cron — DB 레벨에서 Edge Function 호출
- Inngest / Trigger.dev — 서버리스 백그라운드 작업 (재시도 로직 내장)
5단계: 웹훅으로 결제 상태 동기화
결제 취소, 환불, 카드 만료 등의 이벤트는 토스페이먼츠 웹훅으로 전달됩니다. 반드시 처리해야 합니다.
먼저, 결제 취소 함수입니다:
/** 결제 취소 */
export function cancelPayment(params: {
paymentKey: string;
cancelReason: string;
}) {
return tossRequest<TossCancelResponse>(
`/payments/${params.paymentKey}/cancel`,
{
method: "POST",
body: { cancelReason: params.cancelReason },
}
);
}웹훅 수신 API Route를 만듭니다:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const body = await req.json();
// 1. 시크릿 검증
const signature = req.headers.get(
"x-tosspayments-signature"
);
if (!verifyWebhookSignature(signature, body)) {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
// 2. 이벤트 타입별 처리
switch (body.eventType) {
case "PAYMENT_STATUS_CHANGED":
await handlePaymentStatusChange(body.data);
break;
case "BILLING_KEY_DELETED":
await handleBillingKeyDeletion(body.data);
break;
default:
console.log(
"처리하지 않는 이벤트:",
body.eventType
);
}
// 3. 반드시 200 응답 (안 하면 재시도됨)
return NextResponse.json({ received: true });
}실수하기 쉬운 포인트 3가지:
- 반드시 200 응답 — 웹훅에 200을 돌려주지 않으면 토스가 계속 재시도합니다. 내부 처리가 실패해도 일단 200을 보내고, 별도로 에러 로깅하세요.
- 서명 검증 필수 — 웹훅 URL이 노출되면 누구나 가짜 이벤트를 보낼 수 있습니다.
- 멱등성(idempotency) — 같은 이벤트가 여러 번 올 수 있습니다.
paymentKey기준으로 중복 처리를 방지하세요.
실전 팁: 프로덕션 체크리스트
테스트 환경에서 잘 되는데 프로덕션에서 문제가 생기는 경우가 많습니다. 배포 전에 반드시 확인하세요:
- 테스트 키 → 라이브 키 전환:
test_sk_로 시작하는 키를live_sk_로 교체 - 웹훅 URL 등록: 토스 개발자센터에서 프로덕션 웹훅 URL을 등록했는지 확인
- 에러 모니터링: Sentry나 로그 시스템으로 결제 실패를 즉시 감지
- 결제 실패 재시도: 카드 한도 초과 등으로 실패할 수 있으니, 3일 간격으로 2~3회 재시도 로직 구현
- 영수증 이메일: 결제 성공 시 고객에게 영수증 메일 발송 (Resend, Nodemailer 등)
- 환불 정책: 서비스 특성에 맞는 환불 정책을 수립하고
cancelPayment함수 활용
전체 파일 구조 정리
src/
├── lib/
│ └── payments/
│ └── toss.ts # API 클라이언트 + 타입 정의
├── app/
│ └── api/
│ ├── billing/
│ │ ├── route.ts # 빌링키 발급
│ │ └── charge/
│ │ └── route.ts # 정기결제 승인
│ └── webhooks/
│ └── toss/
│ └── route.ts # 웹훅 수신
└── .env.local # API 키 (git에 절대 커밋 금지)마무리
여기까지 따라오셨다면 토스페이먼츠 + Next.js App Router로 정기결제 시스템의 핵심 구조를 이해하셨을 겁니다. 핵심을 정리하면:
- 시크릿 키는 서버에서만 사용하고, Basic 인증 시 콜론을 잊지 말 것
- 빌링키 발급 → 자동결제 → 웹훅 처리의 3단계 흐름을 기억할 것
- 에러 핸들링과 멱등성은 프로덕션 필수
하지만 솔직히, 결제는 SaaS 개발의 일부일 뿐입니다. 실제로는 인증(카카오/네이버 로그인), 이메일 발송, 한국 세금 계산, 관리자 대시보드, 알림톡 발송 등등 해야 할 것들이 산더미입니다.
이 글에서 보여드린 코드는 실제로 한국형 SaaS 보일러플레이트를 만들면서 작성한 코드입니다. 토스페이먼츠 연동뿐 아니라, 카카오 로그인, Supabase, tRPC, Drizzle ORM, 알림톡까지 모두 포함된 Next.js 스타터킷이 있습니다.