토스페이먼츠 완전 연동 가이드: 결제부터 환불 자동화까지
국내 개발자라면 결제 연동 시 가장 먼저 떠오르는 서비스가 바로 토스페이먼츠입니다. 깔끔한 문서, 풍부한 샘플 코드, 그리고 웹훅 자동화까지 갖춘 토스페이먼츠는 스타트업부터 대기업까지 폭넓게 사용됩니다. 이 글에서는 API 키 발급부터 결제 승인, 환불, 웹훅 자동화까지 전체 플로우를 실전 코드와 함께 정리합니다.
1. 시작 전 준비: API 키 발급
토스페이먼츠 개발자센터에 가입하면 두 가지 키를 발급받습니다.
| 키 종류 | 사용 위치 | 주의사항 |
|---|---|---|
| 클라이언트 키 (Client Key) | 프론트엔드 SDK 초기화 | 노출되어도 무방하나 도메인 등록 필수 |
| 시크릿 키 (Secret Key) | 백엔드 API 호출 | 절대 프론트엔드에 노출 금지 |
테스트 환경과 프로덕션 환경 키가 분리되어 있으므로, 개발 중에는 반드시 테스트 키를 사용하세요.
2. 프론트엔드: 결제 위젯 연동
토스페이먼츠 결제 위젯은 SDK를 통해 간단하게 렌더링할 수 있습니다.
// 토스페이먼츠 SDK 로드 (npm install @tosspayments/tosspayments-sdk)
import { loadTossPayments } from "@tosspayments/tosspayments-sdk";
const clientKey = "test_ck_your_client_key";
const tossPayments = await loadTossPayments(clientKey);
// 위젯 초기화
const widgets = tossPayments.widgets({ customerKey: "ANONYMOUS" });
// 결제 금액 설정
await widgets.setAmount({ currency: "KRW", value: 50000 });
// 위젯 렌더링
await Promise.all([
widgets.renderPaymentMethods({
selector: "#payment-method",
variantKey: "DEFAULT",
}),
widgets.renderAgreement({ selector: "#agreement" }),
]);
// 결제 요청 버튼 클릭 시
document.getElementById("pay-btn").addEventListener("click", async () => {
await widgets.requestPayment({
orderId: "order-" + Date.now(),
orderName: "상품명",
successUrl: window.location.origin + "/success",
failUrl: window.location.origin + "/fail",
customerEmail: "customer@example.com",
customerName: "홍길동",
});
});
결제 요청 후 사용자는 토스페이먼츠 결제 UI로 이동하고, 완료되면 successUrl로 리다이렉트됩니다.
3. 백엔드: 결제 승인 처리
프론트엔드에서 넘어온 paymentKey, orderId, amount를 받아 최종 승인을 처리합니다. 금액 위변조 방지를 위해 반드시 백엔드에서 검증해야 합니다.
import httpx
import base64
from fastapi import APIRouter, HTTPException
router = APIRouter()
SECRET_KEY = "test_sk_your_secret_key"
def get_auth_header():
encoded = base64.b64encode(f"{SECRET_KEY}:".encode()).decode()
return {"Authorization": f"Basic {encoded}", "Content-Type": "application/json"}
@router.post("/confirm")
async def confirm_payment(payment_key: str, order_id: str, amount: int):
# DB에서 주문 금액과 비교 검증
order = await get_order_from_db(order_id)
if order["amount"] != amount:
raise HTTPException(status_code=400, detail="금액 불일치")
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.tosspayments.com/v1/payments/confirm",
headers=get_auth_header(),
json={
"paymentKey": payment_key,
"orderId": order_id,
"amount": amount,
},
)
if response.status_code != 200:
error = response.json()
raise HTTPException(status_code=400, detail=error.get("message"))
payment = response.json()
await save_payment_to_db(payment)
return payment
4. 환불(결제 취소) 자동화
환불은 POST /v1/payments/{paymentKey}/cancel 엔드포인트를 사용합니다. 전액 환불과 부분 환불 모두 지원됩니다.
@router.post("/cancel/{payment_key}")
async def cancel_payment(payment_key: str, cancel_amount: int = None, reason: str = "고객 요청"):
body = {"cancelReason": reason}
if cancel_amount:
body["cancelAmount"] = cancel_amount # 없으면 전액 환불
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.tosspayments.com/v1/payments/{payment_key}/cancel",
headers=get_auth_header(),
json=body,
)
if response.status_code != 200:
raise HTTPException(status_code=400, detail=response.json().get("message"))
result = response.json()
latest_cancel = result["cancels"][-1]
return {"status": result["status"], "canceledAt": latest_cancel["canceledAt"]}
환불 처리 시간 비교
| 결제 수단 | 환불 처리 시간 | 비고 |
|---|---|---|
| 신용카드 | 즉시 승인 취소 | 카드사 정책에 따라 청구 취소까지 1~3일 |
| 가상계좌 | 영업일 2일 이내 | 환불 계좌 정보 필수 |
| 계좌이체 | 영업일 2~3일 | 은행 처리 시간 포함 |
| 간편결제 | 즉시~1일 | PG사별 상이 |
5. 웹훅(Webhook)으로 실시간 이벤트 처리
가상계좌 입금 확인, 비동기 결제 완료 등 폴링 없이 실시간 처리가 필요할 때 웹훅을 사용합니다.
from fastapi import Request
import hmac, hashlib
WEBHOOK_SECRET = "your_webhook_secret"
@router.post("/webhook/tosspayments")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("tosspayments-webhook-signature", "")
expected = hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = await request.json()
event_type = payload.get("eventType")
data = payload.get("data", {})
if event_type == "PAYMENT_STATUS_CHANGED":
status = data.get("status")
payment_key = data.get("paymentKey")
if status == "DONE":
await handle_payment_done(payment_key)
elif status in ("CANCELED", "PARTIAL_CANCELED"):
await handle_payment_canceled(payment_key, data)
elif event_type == "DEPOSIT_CALLBACK":
await handle_virtual_account_deposit(data)
return {"ok": True}
웹훅 수신 후 200 응답을 반환해야 합니다. 7회 실패 시 토스페이먼츠가 이메일로 알림을 발송합니다.
6. 멱등성(Idempotency) 처리
네트워크 오류로 인한 중복 요청을 방지하려면 Idempotency-Key 헤더를 사용하세요.
import uuid
async def cancel_with_idempotency(payment_key: str, cancel_amount: int):
idempotency_key = str(uuid.uuid4())
headers = {
**get_auth_header(),
"Idempotency-Key": idempotency_key,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.tosspayments.com/v1/payments/{payment_key}/cancel",
headers=headers,
json={"cancelReason": "중복 결제 환불", "cancelAmount": cancel_amount},
)
return response.json()
동일한 Idempotency-Key로 재요청하면 토스페이먼츠가 기존 응답을 그대로 반환합니다. 유효 기간은 15일입니다.
마무리: 실전 체크리스트
토스페이먼츠 연동 시 반드시 확인해야 할 포인트입니다.
- 백엔드에서 금액 위변조 검증 구현
- 시크릿 키 환경변수 관리 (절대 하드코딩 금지)
- 웹훅 서명 검증 로직 구현
- 멱등성 키로 중복 요청 방지
- 가상계좌 환불 시 환불 계좌 정보 수집 UX 준비
- 테스트 키와 프로덕션 키 분리 관리
코드벤터는 토스페이먼츠를 비롯한 다양한 결제 시스템 연동 경험을 바탕으로, 스타트업과 소규모 프로덕트의 결제 인프라 구축을 지원합니다. 결제 연동이 복잡하게 느껴진다면 코드벤터의 기술 블로그와 오픈소스 예제를 참고하세요. 빠르고 안전한 결제 시스템 구축을 함께 만들어가겠습니다.