19. 샘플 서비스(sample_service) 사용법
🍎쉽게 말하면
sample_service는 "외부 서비스가 결제 서버에 어떻게 연동하는지"를 직접 눌러 보며 배우는 동작하는 예제입니다. 진료 앱·쇼핑몰이 할 일을 그대로 Django로 구현해 둔 것이라, 화면을 따라가면 연동 전 과정을 이해하고 코드를 그대로 가져다 쓸 수 있습니다.함께 보기: 서비스 API · 카드 보관함 · 서비스 알림 · 20. 연동 적용 가이드 — 이 샘플의 화면별 「🧩 바로 적용 가이드」를 한 문서로 통합한 따라 하기 가이드(단건결제·구독 적용 코드 전체) · 21. 에러 테스트 — 결제 실패 재현
19.1 전체 프로세스 한눈에 — 화면으로 따라가기
서비스 개발자가 연동을 시연하는 전체 흐름을 화면 순서대로 보여줍니다. 각 화면 하단의 「개발자 노트」에 그 단계가 호출하는 API가 정리돼 있습니다.
external_user_id 선택POST /cards · 구독·결제 전제POST /subscriptions · 등록 카드로/payments/prepare·confirm · 결제창GET /payments · 매출전표 · 단건 취소POST /notify · 웹훅 수신💬흐름 요지: ③ 카드 등록이 ④ 구독·⑥ 일반결제의 전제다(카드 보관함 모델). 카드가 없으면 결제서버가
404를 반환한다.
① 이메일 선택 (/login) — 고른 이메일이 결제서버의 사용자 식별자 external_user_id(이메일)가 된다.

② 서비스 선택 (/services) — 결제서버의 서비스 목록에서 고르고 API 키를 저장한다.

③ 카드 등록 (/card) — 토스 빌링 인증창으로 카드를 등록한다(구독의 전제 — 단건결제는 결제창 방식이라 무관). 화면 하단에 개발자 노트(기본 펼침) 패널 하나가 있다 — 상단 API 레퍼런스(카드 보관함 API 3종 요약)에 이어 같은 패널 안에 🧩 바로 적용 가이드가 통합돼 있다. 가이드는 카드 보관함 규칙 바(등록→재등록=교체→삭제 409)를 먼저 보여주고, ① 조회(GET /cards — 404=카드 없음 정상 분기) → ② 등록창 열기(requestBillingAuth — 결제창과 다른 창이라는 점, customerKey는 고정 UUID) → ③ successUrl 콜백(customerKey 검증 → POST /cards — authKey 1회용, 재등록=카드 변경) → ④ 삭제(DELETE /cards — 사용 중 구독 409 분기) 순서로 프론트엔드·백엔드 코드를 액터 배지·핵심 라인 하이라이트와 함께 제공한다.

④ 요금제·구독 (/plans) — 요금제와 실제 청구 금액을 보고 등록 카드로 구독한다. 화면 하단에 개발자 노트(기본 펼침) 패널 하나가 있다 — 상단 API 레퍼런스(요금제 API·요금제 변경 r02 규칙)에 이어 같은 패널 안에 🧩 바로 적용 가이드가 통합돼 있다. 가이드는 구독 상태 바((없음)→ACTIVE/TRIAL→자동갱신→CANCELED→EXPIRED, 실패 시 PAST_DUE→SUSPENDED — 접근 판단은 access_allowed 하나)를 먼저 보여주고, ① 요금제 목록 조회(GET /plans — amount는 서버 계산 실결제액) → ② 선택·확인 화면(plan_id만 전달·금액 미전송, 카드 없으면 /card?next= 유도·체험은 예외) → ③ 구독 생성(POST /subscriptions — 토스 인증창 없이 첫 결제 즉시, 404/409/402/422 에러 분기) → ④ 이후 운영(자동갱신은 결제서버 몫, access_allowed로 접근 제어) → 이어서 요금제 변경 규칙 바(서버가 상시할인 적용가로 판정: 새 ≥ 현재 = 업그레이드 즉시 결제+환불+기간 리셋, 미만 = 다운그레이드 예약) 아래 ⑤ 변경 방향 표시·확인 화면(구독 중이면 ↑/↓ 변경 링크 — 표시용 미러일 뿐 최종 판정은 서버, 업그레이드만 환불액 입력·비우면 초 단위 일할계산) → ⑥ 변경 실행(POST /change-plan 하나 — 응답 change_type으로 UPGRADE/DOWNGRADE 분기, charged_amount/refunded_amount/refund_status=FAILED(전환 유지·수동 환불) 처리, 402=결제 실패 시 구독 무변경, 예약 취소 cancel_plan_change()) 순서로 프론트엔드·백엔드 코드를 액터 배지·핵심 라인 하이라이트와 함께 제공한다.

⑤ 내 구독 (/my) — 상태·다음 결제일·등록 카드와 함께 취소·재개·카드 변경·수동 결제 버튼이 상태에 따라 보인다. 화면 하단에 개발자 노트(기본 펼침) 패널 하나가 있다 — 상단 API 레퍼런스(상태 머신·라이프사이클 API 5종)에 이어 같은 패널 안에 🧩 바로 적용 가이드가 통합돼 있다. 가이드는 "조회 1번 + 액션 4개(모두 본문 없는 POST)"라는 요약 바를 먼저 보여주고, ① 구독 조회(GET /subscriptions/{email} — 응답 하나에 상태·기간·카드·예약까지, 404=구독 없음) → ② 상태별 버튼 렌더(상태 머신과 1:1 분기 — PAST_DUE는 접근 허용, SUSPENDED는 차단) → ③ 라이프사이클 액션 4종(취소·재개·수동 결제·변경 예약 취소 — 공통 핸들러 패턴, 402=수동 결제 실패 시 카드 변경 동선, 수동 결제 503(결과 불명)·409("처리 중")은 실패 아님 — 재결제 금지·내역 확인, 보안 A-1/R-1) → ④ 접근 제어(access_allowed 하나로 — PAST_DUE·CANCELED(만료 전)도 true) 순서로 프론트엔드·백엔드 코드를 액터 배지·핵심 라인 하이라이트와 함께 제공한다.

⑥ 일반결제 (/pay) — 구독과 무관한 단건 결제. 결제창 전환(2026-07-15): 빌링키를 쓰지 않아 카드 등록이 필요 없다. 흐름은 세 단계다.
- 상품명·금액 입력 → "결제 진행" → 샘플 서버가
POST /api/v1/payments/prepare로 주문을 선점(PENDING)하고toss_order_id를 받는다. - 결제 진행 페이지(
oneoff_window.html)가 SDKpayment.requestPayment()를 Redirect 방식으로 자동 실행 → 토스 결제창(카드/간편결제 통합창)이 열린다. 이때 결제창의 주문번호(orderId)가 곧toss_order_id다. - 인증 완료 →
/pay/success로 리다이렉트(쿼리:paymentKey·orderId·amount) → 샘플 서버가 금액 1차 대조 후POST /api/v1/payments/confirm으로 승인을 마치고 결과 화면과 로컬 기록(OneOffRecord)을 남긴다. 인증 실패/창 닫기는/pay/fail(에러 코드·메시지 표시)로 온다.
화면 하단에 통합 다크 패널(개발자 노트, 기본 펼침)이 하나 있다 — 상단은 API 레퍼런스(전체 시퀀스·에러 처리 규칙: 503 결과불명 시 재결제 금지, confirm 멱등, 35분 EXPIRED, 새 order_id 재시도·구현 파일 위치), 이어서 같은 패널 안에 🧩 바로 적용 가이드가 이어진다. 가이드는 이 패널만 보고 연동을 완성할 수 있도록 구성돼 있다: 맨 위 주문 상태 바((없음)→PENDING→DONE·이탈 시 35분 후 FAILED(EXPIRED)) → 준비물(URL 라우팅 3개 + payment_client.py를 못 쓰는 스택을 위한 HMAC 서명 스펙·포팅용 코드 — 헤더 4개, 정준 문자열 5요소, ±300초·nonce 10분 1회용, 401 원인) → 5단계 타임라인(단계마다 액터 배지·상세 설명·제목 바 코드 블록·"왜 중요한가" 포인트): ① 버튼 클릭(금액은 서버가 결정) → ② prepare(요청/응답 와이어 포맷 포함, 멱등) → ③ 결제창(pay_window.html 완성본 — catch 처리·테스트 배지·이중 실행 안전) → ④ successUrl→즉시 confirm(와이어 포맷·에러 분기 503/404/409/422·세션 유실 시 amount 생략 대응) → ⑤ 완료 화면·failUrl → 연동 검증 체크포인트 ①~⑥(정상 흐름·어드민/토스 콘솔 대조·새로고침 멱등·창 닫기 EXPIRED·금액 위변조 422·취소) → 적용 전 체크리스트(서명 401 디버깅 팁 포함). 상세 API 스키마는 서비스 API 13.6, 요약 코드는 [샘플 README의 "결제창 단건 결제 연동 가이드"] 절 참고.

결제창 안에서의 사용자 동선 — 아래 캡처는 실제 토스 테스트 결제창(2026-07-15 실결제 검증)이다.
- (a) 결제수단 선택 — 결제창이 열리면 간편결제(카카오페이·SSG페이 등)와 신용·체크카드(카드사별)가 나열된다. 우측에 prepare 때 보낸
order_name(상품명)과 금액이 표시된다 — 금액은 서버(prepare) 저장값과 같아야 하며, 위변조 시 승인 단계에서 토스/서버가 거절한다. 테스트 키로 열면 "실제 결제가 안되는 테스트입니다" 배지가 붙는다.

- (b) 카드 상세(인증 준비) — 카드사를 고르면 할부·포인트·이메일(선택)과 [필수] 약관 동의 → 「다음」 단계가 나온다. 「다음」부터는 카드사 인증 위젯(앱 인증·QR 등 카드사별 상이)으로 넘어가며, 인증을 마치면 토스가
successUrl로 리다이렉트한다. 카드번호는 토스/카드사 화면에서만 입력되므로 서비스·결제서버는 카드 정보를 전혀 다루지 않는다.

- (c) 승인 완료(/pay/success) — 리다이렉트 즉시 샘플 서버가 confirm을 마친 결과 화면. 주문번호(서비스
order_id)와 토스 주문번호(toss_order_id— 토스 콘솔 대조용), 금액, 상태DONE, 그리고 지금 취소 시 수수료·환불액 안내와 「결제 취소」 버튼이 함께 표시된다. 개발자 노트에는 이 화면이 지켜야 할 규칙(503은 재결제 금지, confirm은 successUrl에서 즉시·10분 규칙, 멱등, 취소 흐름)이 정리돼 있다.

⑥-1 에러 재현 테스트 (/pay/error-test) — QA가 결제 실패 케이스를 재현하는 화면(요청 qa/r01). 상단 메뉴 에러 테스트에서 접근한다. 상세 규칙·게이트·에러코드 표는 21. 에러 테스트 참고. 재현할 토스 에러코드와 결제 유형을 고르면 결제서버가 토스 호출에 TossPayments-Test-Code 헤더를 실어 그 실패를 응답한다. 두 유형을 동일하게 테스트한다:
- 일반결제(단건) — 결제창(prepare→창→confirm) 흐름. 코드를
toss_order_id키로 세션에 저장했다가 successUrl 콜백에서 confirm 헤더로 전달한다(결제창에서는 테스트 카드로 정상 인증, 실패는 승인 단계에서 주입). - 구독 생성 — 저장된 카드(빌링키)로 즉시 청구(charge)라 결제창이 없다.
create_subscription호출에 헤더를 실어 결과를 바로 표시한다(카드 등록 + 기존 구독 없음이 전제). 4xx로 실패하면 서버가 구독을 EXPIRED, 결제를 FAILED로 남겨 결제 내역의 구독 결제 표에 실패가 노출되고, EXPIRED라 즉시 재구독할 수 있다. ※ 서버 API는 수동결제(POST /subscriptions/{uid}/pay)에도 같은 헤더를 지원하나, 샘플 화면에서는 노출하지 않는다.
구현은 shop/views.py의 oneoff_error_test_view, shop/payment_client.py(confirm_one_off_payment_window·create_subscription의 test_error_code). 실제 적용은 결제서버가 비운영(STG/dev) + TEST_ERROR_INJECTION_ENABLED=true + 서비스가 토스 test 키일 때만이며, 운영에선 헤더가 무시된다. 4xx 계열(REJECT_CARD_COMPANY 등)은 확정 실패(FAILED), FAILED_CARD_COMPANY_RESPONSE(500)는 이중결제 방지로 503 결과불명(PENDING 유지) 으로 온다. 자세한 헤더 규칙은 서비스 API — QA 에러 재현 헤더 참고.
⑦ 결제 내역 (/history) — 단건 결제 표가 상단, 구독 결제(서버 API) 표가 그 아래에 온다(방금 결제한 단건을 바로 확인하는 동선). 단건 표는 서버 응답(GET /payments/{email})의 단건 결제를 기준으로 그려 취소·실패 건도 항상 표시되며(상태 배지: 완료·취소됨·부분취소·FAILED·PENDING), 상품명은 서버 응답의 order_name을 우선 사용한다(실패·과거·타 세션 건도 상품명이 보인다). 서버가 상품명을 보관하므로 별도 로컬 저장이 없어도 되고, 로컬 OneOffRecord는 fallback으로만 쓴다. 두 표 모두 섹션 접기를 지원한다 — 제목 행(▶ 단건 결제 n건 · 접기/펼치기)을 클릭해 표를 접고 펼칠 수 있다(기본 펼침, 내역이 많을 때 화면 정리용). 단건은 취소(환불)할 수 있다. 각 결제의 매출전표(영수증) 열에서 서버 응답의 receipt_url을 링크로 노출해, 카드결제 완료 건은 토스 영수증을 새 탭으로 열어 볼 수 있다(영수증이 없는 건은 -). 토스에 전달된 주문번호가 다른 건(단건결제)은 주문번호 아래에 응답의 toss_order_id가 "토스: t…"로 병기된다 — 토스 상점 콘솔의 주문번호와 같은 값이다.
취소(환불) 이력이 있는 결제는 행 바로 아래에 "↳ 취소 N회차" 줄이 붙는다 — 취소 시각·회차 환불액·수수료·처리 주체(관리자/서비스/시스템)·사유가 회차마다 한 줄씩 표시된다(부분취소가 여러 번인 결제도 "언제 얼마씩" 확인 가능, 단건·구독 표 모두). 데이터는 get_payments(..., include_cancellations=True)가 조회에 ?include_cancellations=true를 붙여 받아온 각 결제의 cancellations[]다. 쿼리 파라미터는 HMAC 서명 대상이 아니므로(payment_client._request의 params 인자 사용) 서명 코드는 그대로다.
화면 하단에 개발자 노트(기본 펼침) 패널 하나가 있다 — 상단 API 레퍼런스(내역 조회·취소 수수료 규칙)에 이어 같은 패널 안에 🧩 바로 적용 가이드가 통합돼 있다. 가이드는 취소 규칙 바(외부 취소는 단건만, 부분취소는 DONE인 채 canceled_amount 누적, 수수료는 서버 계산)를 먼저 보여주고, ① 내역 조회(GET /payments/{email} — 취소 필드 6종 포함, 내 주문 기록과 order_id 결합) → ② 목록 렌더(cancelable일 때만 결제취소 버튼 — 클릭 시 결제취소 모달이 열려 취소 가능 금액(cancel_remaining)을 보여주고, 부분취소 금액 입력 또는 「전체금액」 체크로 전액 자동입력, 한도 초과·1원 미만은 인라인 에러와 함께 제출 차단, 예상 수수료·환불 미리보기) → ③ 결제 취소 실행(POST /payments/{order_id}/cancel — reason + 선택 cancel_amount(부분취소 원금) 전송, 422(한도 초과)/CANCEL_DISABLED(402)/409/404 에러 분기 — 409 "취소가 이미 진행 중"은 동시 취소 직렬화(보안 A-2)라 재시도 대상, "관리자가 취소 처리한 결제" 409는 고객센터 안내. 부분취소 성공 시 남은 취소가능금액을 안내하고 전액 소진 시에만 로컬 기록을 취소 처리) → ④ 표시 규칙(부분취소 판정 canceled_amount>0, 실수령 net_amount, 매출전표·토스 주문번호 대조) 순서로 프론트엔드·백엔드 코드를 제공한다.

💡참고(작업 배너): 결제서버에 진행·예정 작업 공지(공지사항 게시판의 '작업' 타입 중 게시 + 상단노출(is_pinned) 로 설정되고 종료 전인 글)가 있으면, 샘플의 모든 화면 상단에 주황색 배너("서버 작업 안내 — 제목·기간")가 표시된다. 무인증
GET /api/v1/work-notices를 60초 캐시로 조회하며, 결제서버에 연결할 수 없으면 배너 없이 정상 동작한다(best-effort).참고(매출전표 보기): 결제 내역의 매출전표 열에 있는 링크를 클릭하면 토스가 호스팅하는 매출전표(영수증) 페이지가 새 탭으로 열린다. 서버는
GET /api/v1/payments/{uid}응답의 각 결제에receipt_url을 함께 내려주며(카드결제 완료 건만 보통 존재), 서비스는 이 값을 그대로 링크로 쓰면 된다 — 어드민 결제목록과 동일한 링크다. 전표 화면의 주문번호에는 토스가 상점 구분용 접두어(예:19e341_)를 붙여 표시하는데, API의 주문번호에는 없는 값이므로_뒤 부분(=toss_order_id)으로 대조한다. 자세한 응답 필드는 서비스 API — 결제 내역 조회 참고.

⑧ 받은 알림 (/notifications) — 결제서버가 보낸 웹훅(상태 변화 알림)을 서명 검증해 받은 내역.

💡참고: 아래 19.2~19.9는 위 흐름을 개념 → 코드 → 실행 → 규칙으로 풀어 설명합니다. 처음이라면 이 화면 흐름을 먼저 훑고 내려가세요.
19.2 이게 무엇이고, 왜 있나
sample_service(Sample Shop)는 결제 서버(payment_system)의 외부 서비스 역할을 하는 참조 구현입니다. 실제 토스페이먼츠 테스트 키로 카드 등록 → 빌링키 발급 → 결제 승인까지 진짜 API로 동작합니다.
목적은 둘입니다.
- 배우기: 서비스 개발자가 화면을 눌러 보며 "구독·결제를 붙이려면 무엇을 어떤 순서로 호출하는가"를 체득.
- 가져다 쓰기: 핵심 연동 코드(
shop/payment_client.py)를 그대로 복사해 자기 서비스에 이식.
💡참고: 이건 "결제 서버"가 아니라 결제 서버를 호출하는 쪽(고객을 가진 우리 앱)입니다. 둘은 별개 프로세스로 함께 띄워 연동을 시연합니다.
19.3 큰 그림 — 세 주체
연동에는 세 주체가 등장합니다. 샘플 서비스는 가운데 "외부 서비스(앱)" 자리를 연기합니다.
💡참고: 파란 화살표(②③)가 내 서비스가 작성하는 코드입니다 — 결제서버로의 HMAC API 호출(
payment_client.py)과 웹훅 수신(/notify). 회색은 토스·브라우저가 알아서 하는 부분입니다.
- 사용자 ↔ 토스: 카드번호는 토스 결제창에서만 다룬다(서비스·결제서버는 카드번호를 보지 않음).
- 샘플 ↔ 결제서버: 모든 호출은 API 키 + HMAC 서명으로 인증(
shop/payment_client.py). - 결제서버 → 샘플: 구독·결제·카드 상태가 바뀌면 알림(웹훅)을
POST /notify로 받는다.
19.4 화면이 곧 문서 — 「개발자 노트」
모든 화면 하단에 다크 패널 「🔍 개발자 노트」가 있습니다. 노트 첫머리에는 「💡 쉽게 말하면」 요약 박스가 있어 그 화면의 역할과 개발자가 해야 할 일을 평이한 말로 먼저 설명하고(예: "구독 결제는 미리 맡겨둔 카드로 자동 청구됩니다 — 등록만 해두면 이후는 결제서버가 알아서"), 그 아래 「📖 관련 매뉴얼」 링크 칩이 이 매뉴얼의 해당 장(⚡ 연동 가이드 한 페이지·13. 서비스 API·기능 원리 장 등)으로 바로 연결됩니다 — 화면에서 막히면 칩을 눌러 깊은 문서로 넘어가는 동선입니다. 이어서 그 화면이 어떤 API를 호출하는지를 엔드포인트마다 미니 스펙으로 보여줍니다 — 요청/응답/오류 라벨 행으로 구조화되어 요청 본문은 필드당 한 줄씩, 응답·오류는 상태코드 칩(200/402/409/503…)이 붙은 코드별 한 줄씩이라 훑어 읽기 좋습니다. 어느 뷰가 구현하며 무슨 규칙을 지켜야 하는지도 그 자리에서 확인할 수 있습니다. 주요 화면(카드·요금제·내 구독·단건결제·결제 내역·에러 테스트)에서는 같은 패널 안에 🧩 바로 적용 가이드(단계별 프로세스 + 복사해서 붙이는 코드)가 이어져, 결제서버를 연동하는 서비스 개발자가 패널 하나만 보고 레퍼런스 확인부터 코드 적용까지 끝낼 수 있습니다. 즉 화면을 따라가는 것만으로 API 레퍼런스를 함께 읽게 됩니다.
화면 ↔ 호출 API ↔ 클라이언트 함수 매핑:
| 화면(경로) | 시연하는 일 | 호출 API | payment_client.py |
|---|---|---|---|
로그인 /login |
사용자 식별자(external_user_id(이메일)) 선택 |
— | — |
서비스 선택 /services |
결제서버의 서비스 목록·키 입력 | GET /api/v1/services(무인증) |
list_services |
카드 /card |
카드 등록/변경/조회/삭제(연동의 전제) | POST·GET·DELETE /api/v1/cards |
register_card/get_card/delete_card |
요금제 /plans |
구독 가능한 요금제·금액 | GET /api/v1/plans |
get_plans |
구독 /subscribe/{plan_id} |
등록 카드로 구독 생성 | POST /api/v1/subscriptions |
create_subscription |
내 구독 /my |
조회·취소·재개·수동결제·카드변경 | GET·.../cancel·/resume·/pay |
get_subscription/cancel/resume/manual_pay |
일반결제 /pay |
결제창 단건 결제(준비→결제창→승인) | POST /api/v1/payments/prepare·/confirm |
prepare_one_off_payment/confirm_one_off_payment_window |
결제 내역 /history |
구독+단건 내역, 매출전표 링크, 단건 취소 | GET /api/v1/payments/{uid}(응답 receipt_url) · POST .../{order_id}/cancel |
get_payments/cancel_one_off_payment |
받은 알림 /notifications |
웹훅 수신·서명검증 데모 | POST /notify(수신측) |
— (수신 뷰 views.notify_receive_view) |
19.5 코드 구조 — 어디를 보면 되나
| 파일 | 역할 |
|---|---|
shop/payment_client.py |
연동의 핵심. HMAC 서명(sign_request) + 모든 API 호출(_request)이 한 파일에. Django 의존이 거의 없어 이 파일만 복사하면 연동 끝(settings 폴백 2줄만 교체). |
shop/views.py |
화면 흐름·토스 successUrl 콜백 처리(billing_success_view → POST /api/v1/cards), 카드 미등록 시 /card 유도, 401 키 재입력, 웹훅 수신(notify_receive_view). |
shop/templates/shop/*.html |
각 화면 + 하단 「개발자 노트」. card.html에 토스 SDK requestBillingAuth() 호출부. |
shop/urls.py |
경로 → 뷰 매핑. |
🍎쉽게 말하면 "무엇을 호출하나"는
payment_client.py, "언제 호출하나(화면 흐름)"는views.py를 보면 됩니다.
19.6 실행 방법
샘플과 결제 서버 두 프로세스를 함께 띄웁니다. 먼저 결제 서버에 서비스를 등록해 키를 발급받아야 합니다.
(1) 사전 — 결제 서버에서 서비스 등록
- 결제 서버 어드민(
/admin) → 서비스 등록(허용 IP에127.0.0.1포함). - 등록 직후 표시되는 API 키 / HMAC 시크릿을 복사(서비스 상세의 키 복사 버튼). 이 값은
.env가 아니라 실행 후/services화면에 입력한다. - 요금제 1개 이상 생성(체험 요금제 포함 권장).
(2) 샘플 설정 — .env
cp .env.example .env 후 채운다. 서비스 API 키·HMAC 시크릿은 .env에 넣지 않는다 — 실행 후 /services 화면에서 직접 입력한다.
PAYMENT_API_BASE=http://127.0.0.1:8000 # 결제 서버 주소(포트 포함)
# 토스 빌링 client key — 비우면 /card 화면의 '수동 authKey' 폴백으로 테스트 가능
TOSS_CLIENT_KEY=test_ck_xxx
💡참고: 서비스 API 키·HMAC 시크릿은 앱 실행 후
/services화면에서 서비스를 고르고 입력하면 세션에 저장되어 이후 모든 API 호출에 쓰인다(화면에서 여러 서비스 전환 가능). 그래서.env에는 두지 않는다. 보호 화면은 키 입력 전까지 자동으로/services로 유도한다.
(3) 구동 — 로컬
# 터미널 1 — 결제 서버
cd payment_system && uv run uvicorn app.main:app --port 8000
# 터미널 2 — 샘플
cd sample_service
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python manage.py migrate
.venv/bin/python manage.py runserver 8001
→ 브라우저에서 http://127.0.0.1:8001 접속.
(3') 구동 — docker
cd sample_service && docker compose up -d --build # http://localhost:8001 (호스트 8001 → 컨테이너 8000)
19.7 데모 시나리오 — 권장 순서
⚠️중요: 카드 등록이 구독·결제보다 먼저입니다(카드 보관함 모델). 구독/결제 화면에서 카드가 없으면 자동으로
/card로 유도하고, 등록을 마치면 원래 흐름으로 복귀합니다.참고: 각 화면 캡처는 맨 앞 "전체 프로세스 한눈에" 섹션에 순서대로 있습니다. 아래는 따라 할 때의 단계와 확인 지점입니다.
- 이메일 선택(
/login) → 서비스 선택(/services). - 카드 등록(
/card) — "카드 등록창 열기" → 토스 테스트 카드 입력(테스트 모드는 청구 없음). 완료 시POST /api/v1/cards로 빌링키 발급·보관, 마스킹 카드만 표시. (토스 키가 없으면 수동 authKey 폴백으로 흐름 테스트) - 구독(
/plans→ 구독하기) — 등록 카드로 즉시 구독(토스 재인증 없음). 또는 일반결제(/pay). - 확인 — 샘플
/my(ACTIVE·다음 결제일), 결제서버 어드민(구독·결제 내역), 토스 개발자센터(테스트 결제). - 라이프사이클 —
/my에서 취소(만료일까지 유지)·재개, PAST_DUE/SUSPENDED 시 수동 결제, 카드 변경(재등록=교체, 기존 구독이 새 카드 자동 참조). - 알림(
/notifications) — 상태 변화 시 결제서버가 보낸 웹훅 수신 내역 확인.
19.7.1 요금제 변경 데모 (선택 → 확인 → 실행)
구독 중에 다른 요금제로 갈아타는 흐름입니다. 판정·환불 규칙은 정책 문서를 따르며, 샘플은 그 흐름을 화면으로 보여줍니다.
① /plans — 요금제 선택. 구독 중이면 요금제마다 상태가 표시됩니다.
② 확인 화면(/change-plan/<id>) — 금액을 비교해 보여줍니다. 업그레이드(현재보다 높은 금액)는 즉시 결제+환불, 다운그레이드(낮은 금액)는 다음 결제일 예약입니다.
③ /my — 다운그레이드 예약 상태와 취소. 예약이 있으면 배너와 「변경 예약 취소」 버튼이 나타납니다.
19.7.2 체험(trial)은 카드 없이 시작
체험 요금제는 카드를 등록하지 않아도 시작할 수 있습니다. 체험 만료 전까지 카드를 등록하면 만료일에 자동 결제로 이어지고, 등록하지 않으면 체험 종료와 함께 즉시 만료됩니다.
19.8 내 서비스에 가져다 쓰기 — 지켜야 할 규칙
shop/payment_client.py를 복사하는 게 출발점입니다. 그리고 「개발자 노트」에도 반복되는 연동 4규칙을 지킵니다.
19.8.1 사용자 식별자(external_user_id(이메일))는 반드시 이메일
결제 서버의 모든 사용자 키 external_user_id(이메일)에는 이메일만 넣어야 합니다. 샘플은 로그인한 사용자의 이메일을 그대로 식별자로 전달합니다 — shop/views.py에서 external_user_id=user.email로 호출합니다(카드 등록·구독 생성·결제·조회 전부 동일한 이메일 사용).
- 서버가 정규화합니다: 받는 즉시 앞뒤 공백 제거 + 소문자로 바꿔 저장·조회합니다(
app/core/identifiers.py). 그래서Han@Han.com과han@han.com은 같은 사용자로 취급됩니다. - 형식이 틀리면 거부: 이메일이 아니면
422(InputValidationError)로 거부됩니다. 회원 PK 같은 임의 문자열을 넣으면 안 됩니다. - 경로에 넣을 때 인코딩:
GET /api/v1/cards/{external_user_id}처럼 이메일이 URL 경로에 들어갈 때,+등 특수문자는 URL 인코딩하세요. (HMAC 서명은 클라이언트가 보낸 원본 경로 기준이라 인코딩과 무관하게 동작합니다.)
⚠️주의(내 서비스 적용 시): 사용자 식별자로 정규화된 이메일을 일관되게 보내세요. 같은 사용자가 항상 같은 이메일이어야 카드·구독이 한 사용자로 묶입니다. 사용자가 이메일을 바꾸면 서버는 다른 사용자로 인식하므로(카드·구독 연결이 끊김), 이메일을 식별자로 쓸 때는 "이메일 불변" 또는 "변경 시 마이그레이션" 정책을 정해 두는 것이 안전합니다.
연동 4규칙:
- 타임아웃(
503 PAYMENT_UNRESOLVED)은 실패가 아니다 — 서버가 결제를 PENDING으로 유지했다가 정산 스윕(미확정 결제를 주기적으로 다시 확인해 확정하는 배치)으로 확정한다. 실패로 처리하지 말 것. - 금액은 서버 세션/DB에 보관했다가 서명된 본문으로 전달 — 토스 successUrl 쿼리스트링을 신뢰하지 말 것(변조 방지).
order_id는 서비스 내 고유 + 멱등 — 같은order_id재요청은 기존 결제를 반환한다(이중결제 방지).- 접근 제어는
access_allowed하나로 — 구독 조회 응답의 이 불리언으로 판단하고, 상태별 분기를 직접 구현하지 말 것.
⚠️주의(알림 수신 등록): 샘플은 결제 서버와 별도 docker다. 결제 서버 컨테이너에서 샘플의
/notify에 닿으려면 알림 URL을http://host.docker.internal:8001/notify로 등록한다(localhost:8001·컨테이너명은 닿지 않음)./notifications화면 상단에 이 주소가 복사 버튼과 함께 표시되고, 어드민의 '테스트 알림 전송'으로 연결을 즉시 확인할 수 있다. 수신 측 서명검증·전달 규약은 서비스 알림 참고.
19.9 핵심 호출 예시 (요청/응답)
샘플이 실제로 주고받는 두 호출. 모든 요청에는 payment_client.py가 HMAC 서명 4개 헤더(x-service-key/x-timestamp/x-nonce/x-signature)를 붙인다.
① 카드 등록 — POST /api/v1/cards (토스 successUrl 콜백에서 호출)
// 요청 — customer_key·auth_key는 토스 빌링 인증창에서 받은 값
{ "external_user_id": "han@han.com", "customer_key": "cust-123", "auth_key": "toss_auth_key_xxx" }
// 응답 201 — billingKey는 절대 내려오지 않고 마스킹 카드만
{ "external_user_id": "han@han.com", "card": { "issuerCode": "61", "number": "123456******1234" } }
② 구독 생성 — POST /api/v1/subscriptions (등록 카드로 즉시, 토스 재인증 없음)
// 요청 — 금액·카드 정보 없음(서버가 요금제·등록 카드에서 처리)
{ "external_user_id": "han@han.com", "plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "trial": false }
// 응답 201
{ "id": "…", "plan_name": "프리미엄 월간", "status": "ACTIVE", "access_allowed": true }
③ 웹훅 수신 — POST /notify (결제서버 → 샘플, 수신 측 서명 검증)
결제서버는 구독·결제·카드·요금제 이벤트가 나면 서비스가 등록한 알림 URL로 JSON을 POST한다. 샘플은 API 호출과 같은 서명 규약으로 들어온 헤더(X-Signature/X-Timestamp/X-Nonce)를 _verify_notify_signature로 검증한 뒤 NotificationRecord에 저장한다(shop/views.py:notify_receive_view).
# 수신 측 서명 검증(payment_client.sign_request 미러) — shop/views.py
canonical = f"POST\n{path}\n{ts}\n{nonce}\n{hashlib.sha256(body).hexdigest()}"
expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
verified = hmac.compare_digest(expected, request.headers.get("X-Signature", ""))
🍎쉽게 말하면 서비스가 직접 챙기는 호출은 ①카드 등록 → ②구독 요청 둘뿐이고, 자동결제·연장은 서버가, 상태 변화 통지는 알림(
/notify)이 처리한다. 부가 호출로get_subscription/cancel/resume/manual_pay(구독 관리)·get_card/delete_card(카드 보관함)·get_payments/cancel_one_off_payment(결제 내역)·add_usage_days(사용일 추가)가payment_client.py에 함께 들어 있다. 더 많은 엔드포인트·필드·오류코드는 서비스 API 연동 참고.함께 보기: API 전체 레퍼런스는 서비스 API 연동, 11.8의 최소 연동 예제와 함께 보면 코드가 더 빨리 잡힙니다.