Licensing Schema¶
licensingschema 는 Plan 정의와 길드별 License 권한 상태를 저장한다. License 의 권한 부여 lifecycle 은 Billing 의 결제 lifecycle 과 분리되어 있다.
Schema purpose¶
- PostgreSQL schema —
licensing - Corresponding domain —
engine/licensing/ - sqlc package —
db/queries/licensing/
Plan 은 마스터 데이터, License 는 trans 데이터. 권한 체크 쿼리가 핫 경로이므로 인덱스 최적화 중요.
Tables¶
licensing.plans¶
Plan 마스터.
CREATE TABLE licensing.plans (
id UUID PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
price_krw INTEGER,
billing_cycle TEXT CHECK (billing_cycle IN ('monthly', 'yearly')),
features TEXT[] NOT NULL DEFAULT '{}',
limits JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT plans_code_unique UNIQUE (code),
CONSTRAINT plans_free_no_billing CHECK (
(code = 'FREE' AND price_krw IS NULL AND billing_cycle IS NULL)
OR (code != 'FREE')
)
);
Columns
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID v7 | PK | Plan ID |
code | TEXT | UNIQUE | FREE / PRO / ENTERPRISE |
name | TEXT | NOT NULL | 표시명 |
price_krw | INTEGER | nullable | 월/연 가격 (Enterprise 는 custom) |
billing_cycle | TEXT | CHECK | monthly / yearly |
features | TEXT[] | NOT NULL | Feature 코드 목록 |
limits | JSONB | NOT NULL | 수치 제한 |
is_active | BOOLEAN | NOT NULL | 판매 중단 플래그 |
created_at | TIMESTAMPTZ | NOT NULL |
Indexes
| Index | Columns | Purpose |
|---|---|---|
plans_pkey | (id) | PK |
plans_code_unique | (code) | 코드 조회 |
Invariants
code유일, 영구 불변FREE는price_krw및billing_cycleNULL (CHECK 제약)
licensing.licenses¶
길드별 License.
CREATE TABLE licensing.licenses (
id UUID PRIMARY KEY,
guild_id UUID NOT NULL REFERENCES guild.guilds(id),
plan_id UUID NOT NULL REFERENCES licensing.plans(id),
status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'canceled')),
granted_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
suspended_at TIMESTAMPTZ,
suspended_reason TEXT,
canceled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX licenses_guild_active_unique
ON licensing.licenses (guild_id)
WHERE status IN ('active', 'suspended');
Columns
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID v7 | PK | License ID |
guild_id | UUID | FK, NOT NULL | → guild.guilds.id |
plan_id | UUID | FK, NOT NULL | → plans.id |
status | TEXT | CHECK | active / suspended / canceled |
granted_at | TIMESTAMPTZ | NOT NULL | 부여 시각 |
expires_at | TIMESTAMPTZ | nullable | 만료 시각 (Free 는 NULL) |
suspended_at | TIMESTAMPTZ | — | Suspend 시각 |
suspended_reason | TEXT | — | 사유 |
canceled_at | TIMESTAMPTZ | — | 영구 해지 시각 |
created_at | TIMESTAMPTZ | NOT NULL | |
updated_at | TIMESTAMPTZ | NOT NULL |
Indexes
| Index | Columns | Purpose |
|---|---|---|
licenses_pkey | (id) | PK |
licenses_guild_active_unique | (guild_id) WHERE status IN ('active', 'suspended') | Guild 당 active/suspended 유일 |
licenses_expires_idx | (expires_at) WHERE status = 'active' AND expires_at IS NOT NULL | 만료 임박 조회 |
licenses_guild_status_idx | (guild_id, status) | 상태별 조회 |
Foreign keys
| Column | References | On delete |
|---|---|---|
guild_id | guild.guilds(id) | RESTRICT |
plan_id | licensing.plans(id) | RESTRICT |
Invariants
guild_id당 active/suspended 유일 (partial unique index 로 강제)status = 'suspended'이면suspended_at IS NOT NULLstatus = 'canceled'이면canceled_at IS NOT NULL- Free Plan License 는
expires_at IS NULL, Paid 는 NOT NULL (애플리케이션 레벨 강제)
Relationships¶
erDiagram
PLANS ||--o{ LICENSES : "assigned to"
GUILDS ||--o{ LICENSES : "licensed"
LICENSES ||--o| SUBSCRIPTIONS : "payment link"
Cross-schema references¶
| From | To | Semantics |
|---|---|---|
licensing.licenses.guild_id | guild.guilds.id | 적용 대상 |
billing.subscriptions.license_id | licensing.licenses.id | 결제 연결 |
Query patterns¶
GetActiveLicenseByGuild— 권한 체크 핫 경로 (Plan 과 JOIN)InsertLicense— 신규 부여UpdateLicenseStatus— suspend / resume / cancelExtendExpiresAt—GREATEST(expires_at, $1)사용 (idempotent)UpgradeLicensePlan— Plan 변경ListLicensesByUser— 대시보드 내 길드 목록 (Guild owner join)ListExpiringLicenses— 만료 임박 알림 cron
Hot path: GetActiveLicenseByGuild¶
-- GetActiveLicenseByGuild
SELECT l.*, p.code AS plan_code, p.features, p.limits
FROM licensing.licenses l
JOIN licensing.plans p ON p.id = l.plan_id
WHERE l.guild_id = $1
AND l.status = 'active'
LIMIT 1;
Redis 캐시가 전면에 있어 이 쿼리는 캐시 miss 시에만 실행.
Data retention¶
- Plans — 영구 보관, 판매 중단 시
is_active = false만 설정 - Licenses — 영구 보관 (감사 목적). canceled 도 삭제 안 함.
Plan seed data¶
초기 배포 시 seed:
INSERT INTO licensing.plans (id, code, name, price_krw, billing_cycle, features, limits) VALUES
('018f...', 'FREE', 'Free', NULL, NULL, '{"WEB_JOIN","MEMBER_DB_UP_TO_50"}', '{"member_db": 50}'::jsonb),
('018f...', 'PRO', 'Pro', 9900, 'monthly', '{"DASHBOARD","WEB_JOIN","RECOVERY_LIVE_SYNC","RECOVERY_SNAPSHOT_MANUAL","RECOVERY_SNAPSHOT_SCHEDULED","RECOVERY_RESTORE","ANTINUKE_DETECT","MEMBER_DB_UP_TO_500"}', '{"member_db": 500, "snapshot_manual_max": 1, "snapshot_retention_days": 7}'::jsonb),
('018f...', 'ENTERPRISE', 'Enterprise', NULL, NULL, '{"DASHBOARD","WEB_JOIN","RECOVERY_LIVE_SYNC","RECOVERY_SNAPSHOT_MANUAL","RECOVERY_SNAPSHOT_SCHEDULED","RECOVERY_RESTORE","RECOVERY_RESTORE_POINTS_MULTIPLE","ANTINUKE_DETECT","ANTINUKE_AUTO_ACTION","MEMBER_DB_UNLIMITED"}', '{"member_db": null, "snapshot_manual_max": 3, "snapshot_retention_days": 30}'::jsonb);
가격과 Feature 목록 조정은 migration 으로 반영.
Migration history¶
| Date | Change | Rationale |
|---|---|---|
| 2026-04-xx | 초기 스키마 + Plan seed | MVP |
See also¶
domain/licensing.md— Licensing 도메인data/billing-schema.md— 결제 연결data/guild-schema.md— 참조하는 Guildadr/0011-hybrid-license-model.mdadr/0012-license-subscription-separation.md