콘텐츠로 이동

ADR-0012: License and Subscription Separation

Umbra 의 권한 모델(License)과 결제 모델(Subscription)을 별도 entity 로 분리한다. 서로 다른 lifecycle 을 가지며 이벤트로 동기화된다.

Status

Accepted

  • Decided at — 2026-04-13
  • Decided by — Pablo

Context

Hybrid 라이선스 모델(ADR-0011) 채택 후 데이터 모델을 설계하면서 한 가지 결정이 필요했다. "Guild 의 권한 상태" 와 "결제 구독 상태" 를 단일 entity 로 합칠 것인가, 분리할 것인가?

현실 관찰:

  • Free Plan 의 Guild 는 권한은 있지만 결제는 없다 (Toss 호출 없음)
  • 결제가 실패하면 Grace period 동안 권한은 유지 하지만 결제 상태는 past_due
  • 사용자가 해지 요청하면 current_period_end 까지 권한은 active, subscription 은 canceled 상태
  • 봇 강퇴 시 권한은 즉시 suspend, 결제는 별도로 suspend

즉 "권한의 lifecycle" 과 "결제의 lifecycle" 은 명백히 다른 시간 축 에서 움직인다.

Decision

License 와 Subscription 을 별도 entity, 별도 schema 로 분리한다.

Licensing domain (licensing.licenses)

  • "이 Guild 는 어떤 Plan 권한을 갖는가" 만 관심
  • Status: active | suspended | canceled
  • Free Plan 은 이 테이블에만 존재 (Subscription 없음)
  • Plan 변경, 기간 연장, suspension 의 주체

Billing domain (billing.subscriptions)

  • "이 결제 구독이 어떻게 진행 중인가" 만 관심
  • Status: pending | active | past_due | canceled | suspended
  • Toss Billing 과의 연동 지점
  • License 를 참조 (license_id FK)

두 entity 는 Outbox event 로 동기화된다 (ADR-0016). 예:

  • PaymentSucceeded → License 의 expires_at 연장
  • PaymentFailedFinal → License 를 Free 로 downgrade
  • BotKicked → License suspend + Subscription suspend

선택 근거:

  • 도메인 경계 명확 — Licensing 은 "권한 부여", Billing 은 "돈 받기". 혼합 시 응집도 저하
  • Free Plan 처리 자연스러움 — License 만 존재, Subscription 없음
  • Grace period 표현 자연 — License 는 active, Subscription 은 past_due
  • lifecycle 독립 — 한 쪽 변경이 다른 쪽에 즉시 영향 주지 않음 (이벤트 경유)
  • 테스트 단순 — Licensing 로직만 테스트할 때 결제 mock 불필요

Consequences

Positive

  • 두 도메인의 책임이 명확히 분리
  • Free Plan 을 자연스럽게 표현 (billing 호출 0)
  • Grace period, cancel-at-period-end 같은 시나리오가 두 상태 조합으로 표현
  • Licensing 코드는 Billing 을 몰라도 되고 vice versa (이벤트만 주고받음)

Negative

  • 엔티티 2개 관리 (단일 엔티티 대비 복잡도 ↑)
  • 상태 동기화가 비동기라 수 초 지연 존재 (Outbox poller 주기)
  • "왜 둘을 분리했는지" 를 신규 팀원에게 설명 필요

Neutral

  • Audit log 가 두 엔티티 모두 추적 필요
  • 대시보드는 두 상태를 조합해서 사용자에게 하나의 상태처럼 표시 (예: "활성 — 다음 결제 4/30")

Domain model

Licensing

Plan (마스터 데이터)
├─ id, code (FREE/PRO/ENTERPRISE)
├─ features, limits
├─ price_krw, billing_cycle

License
├─ id (UUID v7)
├─ guild_id → guild.guilds
├─ plan_id → licensing.plans
├─ status (active/suspended/canceled)
├─ granted_at, expires_at, canceled_at, suspended_at
└─ UNIQUE(guild_id) WHERE status = 'active'

Billing

BillingKey
├─ id
├─ user_id → identity.users
├─ customer_key, encrypted_key (AES-256-GCM)
└─ card_last4, card_company

Subscription
├─ id (UUID v7)
├─ license_id → licensing.licenses
├─ payer_user_id → identity.users
├─ billing_key_id → billing.billing_keys
├─ plan_id → licensing.plans
├─ status (pending/active/past_due/canceled/suspended)
├─ current_period_start, current_period_end
├─ next_billing_at (jitter 적용)
├─ cycle_count, retry_count
└─ cancel_at_period_end

State synchronization

Event License 변화 Subscription 변화
BotInstalled Free License 생성
SubscriptionStarted (Pro 결제) License Pro 로 업데이트 Subscription active
PaymentSucceeded expires_at 연장 period 갱신, cycle_count++
PaymentFailed (1차) 유지 (Grace) past_due, retry_count=1
PaymentFailedFinal (4차) Free 로 downgrade canceled
User requests cancel 유지 (period_end 까지) cancel_at_period_end=true
Period end 도달 Free 로 전환 canceled
BotKicked suspended suspended

Alternatives considered

Alternative 1: 단일 entity (License + Subscription 통합)

Pros

  • 엔티티 1개로 단순
  • 모든 상태가 한 곳에

Cons

  • Free Plan 이 부자연스러움 (billing 필드를 null 로 두는 hack)
  • Grace period 를 한 status 로 표현 어려움 (active+past_due 조합 필요)
  • Licensing 과 Billing 로직이 같은 entity 에 묶여 응집도 저하
  • Audit 시 어느 필드가 누구 책임인지 모호

Why rejected — 응집도와 표현력 둘 다 열세.

Alternative 2: License 만 두고 Billing 은 외부 log 로

Pros

  • 단순

Cons

  • 결제 재시도, 빌링키 관리가 1급 시민이 아니게 됨
  • Toss 연동이 여기저기 흩어짐

Why rejected — Billing 이 별도 도메인이어야 할 만큼 복잡하고 중요.

Compliance

  • License 는 licensing schema, Subscription 은 billing schema 에 위치
  • 두 entity 간 참조는 subscription.license_idlicense.id FK 로만
  • Licensing 코드는 Billing 코드를 직접 호출하지 않음 (이벤트 경유)
  • 새 상태 전이는 ADR 또는 도메인 문서 업데이트 필수

Revisit triggers

  • Licensing 과 Billing 이 항상 1:1 로만 사용되고 분리의 이점이 사라지면 통합 검토 (현재는 Free Plan 덕에 1:0 관계 존재)
  • 유즈 케이스 확장으로 한 License 가 여러 Subscription 을 가져야 할 상황 (선불권 + 자동 갱신 병행 등)

References

  • ADR-0011 — Hybrid 모델 (이 분리의 상위 결정)
  • ADR-0013 — Toss Billing
  • ADR-0016 — 두 도메인 동기화 수단
  • ADR-0020 — licensing/billing schema 분리