ADR-0013: Payment: Toss Billing¶
Umbra 의 결제 수단으로 Toss Payments 의 정기결제(Billing) API 를 채택한다. 빌링키는 AES-256-GCM 으로 암호화하여 DB 에 저장하며, 공식 Go SDK 는 없으므로 자체 HTTP 클라이언트를 구현한다.
Status¶
Accepted
- Decided at — 2026-04-13
- Decided by — Pablo
Context¶
Umbra 의 구독 결제 수단을 결정해야 한다. 선택 기준:
- 한국 시장 우선 (한국 사용자 카드 결제)
- 구독형 정기결제 지원
- 빌링키 기반 (카드 정보를 우리가 보관하지 않음)
- 웹훅으로 결제 상태 변경 수신
- Luxtra 의 사업자(279-01-03973) 으로 가맹 가능
후보: Toss Payments, 이니시스(KG), 카카오페이, NICE 결제, Stripe (해외).
Decision¶
Toss Payments 의 Billing API (정기결제 빌링키 기반) 를 채택한다.
- 빌링키 발급 — 클라이언트 SDK (
@tosspayments/payment-sdk) 로 위젯 호출, authKey 받아 서버에서/v1/billing/authorizations/issue호출 - 정기결제 — 서버 cron 이
/v1/billing/{billingKey}호출 - 웹훅 —
/webhooks/toss에서 수신, HMAC 서명 검증 + idempotency - SDK — 공식 Go SDK 없음. 자체 HTTP 클라이언트를
engine/billing/adapter/toss/에 구현 - 빌링키 저장 — AES-256-GCM 암호화, 키는 환경변수 (MVP)
선택 근거:
- 한국 시장 표준 — 개인 카드 정기결제 UX 가 가장 자연스러움
- PCI-DSS 부담 0 — 카드 정보는 Toss 가 보관, 우리는 빌링키만
- 가맹 프로세스 간단 — 개인사업자로 가맹 가능
- 위젯 UX 우수 — 클라이언트 SDK 가 결제창 제공
- 문서 품질 — 공식 문서가 한국어로 충실
Consequences¶
Positive¶
- 한국 사용자에게 가장 친숙한 결제 UX
- 카드 정보 직접 취급 안 함 → PCI 부담 0
- 빌링키 발급/정기결제/웹훅 흐름이 명확
- Toss 의 장애 대응력(금융권 수준)
Negative¶
- 공식 Go SDK 부재 → 자체 HTTP 클라이언트 구현/유지보수 부담
- Toss 종속성 (다른 PG 로 이전 시 어댑터 전체 교체 필요)
- 해외 카드 결제 지원 약함 (글로벌 확장 시 Stripe 병행 검토)
Neutral¶
- 빌링키 암호화는 애플리케이션 계층 책임 (Neon 은 컬럼 단위 암호화 미제공)
- 수수료는 Toss 정책 (개인 카드 기준, 협의 가능)
Decision details¶
customerKey format¶
Toss 의 customerKey 는 우리가 정의. User 식별자로 사용.
- 형식:
user_{uuid_v7} - 예:
user_01J0XX...XXXXX - Discord User ID 를 직접 노출하지 않음 (탈퇴/재가입 시 분리 유지)
orderId format¶
Toss 의 orderId 는 결제 단위 식별자. 글로벌 유니크.
- 형식:
sub_{subscription_id}_{cycle}_r{retry} - 예:
sub_01J0XX..._001_r0(첫 결제),sub_01J0XX..._002_r1(2개월차 1차 재시도) - Toss 는 동일 orderId 중복 결제 거부 → 재시도마다 새 orderId
Encryption of billing keys¶
빌링키는 AES-256-GCM 으로 암호화하여 billing.billing_keys.encrypted_key (BYTEA) 에 저장.
- 키는 환경변수
BILLING_KEY_ENCRYPTION_KEY(32 bytes, hex 또는 base64) - Nonce 는 매 암호화마다 생성하여 같은 컬럼 또는 prefix 에 저장
- 키 로테이션은 Phase 2 검토 (KMS 전환 포함)
Webhook handling¶
- Toss 웹훅은
POST /webhooks/toss에서 수신 - 서명 검증 (Toss 시크릿 기반 HMAC)
- Redis SET NX 로 idempotency 체크 (
idem:webhook:{event_id}, TTL 24h) - 이벤트 타입별로 Billing 도메인 호출
- 2xx 응답으로 재전송 방지
Recurring charge workflow¶
- asynq cron 이 매 정각 "다음 결제 대상" 조회
- jitter(±15분) 적용된
next_billing_at시점에 결제 큐에 push (ADR-0022) - Worker pool 이 병렬 처리 (Toss rate limit 고려)
- 성공/실패 결과에 따라 Subscription 상태 갱신, 이벤트 발행
Retry policy on failure¶
- 1차 실패 → 24h 후 재시도
- 2차 실패 → 48h 후 재시도
- 3차 실패 → 72h 후 재시도
- 4차 실패 → 자동 해지 (
PaymentFailedFinal이벤트) - Grace period 동안 License 는 유지 (ADR-0012)
Alternatives considered¶
Alternative 1: 이니시스(KG)¶
Pros
- 한국 PG 최대 규모
Cons
- 가맹 프로세스가 Toss 대비 복잡
- 문서/개발자 경험이 Toss 보다 낮음
- UX 가 구식
Why rejected — Toss 가 개발자 경험 우위.
Alternative 2: Stripe¶
Pros
- 글로벌 표준
- 공식 Go SDK 존재
- 강력한 API
Cons
- 한국 카드 결제 UX 열세 (3D Secure 경험 저하)
- 한국 사업자 가맹 절차 복잡
- 원화 결제 시 환전 이슈
Why rejected — 한국 시장 타겟에 부적합. 해외 확장 시 병행 고려.
Alternative 3: 카카오페이¶
Pros
- 한국 사용자 친숙
Cons
- B2B SaaS 구독형 결제 지원 약함 (주로 단건)
- 빌링키 기반 정기결제의 개발자 경험 부족
Why rejected — 정기결제 적합성 낮음.
Alternative 4: NICE 결제¶
Pros
- 국내 오래된 PG
Cons
- 개발자 경험이 Toss 에 현저히 열세
- API 가 구식
Why rejected — Toss 가 현재 한국 SaaS 에서 사실상 표준.
Compliance¶
- 모든 Toss API 호출은
engine/billing/adapter/toss/를 경유 (다른 도메인에서 직접 호출 금지) - 빌링키는 항상 암호화된 상태로 DB 에 저장, 메모리에만 복호화
- 로그에 빌링키 원본 출력 금지 (카드 번호 마스킹 규칙 동일 적용)
- 웹훅은 반드시 서명 검증 후 idempotency 체크 통과해야 처리
- 테스트 환경은 Toss 테스트 키 사용, 프로덕션 키와 분리
Revisit triggers¶
- Toss 가 신규 API 버전 (v2 등) 을 내면 마이그레이션 ADR
- 해외 결제 수요가 생기면 Stripe 병행 도입 ADR
- KMS 기반 키 관리 필요성이 생기면 암호화 전략 ADR
- 비즈니스 요구로 계좌이체, 가상계좌 같은 비카드 결제 필요 시 ADR