20. 연동 적용 가이드 — 구독·단건결제를 내 서비스에 붙이기
이 문서는 이 장과 샘플 서비스 코드만으로 구독/결제를 내 서비스에 적용할 수 있게 쓴 따라 하기 가이드입니다. API 명세는 13. 서비스 API, 서버 내부 동작은 15·16장이 담당하고, 이 장은 "서비스 개발자가 무엇을 어떤 순서로 만들면 되는가"에 집중합니다. 모든 코드는 샘플 서비스(sample_service/)에서 실결제로 검증된 코드를 그대로 옮긴 것입니다.
🍎쉽게 말하면 "내 서비스에 결제를 붙이려면 코드를 어디에 몇 개 만들면 되는가"를 순서대로 알려주는 조립 설명서입니다. 20.2의 인증 클라이언트 하나를 만들고(또는 샘플에서 복사하고), 단건결제는 20.3, 구독은 20.4, 내역·취소는 20.5만 따라 하면 됩니다.
함께 보기: 샘플 서비스의 각 화면 하단에는 같은 내용의 「🧩 바로 적용 가이드」 패널이 있습니다 — 실제로 눌러보며 확인할 때는 화면을, 한 번에 훑을 때는 이 장을 보세요. 19. 샘플 서비스에 화면 캡처가 있습니다. 인증·엔드포인트별 요청/응답·상태코드 분기를 한 페이지로 압축한 레퍼런스는 ⚡ 서비스 연동 가이드(한 페이지)를 보세요(사이드바 「빠른 연동」에도 있습니다).
| 만들 것 | 샘플 화면(가이드 패널) | 샘플 파일 | 이 장 |
|---|---|---|---|
| 결제서버 호출 클라이언트(HMAC) | — | shop/payment_client.py |
20.2 |
| 단건(일반)결제 — 결제창 | /pay |
shop/templates/shop/oneoff.html |
20.3 |
| 카드 보관함(등록·조회·삭제) | /card |
shop/templates/shop/card.html |
20.4.1 |
| 구독 시작(요금제→생성) | /plans |
shop/templates/shop/plans.html |
20.4.2 |
| 구독 관리(취소·재개·수동결제) | /my |
shop/templates/shop/my.html |
20.4.3 |
| 요금제 변경(업/다운) | /plans → /change-plan |
shop/templates/shop/plans.html |
20.4.4 |
| 결제 내역·취소(환불) | /history |
shop/templates/shop/history.html |
20.5 |
20.1 시작 전 준비
- 서비스 등록·키 발급 — 결제서버 어드민에서 서비스를 등록하면
api_key·hmac_secret이 1회 발급됩니다(재발급 가능). 이 두 값은 내 서버에만 보관합니다. - API 클라이언트 확보 — 샘플의
shop/payment_client.py를 그대로 복사하는 것이 가장 빠릅니다(HMAC 3중 서명 포함, Django 의존 없음 —settings폴백 2줄만 교체). Python이 아니라면 20.2의 서명 스펙을 포팅하세요. - 토스
clientKey— 결제창(단건)·카드 등록창(구독)을 여는 데 필요합니다. 테스트 키로 시작하면 되고 브라우저 노출은 안전합니다. 토스secretKey는 결제서버만 가지므로 내 서비스에는 필요 없습니다. - 요금제(구독을 쓸 경우) — 어드민에서 요금제를 미리 생성합니다(체험 요금제 포함 권장).
20.2 공통 — 결제서버 인증(HMAC) 클라이언트
모든 결제서버 호출에는 헤더 4개가 붙습니다. 자세한 명세는 13.2 인증을 보세요.
| 헤더 | 값 |
|---|---|
x-service-key |
서비스 API 키 원문 |
x-timestamp |
Unix 초 — 서버 시각과 ±300초 이내 |
x-nonce |
요청마다 새 랜덤값 — 10분간 재사용 불가 |
x-signature |
아래 정준 문자열의 HMAC-SHA256(hex) |
# 타 언어 포팅용 최소 구현 — 계산식은 언어 무관 (Python 예시)
import hashlib, hmac, json, time, uuid, requests
def call_payment_server(method, path, json_body=None):
body = json.dumps(json_body).encode() if json_body is not None else b""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
message = "\n".join([method.upper(), path, timestamp, nonce,
hashlib.sha256(body).hexdigest()]) # 정준 문자열 5요소
signature = hmac.new(HMAC_SECRET.encode(), message.encode(),
hashlib.sha256).hexdigest()
resp = requests.request(method, PAYMENT_API_BASE + path, data=body or None,
headers={"x-service-key": API_KEY, "x-timestamp": timestamp,
"x-nonce": nonce, "x-signature": signature,
**({"Content-Type": "application/json"} if json_body is not None else {})},
timeout=30)
if resp.status_code >= 400:
err = resp.json()["error"] # {"error": {"code", "message"}}
raise PaymentAPIError(resp.status_code, err["code"], err["message"])
return resp.json()
💬서명이 틀리면 전부 401입니다. 계속 401이면 ① path에 쿼리 포함 여부(쿼리 제외 원본 경로) ② body 재직렬화(전송 바이트 그대로 해시해야 함) ③ 서버 시각 차(±300초) ④ nonce 재사용 순으로 의심하세요.
사용자 식별자 external_user_id는 이메일만 허용됩니다(서버가 소문자·공백 정규화). 이후 코드의 request.user.email이 이 값입니다.
20.3 단건결제(결제창) 적용
구독과 무관한 1회성 결제입니다. 카드 등록이 필요 없고, 매번 토스 결제창에서 인증합니다.
프로세스 — 누가 무엇을 하나 (주문 상태: (없음) → PENDING(prepare) → DONE(confirm) / 이탈 시 35분 후 FAILED(EXPIRED))
| 단계 | 액터 | 하는 일 |
|---|---|---|
| ① 버튼 클릭 | 브라우저 → 내 서버 | 폼 POST — 아직 결제 통신 없음. 금액은 서버 데이터에서 결정 |
| ② prepare | 내 서버 → 결제서버 | POST /api/v1/payments/prepare — 주문 선점(PENDING), toss_order_id 수신. 이때의 amount로만 승인 |
| ③ 결제창 | 브라우저 ↔ 토스 | SDK requestPayment() — 수단 선택·약관·카드사 인증(카드번호는 토스만 취급) |
| ④ confirm | 토스 → 브라우저 → 내 서버 → 결제서버 | successUrl 리다이렉트 → 금액 1차 대조 → 즉시 POST /api/v1/payments/confirm → DONE |
| ⑤ 완료/실패 | 내 서버 → 브라우저 | 완료 화면(영수증) 또는 failUrl 처리 |
라우팅 — 만들 핸들러는 3개입니다: /pay/start(②) · /pay/success(④) · /pay/fail(⑤).
20.3.1 [내 서버] prepare — 주문 선점과 금액 확정
import uuid
from payment_client import prepare_one_off_payment
def pay_start(request): # "결제하기" 버튼 POST 핸들러
order_id = f"oo-{uuid.uuid4().hex}" # 내 서비스 내 고유 — 재요청은 멱등
amount, order_name = 5000, "1회 이용권" # 금액은 반드시 서버가 결정
resp = prepare_one_off_payment( # POST /api/v1/payments/prepare (HMAC)
order_id=order_id, order_name=order_name, amount=amount,
external_user_id=request.user.email)
request.session["oo"] = {"order_id": order_id, "amount": amount} # ④ 대조용 보관
return render(request, "pay_window.html", {
"client_key": TOSS_CLIENT_KEY, "customer_key": request.user.email,
"toss_order_id": resp["toss_order_id"],
"order_name": order_name, "amount": amount})
요청/응답 와이어 포맷과 필드 제약은 13.6.1에 있습니다. 핵심: 응답 201의 toss_order_id가 결제창 orderId로 쓸 값(토스 콘솔 주문번호와 동일)이고, 같은 order_id 재요청은 기존 주문 반환(멱등)이라 버튼 연타에 안전합니다. prepare 422 = 금액 상한 초과/형식 오류.
20.3.2 [브라우저] 결제창 — pay_window.html 완성본
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>결제 진행</title>
<script src="https://js.tosspayments.com/v2/standard"></script>
</head>
<body>
<p>토스 결제창으로 이동합니다…</p>
<script>
const payment = TossPayments("{{ client_key }}")
.payment({ customerKey: "{{ customer_key }}" }); // 비회원: TossPayments.ANONYMOUS
payment.requestPayment({
method: "CARD", // 카드/간편결제 통합창
amount: { currency: "KRW", value: {{ amount }} }, // prepare와 같은 금액
orderId: "{{ toss_order_id }}", // prepare 응답값(내 order_id 아님!)
orderName: "{{ order_name }}",
successUrl: location.origin + "/pay/success",
failUrl: location.origin + "/pay/fail",
}).catch(function (e) {
document.body.innerHTML = "<p>결제가 진행되지 않았습니다. 다시 시도해주세요.</p>";
});
</script>
</body>
</html>
orderId에 내order_id를 넣으면 confirm에서 404가 납니다 — 반드시toss_order_id.- Redirect 방식 고정 — 모바일에서 iframe/frame 위 호출 금지(SDK 파라미터 상세는 13.6).
- 새로고침으로 결제창이 두 번 열려도 승인은 한 건만 성립합니다(prepare·confirm 멱등).
20.3.3 [내 서버] successUrl — 즉시 confirm(승인)과 에러 분기
토스는 인증 후 10분 내 미승인 시 결제 세션을 폐기합니다(NOT_FOUND_PAYMENT_SESSION) — confirm을 큐·배치로 미루지 말고 successUrl 핸들러에서 바로 호출하세요.
from payment_client import confirm_one_off_payment_window, PaymentAPIError
def pay_success(request): # GET /pay/success?paymentKey&orderId&amount
saved = request.session.get("oo") or {}
amount = int(request.GET["amount"])
if amount != saved.get("amount"): # 1차 대조 — 쿼리 금액을 신뢰하지 않는다
return render(request, "pay_fail.html", {"message": "금액이 일치하지 않습니다"})
try:
payment = confirm_one_off_payment_window( # POST /api/v1/payments/confirm (HMAC)
toss_order_id=request.GET["orderId"],
payment_key=request.GET["paymentKey"], amount=amount)
except PaymentAPIError as e:
if e.status in (503, 409): # 503=결과 불명 · 409=승인 진행 중/상태 충돌
return render(request, "pay_pending.html") # 실패 아님 — 재결제 금지, 내역 확인 안내
return render(request, "pay_fail.html", {"message": f"[{e.code}] {e.message}"})
# payment: status=DONE, receipt_url=매출전표 — 멱등이라 새로고침 안전
return render(request, "pay_done.html", {"p": payment})
| 상황 | 응답 | 서비스가 할 일 |
|---|---|---|
| 타임아웃/결과 불명 | 503 PAYMENT_UNRESOLVED |
재결제 금지 — PENDING 유지, 서버 스윕이 자동 확정. "잠시 후 내역 확인" 안내 |
| 중복 호출 — 선행 승인 완료 후(새로고침) | 200 기존 결제 | 멱등 — 그대로 결과 표시 |
| 중복 호출 — 선행 승인 진행 중(동시·연타) | 409 "결제 승인이 이미 진행 중" | 실패 아님·재결제 금지 — 주문별 락으로 직렬화(보안 H-3). 잠시 후 재시도하면 멱등 DONE으로 수렴 |
| 주문 없음 / 상태 충돌 | 404 / 409 | 진행 중이면 내역 확인 후 재시도, 상태 충돌이면 새 order_id로 유도 |
| 대조 금액 불일치·정의되지 않은 필드 / 승인 거절 | 422 / 4xx(토스 코드) | 실패 안내(코드·메시지 그대로). 요청 스키마는 extra='forbid' — 문서에 없는 필드는 422 |
💬세션이 사라졌다면(브라우저 교체 등) 1차 대조를 건너뛰고
amount를 생략한 confirm을 보내면 됩니다 — 승인은 어차피 서버 저장 금액으로만 됩니다.
20.3.4 [내 서버] failUrl — 실패/창닫기
def pay_fail(request): # GET /pay/fail?code&message
code = request.GET.get("code", "")
# PAY_PROCESS_CANCELED = 사용자가 결제창을 닫음(orderId 쿼리 없음)
# PAY_PROCESS_ABORTED = 결제 실패. 재결제는 반드시 새 order_id로
return render(request, "pay_fail.html",
{"message": request.GET.get("message") or code})
이탈한 주문은 결제서버 정산 스윕이 35분 후 FAILED(EXPIRED)로 정리합니다 — 서비스가 치울 것 없습니다.
20.3.5 연동 검증 체크포인트
① 정상 흐름(결제창 테스트 배지 → DONE·receipt_url) → ② 결제서버 어드민 결제 메뉴(종류 "일반")·토스 개발자센터 내역에서 같은 toss_order_id 대조 → ③ 완료 화면 새로고침 멱등 → ④ 창 닫기 → failUrl PAY_PROCESS_CANCELED + 35분 후 EXPIRED → ⑤ successUrl 쿼리 amount 변조 시 422 → ⑥ 취소(20.5). 전부 통과하면 라이브 전환은 키 교체뿐입니다.
20.4 구독 적용 — 카드 등록부터 관리까지
구독은 카드 보관함 모델입니다: 카드를 한 번 등록해 두면(빌링키 발급·보관) 구독 생성·자동갱신 때 토스 인증 없이 그 카드로 결제됩니다. (서비스, 사용자)당 카드 1장, 구독 1개입니다.
20.4.1 카드 보관함 — 조회·등록(변경)·삭제
조회 — 404는 에러가 아니라 "카드 없음" 정상 분기입니다. 응답은 마스킹 정보만(billingKey 미반환).
from payment_client import get_card, PaymentAPIError
card = None
try:
card = get_card(request.user.email) # GET /api/v1/cards/{email}
except PaymentAPIError as e:
if e.status != 404:
raise
등록 — 프론트는 토스 빌링 인증창(requestBillingAuth — 단건결제의 결제창과 다른 창)을 엽니다. customerKey는 사용자별 고정·고유·추측 불가값(UUID 권장, DB 저장·재사용)입니다.
<script src="https://js.tosspayments.com/v2/standard"></script>
<script>
const payment = TossPayments(CLIENT_KEY).payment({ customerKey: CUSTOMER_KEY });
document.getElementById("open-card").addEventListener("click", () => {
payment.requestBillingAuth({
method: "CARD",
successUrl: location.origin + "/billing/success", // authKey·customerKey 쿼리 수신
failUrl: location.origin + "/billing/fail",
customerEmail: USER_EMAIL, customerName: USER_NAME,
});
});
</script>
successUrl 콜백에서 customerKey 일치 검증(위조 콜백 방지) 후 POST /api/v1/cards로 넘기면 결제서버가 빌링키를 발급·보관합니다. authKey는 1회용입니다. 같은 사용자로 재호출하면 기존 카드가 교체됩니다 — 이것이 곧 "카드 변경"이고, 진행 중 구독이 다음 결제부터 새 카드를 자동 참조합니다(별도 API 없음).
from payment_client import register_card
def billing_success(request): # GET /billing/success?authKey&customerKey
auth_key = request.GET.get("authKey", "")
customer_key = request.GET.get("customerKey", "")
if not auth_key or customer_key != request.user.customer_key: # 위조 방지
return redirect("/card")
register_card(external_user_id=request.user.email,
customer_key=customer_key, auth_key=auth_key) # POST /api/v1/cards
return redirect("/card")
삭제 — DELETE /api/v1/cards/{email}(성공 204). 사용 중 구독(TRIAL/ACTIVE/PAST_DUE/SUSPENDED)이 있으면 409로 거절됩니다 — "구독 해지 후 삭제 또는 카드 변경"으로 안내하세요.
20.4.2 구독 시작 — 요금제 목록 → 생성
from payment_client import get_plans, create_subscription, PaymentAPIError
plans = get_plans() # GET /api/v1/plans — 활성 요금제만. amount=상시할인 적용 실결제액(서버 계산)
def subscribe(request, plan_id): # 확인 화면의 「구독하기」 POST
trial = request.POST.get("trial") == "1"
try:
sub = create_subscription( # POST /api/v1/subscriptions (HMAC)
plan_id=str(plan_id),
external_user_id=request.user.email,
trial=trial) # 금액 필드 없음 — 서버가 계산
except PaymentAPIError as e:
if e.status == 404: # 카드 미등록(또는 요금제 없음) → 등록 후 복귀
return redirect(f"/card?next=/subscribe/{plan_id}")
if e.status == 409: # 이미 구독 있음 — 서비스·사용자당 1개
return redirect("/my")
if e.status == 402: # 첫 결제 실패(한도 등) — 구독 EXPIRED·결제 FAILED로 남고 재구독 가능
return render(request, "subscribe_fail.html",
{"message": f"[{e.code}] {e.message}"})
if e.status == 422: # trial 불가 요금제에 trial=true 등
return render(request, "subscribe_fail.html", {"message": e.message})
raise
# sub: 201 — status=ACTIVE(또는 TRIAL)·기간·다음 결제일·카드까지 포함(재조회 불필요)
return render(request, "subscribe_done.html", {"sub": sub})
- 요금제 선택 시
plan_id만 전달합니다 — 구독 생성 요청에 금액 필드 자체가 없어 조작이 불가능합니다. - 토스 인증창은 뜨지 않습니다 — 보관된 카드로 첫 결제가 즉시 처리됩니다.
- 체험(trial)은 카드 없이도 시작 가능하나, 만료 시 카드가 없으면 즉시 만료됩니다.
20.4.3 구독 관리 — 조회 1번 + 액션 4개
조회 응답 하나에 화면 구성 전부(상태·access_allowed·기간·다음 결제일·마스킹 카드·재시도 횟수·변경 예약)가 옵니다. 취소·재개·수동 결제·변경 예약 취소는 본문 없는 POST 한 줄이고 응답은 항상 갱신된 SubscriptionResponse라 공통 핸들러 하나로 처리합니다.
from payment_client import (get_subscription, cancel, resume, manual_pay,
cancel_plan_change, PaymentAPIError)
def _action(request, fn, ok_msg):
try:
fn(request.user.email) # POST /api/v1/subscriptions/{email}/…
messages.success(request, ok_msg)
except PaymentAPIError as e:
# 404=구독/예약 없음 · 409=대상 상태 아님 · 402=수동 결제 실패(→ 카드 변경 안내)
messages.error(request, f"[{e.code}] {e.message}")
return redirect("/my")
def my_cancel(request): return _action(request, cancel, "취소되었습니다 — 만료일까지 이용 가능")
def my_resume(request): return _action(request, resume, "재개되었습니다")
def my_pay(request): return _action(request, manual_pay, "결제 완료 — 구독이 정상화되었습니다")
def my_change_cancel(request): return _action(request, cancel_plan_change, "변경 예약을 취소했습니다")
의미를 정확히: 취소는 즉시 종료가 아니라 예약(만료일까지 이용, 만료 전 재개 가능), 수동 결제는 연체(PAST_DUE)·정지(SUSPENDED)의 미수금 즉시 청구(성공 시 ACTIVE 복귀 + 기준일 리셋)입니다. 상태별 버튼 구성은 19장 ⑤의 캡처와 shop/templates/shop/my.html을 그대로 참고하세요.
접근 제어는 access_allowed 불리언 하나로만 판단합니다 — PAST_DUE(연체)·CANCELED(만료 전)도 true이고 SUSPENDED·EXPIRED만 false입니다. 상태 문자열로 직접 분기하면 이 정책을 놓칩니다.
20.4.4 요금제 변경 — 업그레이드/다운그레이드
방향은 서버가 상시할인 적용가로 판정합니다: 새 요금제 ≥ 현재 = 업그레이드(즉시 새 요금제 결제 + 기존 결제 환불 + 이용기간 리셋), 미만 = 다운그레이드(청구 없이 pending_plan_id 예약 → 다음 결제일에 전환). 요청은 하나의 API고 응답 change_type으로 분기합니다.
from payment_client import change_plan, PaymentAPIError
def change_plan_submit(request, plan_id):
raw = (request.POST.get("refund_amount") or "").strip() # 업그레이드 환불액(선택)
refund_amount = int(raw) if raw.isdigit() else None # None = 서버가 초 단위 일할계산
try:
res = change_plan(external_user_id=request.user.email,
plan_id=str(plan_id), refund_amount=refund_amount)
except PaymentAPIError as e:
# 402=업그레이드 결제 실패(구독 무변경) · 409=ACTIVE 아님/동일 요금제 ·
# 422=환불금액이 잔여 환불가능액 초과 · 404=요금제/구독 없음
messages.error(request, f"[{e.code}] {e.message}")
return redirect("/plans")
if res["change_type"] == "UPGRADE":
msg = f"변경 완료 — 결제 {res['charged_amount']:,}원 · 환불 {res['refunded_amount']:,}원"
if res.get("refund_status") == "FAILED": # 전환은 확정, 환불만 실패(운영자 수동 환불)
msg += " (환불은 관리자가 수동 처리 예정)"
messages.success(request, msg)
else: # DOWNGRADE — 예약만
messages.success(request,
f"다음 결제일에 '{res['pending_plan_name']}' 요금제로 자동 전환됩니다")
return redirect("/my")
업그레이드는 결제 먼저, 환불 나중이라 결제가 실패하면 402가 나고 구독은 그대로입니다. 같은 금액이면 업그레이드로 처리됩니다. 상세 규칙·필드는 13.5.5.
20.5 결제 내역과 취소(환불)
GET /api/v1/payments/{email}은 구독+단건을 최신 50건 반환하며, 각 결제에 취소 필드 7종이 포함됩니다: cancelable(지금 취소 가능?) / cancel_blocked_reason(불가 사유 — ADMIN_HANDLED·CANCEL_DISABLED·null) / cancel_remaining(취소 가능 원금 — 부분취소 요청의 상한) / cancel_fee(수수료) / cancel_refund_amount(환불액) / canceled_amount(실제 누적 환불) / net_amount(실수령). 취소 전엔 예상액, 후엔 실제액입니다 — 수수료를 직접 계산하지 마세요(수수료율은 cancel_fee_percent로도 함께 옵니다. 공식·필드 상세는 13.6.4).
회차별 취소 이력이 필요하면 쿼리에 ?include_cancellations=true를 붙이세요(get_payments(..., include_cancellations=True)) — 각 결제에 cancellations[](시간순: canceled_at·cancel_amount·cancel_fee·reason·actor_type)가 포함됩니다(취소 없으면 빈 배열, 미요청이면 null — 하위호환). 부분취소가 여러 번인 결제도 "언제 얼마씩 왜" 환불됐는지 표시할 수 있습니다. 쿼리 파라미터는 HMAC 서명 대상이 아니므로 서명 코드는 그대로 둡니다(샘플 payment_client._request의 params 인자처럼 path와 분리해 전달할 것 — path에 이어 붙이면 401).
외부 서비스가 취소할 수 있는 것은 단건(ONE_OFF)뿐입니다(구독 결제 환불은 어드민 전용). 취소 버튼은 서버의 cancelable 판정 그대로만 노출하고, 확인 UI에 예상 수수료·환불액을 미리 보여주세요.
⚠️ 잔여 금액이 남았다고 항상 취소할 수 있는 것은 아닙니다. 관리자가 어드민에서 한 번이라도 취소를 실행한 결제는 이후 서비스 취소가 영구 차단됩니다(이중환불·정산 혼선 방지). 이 경우 응답은 cancelable=false + cancel_blocked_reason="ADMIN_HANDLED"이고 cancel_remaining에는 남은 금액이 그대로 담깁니다.
cancel_remaining > 0으로 버튼을 열지 마세요. 반드시 cancelable로 판정하고, cancel_blocked_reason이 있으면 사유 안내를 띄우세요(ADMIN_HANDLED → "고객센터로 문의해주세요", CANCEL_DISABLED → "이 서비스는 취소를 지원하지 않습니다"). 그러지 않으면 사용자가 취소를 눌렀을 때 409 오류를 보게 됩니다.
부분취소도 지원됩니다 — cancel_one_off_payment(order_id, reason, cancel_amount=3000)처럼 취소할 원금을 지정하면 그만큼만 취소되고(수수료는 원금에 비례 공제), 잔여가 남는 한 반복할 수 있습니다. 상한은 응답의 cancel_remaining(취소 가능 원금)이며 초과 시 422입니다. 샘플의 결제 내역 화면은 결제취소 모달로 이 흐름을 구현합니다: 취소 가능 금액 표시 → 금액 입력(또는 "전체금액" 체크 시 자동입력) → 한도 초과 시 인라인 에러 + 제출 차단 → 서버 재검증. 관리자가 어드민에서 취소를 실행한 결제는 외부 취소가 409로 거절되니 고객센터 안내로 분기하세요.
from payment_client import cancel_one_off_payment, PaymentAPIError
def pay_cancel(request):
order_id = request.POST["order_id"]
try:
result = cancel_one_off_payment(order_id, reason="사용자 취소")
# result: status=CANCELED, cancel_fee=실제 차감액, canceled_amount=실제 환불액
MyOrder.objects.filter(order_id=order_id).update(canceled=True)
messages.success(request,
f"취소 완료 — 수수료 {result['cancel_fee']:,}원 차감, "
f"{result['canceled_amount']:,}원 환불")
except PaymentAPIError as e:
if e.code == "CANCEL_DISABLED": # 402 — 서비스 취소 정책이 꺼져 있음
messages.error(request, "온라인 취소 미지원 — 고객센터로 안내")
elif e.status == 409: # 취소 불가 상태·이미 부분취소된 건
messages.error(request, f"취소할 수 없는 결제입니다: {e.message}")
elif e.status == 404:
messages.error(request, "결제를 찾을 수 없습니다")
else:
raise
return redirect("/history")
부분취소 판정 주의 — 어드민이 부분취소하면 status는 DONE인 채 canceled_amount만 커집니다. status == "CANCELED"만으로 취소를 판정하지 말고:
def cancel_state(p):
if p["status"] == "CANCELED":
return "취소됨" # 전액 환불
if p["canceled_amount"] > 0:
return "부분취소" # status=DONE인데 환불 있음
return "완료"
# 실수령은 net_amount(= amount − canceled_amount)로 표시
20.6 적용 완료 체크리스트
| 구분 | 확인 |
|---|---|
| 보안 | 시크릿(hmac_secret·토스 secretKey)은 서버에만 — 브라우저엔 clientKey·customerKey만 |
| 금액 | 항상 서버가 결정·보관 — successUrl 쿼리·클라이언트 입력은 대조용으로만 |
| 단건 | confirm은 successUrl에서 즉시(10분 규칙) · 503은 재결제 금지 · 재결제는 새 order_id |
| 카드 | customerKey 고정 UUID 저장·콜백 검증 · authKey 1회용 · 재등록=변경 · 삭제 409는 정상 |
| 구독 | 1인 1구독(409) · 카드 선등록(404→유도, 체험 예외) · 접근은 access_allowed 하나로 |
| 변경 | 방향·금액 판정은 서버 — 402=구독 무변경 · refund_status=FAILED는 안내만 |
| 취소 | 단건만 · cancelable·수수료 필드 그대로 사용 · 부분취소는 canceled_amount로 판정 |
| 운영 | 자동갱신·재시도·정지·EXPIRED 정리는 결제서버 몫 · 상태 알림이 필요하면 17장 웹훅 수신 등록 |
검증을 마쳤다면 라이브 전환은 토스 라이브 키 + 운영 결제서버 키로 교체하면 끝입니다(코드 변경 없음).