콘텐츠로 이동

Licensing Schema

licensing schema 는 Plan 정의와 길드별 License 권한 상태를 저장한다. License 의 권한 부여 lifecycle 은 Billing 의 결제 lifecycle 과 분리되어 있다.

Schema purpose

  • PostgreSQL schemalicensing
  • Corresponding domainengine/licensing/
  • sqlc packagedb/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 유일, 영구 불변
  • FREEprice_krwbilling_cycle NULL (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 NULL
  • status = '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 / cancel
  • ExtendExpiresAtGREATEST(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 — 참조하는 Guild
  • adr/0011-hybrid-license-model.md
  • adr/0012-license-subscription-separation.md