블로그 목록
결제

2026 토스페이먼츠 Next.js 연동 완벽 가이드 (App Router)

Layered Labs|

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
# .env.local
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxx
TOSS_PAYMENTS_SECRET_KEY=test_sk_xxxxxxxxxxxxxxxx

주의: 시크릿 키는 NEXT_PUBLIC_ 접두사를 붙이면 안 됩니다. 브라우저에 노출되면 큰 보안 사고로 이어집니다.


2단계: 토스 API 클라이언트 만들기

토스페이먼츠 API 호출을 위한 공통 클라이언트를 먼저 만듭니다. 모든 API 호출에서 재사용할 수 있는 타입 안전한 래퍼입니다.

src/lib/payments/toss.ts
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에서 이 함수를 호출합니다:

src/app/api/billing/route.ts
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,
  });
}

프론트엔드 흐름은 이렇습니다:

  1. 토스 SDK로 카드 등록 팝업을 띄움
  2. 사용자가 카드 정보 입력 → authKey 발급됨
  3. authKey를 위의 API Route로 전송
  4. 서버에서 빌링키를 발급받아 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는 이렇게 만듭니다:

src/app/api/billing/charge/route.ts
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를 만듭니다:

src/app/api/webhooks/toss/route.ts
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가지:

  1. 반드시 200 응답 — 웹훅에 200을 돌려주지 않으면 토스가 계속 재시도합니다. 내부 처리가 실패해도 일단 200을 보내고, 별도로 에러 로깅하세요.
  2. 서명 검증 필수 — 웹훅 URL이 노출되면 누구나 가짜 이벤트를 보낼 수 있습니다.
  3. 멱등성(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 스타터킷이 있습니다.

위 코드가 모두 포함된 한국형 SaaS 보일러플레이트

토스페이먼츠 + 카카오 로그인 + Supabase + tRPC + 알림톡 — 설정 없이 바로 시작

가격 보기
Written by 레이어드 랩스 | 한국 개발자를 위한 SaaS 인프라