콘텐츠로 이동

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

References

  • ADR-0011 — Hybrid 모델 (결제 주체 = User)
  • ADR-0012 — License/Subscription 분리
  • ADR-0022 — 결제 시점 jitter