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_idFK)
두 entity 는 Outbox event 로 동기화된다 (ADR-0016). 예:
PaymentSucceeded→ License 의expires_at연장PaymentFailedFinal→ License 를 Free 로 downgradeBotKicked→ 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 는
licensingschema, Subscription 은billingschema 에 위치 - 두 entity 간 참조는
subscription.license_id→license.idFK 로만 - Licensing 코드는 Billing 코드를 직접 호출하지 않음 (이벤트 경유)
- 새 상태 전이는 ADR 또는 도메인 문서 업데이트 필수
Revisit triggers¶
- Licensing 과 Billing 이 항상 1:1 로만 사용되고 분리의 이점이 사라지면 통합 검토 (현재는 Free Plan 덕에 1:0 관계 존재)
- 유즈 케이스 확장으로 한 License 가 여러 Subscription 을 가져야 할 상황 (선불권 + 자동 갱신 병행 등)