토스페이먼츠 링크페이 연동 가이드
토스페이먼츠 링크페이(LinkPay)를 Next.js(App Router) 기반 웹사이트에 붙이는 최소 예시입니다. Vue 3 혼용 프로젝트에도 그대로 쓸 수 있으며, 아래 코드를 그대로 복사해 시작할 수 있습니다.
토스페이먼츠 심사 조건 안내
아래 항목은 심사 통과를 위해 상점 웹사이트에 미리 갖춰둬야 할 요건입니다. 심사 제출 전에 체크리스트처럼 훑어보세요.
- 결제 플로우 완성 — 상품 → 주문 → 체크아웃 → 결제 전 단계 동작 확인
- 회원가입이 필요한 서비스일 경우, 심사자용 테스트 아이디/비밀번호 공유
- 사업자 정보 footer 전역 명시 — 사업자등록증 기준으로 상호명, 사업자등록번호, 대표자명, 사업장 주소, 유선번호와 이용약관·개인정보 처리방침·반품/환불 규정 링크
- 판매하는 서비스 1개 이상 실제 상품 등록
- 카테고리로 나뉘어질 경우 비어있는 카테고리 삭제
- 랜딩 페이지 구조보다는 개별 HTML로 페이지 구성 권장 (선택사항)
- 법인/개인사업자 공통 — 신용도 확인: 연체·미납 내역 없어야 통과 가능성 ↑
전체 흐름
[사용자 페이지] [상점 서버] [Toss 서버]
상품 버튼 클릭
│
│ POST /api/linkpay/ensure
│ { name, amount }
├──────────────────▶ GET /v1/products (존재 확인)
│ └── 없으면 POST /v1/products
│ ▲
│ │ productKey, url
│ ◀───── { url } ────┘
│
│ window.location = url
│
└─ Toss 스토어프론트 → 체크아웃 → 결제 완료1. 환경변수
링크페이는 시크릿 키 하나만 필요합니다. 브라우저 SDK 없이 Toss 호스팅 URL로 바로 리다이렉트되는 구조라 NEXT_PUBLIC_* 클라이언트 키는 불필요합니다.
# 토스페이먼츠 LinkPay 전용 시크릿 키
TOSS_LINKPAY_SECRET_KEY=test_sk_E92LAa5PVbLpJKvwPb0R87YmpXyJ키 단계별 구분
| 상황 | 사용 키 | 비고 |
|---|---|---|
| 개발 초기 — 당장 돌려보기 | test_sk_E92LAa5PVbLpJKvwPb0R87YmpXyJ | Toss 공용 샌드박스 키 |
| 자사 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. 주의사항 체크리스트
- 서버 프록시 필수 — 시크릿 키를 브라우저에 절대 노출하지 마세요
runtime = 'nodejs'명시 — App Router 기본은 Edge 런타임이라Buffer가 없습니다- find-or-create 로직 — 무지성 create는 상점 상품 목록을 오염시킵니다
- 에러는 JSON으로 래핑 — Toss가 HTML 에러를 반환할 때도 클라이언트가 터지지 않도록
validThrough— 미지정 시 무기한 유효. 필요하면 ISO 8601 형식으로 명시- 테스트/라이브 전환 —
test_sk_는 실결제가 안 됩니다. 배포 전live_sk_로 교체 - 비실물 상품 — 링크페이 사용에 별도 계약 필수, 결제수단(카드/계좌이체 등)도 계약된 것만 노출됩니다
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가 심사 준비부터 실제 코드 연동까지 직접 처리해드립니다.