서비스를 오래 굴리면 스키마가 제품 결정을 배신한다. PonsLink도 예외가 아니었다. 한때는 "users 하나면 되겠지"로 시작했다가, 지금은 같은 Postgres 인스턴스 안에 두 개의 DB를 둔다. `ponslink`(22테이블)와 `ponswarp`(15테이블). 이 글은 2026-08-07 실측 덤프를 기준으로 그 구조를 해설한다.
왜 DB를 둘로 나눴는가
| DB | 도메인 | 대표 테이블 |
|---|---|---|
| `ponslink` | 룸·세션·미디어·웹훅·플랜·테넌트 | rooms, sessions, participants, media_tracks, webhook_endpoints, idempotency_keys |
| `ponswarp` | 계정·결제·클라우드 공유 | users, auth_sessions, subscriptions, cloud_shares, drop_passes, *_events |
세션/미디어 부하는 짧고 빈번하다. 계정/결제/공유는 상대적으로 드물지만 무결성 요구가 세다. 물리 분리는 백업·마이그레이션·장애 반경을 가르기 위한 선택이다. API 런타임 env는 `DATABASE_URL=…/ponslink`를 주 DB로 쓴다.
ponswarp — 계정·결제·공유 코어
users: `email` UNIQUE, `plan` default `free`, `stripe_customer_id`/`google_sub` UNIQUE. 타임스탬프는 bigint epoch ms.
auth_sessions: 토큰 원문은 저장하지 않는다. `token_hash` UNIQUE, `expires_at`, `revoked_at`. 부분 인덱스 `WHERE revoked_at IS NULL`로 활성 세션만 스캔한다.
subscriptions: Stripe/PayPal/LemonSqueezy 식별자를 컬럼으로 나란히 둔다. 공급자별 UNIQUE 제약으로 중복 구독을 막는다.
cloud_shares / cloud_share_files / cloud_download_sessions / cloud_usage_events: 공유 링크 → 파일 목록(R2 `object_key`) → 다운로드 세션(`token_hash`) → 사용 이벤트(bytes). `plan_snapshot`(jsonb)으로 "공유 생성 시점의 요금제 조건"을 고정한다. 요금제가 나중에 바뀌어도 이미 만든 공유의 규칙은 흔들리지 않는다.
drop_passes: 1회성 유료 공유권. `max_total_bytes`, `max_file_bytes`, `retention_seconds`, `remaining_uses`. Stripe/PayPal/LemonSqueezy 결제 식별자를 UNIQUE로 잡는다.
결제 이벤트 원장: `stripe_events` / `paypal_events` / `lemonsqueezy_events` — 공급자 이벤트 id를 PK로 둔다. 웹훅이 두 번 와도 두 번 적용되지 않는다.
admin_members / admin_audit_logs: 역할·상태·감사(actor/action/target/ip/ua/request_id).
ponslink — 세션·미디어·웹훅
`tenants → projects → rooms → sessions/participants/media_tracks/speech_transcripts/session_tokens`. 협업 문서는 `collaboration_resources → resource_documents`. 개발자 플랫폼 면은 `api_keys`, `provider_credentials`, `provider_usage`, `webhook_endpoints → webhook_deliveries`, `idempotency_keys`.
웹훅 전달과 결제 멱등성은 같은 패턴이다. 원장에 먼저 쓰고, 상태를 바꾼다.
설계 원칙 다섯 줄
1. 타임스탬프는 bigint epoch ms — JS 직렬화가 단순하다.
2. 비밀은 해시만 저장한다 (`token_hash`, `password_hash`).
3. 결제/웹훅은 이벤트 원장 + 멱등성 키로 중복을 죽인다.
4. 요금제 조건은 스냅샷한다 (`plan_snapshot`).
5. 소프트 삭제·부분 인덱스로 활성 행 스캔을 싸게 유지한다.
스키마는 제품의 기억이다. 다음 글에서는 이 테이블들이 요청→세션→아카이브 라이프사이클에서 어떻게 채워지는지 본다.
댓글