ADR-0025: Snapshot JSONB Storage¶
스냅샷은 PostgreSQL JSONB 컬럼에 전체 길드 상태를 단일 문서로 저장한다. 정규화된 관계형 저장이 아닌 문서 저장이다.
Status¶
Accepted
- Decided at — 2026-04-13
- Decided by — Pablo
Context¶
스냅샷은 특정 시점의 길드 구조 전체(역할 N개, 채널 M개, 권한 오버라이드 K개 등)를 저장한다. 저장 옵션:
- 정규화 관계형 —
snapshot,snapshot_roles,snapshot_channels,snapshot_overrides테이블로 분해 - JSONB 단일 문서 —
snapshots.payload JSONB에 전체 구조 저장 - 외부 object store — S3/R2 에 JSON 파일, DB 에는 메타데이터만
현실 관찰:
- 스냅샷은 쓰기 1번 / 읽기 ~N번 패턴 (작성 후 복구 또는 조회 시만 읽음)
- 스냅샷 내부 개별 요소를 쿼리할 일이 거의 없음 (전체를 한 번에 읽음)
- 스냅샷 크기는 대형 길드라도 1MB 수준 (JSONB 에 충분)
- 복구 시 원자성 필요 (부분 로드 금지)
Decision¶
스냅샷을 PostgreSQL JSONB 컬럼 에 저장한다.
CREATE TABLE recovery.snapshots (
id UUID PRIMARY KEY,
guild_id UUID NOT NULL REFERENCES guild.guilds(id),
payload JSONB NOT NULL,
payload_size INTEGER NOT NULL,
trigger TEXT NOT NULL, -- 'manual' | 'scheduled' | 'antinuke' | 'pre_restore'
created_by UUID REFERENCES identity.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX snapshots_guild_created ON recovery.snapshots(guild_id, created_at DESC);
Payload schema¶
{
"version": 1,
"snapshot_at": "2026-04-13T00:00:00Z",
"guild": {
"name": "My Guild",
"icon_hash": "...",
"afk_channel_id": "...",
"afk_timeout": 300
},
"roles": [
{
"discord_id": "...",
"name": "Admin",
"color": 0xFF0000,
"permissions": "...",
"position": 1,
"mentionable": true
}
],
"channels": [
{
"discord_id": "...",
"name": "general",
"type": 0,
"parent_id": "...",
"position": 0,
"topic": "...",
"permission_overrides": [...]
}
]
}
Payload 형식은 version 필드로 버전 관리. 새 필드는 향후 확장 가능.
선택 근거:
- 원자적 읽기/쓰기 — 단일 INSERT 로 전체 스냅샷 저장, 단일 SELECT 로 전체 로드. 부분 저장 불가능
- 구현 단순 — 정규화 테이블 수 개보다 관리 용이
- 복구 워크플로우 자연스러움 — Temporal Activity 가 payload 전체를 한 번에 로드 후 처리
- PostgreSQL JSONB 성능 — 수 MB 이하 문서의 읽기/쓰기가 충분히 빠름
- 스키마 진화 —
version필드로 하위 호환 관리
Consequences¶
Positive¶
- 저장/조회 구조 단순 (1 테이블)
- 스냅샷 원자성 보장 (트랜잭션 경계 = INSERT 하나)
- 백업/마이그레이션 용이 (단일 컬럼)
- 복구 워크플로우가 payload 전체를 일관되게 받음
Negative¶
- 스냅샷 내부 개별 요소 쿼리 불가 (예: "이 역할이 언제 만들어졌는가" 질문)
- JSONB 크기가 매우 크면 (10MB+) 쓰기 성능 저하 가능
- Schema drift 감지 어려움 (타입 체크는 애플리케이션 레이어)
Neutral¶
- 개별 요소 분석이 필요해지면 audit.events 같은 별도 저장소 활용
- TOAST (PostgreSQL 대용량 컬럼 저장) 에 의존 — 자동 압축됨
Retention policy¶
Plan 별 보관 정책:
- Free — 스냅샷 없음 (Recovery 미제공)
- Pro — 수동 스냅샷 최대 1개 + 일일 자동 스냅샷 7일 보관
- Enterprise — 수동 스냅샷 최대 3개 + 일일 자동 스냅샷 30일 보관
초과분 자동 삭제 (rolling retention):
- 매일 새벽 asynq cron 이 Plan 초과 스냅샷 삭제
- AntiNuke 로 생성된 스냅샷은 Plan 한도 외 추가 보관 (7일, 플랜 무관)
- Pre-restore 스냅샷은 24시간 보관
Size limit¶
- 스냅샷 크기 한도: 5MB (현실적 초대형 길드도 여유)
- 한도 초과 시 스냅샷 생성 실패 + 사용자 알림 (도움 요청 유도)
Versioning¶
Payload schema 는 version 필드로 관리한다.
- v1 — MVP
- v2 이상 — 호환 가능한 확장은 옵션 필드 추가로
- Breaking change — 새 버전 지정 + 읽기 시점 migration 로직 또는 재스냅샷 유도
복구 코드는 읽을 때 version 체크 후 처리. 미지원 버전은 에러.
Alternatives considered¶
Alternative 1: 정규화 관계형 저장¶
Pros
- SQL 로 개별 요소 쿼리 가능
- Foreign Key 로 데이터 무결성
Cons
- 스냅샷 저장 시 여러 테이블 INSERT → 트랜잭션 복잡
- 복구 시 여러 쿼리로 payload 재구성 → JOIN 비용
- Schema 변경 시 migration 부담 ↑
Why rejected — 스냅샷의 읽기 패턴(전체 로드)에 정규화 이점이 없음. 복잡도만 증가.
Alternative 2: S3/R2 외부 object store¶
Pros
- 대용량 파일 저장에 특화
- DB 부하 감소
Cons
- 인프라 추가 (S3 또는 R2 관리)
- 트랜잭션 경계 분리 (스냅샷 저장과 DB 레코드 동기화 문제)
- MVP 에 과함
Why rejected — 5MB 이하 문서는 JSONB 가 충분. Phase 2+ 에서 크기가 폭증하면 검토.
Alternative 3: JSONB + 추가 정규화 테이블 (hybrid)¶
Pros
- 전체 저장 + 개별 쿼리 가능
Cons
- 같은 데이터 중복 저장 → drift 가능
- 복잡도 ↑ without 명확한 이점
Why rejected — drift 위험이 큼.
Compliance¶
- 스냅샷 payload 는
recovery.snapshots.payload에만 저장 - payload 직접 참조는
engine/recovery/subdomain/snapshot/에서만 - 새 payload 필드 추가 시
version유지, breaking change 시 증가 - 5MB 한도 체크는 애플리케이션 레이어에서 강제
- Retention cron 은 asynq job 으로 매일 실행
Revisit triggers¶
- 스냅샷 크기가 5MB 를 초과하는 사례가 빈번하면 chunking 또는 S3 저장 검토
- 개별 요소 쿼리 요구가 반복되면 이벤트 sourcing 또는 보조 테이블 추가 검토
- PostgreSQL JSONB 성능이 병목이 되면 전용 document DB 재평가