토스페이먼츠 공식 문서는 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에 paymentKeyorderId가 포함된다. 이걸 서버에서 승인해야 실제로 결제가 확정된다.

// 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 번들 사이즈 최적화
  • billingKey DB 저장 — 정기 구독 시 필수
  • 웹훅 엔드포인트 — Server Action 실패 시 안전장치
  • 에러 로깅 — 실패 시 failReason을 DB에 기록

토스페이먼츠 + Next.js App Router 조합은 공식 문서만으로는 부족하다. 특히 Server Actions에서의 금액 검증, 빌링키 관리, 웹훅 이중 검증은 직접 구현해보지 않으면 놓치기 쉬운 부분이다.

이 글에서 다룬 코드는 실제 프로덕션에서 사용 중인 구조다. 토스페이먼츠뿐 아니라 카카오 로그인, SMS 인증, 이메일까지 이미 세팅된 Next.js 스타터킷이 필요하다면 kindie.cc를 확인해보자.