왜 토스페이먼츠인가?

국내 서비스를 개발하다 보면 결제 연동은 피할 수 없는 관문입니다. KG이니시스, NHN KCP, 페이플 등 여러 PG사가 있지만, 최근 개발자 경험(DX) 면에서 토스페이먼츠가 압도적으로 앞서 나가고 있습니다. 깔끔한 REST API, 풍부한 공식 문서, 그리고 실제로 작동하는 샌드박스 환경은 1인 개발자나 스타트업이 빠르게 결제를 붙이기에 최적입니다.
이 글에서는 SvelteKit 프론트엔드 + FastAPI 백엔드 구조에서 토스페이먼츠 결제위젯을 연동한 실제 경험을 공유합니다. 단순한 공식 문서 번역이 아니라, 실제로 마주쳤던 SSR 이슈, 웹훅 처리, 환불 흐름까지 빠짐없이 다룹니다.
전체 결제 플로우 이해하기
구현에 들어가기 전에 토스페이먼츠의 결제 흐름을 정확히 이해하는 것이 중요합니다. 토스페이먼츠는 2단계 승인 방식을 사용합니다.
- 1단계 — 결제 요청(클라이언트): 사용자가 결제 수단을 선택하고 결제 버튼을 누르면, 토스페이먼츠 SDK가 결제창을 띄웁니다. 결제가 완료되면 토스 서버에서
paymentKey,orderId,amount를 success URL로 리다이렉트합니다. - 2단계 — 결제 승인(서버): 클라이언트에서 받은 세 값을 백엔드로 전달하고, 백엔드가 토스 서버의
/v1/payments/confirmAPI를 호출하여 최종 승인합니다. 이 단계가 없으면 실제 결제가 이루어지지 않습니다.
이 2단계 구조 때문에 클라이언트 단독으로는 절대 결제를 완료할 수 없고, 반드시 백엔드가 필요합니다. FastAPI가 이 역할을 맡습니다.
SvelteKit에서 결제위젯 초기화 — SSR 이슈 해결
가장 먼저 부딪히는 문제는 SSR(Server-Side Rendering)입니다. SvelteKit은 기본적으로 페이지를 서버에서 렌더링하는데, 토스페이먼츠 SDK는 브라우저 환경(window 객체)을 필요로 합니다. 서버에서 SDK를 로드하려 하면 window is not defined 에러가 발생합니다.
해결책은 onMount와 동적 import를 활용하는 것입니다.
<!-- src/routes/checkout/+page.svelte -->
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let paymentWidget = null;
let paymentMethodsWidget = null;
const ORDER_ID = crypto.randomUUID(); // 고유 주문 ID
const AMOUNT = 50000;
onMount(async () => {
// 브라우저에서만 SDK 로드 (SSR 방지)
const { loadTossPayments } = await import('@tosspayments/payment-widget-sdk');
paymentWidget = await loadTossPayments(import.meta.env.VITE_TOSS_CLIENT_KEY);
// 결제 수단 위젯 렌더링
paymentMethodsWidget = await paymentWidget.renderPaymentMethods(
'#payment-widget',
{ value: AMOUNT },
{ variantKey: 'DEFAULT' }
);
// 약관 동의 위젯 렌더링
await paymentWidget.renderAgreement('#agreement-widget');
});
async function handlePayment() {
await paymentWidget.requestPayment({
orderId: ORDER_ID,
orderName: '프리미엄 구독 1개월',
customerName: '홍길동',
customerEmail: 'user@example.com',
successUrl: `${window.location.origin}/checkout/success`,
failUrl: `${window.location.origin}/checkout/fail`,
});
}
</script>
<div id="payment-widget"></div>
<div id="agreement-widget"></div>
<button on:click={handlePayment}>결제하기</button>
loadTossPayments를 최상단 import가 아닌 onMount 내부에서 동적으로 불러오는 것이 핵심입니다. 이렇게 하면 서버에서는 이 코드가 실행되지 않으므로 SSR 에러를 완전히 피할 수 있습니다.
결제 성공 페이지에서 백엔드 승인 요청
결제가 완료되면 토스 서버는 ?paymentKey=...&orderId=...&amount=... 쿼리 파라미터와 함께 success URL로 리다이렉트합니다. 이 값들을 받아서 즉시 백엔드에 승인 요청을 보내야 합니다.
<!-- src/routes/checkout/success/+page.svelte -->
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let status = 'loading'; // loading | success | error
let errorMsg = '';
onMount(async () => {
const { paymentKey, orderId, amount } = $page.url.searchParams;
try {
const res = await fetch('/api/payments/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentKey, orderId, amount: Number(amount) }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || '결제 승인 실패');
}
status = 'success';
} catch (e) {
status = 'error';
errorMsg = e.message;
}
});
</script>
{#if status === 'loading'}
<p>결제를 처리 중입니다...</p>
{:else if status === 'success'}
<h1>결제 완료!</h1>
<p>주문이 성공적으로 처리되었습니다.</p>
{:else}
<h1>결제 실패</h1>
<p>{errorMsg}</p>
{/if}
FastAPI 백엔드 — 결제 승인 API 구현
이제 FastAPI 측에서 실제 결제 승인을 처리하는 코드를 작성합니다. 토스페이먼츠 결제 승인 API는 시크릿 키를 Base64 인코딩하여 Basic Auth로 호출합니다.
# app/routers/payments.py
import base64
import httpx
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Order
router = APIRouter(prefix="/api/payments", tags=["payments"])
TOSS_SECRET_KEY = "test_sk_..." # 환경변수에서 읽는 것을 권장
TOSS_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"
class ConfirmRequest(BaseModel):
paymentKey: str
orderId: str
amount: int
def get_toss_auth_header(secret_key: str) -> str:
"""토스페이먼츠 Basic Auth 헤더 생성"""
encoded = base64.b64encode(f"{secret_key}:".encode()).decode()
return f"Basic {encoded}"
@router.post("/confirm")
async def confirm_payment(
body: ConfirmRequest,
db: AsyncSession = Depends(get_db)
):
# 1. DB에서 주문 검증 (금액 위변조 방지)
order = await db.get(Order, body.orderId)
if not order:
raise HTTPException(status_code=404, detail="주문을 찾을 수 없습니다")
if order.amount != body.amount:
raise HTTPException(status_code=400, detail="결제 금액이 일치하지 않습니다")
# 2. 토스 결제 승인 API 호출
async with httpx.AsyncClient() as client:
response = await client.post(
TOSS_CONFIRM_URL,
headers={
"Authorization": get_toss_auth_header(TOSS_SECRET_KEY),
"Content-Type": "application/json",
},
json={
"paymentKey": body.paymentKey,
"orderId": body.orderId,
"amount": body.amount,
},
)
if response.status_code != 200:
error_data = response.json()
raise HTTPException(
status_code=400,
detail=error_data.get("message", "결제 승인 실패")
)
# 3. 주문 상태 업데이트
payment_data = response.json()
order.status = "paid"
order.payment_key = body.paymentKey
await db.commit()
return {"success": True, "paymentKey": body.paymentKey}
주의할 점은 반드시 DB에서 주문 금액을 검증해야 한다는 것입니다. 클라이언트에서 넘어온 amount를 그대로 믿으면 금액을 임의로 바꾼 요청이 통과될 수 있습니다. 항상 서버의 원본 주문 금액과 대조하세요.
웹훅(Webhook) 처리 — 안전한 비동기 결제 확인
결제 승인 API 외에도 웹훅 처리는 반드시 구현해야 합니다. 사용자 네트워크 단절, 브라우저 강제 종료 등의 상황에서 클라이언트가 success URL에 도달하지 못할 수 있습니다. 이때 토스 서버는 설정된 웹훅 URL로 결제 완료 이벤트를 직접 전송합니다.
# app/routers/webhook.py
import hmac
import hashlib
from fastapi import APIRouter, Request, HTTPException
from app.services.payment_service import process_payment_completion
router = APIRouter(prefix="/api", tags=["webhook"])
@router.post("/webhook/toss")
async def toss_webhook(request: Request):
body = await request.body()
payload = await request.json()
event_type = payload.get("eventType")
data = payload.get("data", {})
if event_type == "PAYMENT_STATUS_CHANGED":
payment_status = data.get("status")
order_id = data.get("orderId")
payment_key = data.get("paymentKey")
if payment_status == "DONE":
# 결제 완료 처리 (멱등성 보장 — 중복 처리 방지)
await process_payment_completion(order_id, payment_key)
elif payment_status == "CANCELED":
# 결제 취소 처리
await process_payment_cancellation(order_id)
# 토스에게 200 OK를 즉시 반환해야 함
return {"received": True}
async def process_payment_completion(order_id: str, payment_key: str):
"""멱등성을 보장하는 결제 완료 처리"""
# DB에서 이미 처리된 주문인지 확인
order = await Order.get_by_id(order_id)
if order and order.status == "paid":
return # 이미 처리됨 — 스킵
# 주문 상태 업데이트
await Order.update_status(order_id, "paid", payment_key)
웹훅 엔드포인트는 반드시 200 OK를 빠르게 반환해야 합니다. 토스 서버는 타임아웃 내에 응답이 없으면 웹훅을 재전송하므로, 무거운 작업은 백그라운드 태스크로 처리하고 응답을 먼저 반환하는 것이 좋습니다.
또한 멱등성(Idempotency)을 반드시 구현해야 합니다. 네트워크 이슈로 웹훅이 여러 번 전송될 수 있으므로, 이미 처리된 주문을 다시 처리하지 않도록 DB에서 상태를 확인합니다.
환불(결제 취소) API 구현
결제만큼 중요한 것이 환불입니다. 토스페이먼츠는 전액 취소와 부분 취소 모두 지원하며, FastAPI에서 간단히 구현할 수 있습니다.
# app/routers/refund.py
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
import httpx
router = APIRouter(prefix="/api/payments", tags=["refund"])
class RefundRequest(BaseModel):
paymentKey: str
cancelReason: str
cancelAmount: Optional[int] = None # None이면 전액 취소
@router.post("/cancel")
async def cancel_payment(body: RefundRequest):
cancel_payload = {"cancelReason": body.cancelReason}
# 부분 취소인 경우 금액 포함
if body.cancelAmount is not None:
cancel_payload["cancelAmount"] = body.cancelAmount
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.tosspayments.com/v1/payments/{body.paymentKey}/cancel",
headers={
"Authorization": get_toss_auth_header(TOSS_SECRET_KEY),
"Content-Type": "application/json",
},
json=cancel_payload,
)
if response.status_code != 200:
error = response.json()
raise HTTPException(
status_code=400,
detail=error.get("message", "환불 처리 실패")
)
return response.json()
부분 취소를 사용할 때는 주의할 점이 있습니다. 토스페이먼츠는 취소 가능 잔액을 추적하므로, 이미 취소된 금액을 초과하는 부분 취소 요청은 에러를 반환합니다. 취소 이력을 DB에 관리하고, 취소 가능 잔액을 계산해서 검증하는 로직을 추가하는 것을 권장합니다.
샌드박스 테스트 환경 설정
실제 돈을 쓰지 않고 테스트하려면 토스페이먼츠 개발자 센터에서 발급받은 테스트 키를 사용합니다. 테스트 키는 test_로 시작합니다.
SvelteKit의 .env 파일에 클라이언트 키를, FastAPI의 환경변수에 시크릿 키를 분리해서 관리하세요.
# .env (SvelteKit)
VITE_TOSS_CLIENT_KEY=test_ck_D5GePWvyJnrK0W0k6q8gLzN97Eoq
# .env (FastAPI)
TOSS_SECRET_KEY=test_sk_zXLkKEypNArWmo50nX3lmeaxYG5R
샌드박스 환경에서는 특정 카드 번호로 성공/실패 시나리오를 테스트할 수 있습니다. 토스페이먼츠 테스트 페이지에서 제공하는 테스트 카드 번호 목록을 참고하면 다양한 결제 상황을 시뮬레이션할 수 있습니다.
자주 만나는 에러와 해결법
실제 연동 과정에서 자주 마주치는 에러들을 정리했습니다.
- ALREADY_PROCESSED_PAYMENT: 같은
orderId로 이미 승인된 결제입니다. 결제마다 고유한orderId를 생성해야 합니다.crypto.randomUUID()를 사용하세요. - INVALID_STOPPED_CARD: 사용 중지된 카드입니다. 테스트 환경에서는 토스 제공 테스트 카드를 사용하세요.
- UNAUTHORIZED_KEY: 클라이언트 키와 시크릿 키를 혼용했을 때 발생합니다. 프론트엔드에는 반드시 클라이언트 키(
ck_), 백엔드에는 시크릿 키(sk_)를 사용하세요. - SvelteKit SSR 빌드 에러:
@tosspayments/payment-widget-sdk모듈이 서버에서 임포트될 때 발생합니다. 반드시onMount내부에서 동적 import를 사용하세요.
마치며
토스페이먼츠는 국내 PG사 중에서 가장 개발자 친화적인 서비스입니다. 공식 문서가 잘 정리되어 있고, SDK도 TypeScript 기반으로 타입 안전성을 보장합니다. SvelteKit과의 조합에서 SSR 이슈만 잘 해결하면 생각보다 빠르게 결제 시스템을 구축할 수 있습니다.
핵심 체크리스트를 요약하면: ① onMount에서 동적 import로 SSR 회피, ② 백엔드에서 금액 검증 후 2단계 승인, ③ 웹훅으로 결제 완료 이중 보장, ④ 멱등성 구현으로 중복 처리 방지. 이 네 가지만 지켜도 안전하고 견고한 결제 시스템을 만들 수 있습니다.
코드벤터는 1인 개발자와 소규모 팀이 빠르게 제품을 만들어 출시할 수 있도록, 실전에서 검증된 기술 노하우를 꾸준히 공유합니다. 다음 글에서는 AWS Lightsail + GitHub Actions를 활용한 무중단 배포 파이프라인 구축을 다룰 예정입니다.


