토스페이먼츠 공식 문서는 Pages Router 기준이거나, React 예제가 대부분이다. App Router에서 Server Actions, "use server", dynamic import를 조합해서 결제를 붙이려면 생각보다 삽질이 많다.
이 글은 실제 프로덕션에 배포된 코드를 기반으로, 단건 결제부터 정기 구독까지 전체 플로우를 정리한다.
전체 아키텍처
브라우저 (클라이언트)
↓ ① 결제 요청
토스페이먼츠 결제 UI (iframe)
↓ ② 결제 완료 → successUrl로 리다이렉트
Next.js Server (/payments/success)
↓ ③ Server Action에서 토스 API로 결제 승인
토스페이먼츠 API (/v1/payments/confirm)
↓ ④ 승인 결과 → DB에 기록
↓ ⑤ (비동기) 웹훅으로 이중 검증
핵심 원칙: 클라이언트는 결제 UI만 띄우고, 실제 승인은 반드시 서버에서. 클라이언트에서 승인하면 금액 위변조에 취약하다.
1단계: 환경 설정
npm install @tosspayments/tosspayments-sdk
.env.local:
# 클라이언트용 (브라우저에 노출돼도 안전)
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxx
# 서버용 (절대 클라이언트에 노출 금지)
TOSS_SECRET_KEY=test_sk_xxxxxxxxxxxx
삽질 #1:
NEXT_PUBLIC_접두사가 없으면 클라이언트 컴포넌트에서undefined다. 반대로 시크릿 키에NEXT_PUBLIC_을 붙이면 브라우저에 노출된다. 이름 규칙을 꼭 지키자.
2단계: 서버 — 토스 API 클라이언트
// lib/payments/toss.ts
import "server-only"; // 클라이언트 번들에 포함 방지
const TOSS_API_BASE = "https://api.tosspayments.com";
function getEncodedKey() {
const secretKey = process.env.TOSS_SECRET_KEY!;
// 시크릿 키 뒤에 콜론(:)을 붙여서 Base64 인코딩
return Buffer.from(`${secretKey}:`).toString("base64");
}
export async function confirmPayment(
paymentKey: string,
orderId: string,
amount: number
) {
const response = await fetch(`${TOSS_API_BASE}/v1/payments/confirm`, {
method: "POST",
headers: {
Authorization: `Basic ${getEncodedKey()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ paymentKey, orderId, amount }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "결제 확인 중 오류가 발생했습니다.");
}
return response.json();
}
삽질 #2:
import "server-only"를 빼먹으면 이 파일이 클라이언트 번들에 포함될 수 있다. 시크릿 키가 브라우저에 노출되는 보안 사고로 이어진다.
삽질 #3:
Buffer.from(${secretKey}:)— 콜론을 빼먹으면 인증이 실패한다. 토스 문서에 작게 적혀 있는데 놓치기 쉽다.
3단계: 클라이언트 — 결제 요청 컴포넌트
// components/payments/checkout-form.tsx
"use client";
import { useState } from "react";
export function CheckoutForm({
planName,
amount,
userId,
userEmail,
userName,
}: {
planName: string;
amount: number;
userId: string;
userEmail: string;
userName: string;
}) {
const [isLoading, setIsLoading] = useState(false);
async function handlePayment() {
setIsLoading(true);
try {
// Dynamic import — 번들 사이즈 절약
const { loadTossPayments } = await import(
"@tosspayments/tosspayments-sdk"
);
const tossPayments = await loadTossPayments(
process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!
);
const payment = tossPayments.payment({ customerKey: userId });
const orderId = `order-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
await payment.requestPayment({
method: "CARD",
amount: { currency: "KRW", value: amount },
orderId,
orderName: planName,
customerEmail: userEmail,
customerName: userName,
successUrl: `${window.location.origin}/payments/success?amount=${amount}`,
failUrl: `${window.location.origin}/payments/fail`,
});
} catch {
alert("결제 처리 중 오류가 발생했습니다.");
setIsLoading(false);
}
}
return (
<button onClick={handlePayment} disabled={isLoading}>
{isLoading ? "결제 처리 중..." : `₩${amount.toLocaleString()} 결제하기`}
</button>
);
}
삽질 #4:
@tosspayments/tosspayments-sdk를 일반 import로 가져오면 번들 사이즈가 불필요하게 커진다. 결제 버튼을 누를 때만 로드하면 되니까await import()로 dynamic import하자. 이것만으로 초기 로딩이 눈에 띄게 빨라진다.
삽질 #5:
orderId는 매 요청마다 고유해야 한다. 같은orderId로 두 번 요청하면 토스에서 에러를 준다.
4단계: 서버 — 결제 승인 (Server Action)
결제가 완료되면 토스가 successUrl로 리다이렉트한다. URL에 paymentKey와 orderId가 포함된다. 이걸 서버에서 승인해야 실제로 결제가 확정된다.
// actions/payments.ts
"use server";
import { confirmPayment } from "@/lib/payments/toss";
import { db } from "@/lib/db";
import { payments } from "@/lib/db/schema";
import { requireAuth } from "@/lib/auth";
import { eq } from "drizzle-orm";
import { z } from "zod";
const schema = z.object({
paymentKey: z.string().min(1),
orderId: z.string().min(1),
amount: z.number().int().positive(),
planId: z.string().min(1),
});
export async function confirmPaymentAction(input: z.infer<typeof schema>) {
const user = await requireAuth();
const validated = schema.safeParse(input);
if (!validated.success) {
return { error: "잘못된 요청입니다." };
}
const { paymentKey, orderId, planId } = validated.data;
// 핵심: 서버에서 금액을 다시 계산한다
// 클라이언트가 보낸 amount를 그대로 믿으면 안 된다
const expectedAmount = getPlanPrice(planId);
if (validated.data.amount !== expectedAmount) {
return { error: "결제 금액이 올바르지 않습니다." };
}
// 멱등성: 같은 orderId로 두 번 처리 방지
const existing = await db.query.payments.findFirst({
where: eq(payments.id, orderId),
});
if (existing?.status === "paid") {
return { success: true };
}
try {
const result = await confirmPayment(paymentKey, orderId, expectedAmount);
if (result.status !== "DONE") {
return { error: `결제 상태 이상: ${result.status}` };
}
// 성공 기록
await db.insert(payments).values({
id: orderId,
userId: user.id,
status: "paid",
amount: expectedAmount,
paymentKey,
payMethod: result.method,
orderName: "플랜 결제",
paidAt: new Date(result.approvedAt),
}).onConflictDoUpdate({
target: payments.id,
set: {
status: "paid",
paymentKey,
paidAt: new Date(result.approvedAt),
updatedAt: new Date(),
},
});
return { success: true };
} catch (err) {
// 실패 기록 (디버깅용)
await db.insert(payments).values({
id: orderId,
userId: user.id,
status: "failed",
amount: expectedAmount,
orderName: "플랜 결제",
failReason: err instanceof Error ? err.message : "알 수 없는 오류",
}).onConflictDoNothing();
return { error: "결제 승인에 실패했습니다." };
}
}
삽질 #6 (가장 중요):
amount를 절대 클라이언트가 보낸 값 그대로 쓰면 안 된다. 공격자가amount=1로 바꿔서 보낼 수 있다. 서버에서planId로 가격을 다시 조회한 뒤, 클라이언트가 보낸 금액과 비교해야 한다. 이게 빠지면 1원 결제 사고가 난다.
삽질 #7:
onConflictDoUpdate— 네트워크 지연으로 같은 요청이 두 번 올 수 있다.orderId를 primary key로 쓰고 upsert 처리하면 멱등성이 보장된다.
5단계: 정기 구독 (빌링키 방식)
단건 결제와 다른 점: 카드 정보를 “빌링키”로 저장한 뒤, 서버에서 원하는 시점에 과금한다.
클라이언트 — 카드 등록
// 단건 결제와 다른 부분만 발췌
// 단건: payment.requestPayment()
// 구독: payment.requestBillingAuth() ← 이게 다르다
await payment.requestBillingAuth({
method: "CARD",
customerEmail: userEmail,
customerName: userName,
successUrl: `${origin}/payments/billing/success?planId=${planId}`,
failUrl: `${origin}/payments/billing/fail`,
});
서버 — 빌링키 발급 + 첫 결제
// actions/subscriptions.ts (핵심 부분)
"use server";
export async function activateSubscriptionAction(input: {
authKey: string;
customerKey: string;
planId: string;
billingCycle: "monthly" | "yearly";
}) {
const user = await requireAuth();
// 1. authKey → billingKey 교환
const response = await fetch(
"https://api.tosspayments.com/v1/billing/authorizations/issue",
{
method: "POST",
headers: {
Authorization: `Basic ${getEncodedKey()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
authKey: input.authKey,
customerKey: input.customerKey,
}),
}
);
const { billingKey } = await response.json();
// 2. billingKey로 첫 결제 실행
await fetch(
`https://api.tosspayments.com/v1/billing/${billingKey}`,
{
method: "POST",
headers: {
Authorization: `Basic ${getEncodedKey()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
customerKey: input.customerKey,
orderId: `sub-${crypto.randomUUID()}`,
orderName: "Pro 플랜 월간 구독",
amount: 9900,
currency: "KRW",
}),
}
);
// 3. 구독 레코드 생성 (billingKey 저장)
const periodEnd = new Date();
periodEnd.setMonth(periodEnd.getMonth() + 1);
await db.insert(subscriptions).values({
userId: user.id,
planId: input.planId,
status: "active",
billingCycle: input.billingCycle,
billingKey, // 이걸 저장해야 다음 달에 자동 결제 가능
currentPeriodStart: new Date(),
currentPeriodEnd: periodEnd,
});
return { success: true };
}
삽질 #8:
requestBillingAuth()는 결제를 하지 않는다. 카드 등록만 한다. 실제 과금은 서버에서/v1/billing/{billingKey}를 호출해야 한다. 이걸 모르고 “결제가 안 된다”고 한참 삽질했다.
삽질 #9:
billingKey를 DB에 저장하자. 다음 달 자동 결제 때 이 키가 필요하다. 분실하면 사용자에게 카드를 다시 등록하라고 해야 한다.
6단계: 웹훅 — 이중 안전장치
Server Action이 실패해도 토스가 웹훅으로 결제 상태를 알려준다.
// app/api/webhooks/toss/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { payments } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
export async function POST(request: Request) {
const { eventType, data } = await request.json();
if (eventType === "PAYMENT_STATUS_CHANGED") {
const { orderId, paymentKey, status } = data;
switch (status) {
case "DONE":
await db.update(payments)
.set({ status: "paid", paymentKey, paidAt: new Date() })
.where(eq(payments.id, orderId));
break;
case "CANCELED":
await db.update(payments)
.set({ status: "canceled" })
.where(eq(payments.id, orderId));
break;
case "EXPIRED":
case "ABORTED":
await db.update(payments)
.set({ status: "failed", failReason: status })
.where(eq(payments.id, orderId));
break;
}
}
return NextResponse.json({ received: true });
}
삽질 #10: 로컬에서 웹훅을 테스트하려면
ngrok이나cloudflare tunnel이 필요하다. 토스 대시보드에서 웹훅 URL을 터널 URL로 설정하자.
DB 스키마 (Drizzle ORM)
결제에 필요한 최소 테이블:
// lib/db/schema/billing.ts
import { pgTable, text, integer, timestamp, pgEnum } from "drizzle-orm/pg-core";
export const paymentStatusEnum = pgEnum("payment_status", [
"pending", "paid", "failed", "canceled", "refunded",
]);
export const payments = pgTable("payments", {
id: text("id").primaryKey(), // orderId
userId: text("user_id").notNull(),
status: paymentStatusEnum("status").default("pending").notNull(),
amount: integer("amount").notNull(),
paymentKey: text("payment_key"), // 토스에서 받은 키
payMethod: text("pay_method"), // CARD, TRANSFER 등
orderName: text("order_name").notNull(),
paidAt: timestamp("paid_at"),
failReason: text("fail_reason"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const subscriptions = pgTable("subscriptions", {
id: text("id").$defaultFn(() => crypto.randomUUID()),
userId: text("user_id").notNull(),
planId: text("plan_id").notNull(),
status: text("status").default("active").notNull(),
billingCycle: text("billing_cycle").default("monthly").notNull(),
billingKey: text("billing_key"), // 정기 결제용
currentPeriodStart: timestamp("current_period_start").notNull(),
currentPeriodEnd: timestamp("current_period_end").notNull(),
});
체크리스트
빠뜨리기 쉬운 것들:
import "server-only"— 시크릿 키가 있는 파일에 필수- 금액 위변조 방지 — 서버에서 가격 재계산
- 멱등성 — 같은
orderId중복 처리 방지 - Dynamic import — SDK 번들 사이즈 최적화
billingKeyDB 저장 — 정기 구독 시 필수- 웹훅 엔드포인트 — Server Action 실패 시 안전장치
- 에러 로깅 — 실패 시
failReason을 DB에 기록
토스페이먼츠 + Next.js App Router 조합은 공식 문서만으로는 부족하다. 특히 Server Actions에서의 금액 검증, 빌링키 관리, 웹훅 이중 검증은 직접 구현해보지 않으면 놓치기 쉬운 부분이다.
이 글에서 다룬 코드는 실제 프로덕션에서 사용 중인 구조다. 토스페이먼츠뿐 아니라 카카오 로그인, SMS 인증, 이메일까지 이미 세팅된 Next.js 스타터킷이 필요하다면 kindie.cc를 확인해보자.