토스페이먼츠 API 개별 연동 가이드
2026.02.01 17:13조회 22
·2026.02.01 17:13·조회 22

토스페이먼츠 API 개별 연동 가이드
Next.js + React + TypeScript (Frontend) + Express + TypeScript (Backend)
이 문서는 토스페이먼츠 API 개별 연동 방식을 사용하여 결제 기능을 구현하는 방법을 단계별로 설명합니다.
참고로 claude code와 같은 AI를 이용해 개발한다면 토스페이먼츠 MCP서버를 설치한 후 개발하면 큰 도움이 됩니다.
목차
1. 결제위젯 vs API 개별 연동 비교
1.1 비교표
| 항목 | 결제위젯 | API 개별 연동 |
|---|---|---|
| 키 형식 | test_gck_*, test_gsk_* | test_ck_*, test_sk_* |
| 결제 UI | 토스페이먼츠 제공 위젯 | 직접 구현 |
| 결제수단 선택 | 위젯에서 자동 표시 | 직접 UI 구현 |
| 커스터마이징 | 상점관리자에서 제한적 설정 | 코드로 자유롭게 |
| 연동 난이도 | 쉬움 | 중간 |
| 자동결제(빌링) | ❌ 미지원 | ✅ 지원 |
| 간편결제 | ✅ 위젯에 포함 | ✅ 별도 연동 |
1.2 결제위젯 방식
// 결제위젯: widgets() 사용 const widgets = tossPayments.widgets({ customerKey }); await widgets.renderPaymentMethods({ selector: '#payment-method' }); await widgets.renderAgreement({ selector: '#agreement' }); await widgets.requestPayment({ orderId, orderName, ... });
장점:
- 토스페이먼츠가 제공하는 최적화된 UI
- 빠른 연동 (노코드로 결제수단 관리)
- 모든 결제수단 한 번에 지원
단점:
- UI 커스터마이징 제한
- 자동결제(빌링) 미지원
1.3 API 개별 연동 방식
// API 개별 연동: payment() 사용 const payment = tossPayments.payment({ customerKey }); await payment.requestPayment({ method: 'CARD', // 결제수단 직접 지정 amount: { currency: 'KRW', value: 1000 }, orderId, orderName, ... });
장점:
- 결제 UI 완전 커스터마이징
- 자동결제(빌링/정기결제) 지원
- 특정 결제수단만 선택적 연동 가능
단점:
- 결제수단 선택 UI 직접 구현 필요
- 연동 코드량 증가
1.4 언제 어떤 방식을 사용할까?
┌─────────────────────────────────────────────────────────────────┐ │ 선택 가이드 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ✅ 결제위젯 선택: │ │ - 일반 결제만 필요 │ │ - 빠른 연동이 목표 │ │ - 토스페이먼츠 UI를 그대로 사용해도 됨 │ │ │ │ ✅ API 개별 연동 선택: │ │ - 자동결제(빌링/정기결제) 필요 │ │ - 결제 UI를 브랜드에 맞게 커스터마이징 │ │ - 특정 결제수단만 지원 │ │ - 향후 구독 서비스 확장 예정 │ │ │ └─────────────────────────────────────────────────────────────────┘
2. 사전 준비
2.1 토스페이먼츠 가입 및 키 발급
- 토스페이먼츠 개발자센터 가입
- API 키 메뉴에서 API 개별 연동 키 확인:
- 클라이언트 키:test_ck_xxxxxxxx(프론트엔드)
- 시크릿 키:test_sk_xxxxxxxx(백엔드)
- 웹훅 시크릿: 64자리 hex 문자열 (웹훅 검증)
2.2 환경 변수 설정
Backend (.env)
# 토스페이먼츠 API 개별 연동 키 TOSS_SECRET_KEY=test_sk_xxxxxxxxxxxxxxxx TOSS_WEBHOOK_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Frontend (.env.local)
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxx
2.3 SDK 설치
Frontend
npm install @tosspayments/tosspayments-sdk
Backend
npm install axios
3. 프로젝트 구조
3.1 Backend 구조
backend/src/ ├── payment/ │ ├── routes/ │ │ └── payment.routes.ts # 라우트 정의 │ ├── controllers/ │ │ ├── order.controller.ts # 주문 생성 │ │ ├── payment.controller.ts # 결제 승인 │ │ └── webhook.controller.ts # 웹훅 처리 │ ├── services/ │ │ ├── order.service.ts # 주문 비즈니스 로직 │ │ ├── payment.service.ts # 결제 비즈니스 로직 │ │ └── toss.service.ts # 토스페이먼츠 API 호출 │ └── repositories/ │ ├── order.repository.ts # 주문 DB 접근 │ └── payment.repository.ts # 결제 DB 접근
3.2 Frontend 구조
frontend/src/ ├── app/ │ ├── checkout/ │ │ └── page.tsx # 결제 페이지 │ └── payment/ │ ├── success/page.tsx # 결제 성공 처리 │ ├── fail/page.tsx # 결제 실패 처리 │ └── complete/page.tsx # 결제 완료 안내 ├── lib/ │ └── payment-api.ts # 결제 API 클라이언트 └── types/ └── payment.ts # 결제 타입 정의
4. Backend 구현
4.1 DB 스키마
-- 주문 테이블 (결제 요청 전 생성) CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) UNIQUE NOT NULL, -- 토스페이먼츠 orderId user_id INT NOT NULL, -- 구매자 ID order_name VARCHAR(100) NOT NULL, -- 주문명 amount INT NOT NULL, -- 결제 금액 status ENUM('pending', 'paid', 'failed', 'cancelled') DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NULL, -- 주문 만료 시각 (30분) INDEX idx_order_id (order_id), INDEX idx_user (user_id) ); -- 결제 이력 테이블 CREATE TABLE payment_history ( id INT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL, user_id INT NOT NULL, payment_key VARCHAR(200) UNIQUE NOT NULL, -- 토스페이먼츠 paymentKey method VARCHAR(50) NOT NULL, -- 결제수단 amount INT NOT NULL, -- 결제 금액 balance_amount INT NOT NULL, -- 취소 가능 잔액 status ENUM('DONE', 'CANCELED', 'PARTIAL_CANCELED') NOT NULL, approved_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_id), INDEX idx_user (user_id), INDEX idx_payment_key (payment_key) );
4.2 토스페이먼츠 API 서비스
// backend/src/payment/services/toss.service.ts import axios, { AxiosError } from 'axios'; const TOSS_API_BASE = 'https://api.tosspayments.com/v1'; const SECRET_KEY = process.env.TOSS_SECRET_KEY || ''; /** * 시크릿 키 인코딩 * Base64("{secretKey}:") 형식 - 콜론(:) 필수! */ export function encodeSecretKey(secretKey: string): string { return Buffer.from(`${secretKey}:`).toString('base64'); } /** * Authorization 헤더 생성 */ function getAuthHeader(): string { return `Basic ${encodeSecretKey(SECRET_KEY)}`; } /** * 토스페이먼츠 API 에러 타입 */ export interface TossApiError { code: string; message: string; } /** * 결제 승인 응답 타입 */ export interface TossPaymentResponse { paymentKey: string; orderId: string; orderName: string; status: 'DONE' | 'CANCELED' | 'PARTIAL_CANCELED' | 'ABORTED' | 'EXPIRED'; totalAmount: number; balanceAmount: number; method: string; requestedAt: string; approvedAt: string | null; card?: { cardCompany: string; number: string; installmentPlanMonths: number; approveNo: string; }; easyPay?: { provider: string; amount: number; }; cancels?: Array<{ cancelAmount: number; cancelReason: string; canceledAt: string; }>; } export class TossService { /** * 결제 승인 API 호출 * POST /v1/payments/confirm */ async confirmPayment(params: { paymentKey: string; orderId: string; amount: number; }): Promise<TossPaymentResponse> { try { const response = await axios.post<TossPaymentResponse>( `${TOSS_API_BASE}/payments/confirm`, params, { headers: { Authorization: getAuthHeader(), 'Content-Type': 'application/json', }, } ); return response.data; } catch (error) { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<TossApiError>; const tossError = axiosError.response?.data; throw { status: axiosError.response?.status || 502, code: tossError?.code || 'TOSS_API_ERROR', message: tossError?.message || '토스페이먼츠 API 호출에 실패했습니다', }; } throw error; } } /** * 결제 취소 API 호출 * POST /v1/payments/{paymentKey}/cancel */ async cancelPayment( paymentKey: string, params: { cancelReason: string; cancelAmount?: number; } ): Promise<TossPaymentResponse> { try { const response = await axios.post<TossPaymentResponse>( `${TOSS_API_BASE}/payments/${paymentKey}/cancel`, params, { headers: { Authorization: getAuthHeader(), 'Content-Type': 'application/json', }, } ); return response.data; } catch (error) { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<TossApiError>; const tossError = axiosError.response?.data; throw { status: axiosError.response?.status || 502, code: tossError?.code || 'TOSS_API_ERROR', message: tossError?.message || '결제 취소에 실패했습니다', }; } throw error; } } /** * 결제 조회 API 호출 * GET /v1/payments/{paymentKey} */ async getPayment(paymentKey: string): Promise<TossPaymentResponse> { try { const response = await axios.get<TossPaymentResponse>( `${TOSS_API_BASE}/payments/${paymentKey}`, { headers: { Authorization: getAuthHeader(), }, } ); return response.data; } catch (error) { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<TossApiError>; const tossError = axiosError.response?.data; throw { status: axiosError.response?.status || 502, code: tossError?.code || 'TOSS_API_ERROR', message: tossError?.message || '결제 정보 조회에 실패했습니다', }; } throw error; } } } export const tossService = new TossService();
4.3 주문 생성 API
// backend/src/payment/controllers/order.controller.ts import { Request, Response } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { query, run } from '@/config/database'; interface CreateOrderRequest { planId: number; } /** * 주문 생성 * POST /api/orders */ export async function createOrderHandler(req: Request, res: Response): Promise<void> { try { const userId = req.user?.sub; if (!userId) { res.status(401).json({ message: '로그인이 필요합니다' }); return; } const { planId } = req.body as CreateOrderRequest; if (!planId) { res.status(400).json({ message: 'planId는 필수입니다' }); return; } // 요금제 조회 const plan = await query<any>( 'SELECT * FROM pricing_plans WHERE id = ? AND is_active = 1', [planId] ); if (plan.length === 0) { res.status(404).json({ message: '요금제를 찾을 수 없습니다' }); return; } const selectedPlan = plan[0]; // 사용자 정보 조회 const users = await query<any>( 'SELECT email, nickname FROM auth_users WHERE id = ?', [userId] ); if (users.length === 0) { res.status(404).json({ message: '사용자를 찾을 수 없습니다' }); return; } const user = users[0]; // 주문 ID 생성 (UUID 기반) const orderId = `order_${uuidv4().replace(/-/g, '')}`; const orderName = `${selectedPlan.name} ${selectedPlan.credits}회`; const amount = selectedPlan.price; // 주문 만료 시간 (30분 후) const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 주문 저장 await run( `INSERT INTO orders (order_id, user_id, plan_id, order_name, amount, credits, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [orderId, userId, planId, orderName, amount, selectedPlan.credits, expiresAt] ); res.status(201).json({ orderId, orderName, amount, credits: selectedPlan.credits, customerEmail: user.email || '', customerName: user.nickname || '고객', }); } catch (error: any) { console.error('Create order error:', error); res.status(error.status || 500).json({ message: error.message || '주문 생성 중 오류가 발생했습니다', }); } }
4.4 결제 승인 API
// backend/src/payment/controllers/payment.controller.ts import { Request, Response } from 'express'; import { query, queryOne, run, getConnection } from '@/config/database'; import { tossService } from '../services/toss.service'; interface ConfirmPaymentRequest { orderId: string; paymentKey: string; amount: number; } /** * 결제 승인 * POST /api/payments/confirm * * 중요: 금액 검증 필수! (금액 조작 방지) */ export async function confirmPaymentHandler(req: Request, res: Response): Promise<void> { try { const userId = req.user?.sub; if (!userId) { res.status(401).json({ message: '로그인이 필요합니다' }); return; } const { orderId, paymentKey, amount } = req.body as ConfirmPaymentRequest; // 1. 주문 조회 const order = await queryOne<any>( 'SELECT * FROM orders WHERE order_id = ?', [orderId] ); if (!order) { res.status(404).json({ message: '주문 정보를 찾을 수 없습니다' }); return; } // 2. 본인 주문 확인 if (order.user_id !== userId) { res.status(403).json({ message: '본인의 주문만 결제할 수 있습니다' }); return; } // 3. 주문 상태 확인 if (order.status !== 'pending') { res.status(400).json({ message: '이미 처리된 주문입니다' }); return; } // 4. ⚠️ 금액 검증 (CRITICAL: 금액 조작 방지) if (order.amount !== amount) { console.error(`Amount mismatch! Order: ${order.amount}, Request: ${amount}`); res.status(400).json({ message: '결제 금액이 일치하지 않습니다', code: 'AMOUNT_MISMATCH', }); return; } // 5. 토스페이먼츠 결제 승인 API 호출 let paymentResult; try { paymentResult = await tossService.confirmPayment({ paymentKey, orderId, amount, }); } catch (error: any) { // 토스 API 오류 시 주문 상태 업데이트 await run( 'UPDATE orders SET status = ? WHERE order_id = ?', ['failed', orderId] ); res.status(error.status || 502).json({ message: error.message || '결제 승인에 실패했습니다', code: error.code || 'TOSS_API_ERROR', }); return; } // 6. 트랜잭션: 주문 업데이트 + 결제 이력 저장 + 크레딧 지급 const connection = await getConnection(); await connection.beginTransaction(); try { // 주문 상태 업데이트 await connection.execute( 'UPDATE orders SET status = ? WHERE order_id = ?', ['paid', orderId] ); // 결제 이력 저장 await connection.execute( `INSERT INTO payment_history (order_id, user_id, payment_key, method, amount, balance_amount, credits_granted, status, approved_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ orderId, userId, paymentKey, paymentResult.method, paymentResult.totalAmount, paymentResult.balanceAmount, order.credits, paymentResult.status, paymentResult.approvedAt, ] ); // 크레딧 지급 (예: user_subscriptions 테이블 업데이트) // 이 부분은 서비스 로직에 맞게 구현 await connection.commit(); res.status(200).json({ success: true, payment: { paymentKey, orderId, amount: paymentResult.totalAmount, method: paymentResult.method, status: paymentResult.status, approvedAt: paymentResult.approvedAt, }, credits: { granted: order.credits, }, }); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error: any) { console.error('Confirm payment error:', error); res.status(error.status || 500).json({ message: error.message || '결제 승인 중 오류가 발생했습니다', }); } }
4.5 라우트 설정
// backend/src/payment/routes/payment.routes.ts import { Router } from 'express'; import { requireAuth } from '@/middleware/auth'; import { createOrderHandler } from '../controllers/order.controller'; import { confirmPaymentHandler, getMyPaymentHistoryHandler } from '../controllers/payment.controller'; const router = Router(); // 주문 생성 (로그인 필수) router.post('/orders', requireAuth, createOrderHandler); // 결제 승인 (로그인 필수) router.post('/payments/confirm', requireAuth, confirmPaymentHandler); // 결제 이력 조회 (로그인 필수) router.get('/payments/history', requireAuth, getMyPaymentHistoryHandler); export default router;
5. Frontend 구현
5.1 타입 정의
// frontend/src/types/payment.ts // 주문 생성 요청 export interface CreateOrderRequest { planId: number; } // 주문 생성 응답 export interface CreateOrderResponse { orderId: string; orderName: string; amount: number; credits: number; customerEmail: string; customerName: string; } // 결제 승인 요청 export interface ConfirmPaymentRequest { orderId: string; paymentKey: string; amount: number; } // 결제 승인 응답 export interface ConfirmPaymentResponse { success: boolean; payment: { paymentKey: string; orderId: string; amount: number; method: string; status: string; approvedAt: string; }; credits: { granted: number; }; }
5.2 API 클라이언트
// frontend/src/lib/payment-api.ts import { api } from './api'; import type { CreateOrderRequest, CreateOrderResponse, ConfirmPaymentRequest, ConfirmPaymentResponse, } from '@/types/payment'; /** * 주문 생성 */ export async function createOrder( request: CreateOrderRequest ): Promise<CreateOrderResponse> { return api.post('/api/orders', request); } /** * 결제 승인 */ export async function confirmPayment( request: ConfirmPaymentRequest ): Promise<ConfirmPaymentResponse> { return api.post('/api/payments/confirm', request); }
5.3 결제 페이지 (Checkout)
// frontend/src/app/checkout/page.tsx 'use client'; import { useEffect, useState, useRef, Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { loadTossPayments } from '@tosspayments/tosspayments-sdk'; import { createOrder } from '@/lib/payment-api'; import type { CreateOrderResponse } from '@/types/payment'; const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!; // 결제수단 타입 type PaymentMethod = 'CARD' | 'TRANSFER' | 'VIRTUAL_ACCOUNT' | 'MOBILE_PHONE'; // 결제수단 목록 const PAYMENT_METHODS: { value: PaymentMethod; label: string; icon: string }[] = [ { value: 'CARD', label: '신용/체크카드', icon: '💳' }, { value: 'TRANSFER', label: '계좌이체', icon: '🏦' }, { value: 'VIRTUAL_ACCOUNT', label: '가상계좌', icon: '📋' }, { value: 'MOBILE_PHONE', label: '휴대폰 결제', icon: '📱' }, ]; function CheckoutContent() { const searchParams = useSearchParams(); const router = useRouter(); const planId = searchParams.get('planId'); const [isLoading, setIsLoading] = useState(true); const [isProcessing, setIsProcessing] = useState(false); const [order, setOrder] = useState<CreateOrderResponse | null>(null); const [error, setError] = useState(''); const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>('CARD'); const [agreedToTerms, setAgreedToTerms] = useState(false); const isInitialized = useRef(false); // 1. 주문 생성 useEffect(() => { if (!planId) { setError('요금제를 선택해주세요.'); setIsLoading(false); return; } // 로그인 체크 const token = localStorage.getItem('accessToken'); if (!token) { sessionStorage.setItem('redirectAfterLogin', `/checkout?planId=${planId}`); router.push('/login'); return; } if (isInitialized.current) return; isInitialized.current = true; const initializeOrder = async () => { try { const orderData = await createOrder({ planId: Number(planId) }); setOrder(orderData); } catch (err: unknown) { console.error('주문 생성 실패:', err); setError(err instanceof Error ? err.message : '주문 생성에 실패했습니다.'); } finally { setIsLoading(false); } }; initializeOrder(); }, [planId, router]); // 2. 결제 요청 const handlePayment = async () => { if (!order || !agreedToTerms) return; setIsProcessing(true); try { // 토스페이먼츠 SDK 초기화 const tossPayments = await loadTossPayments(clientKey); // API 개별 연동: payment() 사용 const customerKey = `customer_${order.orderId}`; const payment = tossPayments.payment({ customerKey }); // 결제창 호출 await payment.requestPayment({ method: selectedMethod, amount: { currency: 'KRW', value: order.amount, }, orderId: order.orderId, orderName: order.orderName, successUrl: `${window.location.origin}/payment/success`, failUrl: `${window.location.origin}/payment/fail`, customerEmail: order.customerEmail, customerName: order.customerName, // 카드 결제 옵션 ...(selectedMethod === 'CARD' && { card: { useEscrow: false, flowMode: 'DEFAULT', useCardPoint: false, }, }), // 가상계좌 옵션 ...(selectedMethod === 'VIRTUAL_ACCOUNT' && { virtualAccount: { cashReceipt: { type: '소득공제' }, useEscrow: false, validHours: 24, }, }), }); } catch (err: unknown) { console.error('결제 요청 실패:', err); setIsProcessing(false); const errorWithCode = err as { code?: string; message?: string }; // PAY_PROCESS_CANCELED: 사용자가 결제창 닫음 if (errorWithCode.code !== 'PAY_PROCESS_CANCELED') { setError(errorWithCode.message || '결제 요청 중 오류가 발생했습니다.'); } } }; // 로딩 상태 if (isLoading) { return ( <div className="flex items-center justify-center min-h-screen"> <p>주문 정보를 불러오는 중...</p> </div> ); } // 에러 상태 if (error) { return ( <div className="flex flex-col items-center justify-center min-h-screen p-4"> <p className="text-red-500 mb-4">{error}</p> <Link href="/pricing" className="text-blue-500"> 요금제 선택으로 돌아가기 </Link> </div> ); } return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-xl font-bold mb-6">결제하기</h1> {/* 주문 정보 */} {order && ( <div className="bg-gray-50 p-4 rounded-lg mb-4"> <p className="text-sm text-gray-500">주문 상품</p> <p className="font-semibold">{order.orderName}</p> <p className="text-xl font-bold text-blue-600 mt-2"> {order.amount.toLocaleString()}원 </p> </div> )} {/* 결제 수단 선택 */} <div className="bg-white border rounded-lg p-4 mb-4"> <p className="font-semibold mb-3">결제 수단 선택</p> <div className="grid grid-cols-2 gap-2"> {PAYMENT_METHODS.map((method) => ( <button key={method.value} onClick={() => setSelectedMethod(method.value)} className={`p-3 rounded-lg border text-left ${ selectedMethod === method.value ? 'border-blue-500 bg-blue-50' : 'border-gray-200' }`} > <span className="text-lg">{method.icon}</span> <p className="text-sm mt-1">{method.label}</p> </button> ))} </div> </div> {/* 약관 동의 */} <div className="bg-white border rounded-lg p-4 mb-4"> <label className="flex items-start gap-3 cursor-pointer"> <input type="checkbox" checked={agreedToTerms} onChange={(e) => setAgreedToTerms(e.target.checked)} className="mt-1" /> <div> <p className="font-medium">결제 진행에 동의합니다</p> <p className="text-sm text-gray-500"> 결제 정보 확인 및 개인정보 제3자 제공에 동의합니다. </p> </div> </label> </div> {/* 결제 버튼 */} <button onClick={handlePayment} disabled={!agreedToTerms || isProcessing} className={`w-full py-4 rounded-lg font-semibold ${ agreedToTerms && !isProcessing ? 'bg-blue-500 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed' }`} > {isProcessing ? '결제 진행 중...' : `${order?.amount.toLocaleString()}원 결제하기`} </button> </div> ); } export default function CheckoutPage() { return ( <Suspense fallback={<div>로딩 중...</div>}> <CheckoutContent /> </Suspense> ); }
5.4 결제 성공 페이지
// frontend/src/app/payment/success/page.tsx 'use client'; import { useEffect, useState, Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { confirmPayment } from '@/lib/payment-api'; function PaymentSuccessContent() { const searchParams = useSearchParams(); const router = useRouter(); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [error, setError] = useState(''); useEffect(() => { const processPayment = async () => { const orderId = searchParams.get('orderId'); const paymentKey = searchParams.get('paymentKey'); const amount = searchParams.get('amount'); if (!orderId || !paymentKey || !amount) { setStatus('error'); setError('결제 정보가 올바르지 않습니다.'); return; } try { // 서버에 결제 승인 요청 await confirmPayment({ orderId, paymentKey, amount: Number(amount), }); setStatus('success'); // 3초 후 완료 페이지로 이동 setTimeout(() => { router.push(`/payment/complete?orderId=${orderId}`); }, 3000); } catch (err: unknown) { setStatus('error'); setError(err instanceof Error ? err.message : '결제 승인에 실패했습니다.'); } }; processPayment(); }, [searchParams, router]); if (status === 'loading') { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <div className="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full" /> <p className="mt-4">결제를 확인하고 있습니다...</p> </div> ); } if (status === 'error') { return ( <div className="flex flex-col items-center justify-center min-h-screen p-4"> <p className="text-xl font-bold text-red-500 mb-2">결제 확인 실패</p> <p className="text-gray-600 mb-4">{error}</p> <button onClick={() => router.push('/pricing')} className="px-6 py-2 bg-gray-200 rounded-lg" > 돌아가기 </button> </div> ); } return ( <div className="flex flex-col items-center justify-center min-h-screen"> <div className="text-green-500 text-4xl mb-4">✓</div> <p className="text-xl font-bold">결제가 완료되었습니다</p> <p className="text-gray-600 mt-2">잠시 후 이동합니다...</p> </div> ); } export default function PaymentSuccessPage() { return ( <Suspense fallback={<div>로딩 중...</div>}> <PaymentSuccessContent /> </Suspense> ); }
5.5 결제 실패 페이지
// frontend/src/app/payment/fail/page.tsx 'use client'; import { Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; function PaymentFailContent() { const searchParams = useSearchParams(); const code = searchParams.get('code') || 'UNKNOWN_ERROR'; const message = searchParams.get('message') || '결제에 실패했습니다.'; return ( <div className="flex flex-col items-center justify-center min-h-screen p-4"> <div className="text-red-500 text-4xl mb-4">✕</div> <p className="text-xl font-bold mb-2">결제 실패</p> <p className="text-gray-600 text-center mb-2">{message}</p> <p className="text-sm text-gray-400 mb-6">오류 코드: {code}</p> <Link href="/pricing" className="px-6 py-2 bg-blue-500 text-white rounded-lg" > 다시 시도하기 </Link> </div> ); } export default function PaymentFailPage() { return ( <Suspense fallback={<div>로딩 중...</div>}> <PaymentFailContent /> </Suspense> ); }
6. 테스트
6.1 테스트 카드 정보
| 항목 | 값 |
|---|---|
| 카드 번호 | 4330000000000000 |
| 유효기간 | 12/25 (미래 날짜) |
| CVC | 123 |
| 비밀번호 앞 2자리 | 00 |
| 생년월일 | 940101 |
6.2 테스트 플로우
1. /pricing 페이지에서 요금제 선택 2. /checkout 페이지로 이동 3. 결제수단 선택 (카드) 4. 약관 동의 체크 5. [결제하기] 버튼 클릭 6. 토스페이먼츠 결제창에서 테스트 카드 정보 입력 7. /payment/success 페이지에서 결제 승인 처리 8. /payment/complete 페이지로 이동
6.3 결제 확인
- 토스페이먼츠 개발자센터 > 결제내역 > 테스트 모드
- paymentKey 또는 orderId로 검색
7. 프로덕션 배포
7.1 라이브 키로 변경
# Backend (.env) TOSS_SECRET_KEY=live_sk_xxxxxxxxxxxxxxxx # Frontend (.env.local) NEXT_PUBLIC_TOSS_CLIENT_KEY=live_ck_xxxxxxxxxxxxxxxx
7.2 체크리스트
- 라이브 키 설정
- 웹훅 URL 등록 (상점관리자)
- 정산 계좌 등록
- 실제 결제 테스트 (소액)
- 환불 테스트
참고 자료
김성박
댓글
댓글을 작성하려면 이 필요합니다.