OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 74 / 90
stack-analysis
CHAPTER 74 / 90
읽기 약 2
FUNCTION

결제 PG 연동: 토스페이먼츠 실전


핵심 개념

Toss Payments SDK·결제 흐름·webhook·환불 — 한국 PG 연동 표준.

본문

토스페이먼츠 셋업

BASH📋 코드 (1줄)
pnpm add @tosspayments/payment-sdk
TYPESCRIPT📋 코드 (4줄)
// .env
TOSS_CLIENT_KEY=test_ck_...
TOSS_SECRET_KEY=test_sk_...
TOSS_WEBHOOK_SECRET=...

결제 흐름 (8단계)

📋 코드 (8줄)
1. 사용자가 결제하기 클릭
2. 우리 서버 → orderId 생성 (status: pending)
3. 클라이언트 → Toss SDK.requestPayment()
4. 토스 결제창 (카드/계좌/간편결제 선택)
5. 사용자 승인 → 토스 → successUrl 리다이렉트
6. 우리 서버 → /confirm 호출 (paymentKey + amount 검증)
7. 토스 → 실제 결제 승인
8. 우리 서버 → DB 갱신 + 사용자에게 표시

클라이언트 — 결제창 호출

TSX📋 코드 (19줄)
'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)

TYPESCRIPT📋 코드 (83줄)
// 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 (이중 안전망)

TYPESCRIPT📋 코드 (28줄)
// 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 });
}

환불 (취소)

TYPESCRIPT📋 코드 (41줄)
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 추상화

TYPESCRIPT📋 코드 (13줄)
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