stack-analysis
CHAPTER 74 / 90
읽기 약 2분
FUNCTION
결제 PG 연동: 토스페이먼츠 실전
핵심 개념
Toss Payments SDK·결제 흐름·webhook·환불 — 한국 PG 연동 표준.
본문
토스페이먼츠 셋업
pnpm add @tosspayments/payment-sdk// .env
TOSS_CLIENT_KEY=test_ck_...
TOSS_SECRET_KEY=test_sk_...
TOSS_WEBHOOK_SECRET=...결제 흐름 (8단계)
1. 사용자가 결제하기 클릭
2. 우리 서버 → orderId 생성 (status: pending)
3. 클라이언트 → Toss SDK.requestPayment()
4. 토스 결제창 (카드/계좌/간편결제 선택)
5. 사용자 승인 → 토스 → successUrl 리다이렉트
6. 우리 서버 → /confirm 호출 (paymentKey + amount 검증)
7. 토스 → 실제 결제 승인
8. 우리 서버 → DB 갱신 + 사용자에게 표시클라이언트 — 결제창 호출
'use client';
import { loadTossPayments } from '@tosspayments/payment-sdk';
async function startPayment(order: Order) {
const tossPayments = await loadTossPayments(process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!);
await tossPayments.requestPayment('카드', { // '계좌이체' '가상계좌' '카카오페이' 등
amount: order.total,
orderId: order.id,
orderName: order.items[0].productName + (order.items.length > 1 ? ` 외 ${order.items.length - 1}건` : ''),
customerName: order.customerName,
customerEmail: order.customerEmail,
successUrl: `${window.location.origin}/checkout/success`,
failUrl: `${window.location.origin}/checkout/fail`,
}).catch((err) => {
if (err.code === 'USER_CANCEL') return;
throw err;
});
}서버 — 결제 승인 (confirm)
// app/api/checkout/confirm/route.ts
export async function POST(req: Request) {
const { paymentKey, orderId, amount } = await req.json();
// 1. orderId 유효성 검증
const order = await db.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order) return new Response('Order not found', { status: 404 });
if (order.status !== 'pending') {
return new Response('Already processed', { status: 409 });
}
// 2. 금액 검증 (서버 계산값과 일치)
if (order.total !== Number(amount)) {
return new Response('Amount mismatch', { status: 400 });
}
// 3. 토스 결제 승인 호출
const auth = Buffer.from(`${process.env.TOSS_SECRET_KEY}:`).toString('base64');
const res = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ paymentKey, orderId, amount }),
});
const data = await res.json();
if (!res.ok) {
// 결제 실패
await db.payment.create({
data: {
orderId, pgProvider: 'toss', amount: Number(amount),
status: 'failed', failedReason: data.message,
},
});
return Response.json({ error: data.message }, { status: 400 });
}
// 4. 트랜잭션 — 결제 + 주문 + 재고
await db.$transaction(async (tx) => {
await tx.payment.create({
data: {
orderId,
pgProvider: 'toss',
pgTransactionId: data.paymentKey,
amount: Number(amount),
status: 'completed',
paidAt: new Date(data.approvedAt),
method: data.method,
rawData: data,
},
});
await tx.order.update({
where: { id: orderId },
data: { status: 'paid', paidAt: new Date() },
});
// 재고 차감
for (const item of order.items) {
await tx.productVariant.update({
where: { id: item.variantId },
data: { stockQuantity: { decrement: item.quantity } },
});
}
// 예약 해제
await tx.inventoryReservation.deleteMany({ where: { orderId } });
// 장바구니 비우기
await tx.cart.deleteMany({ where: { userId: order.userId } });
});
// 5. 후속 작업 (이메일·알림)
await emailQueue.add('order_paid', { orderId });
return Response.json({ ok: true, order });
}Webhook (이중 안전망)
// app/api/webhooks/toss/route.ts
import crypto from 'crypto';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('toss-signature')!;
const expected = crypto.createHmac('sha256', process.env.TOSS_WEBHOOK_SECRET!)
.update(body).digest('base64');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
switch (event.eventType) {
case 'PAYMENT.DONE':
// 이미 confirm으로 처리된 경우 스킵
break;
case 'PAYMENT.CANCELED':
case 'PAYMENT.PARTIAL_CANCELED':
await handleRefund(event.data);
break;
}
return Response.json({ received: true });
}환불 (취소)
async function refundPayment(orderId: string, reason: string, amount?: number) {
const payment = await db.payment.findFirst({
where: { orderId, status: 'completed' },
});
if (!payment) throw new NotFoundError('Payment');
const refundAmount = amount ?? payment.amount;
const auth = Buffer.from(`${process.env.TOSS_SECRET_KEY}:`).toString('base64');
const res = await fetch(
`https://api.tosspayments.com/v1/payments/${payment.pgTransactionId}/cancel`,
{
method: 'POST',
headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
cancelReason: reason,
cancelAmount: refundAmount,
}),
}
);
const data = await res.json();
await db.refund.create({
data: {
paymentId: payment.id,
amount: refundAmount,
reason,
status: 'completed',
pgRefundId: data.transactionKey,
},
});
await db.order.update({
where: { id: orderId },
data: { status: 'refunded' },
});
// 재고 복구 (옵션)
// ...
}다중 PG 추상화
interface PaymentProvider {
confirm(params: { paymentKey: string; orderId: string; amount: number }): Promise<PaymentResult>;
refund(params: { transactionId: string; amount: number; reason: string }): Promise<RefundResult>;
}
class TossProvider implements PaymentProvider { /* ... */ }
class StripeProvider implements PaymentProvider { /* ... */ }
class KakaoPayProvider implements PaymentProvider { /* ... */ }
function getProvider(name: string): PaymentProvider {
return { toss: tossProvider, stripe: stripeProvider, kakao: kakaoProvider }[name]!;
}다음 챕터
CH.75 "주문 상태 머신".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 PG 연동 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
PG 연동 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 PG 연동 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 PG 연동 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
결제 PG 연동: 토스페이먼츠 실전은 이 3가지만 확실히 잡으세요
1.결제는 confirm 단계에서 서버 검증 — 클라이언트 amount 신뢰 X
2.Webhook은 이중 안전망 — confirm 실패 케이스 처리
3.환불은 PG API 호출 + DB 업데이트 + 재고 복구
공유하기
진행도 74 / 90