🛠️ 개발자 매뉴얼

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 시작 전 준비

  1. 서비스 등록·키 발급 — 결제서버 어드민에서 서비스를 등록하면 api_key·hmac_secret1회 발급됩니다(재발급 가능). 이 두 값은 내 서버에만 보관합니다.
  2. API 클라이언트 확보 — 샘플의 shop/payment_client.py를 그대로 복사하는 것이 가장 빠릅니다(HMAC 3중 서명 포함, Django 의존 없음 — settings 폴백 2줄만 교체). Python이 아니라면 20.2의 서명 스펙을 포팅하세요.
  3. 토스 clientKey — 결제창(단건)·카드 등록창(구독)을 여는 데 필요합니다. 테스트 키로 시작하면 되고 브라우저 노출은 안전합니다. 토스 secretKey결제서버만 가지므로 내 서비스에는 필요 없습니다.
  4. 요금제(구독을 쓸 경우) — 어드민에서 요금제를 미리 생성합니다(체험 요금제 포함 권장).

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로 넘기면 결제서버가 빌링키를 발급·보관합니다. authKey1회용입니다. 같은 사용자로 재호출하면 기존 카드가 교체됩니다 — 이것이 곧 "카드 변경"이고, 진행 중 구독이 다음 결제부터 새 카드를 자동 참조합니다(별도 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._requestparams 인자처럼 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장 웹훅 수신 등록

검증을 마쳤다면 라이브 전환은 토스 라이브 키 + 운영 결제서버 키로 교체하면 끝입니다(코드 변경 없음).