콘텐츠로 이동

Billing, Subscription & Entitlement

분류: Layer 13 - Product Engineering & Growth Systems

Product Engineer가 결제 기능을 다룬다는 것은 checkout 버튼을 붙이는 것에서 끝나지 않는다. 사용자가 무엇을 구매했는지, 돈이 실제로 회수되었는지, 지금 어떤 기능을 쓸 수 있는지, 사용량이 다음 청구서에 얼마로 반영될지를 서로 다른 사실로 모델링해야 한다. Webhook이 중복·지연·역순으로 오거나 아예 누락되어도 이 사실들이 다시 수렴하도록 설계하는 것이 핵심이다.

Billing, Subscription & Entitlement는 판매 상품, 반복 청구 관계, 결제 사실, 사용량, 기능 접근 권리를 각각 모델링하고 실패 후에도 일관된 상태로 회복시키는 Product Engineering 역량이다.

  • Plan(플랜) 은 고객에게 판매하는 기능 묶음과 가격 정책이다. Free, Team 같은 이름은 판매 언어이지 런타임 권한 그 자체가 아니다.
  • Subscription(구독) 은 고객과 반복 청구 상품 사이의 시간 관계다. 활성, 연체, 기간 말 해지 같은 lifecycle(수명 주기)을 가진다.
  • Invoice(청구서) 는 특정 기간에 얼마를 왜 청구하는지 적은 회계 단위다. 구독 하나에서 여러 청구서가 생긴다.
  • Payment(결제) 는 특정 청구 금액을 실제로 회수하려는 시도와 결과다. 하나의 청구서에 실패와 재시도를 포함한 여러 결제 상태가 있을 수 있다.
  • Entitlement(이용 권리) 는 현재 account나 workspace가 어떤 기능·용량·기간에 접근할 수 있는지 표현한 제품 계약이다.
  • Provisioning(권리 반영) 은 entitlement를 계산하고 API, UI, background job이 조회할 수 있는 접근 상태로 반영하는 과정이다.
  • Meter(사용량 계량기) 는 좌석, API 호출, 저장 공간처럼 청구할 사용량 사실을 기간별로 집계하는 규칙이다.
  • Webhook(웹훅) 은 결제 제공자가 상태 변화를 비동기로 알려 주는 HTTP 이벤트다. 전달 알림이지 유일한 원장은 아니다.
  • Idempotency(멱등성) 는 같은 논리 작업을 여러 번 처리해도 최종 결과가 한 번 처리한 것과 같게 만드는 성질이다.
  • Reconciliation(대사) 은 외부 결제 사실과 내부 구독·사용량·권리 상태를 주기적으로 비교해 차이를 찾아 고치는 과정이다.

이 문서의 예시는 다음 제품 정책을 사용한다. 숫자와 상태명은 학습을 위한 구체적 계약이며 모든 SaaS의 보편 기준은 아니다.

정책 축예시 계약
무료청구하지 않으며 활성 seat를 최대 3개까지 허용하는 hard cap
유료Team plan, 월 12달러/active seat, members.limit = 20
active seat해당 workspace에서 현재 active 상태인 Membership 수
유료 상한Entitlement의 members.limit = 20, 청구 meter active_seat_count와 분리
연체현재 미납 invoice의 최초 invoice.payment_failed.created부터 7일간 권리 유지
grace 만료결제가 회복되지 않으면 쓰기·유료 기능을 제한하되 데이터 보존은 별도 정책으로 처리

Hard cap(강제 상한) 은 초과를 허용한 뒤 경고하는 soft limit과 다르다. 무료 workspace의 네 번째 활성 membership은 같은 트랜잭션에서 거절되어야 하며, 나중에 들어오는 결제 provider 이벤트에 맡겨서는 안 된다.

2. 왜 checkout 구현만으로는 닫히지 않는가

섹션 제목: “2. 왜 checkout 구현만으로는 닫히지 않는가”

SaaS(Software as a Service, 서비스형 소프트웨어)에서 billing은 revenue(매출)와 user experience(사용자 경험)가 만나는 민감한 흐름이다.

  • 결제에 성공했는데 유료 기능이 열리지 않으면 사용자는 돈을 잃었다고 느낀다.
  • 결제가 실패했는데 기능이 계속 열리면 revenue leakage(받아야 할 매출 누수)가 생긴다.
  • plan 이름으로 직접 기능 접근을 판단하면 가격제 변경 때 제품 코드가 흔들린다.
  • webhook이 중복·지연·역순·누락되면 subscription과 entitlement가 어긋난다.
  • seat, usage limit, trial, coupon, cancellation이 얽히면 같은 고객을 두고 청구서와 제품 화면이 서로 다른 말을 하기 쉽다.

Checkout 응답만 믿는 설계에는 두 가지 빈틈이 있다. 첫째, 브라우저는 결제 후 redirect 전에 닫힐 수 있다. 둘째, 결제 성공과 구독 활성화, 청구서 확정, 권리 반영은 서로 다른 시각에 끝날 수 있다. 따라서 “성공 페이지를 보았다”는 UX 사실을 “돈이 회수되었다”는 재무 사실이나 “권리가 열렸다”는 제품 사실로 사용할 수 없다.

Product Engineer는 결제 provider의 객체와 내부 제품 객체를 분리해서 이해해야 한다. Provider의 Subscription은 외부 청구 관계의 권위이고, 내부 Entitlement는 제품 접근 판단의 권위다. 둘 사이에는 명시적인 번역 정책과 복구 경로가 필요하다.

2.5 선행 방식의 한계 - 왜 Entitlement가 필요한가

섹션 제목: “2.5 선행 방식의 한계 - 왜 Entitlement가 필요한가”

초기 제품은 plan === "pro"이면 특정 기능을 여는 방식으로 충분해 보인다. 하지만 add-on, enterprise 예외 계약, trial, grace period, 점진적 rollout이 생기면 plan 조건문이 UI와 API 곳곳에 퍼진다. 같은 Team plan이라도 한 workspace에는 SSO(Single Sign-On, 한 번의 인증으로 여러 시스템을 쓰는 기능) add-on이 있고 다른 workspace에는 없을 수 있다.

판매 plan과 entitlement를 분리하면 이 문제를 다음과 같이 바꿀 수 있다.

판매 계약: Team + SSO add-on + 월별 active-seat 과금
↓ 정책 평가
제품 권리: advanced_export=true, sso=true, members.limit=20
↓ 접근 판정
실행 결과: API 허용 / UI 노출 / background export 실행

Stripe Billing Entitlements는 product feature를 상품에 연결하고 active entitlement 변경을 webhook으로 알리며, 빠른 권한 판정을 위해 entitlement를 내부에 저장할 것을 권장한다. 그러나 provider의 entitlement 기능을 쓰더라도 무료 seat hard cap, 내부 role, enterprise override, 데이터 보존 같은 제품 규칙까지 자동으로 결정해 주지는 않는다.

핵심 철학은 가격표를 권한 코드로 사용하지 않고, 돈의 사실을 제품 정책으로 번역한 결과를 entitlement로 사용한다는 것이다. 이 경계가 있으면 판매 상품을 바꾸면서도 안정적인 featureKey 계약을 유지할 수 있다.

퀴즈

plan 이름으로 직접 기능 접근을 판단하면 왜 위험한가?

힌트: pricing은 바뀌지만 기능 접근 규칙은 더 안정적인 계약이어야 한다.

정답 보기

plan 변경, add-on, enterprise 예외, trial, grace period가 생기면 plan 조건문이 코드 곳곳에 퍼진다. entitlement를 별도 모델로 두면 결제 상품과 내부 기능 접근 권한을 느슨하게 연결하고, 같은 plan 안의 예외도 명시적으로 표현할 수 있다.

3. 돈의 사실, 권리의 사실, 파생 상태를 분리한다

섹션 제목: “3. 돈의 사실, 권리의 사실, 파생 상태를 분리한다”

3.1 객체는 비슷해 보여도 질문이 다르다

섹션 제목: “3.1 객체는 비슷해 보여도 질문이 다르다”
객체답하는 질문내부 모델링 포인트
Customerprovider가 누구에게 청구하는가내부 workspace/account와 안정적으로 매핑
Product/Price무엇을 어떤 통화·주기로 판매하는가내부 plan과 policy version에 매핑
Subscription반복 청구 관계가 어떤 lifecycle인가active, past_due, canceled
Invoice이번 기간에 얼마가 확정·미수·무효인가open, paid, void, uncollectible
Payment/PaymentIntent돈 회수가 성공·처리 중·추가 행동 필요인가실패 이유와 재시도 가능성 분리
Payment Method어떤 결제 수단으로 돈을 회수하는가만료·교체·인증 필요와 기본 수단 갱신
Entitlement지금 어떤 기능과 한도를 허용하는가feature key, limit, source, validity
Seat usage현재 active seat가 몇 개인가versioned quantity snapshot
Access state요청 시 허용·grace·제한 중 무엇인가빠르게 조회 가능한 파생 projection

Projection(프로젝션) 은 원장 사실과 정책을 읽어 빠르게 조회할 형태로 만든 파생 상태다. 재계산할 수 있어야 하며, projection 한 행이 망가졌다고 돈의 원장이나 membership 역사를 덮어써서는 안 된다.

3.2 Authority와 ledger를 먼저 정한다

섹션 제목: “3.2 Authority와 ledger를 먼저 정한다”

Authority(권위 원천) 는 충돌이 났을 때 어느 기록을 사실로 채택할지 정한 소유권이다. Ledger(원장) 는 이미 일어난 결제·좌석·권리 변경 사실을 식별자와 시각과 함께 누적해 감사하고 다시 계산할 수 있게 한 기록이다. 반드시 회계 제품일 필요는 없지만, 과거 사실을 최신 값 하나로 덮어쓰지 않는다는 성질이 중요하다.

사실권위 원천파생하거나 복구할 대상
외부 invoice/payment/subscription의 현재 상태provider object API의 현재 조회 결과내부 billing snapshot
외부 상태가 과거에 어떻게 바뀌었는가raw provider event와 내부 inbox ledger감사·재처리 입력
membership의 생성·활성·비활성내부 product DB와 membership audit logactive seat 집계
어떤 plan이 어떤 feature를 주는가versioned entitlement policyworkspace entitlement
현재 요청을 허용하는가내부 entitlement/access projectionAPI·UI·worker의 접근 판정
provider에 보고한 metered usage내부 seat billing ledger와 provider meter receiptinvoice line과 usage summary

“Provider가 모든 것의 source of truth다”도, “우리 DB만 믿는다”도 충분하지 않다. Provider는 결제 사실을 알지만 내부 membership의 의미를 모른다. 제품 DB는 seat가 활성화된 이유를 알지만 카드 결제가 최종 승인되었는지 단독으로 확정할 수 없다. 또한 event payload는 발생 당시 상태를 보존하는 과거 변화 ledger이지 현재 상태 authority가 아니다. 현재 subscription·invoice·payment 판단은 provider object API의 현재 조회 결과를 사용하고, raw event와 inbox는 순서 재구성·감사·재처리에 사용한다.

Subscription, payment, access를 하나의 status 컬럼으로 합치면 모순을 표현할 수 없다. 예를 들어 장기 처리 결제 수단에서는 subscription이 active인데 payment가 processing일 수 있고, recurring payment 실패 직후에는 subscription이 past_due여도 제품 정책상 access는 grace일 수 있다.

trialing -> active
trialing -> canceled
incomplete -> active
incomplete -> incomplete_expired
active -> past_due
past_due -> active
past_due -> unpaid | canceled
active + cancel_at_period_end=true
-> cancellation_effective_at 도달 -> canceled

상태명과 가능한 전이는 provider 설정과 collection method에 따라 달라진다. Stripe의 subscription overviewcollection methods 문서는 초기 결제 실패의 incomplete와 갱신 결제 실패의 past_due, invoice·PaymentIntent 상태가 서로 다름을 설명한다. 내부 모델은 사용하는 provider 버전과 설정을 계약으로 고정해야 한다.

invoice: draft -> open -> paid
├-> void
└-> uncollectible
payment: requires_payment_method | requires_action | processing -> succeeded

invoice.paid는 청구서가 지불되었다는 사실이고 payment.succeeded는 특정 결제 시도의 성공이다. 구독 접근을 결정할 때 “최근 payment 하나”만 보면 재시도나 credit 적용을 놓칠 수 있으므로, 어떤 invoice가 서비스 기간의 의무를 충족했는지 정책으로 연결해야 한다.

이 문서의 제품 정책은 provider 상태를 다음 내부 상태로 번역한다.

Subscription/payment 사실내부 access제품 동작
무료, subscription 없음free핵심 기능 허용, 활성 seat 3개 hard cap
trialingtrialtrial entitlement와 종료 시각 표시
active, 현재 의무 충족paidpaid entitlement, members.limit=20
past_due, 최초 실패 후 7일 이내gracepaid entitlement 유지, 결제 복구 행동 제공
past_due, grace 만료restricted유료 쓰기 제한, billing·데이터 내보내기 경로 유지
active, cancel_at_period_end=true, 기간 남음paid내부 cancellation_effective_at까지 권리 유지
canceled 또는 unpaidfree 또는 restricted무료 cap과 데이터 보존 정책을 평가

Stripe Subscription object에서 cancel_at_period_end는 독립 status가 아니라 boolean이다. 값이 true여도 종료 전 subscription statusactive이며, provider가 실제 취소를 완료한 뒤 canceled가 된다.

Stripe의 2025-03-31.basil changelog는 Subscription top-level의 current_period_start/end를 제거하고 각 items.data[].current_period_start/end로 옮겼다. 따라서 translator가 subscription.current_period_end를 직접 읽게 하드코딩해서는 안 된다. Provider API version을 아는 adapter가 구버전 단일 기간 필드 또는 최신 item 기간들을 읽고, 내부에는 하나의 cancellation_effective_at으로 정규화한다.

단일 interval 상품이면 access를 부여하는 billable item의 종료 시각을 쓴다. Mixed interval subscription이면 “어느 item이 끝날 때 전체 제품 접근을 닫는가”를 상품 정책으로 먼저 정해야 한다. 예를 들어 핵심 base item 종료를 쓰거나, 모든 필수 item 중 earliest/latest를 선택할 수 있다. Stripe mixed-interval cancellation changelogmin_period_endmax_period_end를 제공하며 기존 cancel_at_period_end는 earliest item period end로 해석된다고 설명한다. Adapter는 이 provider 동작과 내부 상품 정책의 불일치를 검증하고, access layer는 provider 필드 경로가 아니라 정규화된 cancellation_effective_at만 읽는다.

마찬가지로 past_due가 되었다고 즉시 권리를 끊거나 active라고 무조건 권리를 열지 않고, provider snapshot, service period, grace_expires_at, policy version을 함께 평가한다.

type AccessState = "free" | "trial" | "paid" | "grace" | "restricted";
type Entitlement = {
workspaceId: string;
featureKey: "advanced_export" | "sso" | "api_access" | "members.limit";
enabled: boolean;
limit?: number;
source: "free" | "subscription" | "trial" | "enterprise_override";
policyVersion: string;
validUntil?: string;
};

이 모델을 두면 UI, API, background job이 같은 featureKey로 접근을 확인할 수 있다. 단, UI에서 버튼을 숨기는 것은 편의일 뿐 보안 경계가 아니다. API와 background worker도 서버 측 entitlement를 확인해야 한다.

members.limit는 제품이 허용하는 가입 상한인 entitlement key이고, active_seat_count는 현재 active Membership 수를 provider에 전달하는 billing meter 이름이다. 권리와 사용량을 같은 active_seat 이름으로 뭉치지 않아야 상한 20과 현재 청구량 6을 구분할 수 있다.

Grace period(유예 기간)는 “대충 일주일”이 아니라 시간 계약이다. 이 worked example은 provider가 자동 결제를 시도하는 charge_automatically collection method로 한정한다. 현재 미납 invoice에 대해 로컬 inbox에 처음 영속한 invoice.payment_failed event의 created 시각grace_started_at으로 기록하고, grace_expires_at = grace_started_at + 7 * 24시간으로 고정한다. Event의 provider 생성 시각과 로컬 수신·처리 시각을 분리해 저장해야 전달 지연이 grace를 늘리지 않는다.

send_invoice 같은 수동 청구는 결제 실패 event가 아니라 due date 경과로 past_due가 될 수 있다. 이 모델을 지원한다면 invoice.due_date 또는 provider가 확인한 연체 전이 시각 중 어떤 값을 grace 기준으로 쓸지 별도 정책으로 정하고, 자동 청구의 invoice.payment_failed.created와 같은 필드처럼 취급하지 않는다.

  • 6일 23시간 59분에는 grace다.
  • 7일 경계가 지나고 결제가 회복되지 않았으면 restricted다.
  • 같은 invoice의 payment retry가 실패해도 시작 시각을 다시 잡지 않는다.
  • 새 webhook이 늦게 왔다는 이유로 수신 시각부터 7일을 다시 세지 않는다.
  • 결제가 회복되면 provider의 최신 invoice·subscription 사실을 확인한 뒤 paid로 되돌린다.

Webhook이 누락되었다면 reconciliation은 Stripe Events API에서 해당 invoice의 invoice.payment_failed event를 찾는다. Events API의 조회 가능 범위는 현재 최대 30일이므로, 범위 안에서 찾으면 그 event의 원래 created를 로컬에 영속한다. 범위 밖이거나 event를 찾지 못했는데 provider object가 여전히 past_due라면 provider에 없는 별도 진입 시각을 추정하지 않는다. 이 예시의 customer-safe fallback은 reconciliation_detected_at을 grace 시작으로 사용해 잘못된 즉시 차단을 피하고, grace_source=fallback_detection과 수동 검토 신호를 남기는 것이다. 이 선택은 최대 7일의 추가 미회수 위험을 감수하므로 고원가 제품은 별도의 보수적 접근 정책을 계약에 명시해야 한다.

반례를 보자. 실패 webhook을 받을 때마다 grace_started_at = now()로 갱신하면 provider가 1일, 3일, 5일에 재시도할 때 유예가 계속 연장된다. 사용자는 의도보다 오래 무료로 유료 기능을 쓰고, 팀은 어떤 invoice 때문에 grace인지 설명할 수 없게 된다. Grace는 webhook 전달 횟수가 아니라 미납 의무의 안정적인 business key에 묶어야 한다.

5. Plan check가 아니라 Entitlement check

섹션 제목: “5. Plan check가 아니라 Entitlement check”

Plan Check와 Entitlement Check

Plan check

if plan === team 같은 조건으로 기능을 연다.

초기에는 빠르지만 add-on, grace, 예외 계약, 가격제 변경에 약하다.

Entitlement check

feature key별 접근 권한과 limit을 조회한다.

add-on, trial, enterprise override, staged rollout에 강하다.

Usage check

entitlement limit과 원장의 현재 사용량을 함께 본다.

seat, API call, storage, export count 같은 제한에 필요하다.

Audit check

어떤 사실과 policy version이 권리를 열고 닫았는지 추적한다.

billing incident, 환불 분쟁, support 대응에 필요하다.

Entitlement는 boolean만으로 충분하지 않을 수 있다. 무료 plan의 active_seatenabled=true, limit=3이고, 유료 active-seat metered plan은 enabled=true, limit=20이다. 유료 plan에서도 20은 가입을 허용하는 Entitlement 상한이고, 청구 quantity는 그 시점의 현재 active Membership 수다. 따라서 상한이 20이어도 active가 7이면 7을 meter하며, “가입할 수 있는가”와 “지금 얼마를 청구할 것인가”를 분리한다.

언제 plan check를 허용할 수 있는가

섹션 제목: “언제 plan check를 허용할 수 있는가”

한 개의 유료 plan만 있는 짧은 prototype에서 중앙의 한 함수가 plan을 entitlement로 번역하는 것은 실용적이다. 그러나 UI, API, worker가 각각 plan === ...를 쓰기 시작했거나 add-on·grace·override 중 하나라도 생기면 feature key 기반 계약으로 전환할 신호다. 핵심 기준은 plan 개수가 아니라 같은 plan 이름이 더 이상 같은 접근 권리를 뜻하지 않는가다.

6. Seat는 접근 제한과 청구 사용량이 다르다

섹션 제목: “6. Seat는 접근 제한과 청구 사용량이 다르다”

무료 workspace에서 active membership 수가 3이면 네 번째 초대를 보내는 것까지 막을 필요는 없다. invited는 아직 제품 접근 권리가 없는 관계이므로 seat를 소비하지 않는다고 정책을 정할 수 있다. 하지만 초대 수락으로 invited -> active 전이할 때는 같은 트랜잭션에서 현재 active count와 limit을 검증해야 한다.

현재: owner + member_A + member_B = active seat 3개
입력: member_C가 초대를 수락
결과: membership 활성화 거절, 기존 3명은 영향 없음
안내: 비활성 member 제거 또는 Team으로 upgrade

먼저 활성화하고 나중 batch job으로 초과를 닫으면 짧은 시간 동안 네 번째 사용자가 권한을 얻고, 동시에 여러 수락이 들어오면 4, 5명까지 열릴 수 있다. Hard cap의 권위는 결제 provider가 아니라 membership 상태를 쓰는 내부 트랜잭션이다.

단순 COUNT(*) < 3은 두 수락이 동시에 2를 읽고 모두 통과할 수 있다. Counter 방식은 트랜잭션을 시작해 workspace별 workspace_seat_usage 행을 SELECT ... FOR UPDATE로 잠근 뒤 consumed_count < 3을 검사한다. 자리가 있으면 같은 트랜잭션에서 counter 증가, Membership 활성화, Invite 수락을 완료하고, 기다리던 두 번째 요청은 갱신된 3을 보고 SEAT_LIMIT_REACHED로 끝난다. 대안은 workspace마다 3개의 finite slot row만 만들고 빈 slot 하나를 원자 점유한 요청만 Membership을 활성화하는 방식이다. 두 방식 모두 “확인”과 “점유”가 한 직렬화 경계 안에 있어야 한다.

이 문서와 Domain 정본에서 active seat는 현재 active 상태인 Membership 수다. Team plan의 Entitlement는 members.limit = 20으로 가입 상한을 정하지만, 청구량은 현재 active Membership quantity다. 로그인 횟수나 기간 중 한 번이라도 활성화되었던 고유 사용자 수가 아니다. Membership이 active -> suspended로 바뀌면 quantity는 7 -> 6으로 내려가고, 다시 활성화하면 현재 snapshot은 6 -> 7이 된다. 현재 수가 20이면 21번째 활성화는 거절되고, Billing에는 구매 상한 20이 아니라 실제 quantity 20이 반영된다.

billing key = (workspace_id, quantity_version)
meter value = current_active_membership_count

Membership 상태 전이가 커밋될 때 active count의 before, after, 단조 증가하는 quantity_version을 seat ledger에 남긴다. 같은 version의 snapshot을 재전송해도 한 번만 반영되도록 request key와 meter event identifier를 안정적으로 만들고, provider receipt를 별도 상태로 기록한다.

여기서 quantity snapshotproration(일할 계산) 을 분리해야 한다. Snapshot은 “지금 몇 seat가 active인가”라는 사용량 사실이고, proration은 기간 중 quantity 변경을 남은 시간에 비례해 청구·크레딧으로 계산할지 정하는 가격 정책이다. 이 예시는 snapshot의 최신 값을 청구 quantity로 사용하고 자동 proration은 하지 않는다.

월초 Team workspace에 active Membership 5개, Entitlement members.limit = 20, seat당 월 12달러가 있다고 하자.

  1. 기간 시작 snapshot이 active Membership 5개이면 quantity는 5, 그대로 끝날 때 금액은 5 × $12 = $60이다.
  2. 10일에 두 명이 활성화되면 현재 snapshot과 provider meter의 최신 quantity는 7, 예상 금액은 7 × $12 = $84가 된다.
  3. 20일에 한 명을 비활성화하면 현재 snapshot은 6으로 내려가고, proration이 없는 이 예시의 기간 말 금액은 6 × $12 = $72다.
  4. 별도 proration 정책을 택하면 quantity 5 -> 7 -> 6은 그대로 보존하되 각 구간의 seat-time이나 provider의 quantity 변경 proration 규칙으로 금액을 계산한다. 예를 들어 단순 30일 월에서 10일에 추가한 2 seat의 남은 20일 비용은 2 × $12 × 20/30 = $16이지만, 세금·반올림·credit 처리의 최종 금액은 provider invoice가 권위다.

이 흐름에서 members.limit = 20은 금액 계산에 곱하는 구매 수량이 아니다. 현재 quantity 6은 상한 안에 있으므로 가입이 유효하고, proration 없는 기간 말 청구는 6 × $12다. 20번째 Membership까지 활성화할 수 있지만 21번째는 Entitlement 경계에서 거절되어 quantity가 21로 올라가지 않는다.

“기간 중 한 번이라도 활성화된 distinct membership 수”는 사용자를 제거해도 기간 quantity가 줄지 않는 별도 과금 모델이며 이 문서의 active-seat 정의가 아니다. 현재 active snapshot, 선구매 quantity, 기간 distinct, seat-time proration은 서로 다른 계약이므로 가격 문구와 meter 구현에서 섞지 않는다.

고객에게 약속한 가치적합한 모델피해야 할 모호함
미리 예약한 license 수만큼 언제든 사용licensed quantity실제 활동 수와 혼용
현재 active Membership 수quantity snapshot + lastsnapshot들을 sum해 과다 청구
기간 중 활성화된 고유 구성원 수별도 distinct-period 모델active-seat라는 이름으로 혼용
기간별 seat-time에 비례구간별 proration 또는 사전 집계quantity와 금액 정책을 한 값으로 봄
API·storage 소비량에 비례usage meter의 sum 또는 count원본 event와 보정 event를 덮어쓰기

Stripe의 per-seat pricing 문서는 고객이 정한 seat quantity를 recurring price의 단위로 쓰는 licensed 모델을 설명한다. Stripe meter 설정 문서는 meter aggregation으로 sum, count, last를 지원한다. 현재 active Membership의 snapshot을 보낼 때는 가장 최근 값이 기간 quantity가 되는 last가 맞고, snapshot 5, 7, 6sum하면 18로 과다 계산된다. Product Engineer는 aggregation과 proration을 각각 명시해야 한다.

Provider의 usage summary는 즉시 갱신되지 않을 수 있다. Stripe Meter Event API는 event identifier의 uniqueness를 rolling 최소 24시간 동안 강제한다고 명시한다. 이 보장은 짧은 accidental retry를 줄이는 provider 전송 계약이지 영구적인 도메인 중복 방지가 아니다. API idempotency key와 meter identifier가 만료된 뒤 같은 논리 snapshot을 다시 보내면 provider가 새 event로 받을 수 있다.

따라서 내부 UNIQUE (workspace_id, quantity_version)이 장기 멱등성의 권위다. 같은 트랜잭션에서 outbox row를 만들고 pending -> sent -> confirmed 상태, provider identifier, receipt, 마지막 확인 시각을 저장한다. Timeout이면 즉시 새 identifier로 보내지 않고 needs_reconcile로 두어 provider receipt·meter summary·오류 event를 조회한다. 24시간이 지난 장기 reconciliation도 outbox와 provider 조회 증거로 “미전송이 확실한가”를 판단한 뒤 재전송하거나 보정하며, provider idempotency를 영구 ledger처럼 취급하지 않는다.

따라서 사용자에게 upgrade 직후 예상 seat 금액을 보여줄 때 provider summary만 조회하면 잠시 5가 보이는 동안 내부 원장은 이미 7일 수 있다.

  • 접근 허용은 내부 membership·entitlement를 사용한다.
  • 예상 사용량은 내부 seat billing ledger를 사용한다.
  • 확정 청구 금액은 provider invoice를 사용한다.
  • 내부 ledger와 provider meter summary의 차이는 reconciliation 대상으로 관찰한다.

이렇게 해야 provider 집계 지연이 제품 접근을 막지 않고, 내부 보고 성공만으로 청구가 확정되었다고 오해하지 않는다.

7. Webhook은 알림이고, 정합성은 설계 결과다

섹션 제목: “7. Webhook은 알림이고, 정합성은 설계 결과다”

Stripe webhook 문서는 event 전달 순서를 보장하지 않으며, 필요한 객체를 API로 다시 조회하라고 설명한다. Live mode 실패 전달은 일정 기간 자동 재시도되므로 같은 event가 여러 번 올 수 있다. 반대로 endpoint 설정 오류, 보존 기간 경과, 내부 queue 손실 때문에 애플리케이션 관점에서 누락될 수도 있다.

  1. Provider signature를 검증해 위조·변조된 요청을 거부한다.
  2. 검증된 event에서 재처리에 필요한 allowlist 최소 필드와 event_id를 과거 변화 ledger인 inbox에 먼저 저장하고 빠르게 응답한다. 원본 raw payload가 진단에 꼭 필요하면 별도 격리 저장소와 짧은 TTL을 적용한다.
  3. Queue worker는 event_id 중복을 막되 event payload를 현재 authority로 쓰지 않고, 관련 subscription·invoice를 provider object API에서 최신 조회한다.
  4. 현재 내부 snapshot과 조회한 외부 사실을 정책 함수에 넣어 entitlement projection을 계산한다.
  5. Seat meter나 provisioning 같은 부작용은 business key로 멱등하게 수행한다.
  6. 처리 결과, policy version, 외부 object id를 audit log에 남긴다.
  7. 주기적 reconciliation이 webhook을 받지 못한 workspace까지 다시 비교한다.

Inbox가 ledger라고 해서 provider payload 전체를 무기한 저장해도 된다는 뜻은 아니다. 목적은 결제 상태 재처리와 제한된 감사로 고정하고, 기본 저장 스키마는 event_id, type, provider created, 필요한 object ID·상태·금액·기간처럼 복구에 필요한 필드만 allowlist한다. Invoice·Customer에 포함된 email, address, description, 자유 형식 metadata는 목적상 필요하지 않으면 수집 단계에서 버리고, 원문 payload가 일시적으로 꼭 필요하면 별도 격리 저장소에 암호화해 짧은 TTL(Time To Live, 자동 만료 기간)을 적용한다.

복호화 권한은 billing incident와 승인된 감사 역할로 제한하고 모든 열람·내보내기를 audit log에 남긴다. 정규화 inbox와 격리 원문의 보존 기간·삭제 job·legal hold 예외를 각각 문서화하고, 목적이 끝난 데이터는 삭제하거나 재식별할 수 없게 처리한다. 이는 European Commission의 GDPR 원칙이 설명하는 purpose limitation, data minimisation, storage limitation, integrity and confidentiality에 맞춘 설계 방향이다. 구체적인 관할·법적 근거·삭제 전파는 Privacy, Consent & Product Data Governance의 정책 경계에서 결정한다.

event_id deduplication만으로는 충분하지 않다. 서로 다른 event 두 개가 같은 최종 subscription 상태를 알릴 수 있고, 같은 quantity snapshot이 다른 내부 message id로 전달될 수도 있다. Transport 중복은 event_id, seat 보고 중복은 (workspace_id, quantity_version) 같은 business key로 각각 막는다.

다음 webhook 이벤트가 들어왔다고 하자. 네트워크 지연 때문에 실제 발생 순서와 수신 순서가 다르고, 한 이벤트는 중복이다.

received_orderevent_idprovider_created_atevent_typeprovider_status
1evt_10210:03invoice.payment_succeededactive
2evt_10110:01customer.subscription.updatedpast_due
3evt_10210:03invoice.payment_succeededactive
4evt_10310:04customer.subscription.updatedactive

처리 흐름은 다음과 같다.

  1. evt_102를 inbox에 기록하고 관련 invoice와 subscription의 최신 상태를 조회한다.
  2. evt_101은 더 오래된 사건이지만 timestamp 비교만으로 버리지 않는다. 같은 invoice인지 확인하고 provider API의 현재 사실이 active/paid라면 access를 past_due로 되돌리지 않는다.
  3. 두 번째 evt_102는 같은 event_id이므로 projection과 부작용을 다시 만들지 않는다.
  4. evt_103에서도 최신 provider snapshot을 기준으로 paid entitlement를 계산한다.
  5. 내부 entitlement가 여전히 free라면 provisioning job을 같은 business key로 재실행하고 repair 기록을 남긴다.

핵심은 “마지막으로 도착한 event가 이긴다”도 “timestamp가 가장 큰 payload가 이긴다”도 아니다. Event는 상태를 다시 확인하라는 신호이며, 최종 판단은 권위 API의 현재 snapshot, 해당 invoice의 의무, 내부 policy version을 함께 사용한다.

7.3 누락까지 포함한 failure worked example

섹션 제목: “7.3 누락까지 포함한 failure worked example”

시나리오

결제 실패 webhook은 누락되고 seat 보고 응답은 timeout 났다

월요일 09:00 갱신 결제가 실패해 provider subscription은 past_due가 되었지만 webhook endpoint는 event를 받지 못했다. 09:10 새 membership m_7이 활성화되어 active seat가 6→7이 되었고 provider meter 호출은 timeout 났다. 실제 provider 수신 여부는 알 수 없다. 수요일에는 지난달 invoice.paid event가 늦게 도착했다.

subscription access, 7일 grace, quantity 7의 중복 보고를 각각 어떤 권위와 business key로 회복해야 하는가?

이를 손으로 따라가 보자.

  1. 월요일 09:00의 누락 때문에 로컬 inbox에는 실패 event가 없다. 이것이 webhook-only 설계의 경계다.
  2. 09:10에는 (workspace_id, quantity_version=42, value=7) snapshot을 내부 seat ledger에 한 번 기록한다. Provider 호출 timeout은 unknown이지 failed가 아니다.
  3. Worker는 rolling 최소 24시간 범위 안에서 같은 API idempotency key와 meter event identifier를 사용한다. Timeout마다 새 key를 만들면 첫 요청도 성공했을 때 두 snapshot event가 생길 위험이 있다.
  4. 수요일에 늦은 지난달 invoice.paid가 와도 현재 invoice와 service period가 다르므로 현재 access를 paid로 덮지 않는다.
  5. 15분 reconciliation이 provider object API에서 현재 past_due와 open invoice를 확인한 뒤, 30일 Events API 범위에서 해당 invoice.payment_failed를 찾아 원래 created=월요일 09:00을 inbox에 복구한다. 이를 기준으로 grace_expires_at=다음 월요일 09:00을 계산한다.
  6. 같은 reconciliation 또는 별도 meter 대사가 quantity version 42의 outbox 상태와 provider receipt·비동기 오류 event·summary를 비교한다. 24시간이 지났다면 identifier 중복 방지를 더는 영구 보장으로 믿지 않고, 미전송 증거가 확인될 때만 재전송하거나 보정한다.
  7. 7일 안에 현재 invoice가 paid가 되면 paid access로 회복한다. 그렇지 않으면 경계 시각 이후 restricted로 전환한다.

이 사례에서 idempotency는 중복을 막지만 누락을 찾지 못한다. Reconciliation은 누락을 찾지만 안정적인 business key가 없으면 repair 과정에서 중복을 만들 수 있다. 둘은 대체 관계가 아니라 한 쌍이다.

8. Reconciliation을 두 번째 처리 경로로 설계한다

섹션 제목: “8. Reconciliation을 두 번째 처리 경로로 설계한다”

Webhook consumer와 reconciliation job은 결국 같은 applyBillingSnapshot() 같은 도메인 함수를 호출해야 한다. 한쪽은 event를 계기로 실행하고 다른 쪽은 시간과 범위를 계기로 실행할 뿐, 상태 번역 규칙이 다르면 repair가 새로운 drift를 만든다.

시나리오

결제 성공 webhook은 왔지만 유료 기능이 열리지 않았다

provider에는 subscription이 active이고 현재 invoice가 paid로 보이지만 내부 workspace entitlement는 free 상태다. 사용자는 결제 완료 직후 premium export가 막혔다고 문의했다.

webhook event log, subscription mapping, entitlement provisioning, retry와 reconciliation을 어떤 순서로 확인할 것인가?

먼저 checkout 화면을 다시 실행하거나 entitlement를 수동으로 바로 열지 않는다. 현재 invoice와 subscription을 provider API에서 조회해 돈의 사실을 확정하고, customer-to-workspace mapping이 맞는지 확인한다. 그다음 해당 snapshot과 policy version으로 entitlement를 재계산하고 같은 provisioning business key로 projection을 복구한다. 마지막으로 왜 자동 reconciliation이 이 drift를 먼저 찾지 못했는지 coverage와 lag를 확인해야 같은 장애가 다른 workspace에 남지 않는다.

비교불일치 예repair 방향
provider subscription vs 내부 snapshot외부 past_due, 내부 active외부 최신 사실을 저장하고 grace 재계산
provider invoice vs access projectioninvoice paid, access restrictedpolicy를 재평가해 entitlement 복원
membership authority vs seat ledger현재 active 7, 최신 snapshot 6새 quantity version으로 7 기록
seat outbox vs provider meter내부 7, provider 6receipt·summary 확인 후 재전송/보정
entitlement policy vs projectionpolicy v4, projection v3v4로 재계산하고 변경 audit 기록

Reconciliation 주기는 “빠를수록 좋다”가 아니라 허용 가능한 drift 시간과 API 비용의 trade-off다. 예를 들어 유료 workspace 10,000개15분마다 균등하게 확인하면 분당 약 667개, 초당 약 11개를 조회해야 한다. Provider rate limit, pagination, 장애 시 backlog를 감당할 수 있는지 계산해야 한다.

모든 workspace를 같은 빈도로 훑을 필요는 없다. 최근 결제 event가 있었거나 past_due, meter unknown, entitlement repair 실패인 대상은 빠른 queue로, 오래 안정적인 active 대상은 느린 full scan으로 나눌 수 있다. 단, event 기반 후보만 스캔하면 누락 event를 영원히 찾지 못하므로 낮은 빈도의 전체 대사는 남겨야 한다.

Repair job이 200 OK를 받았다고 끝난 것이 아니다. 다음 불변식이 다시 참이어야 한다.

  • 현재 invoice 의무와 내부 subscription snapshot이 같은 billing period를 가리킨다.
  • grace_started_at은 현재 미납 business key에 대해 한 번만 정해진다.
  • 내부 최신 quantity·outbox 상태와 provider receipt·summary가 같은 청구 사실을 가리킨다.
  • 같은 policy version과 입력으로 entitlement를 재계산하면 같은 결과가 나온다.
  • API, UI, worker가 동일한 access projection 또는 동일한 판정 서비스를 사용한다.

9. Billing UX는 내부 모순을 숨기지 않는다

섹션 제목: “9. Billing UX는 내부 모순을 숨기지 않는다”

Billing은 시스템 상태이면서 UX다. 사용자는 돈과 접근 권한에 민감하므로 상태뿐 아니라 이유, 경계 시각, 복구 행동을 명확히 보여줘야 한다.

상황UX 원칙
trial 종료 임박남은 기간과 다음 과금 시점을 보여준다
payment failedpast_due와 grace 만료 시각, 결제 수단 갱신 행동을 제공한다
무료 seat limit현재 3/3, 무엇이 active seat인지, 비활성화·upgrade 선택지를 보여준다
metered seat 증가현재 7/20, 청구 quantity 7, proration 정책을 구분해 보여준다
cancellation즉시 해지와 정규화된 cancellation_effective_at을 구분한다
upgrade success어떤 entitlement가 언제 열렸는지 확인시킨다
downgrade잃는 기능, seat 초과 처리, 데이터 보존 정책을 미리 보여준다

내부 access가 grace인데 UI가 “구독 취소됨”으로 표시하거나, entitlement가 아직 반영되지 않았는데 “모든 기능 활성화”를 보여주면 신뢰가 깨진다. UX는 provider status를 그대로 번역하는 뷰가 아니라 내부 제품 정책을 설명하는 인터페이스다.

UX 결정제품 계약 질문구현 계약
upgrade CTA를 어디에 보일까어떤 entitlement가 막혔는가API에 featureKey, accessState, reason code 포함
seat limit을 어떻게 보일까invited와 active 중 무엇을 세는가권위 counter/slot과 membership 전이를 원자화
payment failed를 언제 제한할까7일은 어느 시각부터인가graceStartedAt, graceExpiresAt, invoice key 저장
예상 금액을 어떻게 보일까current quantity와 proration은 무엇인가meter aggregation과 가격 policy version을 표시
downgrade 전에 무엇을 경고할까3-seat cap 초과와 데이터는 어떻게 되는가entitlement diff와 remediation preview 제공

무료로 downgrade할 workspace에 active member가 8명이라면 plan 이름만 free로 바꾸고 임의의 5명을 비활성화해서는 안 된다. 누가 접근을 잃을지 제품이 임의 결정하지 말고, period end 전 관리자에게 8 -> 3 remediation을 요구하거나 읽기 제한 상태로 전환하는 정책이 필요하다. 데이터 삭제 여부는 entitlement 회수와 별도의 retention policy다.

Access를 언제 동기적으로 열 것인가

섹션 제목: “Access를 언제 동기적으로 열 것인가”
  • Checkout 응답만으로 고가치 권리를 영구 활성화하지 않는다.
  • Provider API에서 현재 subscription·invoice를 확인할 수 있고 내부 provisioning이 멱등하면 짧은 동기 확인으로 UX 지연을 줄일 수 있다.
  • 확인이 끝나지 않았으면 pending UX를 보여주고 webhook/reconciliation으로 수렴시킨다.
  • 결제 처리 중에도 접근을 허용할지는 결제 수단 특성, 사기 위험, 서비스 원가, 환불 가능성을 함께 본다.

Grace는 단순 친절 기능이 아니다. 고객의 일시적 결제 실패로 업무가 끊기는 비용과 미회수 서비스 원가를 교환한다. 협업 데이터 읽기는 유지하고 고비용 export나 신규 seat 추가만 막는 단계적 제한도 가능하다. 무엇을 택하든 provider retry schedule과 내부 7일 정책이 서로 독립적임을 명시하고, 어느 쪽 event가 와도 grace 시작 시각이 흔들리지 않게 한다.

Provider entitlement를 그대로 쓸 것인가

섹션 제목: “Provider entitlement를 그대로 쓸 것인가”

상품 feature가 단순하고 예외가 적으면 provider entitlement를 외부 권위로 삼고 내부에 캐시할 수 있다. 내부 role, free cap, usage, 지역별 계약, enterprise override가 결합되면 provider feature를 입력 중 하나로 두고 내부 policy engine이 최종 entitlement를 계산하는 편이 낫다. 판단 기준은 기능 수보다 권리 결정에 provider가 모르는 제품 사실이 필요한가다.

가격 변경, 자동 갱신, 해지, 환불, 세금, grace 중 서비스 제공은 관할과 고객 계약에 따라 요구사항이 달라질 수 있다. Stripe도 entitlement·가격 변경에 법적 요구가 적용될 수 있다고 안내한다. 이 문서의 7일, $12, downgrade 규칙은 법률 조언이 아니라 시스템 모델 예시이며, 실제 정책은 적용 관할과 약관을 법무·재무 담당자와 확인해야 한다.

11. 실패 신호를 상태별로 읽는다

섹션 제목: “11. 실패 신호를 상태별로 읽는다”
관찰 신호의미할 수 있는 실패먼저 확인할 경계
invoice=paid, access=free가 5분 이상 지속provisioning 또는 projection 정체provider snapshot -> policy -> entitlement job
past_due인데 grace_started_at이 계속 바뀜retry event가 grace를 리셋invoice key, 최초 로컬 event, fallback source
같은 quantity version이 두 번 청구에 반영됨domain idempotency 실패unique (workspace_id, quantity_version)
내부 seat 7, provider summary 6보고 누락 또는 비동기 집계 지연receipt, identifier, summary freshness
내부 seat 7, provider invoice 8중복 identifier 또는 보정 오류provider meter event와 내부 ledger
webhook 처리 성공률은 높은데 drift 증가누락 event를 관찰하지 못함full reconciliation coverage
evt_duplicate_total 급증provider retry 또는 느린 endpoint빠른 inbox 저장과 응답 시간
grace 만료 후에도 paid write가 가능access cache 또는 worker 권한 검사 누락API·UI·worker의 공통 enforcement
무료 workspace가 4/3 activecap check racemembership 활성화 트랜잭션·잠금

절대 임계값은 트래픽과 고객 약속에 따라 정한다. 다만 지표는 최소한 money_to_access_lag, entitlement_drift_count, meter_usage_delta, oldest_unprocessed_event_age, reconciliation_repair_total, grace_expiry_backlog을 서로 분리해야 한다. 평균 하나만 보면 소수 고객의 장시간 유료 기능 미개방을 숨길 수 있으므로 p95(95번째 백분위수)나 최대 age도 함께 본다.

12. Analytics, 개인정보, 보안 경계

섹션 제목: “12. Analytics, 개인정보, 보안 경계”

Billing 기능도 제품 지표로 본다.

  • checkout_started -> checkout_completed conversion
  • payment failure recovery rate와 recovery까지 걸린 시간
  • trial_started -> paid conversion
  • upgrade 후 entitlement activation 성공률과 지연
  • downgrade/cancellation reason
  • entitlement provisioning failure와 reconciliation repair count
  • 무료 seat cap 도달률과 upgrade 전환
  • billing support ticket rate

그러나 analytics는 결제 원장이 아니다. 이벤트 유실·중복·스키마 변경이 있을 수 있으므로 매출, invoice, 환불, seat 청구의 최종 계산에 제품 analytics event를 사용하지 않는다. Twilio Segment의 Tracking Plan 문서는 수집 event와 property를 명시적 계약으로 관리하는 방법을 설명한다.

Card number, CVC(Card Verification Code, 카드 보안 코드), payment method 원문, 주소, invoice payload 전체뿐 아니라 원문 email도 analytics에 넣지 않는다. 분석에 provider customer/subscription ID나 내부 workspace/user ID가 꼭 필요하면 원문 노출을 기본값으로 두지 않고 최소 권한, 목적 제한, 보존 기간, 접근 감사를 적용한다. Stripe Integration Security Guide는 카드 소유자 데이터를 저장·처리·전송하는 주체에 PCI DSS(Payment Card Industry Data Security Standard, 카드 결제 정보 보안 표준)가 적용되며, provider와 사업자의 공동 책임임을 설명한다. 실제 범위와 준수 방식은 결제 통합 형태와 조직 정책에 맞춰 보안·컴플라이언스 담당자와 확인한다.

Billing/Subscription/Entitlement 체크

  • 판매 plan, provider subscription, invoice/payment, 내부 access 상태가 분리되어 있다
  • 각 돈·membership·정책·권리 사실의 authority와 ledger가 명확하다
  • plan 이름 대신 entitlement feature key와 limit으로 접근을 판단한다
  • 무료 3-seat hard cap을 membership 활성화 트랜잭션에서 강제한다
  • 유료 active seat가 현재 active Membership quantity로 일관되게 정의되어 있다
  • 유료 members.limit=20 가입 상한과 현재 active seat 청구량을 분리한다
  • quantity snapshot aggregation과 proration 가격 정책을 분리해 재현할 수 있다
  • API version과 item interval을 cancellation_effective_at으로 정규화한다
  • past_due 7일 grace가 로컬 payment_failed event 시각 또는 명시한 fallback에 고정된다
  • webhook 처리는 signature, inbox, idempotency, retry를 포함한다
  • 정규화 inbox에는 허용한 최소 필드만 두고 원문 payload는 별도 격리 저장소에서 암호화·역할 접근·짧은 TTL·삭제·감사를 적용한다
  • 중복·지연·역순뿐 아니라 누락을 reconciliation으로 복구한다
  • meter repair는 내부 quantity business key와 outbox로 24시간 이후에도 멱등하다
  • API, UI, background worker가 같은 access 정책을 집행한다
  • billing UX가 seat 정의, grace 만료, cancellation, downgrade 결과를 설명한다
  • analytics에 카드·email 원문을 넣지 않고 provider/internal ID 접근을 제한한다

Billing은 제품 도메인 모델과 privacy governance를 동시에 요구한다.

  • Frontend Product Quality & RUM: 결제와 권한 흐름의 오류·성능·폼 안정성을 본다.
  • Privacy, Consent & Product Data Governance: 결제 정보와 분석 이벤트의 개인정보 경계를 다룬다.
  • Product Domain Modeling: workspace, membership, entitlement의 상태 전이와 불변식을 기반으로 한다.
  • Retry, Backoff, Idempotency: provider 호출과 queue worker의 중복 부작용 방지를 더 깊게 다룬다.
  • Subscription lifecycle
  • Invoice and PaymentIntent state
  • Entitlement
  • Access provisioning
  • Entitlement provisioning
  • Access projection
  • Webhook inbox
  • Business idempotency key
  • Billing reconciliation
  • Grace period
  • Active-seat metering
  • Seat billing
  • Usage limit
  • Revenue leakage
  • Dunning(미수금 회수 절차)