Billing Schema¶
billingschema 는 빌링키, 구독, 결제 시도 이력을 저장한다. 결제 SaaS 의 가장 민감한 데이터가 위치하며, 빌링키는 AES-256-GCM 으로 암호화 저장된다.
Schema purpose¶
- PostgreSQL schema —
billing - Corresponding domain —
engine/billing/ - sqlc package —
db/queries/billing/
3개 테이블로 구성: billing_keys, subscriptions, payment_attempts.
Tables¶
billing.billing_keys¶
Toss 빌링키 암호화 저장.
CREATE TABLE billing.billing_keys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES identity.users(id),
customer_key TEXT NOT NULL,
encrypted_key BYTEA NOT NULL,
key_nonce BYTEA NOT NULL,
card_company TEXT NOT NULL,
card_last4 TEXT NOT NULL,
card_type TEXT NOT NULL CHECK (card_type IN ('credit', 'check')),
issued_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT billing_keys_customer_key_unique UNIQUE (customer_key),
CONSTRAINT billing_keys_nonce_size CHECK (length(key_nonce) = 12),
CONSTRAINT billing_keys_card_last4_format CHECK (card_last4 ~ '^[0-9]{4}$')
);
Columns
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID v7 | PK | 빌링키 내부 ID |
user_id | UUID | FK, NOT NULL | → identity.users.id |
customer_key | TEXT | UNIQUE, NOT NULL | Toss 고객 키 (user_{uuid}) |
encrypted_key | BYTEA | NOT NULL | AES-256-GCM 암호문 |
key_nonce | BYTEA | NOT NULL, 12 bytes | GCM nonce |
card_company | TEXT | NOT NULL | 표시용 카드사 |
card_last4 | TEXT | CHECK | 4자리 숫자 |
card_type | TEXT | CHECK | credit / check |
issued_at | TIMESTAMPTZ | NOT NULL | Toss 발급 시각 |
deleted_at | TIMESTAMPTZ | — | Soft delete |
created_at | TIMESTAMPTZ | NOT NULL | |
updated_at | TIMESTAMPTZ | NOT NULL |
Indexes
| Index | Columns | Purpose |
|---|---|---|
billing_keys_pkey | (id) | PK |
billing_keys_customer_key_unique | (customer_key) | Toss 키 조회 |
billing_keys_user_active_idx | (user_id) WHERE deleted_at IS NULL | User 의 활성 빌링키 |
Foreign keys
| Column | References | On delete |
|---|---|---|
user_id | identity.users(id) | RESTRICT (soft delete 원칙) |
Invariants
encrypted_key는 항상 AES-256-GCM 암호문 (평문 저장 금지 — 애플리케이션 레벨)key_nonce는 정확히 12 bytes (CHECK 제약)customer_key는user_{uuid}형식 (애플리케이션 레벨 검증)- 삭제는 soft delete — 과거 PaymentAttempt 참조 유지
billing.subscriptions¶
구독.
CREATE TABLE billing.subscriptions (
id UUID PRIMARY KEY,
license_id UUID NOT NULL REFERENCES licensing.licenses(id),
payer_user_id UUID NOT NULL REFERENCES identity.users(id),
guild_id UUID NOT NULL REFERENCES guild.guilds(id),
billing_key_id UUID NOT NULL REFERENCES billing.billing_keys(id),
plan_id UUID NOT NULL REFERENCES licensing.plans(id),
status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'past_due', 'canceled', 'suspended')),
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
next_billing_at TIMESTAMPTZ,
cycle_count INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
canceled_at TIMESTAMPTZ,
suspended_at TIMESTAMPTZ,
suspended_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT subscriptions_retry_range CHECK (retry_count >= 0 AND retry_count <= 4),
CONSTRAINT subscriptions_cycle_nonneg CHECK (cycle_count >= 0)
);
CREATE UNIQUE INDEX subscriptions_guild_active_unique
ON billing.subscriptions (guild_id)
WHERE status IN ('active', 'past_due');
Columns
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID v7 | PK | 구독 내부 ID |
license_id | UUID | FK, NOT NULL | → licensing.licenses.id |
payer_user_id | UUID | FK, NOT NULL | → identity.users.id (결제 주체) |
guild_id | UUID | FK, NOT NULL | → guild.guilds.id (적용 대상) |
billing_key_id | UUID | FK, NOT NULL | → billing_keys.id |
plan_id | UUID | FK, NOT NULL | → licensing.plans.id |
status | TEXT | CHECK | Subscription 상태 |
current_period_start | TIMESTAMPTZ | — | 현재 주기 시작 |
current_period_end | TIMESTAMPTZ | — | 현재 주기 종료 |
next_billing_at | TIMESTAMPTZ | — | 실제 결제 예정 시각 (jitter 포함) |
cycle_count | INTEGER | NOT NULL | 성공한 결제 횟수 |
retry_count | INTEGER | CHECK 0..4 | 현재 주기 재시도 횟수 |
cancel_at_period_end | BOOLEAN | NOT NULL | 해지 예정 플래그 |
canceled_at | TIMESTAMPTZ | — | 실제 해지 시각 |
suspended_at | TIMESTAMPTZ | — | Suspend 시각 |
suspended_reason | TEXT | — | Suspend 사유 |
created_at | TIMESTAMPTZ | NOT NULL | |
updated_at | TIMESTAMPTZ | NOT NULL |
Indexes
| Index | Columns | Purpose |
|---|---|---|
subscriptions_pkey | (id) | PK |
subscriptions_guild_active_unique | (guild_id) WHERE status IN ('active', 'past_due') | Guild 당 active 유일 |
subscriptions_next_billing_idx | (next_billing_at) WHERE status IN ('active', 'past_due') | cron scan (due 조회) |
subscriptions_payer_idx | (payer_user_id, status) | 사용자 대시보드 |
subscriptions_license_idx | (license_id) | License 역참조 |
subscriptions_billing_key_idx | (billing_key_id) | 빌링키 삭제 시 영향 조회 |
Foreign keys
| Column | References | On delete |
|---|---|---|
license_id | licensing.licenses(id) | RESTRICT |
payer_user_id | identity.users(id) | RESTRICT |
guild_id | guild.guilds(id) | RESTRICT |
billing_key_id | billing.billing_keys(id) | RESTRICT |
plan_id | licensing.plans(id) | RESTRICT |
Invariants
- Guild 당 active/past_due 유일 (partial unique index)
status = 'active' OR 'past_due'→next_billing_at IS NOT NULLcancel_at_period_end = true이면status = 'active'(과거 결제 중 취소 예약)retry_count는 0~4, 4 도달 시PaymentFailedFinal트리거canceled_at IS NOT NULL이면status = 'canceled'
billing.payment_attempts¶
결제 시도 이력.
CREATE TABLE billing.payment_attempts (
id UUID PRIMARY KEY,
subscription_id UUID NOT NULL REFERENCES billing.subscriptions(id),
order_id TEXT NOT NULL,
amount_krw INTEGER NOT NULL CHECK (amount_krw > 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
toss_payment_key TEXT,
toss_approved_at TIMESTAMPTZ,
failure_code TEXT,
failure_message TEXT,
cycle INTEGER NOT NULL,
retry_number INTEGER NOT NULL CHECK (retry_number >= 0 AND retry_number <= 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
CONSTRAINT payment_attempts_order_id_unique UNIQUE (order_id),
CONSTRAINT payment_attempts_succeeded_has_key CHECK (
(status = 'succeeded' AND toss_payment_key IS NOT NULL AND toss_approved_at IS NOT NULL)
OR status != 'succeeded'
),
CONSTRAINT payment_attempts_failed_has_code CHECK (
(status = 'failed' AND failure_code IS NOT NULL)
OR status != 'failed'
)
);
Columns
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID v7 | PK | 시도 ID |
subscription_id | UUID | FK, NOT NULL | → subscriptions.id |
order_id | TEXT | UNIQUE, NOT NULL | Toss orderId |
amount_krw | INTEGER | CHECK > 0 | 결제 금액 (원) |
status | TEXT | CHECK | pending / succeeded / failed |
toss_payment_key | TEXT | — | 성공 시 Toss paymentKey |
toss_approved_at | TIMESTAMPTZ | — | Toss 승인 시각 |
failure_code | TEXT | — | 실패 시 Toss 에러 코드 |
failure_message | TEXT | — | 실패 메시지 |
cycle | INTEGER | NOT NULL | 결제 주기 번호 (1부터) |
retry_number | INTEGER | CHECK 0..3 | 재시도 번호 (0 = 1차 시도) |
created_at | TIMESTAMPTZ | NOT NULL | 시도 시작 시각 |
completed_at | TIMESTAMPTZ | — | 완료 시각 |
Indexes
| Index | Columns | Purpose |
|---|---|---|
payment_attempts_pkey | (id) | PK |
payment_attempts_order_id_unique | (order_id) | Toss 결제 조회 |
payment_attempts_subscription_idx | (subscription_id, created_at DESC) | 구독별 이력 |
payment_attempts_status_idx | (status, created_at DESC) | 성공/실패 통계 |
Foreign keys
| Column | References | On delete |
|---|---|---|
subscription_id | billing.subscriptions(id) | RESTRICT |
Invariants
order_id는sub_{subscription_id}_{cycle:03d}_r{retry_number}형식status = 'succeeded'이면toss_payment_key와toss_approved_atNOT NULL (CHECK)status = 'failed'이면failure_codeNOT NULL (CHECK)- 같은
(subscription_id, cycle, retry_number)조합은 유일 —order_idUNIQUE 로 자연 강제
Relationships¶
erDiagram
USERS ||--o{ BILLING_KEYS : owns
USERS ||--o{ SUBSCRIPTIONS : pays
GUILDS ||--o{ SUBSCRIPTIONS : "target of"
LICENSES ||--o| SUBSCRIPTIONS : "backed by"
BILLING_KEYS ||--o{ SUBSCRIPTIONS : "charged via"
PLANS ||--o{ SUBSCRIPTIONS : "subscribed to"
SUBSCRIPTIONS ||--o{ PAYMENT_ATTEMPTS : "has"
Cross-schema references¶
| From | To | Semantics |
|---|---|---|
billing.billing_keys.user_id | identity.users.id | 빌링키 소유자 |
billing.subscriptions.payer_user_id | identity.users.id | 결제 주체 |
billing.subscriptions.guild_id | guild.guilds.id | 적용 대상 |
billing.subscriptions.license_id | licensing.licenses.id | 권한 연결 |
billing.subscriptions.plan_id | licensing.plans.id | 구독 플랜 |
billing.subscriptions.billing_key_id | billing.billing_keys.id | 결제 수단 |
Query patterns¶
Hot path: ListDueForCharge¶
결제 대상 조회 (매 정각 cron).
-- ListDueForCharge
SELECT s.*
FROM billing.subscriptions s
WHERE s.status IN ('active', 'past_due')
AND s.next_billing_at <= $1 -- now + lead_time
ORDER BY s.next_billing_at ASC
LIMIT $2;
InsertPaymentAttempt (결제 시작)¶
INSERT INTO billing.payment_attempts (
id, subscription_id, order_id, amount_krw, status, cycle, retry_number
) VALUES ($1, $2, $3, $4, 'pending', $5, $6);
MarkAttemptSucceeded¶
UPDATE billing.payment_attempts
SET status = 'succeeded',
toss_payment_key = $1,
toss_approved_at = $2,
completed_at = NOW()
WHERE id = $3;
AdvanceSubscriptionPeriod (결제 성공 시)¶
UPDATE billing.subscriptions
SET current_period_start = current_period_end,
current_period_end = $1, -- 새 주기 종료 (jitter 포함)
next_billing_at = $2, -- 다음 결제 시각
cycle_count = cycle_count + 1,
retry_count = 0,
status = 'active',
updated_at = NOW()
WHERE id = $3;
MarkAttemptFailedAndScheduleRetry¶
UPDATE billing.subscriptions
SET status = 'past_due',
retry_count = retry_count + 1,
next_billing_at = $1, -- now + (24/48/72h)
updated_at = NOW()
WHERE id = $2;
Retry timing¶
| retry_number | delay from previous | description |
|---|---|---|
| 0 | — | 정기 결제 (next_billing_at 정각) |
| 1 | 24h | 1차 재시도 |
| 2 | 48h | 2차 재시도 |
| 3 | 72h | 3차 재시도 |
| 4 | — | 자동 해지 (no attempt) |
Data retention¶
- BillingKeys — soft delete 후 90일 보관, 이후
encrypted_key,key_nonce는 NULL 로 wipe (삭제 후 복구 불가) - Subscriptions — 영구 보관
- PaymentAttempts — 영구 보관 (감사, 세무 목적)
Encryption notes¶
- MEK: 32 bytes, 환경변수
BILLING_KEY_ENCRYPTION_KEY(hex 또는 base64) - Algorithm: AES-256-GCM
- Nonce: 12 bytes 랜덤, 매 암호화마다 새로
- AAD:
customer_key를 AAD 로 바인딩 (교체 공격 방지) - Rotation: Phase 2 에서 KMS 로 이전, 기존 키로 재암호화 migration
Migration history¶
| Date | Change | Rationale |
|---|---|---|
| 2026-04-xx | 초기 스키마 | MVP |
See also¶
domain/billing.md— Billing 도메인data/licensing-schema.md— License 연결data/identity-schema.md— User 참조data/guild-schema.md— Guild 참조adr/0013-payment-toss-billing.md— Toss 채택adr/0022-jitter-payment-time.md— jitteradr/0012-license-subscription-separation.md— 분리 원칙