토스페이먼츠 링크페이 연동 가이드

최종 업데이트: 2026년 4월 23일

토스페이먼츠 링크페이(LinkPay)를 Next.js(App Router) 기반 웹사이트에 붙이는 최소 예시입니다. Vue 3 혼용 프로젝트에도 그대로 쓸 수 있으며, 아래 코드를 그대로 복사해 시작할 수 있습니다.

토스페이먼츠 심사 조건 안내

아래 항목은 심사 통과를 위해 상점 웹사이트에 미리 갖춰둬야 할 요건입니다. 심사 제출 전에 체크리스트처럼 훑어보세요.

전체 흐름

[사용자 페이지]           [상점 서버]                [Toss 서버]
   상품 버튼 클릭
         │
         │  POST /api/linkpay/ensure
         │  { name, amount }
         ├──────────────────▶  GET  /v1/products (존재 확인)
         │                     └── 없으면 POST /v1/products
         │                     ▲
         │                     │ productKey, url
         │  ◀───── { url } ────┘
         │
         │  window.location = url
         │
         └─ Toss 스토어프론트 → 체크아웃 → 결제 완료
핵심: 브라우저에서 토스페이먼츠 API를 직접 호출하면 안 됩니다. 시크릿 키가 노출되고 CORS 정책에 막힙니다. 반드시 자기 서버에 프록시 라우트를 두고 그곳에서 호출하세요.

1. 환경변수

링크페이는 시크릿 키 하나만 필요합니다. 브라우저 SDK 없이 Toss 호스팅 URL로 바로 리다이렉트되는 구조라 NEXT_PUBLIC_* 클라이언트 키는 불필요합니다.

# 토스페이먼츠 LinkPay 전용 시크릿 키
TOSS_LINKPAY_SECRET_KEY=test_sk_E92LAa5PVbLpJKvwPb0R87YmpXyJ

키 단계별 구분

상황사용 키비고
개발 초기 — 당장 돌려보기test_sk_E92LAa5PVbLpJKvwPb0R87YmpXyJToss 공용 샌드박스 키
자사 MID 샌드박스test_sk_... (자사 발급)상점관리자 → API 키
프로덕션live_sk_...라이브 계약 완료 후

공용 샌드박스 키로 생성된 상품은 Toss 공용 테스트 상점에 만들어집니다. 자사 상점관리자에서는 안 보이며 실결제는 발생하지 않습니다. 플로우·UI 확인 용도로만 쓰세요.

2. 서버: 상품 find-or-create 프록시

app/api/linkpay/ensure/route.ts — 매 결제마다 새 상품을 만들면 상점 상품 목록이 터집니다. 이름+금액이 같은 상품이 이미 있으면 재사용하고 없을 때만 생성합니다.

import { NextRequest } from 'next/server';

export const runtime = 'nodejs'; // Buffer 쓰려면 nodejs 런타임 필수

const TOSS_BASE = 'https://api.tosspayments.com';

function authHeader() {
  const key = process.env.TOSS_LINKPAY_SECRET_KEY;
  if (!key) throw new Error('TOSS_LINKPAY_SECRET_KEY is not set');
  return 'Basic ' + Buffer.from(`${key}:`).toString('base64');
}

type Product = {
  productKey: string;
  name: string;
  amount: number;
  status: string;
  url: string | null;
};

async function listProducts(): Promise<Product[]> {
  const all: Product[] = [];
  let startingAfter: string | undefined;
  for (let i = 0; i < 5; i++) {
    const qs = new URLSearchParams({ limit: '100' });
    if (startingAfter) qs.set('startingAfter', startingAfter);
    const res = await fetch(`${TOSS_BASE}/v1/products?${qs}`, {
      headers: { Authorization: authHeader() },
    });
    if (!res.ok) throw new Error(`list failed: HTTP ${res.status}`);
    const page = (await res.json()) as Product[];
    if (!Array.isArray(page) || page.length === 0) break;
    all.push(...page);
    if (page.length < 100) break;
    startingAfter = page[page.length - 1].productKey;
  }
  return all;
}

export async function POST(req: NextRequest) {
  try {
    const { name, amount } = (await req.json()) as {
      name?: string;
      amount?: number;
    };
    if (!name || typeof amount !== 'number') {
      return Response.json(
        { error: 'name과 amount는 필수입니다' },
        { status: 400 },
      );
    }

    // 1) 기존 상품 중 이름+금액 일치 & 판매중 상품 탐색
    const products = await listProducts();
    const existing = products.find(
      (p) =>
        p.name === name &&
        p.amount === amount &&
        p.status === 'ON_SALE' &&
        p.url,
    );
    if (existing) {
      return Response.json({
        productKey: existing.productKey,
        url: existing.url,
        reused: true,
      });
    }

    // 2) 없으면 신규 생성 (validThrough 생략 → 무기한)
    const createRes = await fetch(`${TOSS_BASE}/v1/products`, {
      method: 'POST',
      headers: {
        Authorization: authHeader(),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name, amount }),
    });
    const created = await createRes.json().catch(() => ({}));
    if (!createRes.ok) {
      return Response.json(created, { status: createRes.status });
    }
    return Response.json({
      productKey: created.productKey,
      url: created.url,
      reused: false,
    });
  } catch (e) {
    return Response.json(
      { error: e instanceof Error ? e.message : 'unknown error' },
      { status: 500 },
    );
  }
}

3. 클라이언트: Next.js 버전

app/subscribe/page.tsx (혹은 원하는 경로)

'use client';

import { useState } from 'react';

const PRODUCT = {
  name: '프리미엄 월간 구독',
  amount: 9900,
};

export default function SubscribePage() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleClick() {
    setError(null);
    setLoading(true);
    try {
      const res = await fetch('/api/linkpay/ensure', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(PRODUCT),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok || !data?.url) {
        throw new Error(
          data?.message || data?.error || `결제 링크 준비 실패 (HTTP ${res.status})`,
        );
      }
      window.location.href = data.url;
    } catch (err) {
      setError(err instanceof Error ? err.message : '요청 실패');
      setLoading(false);
    }
  }

  return (
    <div>
      <h1>프리미엄 플랜</h1>
      <button type="button" onClick={handleClick} disabled={loading}>
        {loading ? '결제 페이지로 이동 중...' : `${PRODUCT.amount.toLocaleString()}원 결제하기`}
      </button>
      {error && <div>{error}</div>}
    </div>
  );
}

4. 클라이언트: Vue 3 버전

서버 라우트는 Next.js가 그대로 제공하고, Vue 컴포넌트만 다음처럼 작성하면 됩니다.

<script setup lang="ts">
import { ref } from 'vue';

const PRODUCT = { name: '프리미엄 월간 구독', amount: 9900 };
const loading = ref(false);
const error = ref<string | null>(null);

async function handleClick() {
  error.value = null;
  loading.value = true;
  try {
    const res = await fetch('/api/linkpay/ensure', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(PRODUCT),
    });
    const data = await res.json();
    if (!res.ok || !data?.url) {
      throw new Error(data?.message || data?.error || '결제 링크 준비 실패');
    }
    window.location.href = data.url;
  } catch (e: any) {
    error.value = e?.message ?? '요청 실패';
    loading.value = false;
  }
}
</script>

<template>
  <div>
    <h1>프리미엄 플랜</h1>
    <button :disabled="loading" @click="handleClick">
      {{ loading ? '결제 페이지로 이동 중...' : `${PRODUCT.amount.toLocaleString()}원 결제하기` }}
    </button>
    <div v-if="error">{{ error }}</div>
  </div>
</template>

5. 고객 정보를 Toss 주문에 함께 넘기기 (선택)

로그인한 사용자 정보를 Order 객체의 metaFields로 보내고 싶으면, Toss URL에 쿼리 파라미터로 덧붙이면 됩니다.

// redirect 직전에
const url = new URL(data.url);
url.searchParams.set('customerName', currentUser.name);
url.searchParams.set('customerPhone', currentUser.phone);
url.searchParams.set('internalUserId', currentUser.id);
window.location.href = url.toString();

결제 완료 후 GET /v1/orders/{orderKey}로 조회하면 order.metaFields 필드에서 값을 확인할 수 있습니다.

6. 주의사항 체크리스트

7. 주문·결제 확인

# 전체 주문 목록
curl -u "test_sk_...:" https://api.tosspayments.com/v1/orders

# 단건 조회
curl -u "test_sk_...:" https://api.tosspayments.com/v1/orders/{orderKey}

order.payment.status === 'DONE'이면 결제 승인 완료. 웹훅으로 실시간 수신하려면 상점관리자에서 webhook URL을 등록해주세요 (이벤트: PAYMENT_STATUS_CHANGED).

링크페이 연동, 공식 파트너 PayFree가 직접 진행해드립니다

연동문의

결제 연동, 직접 구현할 필요 없습니다

공식 파트너 PayFree가 심사 준비부터 실제 코드 연동까지 직접 처리해드립니다.