[PonsLink] 데이터 모델 설계 회고 — Postgres 두 개 DB, 37개 테이블
서비스를 오래 굴리면 스키마가 제품 결정을 배신한다. 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.
웹훅 전달과 결제 멱등성은 같은 패턴이다. 원장에 먼저 쓰고, 상태를 바꾼다.
설계 원칙 다섯 줄
- 타임스탬프는 bigint epoch ms — JS 직렬화가 단순하다.
- 비밀은 해시만 저장한다 (
token_hash,password_hash). - 결제/웹훅은 이벤트 원장 + 멱등성 키로 중복을 죽인다.
- 요금제 조건은 스냅샷한다 (
plan_snapshot). - 소프트 삭제·부분 인덱스로 활성 행 스캔을 싸게 유지한다.
스키마는 제품의 기억이다. 다음 글에서는 이 테이블들이 요청→세션→아카이브 라이프사이클에서 어떻게 채워지는지 본다.