Quipu-Log 교과서
파트 4 · DB가 공짜로 주던 보장을 파일로 재현

18 · 저장소 레이아웃과 포맷 버저닝

"이 파일이 어떤 포맷인지, 어디에 무엇이 있는지" — DB라면 시스템 카탈로그(pg_catalog, information_schema)가 테이블·컬럼·인덱스 위치를 관리합니다. 파일 위에서 직접 만드는 저장소도 같은 문제를 풀어야 합니다. Quipu-Log의 디렉토리 레이아웃이 카탈로그 역할을 하고, magic 바이트와 FORMAT_VERSION이 포맷 식별을 담당합니다. 그리고 v1→v2 단절이 왜 일어났는지가 이 챕터의 마지막 이야기입니다.

한 문장 요약

저장소 레이아웃은 디렉토리 이름이 곧 "테이블 이름"이고, 세그먼트 헤더의 magic(ALOG)과 FORMAT_VERSION(2)이 "이 파일이 어떤 포맷인지"를 식별합니다. 버전이 다르면 미스파싱 없이 즉시 거부합니다.

당신이 아는 것: DB의 카탈로그와 온디스크 포맷 버전

PostgreSQL을 설치하고 클러스터를 만들면 PG_VERSION이라는 파일이 생깁니다. 여기에 메이저 버전("16")이 적혀 있어, 다른 버전의 프로세스가 이 디렉토리를 열려 할 때 즉시 오류를 냅니다. 내부 데이터 페이지에도 버전 번호와 magic 값이 있어, 페이지가 실제로 Postgres 포맷인지 — 다른 DB 엔진의 파일이 아닌지 — 를 확인합니다.

카탈로그(pg_class, pg_attribute)는 "어떤 테이블이 있고, 각 컬럼의 타입과 위치가 무엇인지"를 메타데이터로 저장합니다. 데이터 파일을 열기 전에 카탈로그를 먼저 읽어야 파싱할 수 있습니다.

DB ↔ 파일시스템

DB의 카탈로그: 별도 시스템 테이블이 스키마·인덱스·파일 위치를 관리. Quipu-Log: 디렉토리 이름 자체가 "테이블 이름" — logs/, registry/user/ 등. 별도 카탈로그 파일이 없고 OS 파일시스템 디렉토리가 카탈로그다. 단, 타입 스키마(어떤 필드가 있는지)는 meta/ 테이블에 append-only로 저장되어 재시작 시 replay된다.

루트 디렉토리 레이아웃

AuditStore::open()의 주석이 레이아웃을 선언합니다.

crates/quipu-core/src/store.rs/// root/
///   meta/         type schemas + custom column registry (replayed on open)
///   logs/         AuditLog rows
///   relations/    log -> target-entity-version relations
///   registry/<t>/ one versioned registry table per entity/actor type
///   access/       (optional) meta-audit table
///   checkpoints/  signed integrity checkpoints
///   LOCK          advisory OS lock file

실제 열어보면 이런 구조입니다.

root/ LOCK advisory 파일 락 (single-writer 보장) meta/ 타입 스키마 + 커스텀 컬럼 정의 (replay on open) seg-0000000000.log merkle.spine logs/ AuditLog rows — 로그의 본체, retention 대상 seg-0000000000.log seg-0000000000.meta seg-0000000001.log ... merkle.spine relations/ log_id → 타깃 엔티티 버전 uid 매핑 registry/ 엔티티 타입별 서브디렉토리 (retention 대상 아님) user/ document/ 각 타입마다 독립 append-only 로그 access/ (opt-in) 메타 감사 — 누가 언제 쿼리했나 checkpoints/ 서명된 무결성 체크포인트 목록 디렉토리 .log 세그먼트 .meta 사이드카 (힌트, 없어도 복구) 타입별 서브디렉토리
루트 디렉토리 레이아웃. 디렉토리 이름이 곧 "테이블 이름"이다. meta/, logs/, relations/가 핵심 3개 테이블, registry/<type>/이 타입당 레지스트리다.

이 레이아웃의 특징은 자기 서술적(self-describing)이라는 점입니다. 디렉토리를 열어서 ls registry/를 하면 어떤 엔티티 타입이 등록되어 있는지 바로 알 수 있습니다. meta/를 replay하면 각 타입의 스키마를 알 수 있고요. 별도 카탈로그 파일 없이 디렉토리 구조 자체가 카탈로그입니다.

magic + FORMAT_VERSION: 파일 식별

각 세그먼트 파일은 14바이트 헤더로 시작합니다.

crates/quipu-core/src/storage/segment.rs/// Segment file header: magic + format version + base index.
pub const MAGIC: [u8; 4] = *b"ALOG";     // 매직 — "이 파일이 Quipu-Log 세그먼트다"
pub const FORMAT_VERSION: u8 = 2;         // 현재 포맷 버전
pub const SEGMENT_HEADER: usize = MAGIC.len() + 1 + 8;
//                                     ^4      ^1  ^8(base_index)  = 13바이트

헤더를 파싱할 때 magic이 "ALOG"가 아니거나 FORMAT_VERSION이 2가 아니면 즉시 오류를 반환합니다. 이것이 "미스파싱 없이 즉시 거부"를 보장하는 장치입니다.

비유

편지봉투 앞면에 "국내 우편"과 우편번호 형식이 찍혀 있다고 상상해 보세요. 우체국 직원이 봉투를 열기 전에 "이게 국내용인지 국제용인지"를 먼저 확인하고, 형식이 맞지 않으면 반송합니다. magic과 version이 그 역할입니다 — 내용을 읽기 전에 "이 파일을 열 수 있는지"를 먼저 확인합니다.

base_index: 세그먼트 헤더에 박힌 절대 좌표

헤더의 base_index(8바이트)는 이 세그먼트의 첫 번째 레코드가 전체 머클 트리에서 몇 번째 잎인지를 나타냅니다. 이 값이 세그먼트 생성 시 한 번 기록되고 이후 절대 바뀌지 않는다는 점이 핵심입니다.

왜 헤더에 박아 두나요? retention이 앞쪽 세그먼트를 삭제하면 "이 세그먼트의 레코드가 전체에서 몇 번째인지"를 별도 카운터로 추적해야 합니다. 카운터를 별도 파일에 영속하면 "카운터 갱신 ↔ 세그먼트 삭제" 사이 크래시가 불일치를 낳습니다. 절대 인덱스를 세그먼트 헤더에 박으면 세그먼트 자체가 "내 레코드는 spine의 몇 번째부터 시작"을 항상 알고 있으므로, 어떤 세그먼트가 지워지더라도 살아남은 레코드의 spine 인덱스는 base_index + offset으로 항상 정확합니다.

crates/quipu-core/src/storage/segment.rs — 설명 주석// `base_index` is the spine leaf index of this segment's first record —
// the count of all records appended to the table before this segment opened.
// Storing it per-segment (not as a mutable counter) is what makes the mapping
// crash-safe across a partial purge.

포맷 진화: v0 → v1 → v2 단절

Quipu-Log의 세그먼트 포맷은 세 번의 버전을 거쳤습니다.

버전헤더 구성변경 내용호환성
v0magic 4 + ver 1 + seed 8초기 포맷
v1magic 4 + ver 1 + seed 8프레임 마다 32B chain-hash 추가v0 비호환
v2magic 4 + ver 1 + base_index 8chain-hash 제거, 머클 spine으로 이관. seed 자리에 base_indexv1 비호환

v1에서는 각 프레임에 "이전 레코드의 해시를 포함한 체인 해시"를 32바이트씩 박았습니다. 직관적으로 tamper-evident해 보이지만 문제가 있었습니다. retention이 앞쪽 레코드를 지우면 체인이 끊겨 검증이 불가능해졌습니다. 무결성 증거가 retention-safe하려면 별도의 구조가 필요했습니다.

v2는 per-frame chain-hash를 완전히 제거하고, 대신 retention에서 지워지지 않는 별도 머클 spine 파일(merkle.spine)에 잎 해시를 쌓습니다. 세그먼트 파일은 "빠른 순차 읽기 + CRC 우연 손상 감지"만 담당하고, 무결성은 spine이 담당합니다. 이 분리 덕분에 "세그먼트는 지워도, 루트 해시와 포함 증명은 계속 유효"합니다.

crates/quipu-core/src/storage/segment.rs — 설명 주석// Format v2 dropped the per-record hash chain entirely
// (no header seed, no per-frame chain hash).
// Tamper-evidence now lives in the retention-independent Merkle spine;
// a segment carries only payloads plus a CRC for accidental-corruption detection.
주의

v1 스토어를 v2 바이너리로 열면 즉시 거부됩니다 — 마이그레이션 도구가 없으므로 재익스포트가 필요합니다. 이것은 의도적인 설계입니다. 프리-1.0 단계에서 실데이터가 적은 지금은 포맷 단절을 수용하고, 본격 채택 후에는 마이그레이션 경로를 제공할 계획입니다.

사이드카: 포맷 단절 없이 메타데이터 추가

세그먼트 헤더를 고치지 않고 메타데이터를 추가하는 방법이 있습니다 — 사이드카 파일입니다. seg-0000000000.meta는 그 세그먼트의 min/max timestamp와 레코드 수, base_index를 담습니다.

crates/quipu-core/src/storage/table.rsstruct SegmentMeta {
    min_timestamp: u64,   // 세그먼트 안 레코드의 최소 타임스탬프
    max_timestamp: u64,   // 최대 타임스탬프 (retention + pruning에 사용)
    records:       u64,
    base_index:    u64,
}

사이드카의 핵심 속성은 힌트 전용이라는 것입니다. 없거나 손상되어도 세그먼트를 skim(처음부터 읽기)해 재구축할 수 있습니다. 머클 검증도 사이드카를 전혀 참조하지 않으므로, 사이드카가 변조되어도 무결성 증거에 영향이 없습니다. 이 덕분에 세그먼트 포맷을 변경(FORMAT_VERSION 올림)하지 않고 새 메타데이터를 추가할 수 있었습니다.

왜 이렇게?

헤더 확장은 FORMAT_VERSION을 올려야 하고, 그러면 기존 세그먼트와 호환성이 끊깁니다. 사이드카는 없어도 되는 힌트이므로 "항상 있다고 가정"하지 않아도 됩니다. min/max timestamp 같은 "재계산 가능한" 정보는 사이드카에, "재계산 불가능한" 정보(base_index)는 헤더에 — 이 원칙으로 포맷 단절 없이 기능을 추가했습니다.

정리

  • Quipu-Log의 디렉토리 레이아웃이 DB 카탈로그 역할을 한다. meta/: 스키마 이벤트 로그, logs/: 로그 행, registry/<type>/: 레지스트리, relations/: log-entity 매핑.
  • 각 세그먼트 헤더의 magic(ALOG) + FORMAT_VERSION(2)이 파일 식별을 담당한다. 불일치 시 즉시 거부.
  • base_index는 헤더에 생성 시 한 번만 기록하는 절대 좌표 — retention으로 앞 세그먼트가 지워져도 살아남은 레코드의 spine 인덱스는 항상 정확.
  • v1 → v2 단절: per-frame chain-hash 제거, 무결성 전담을 retention-safe한 별도 merkle.spine으로 이관.
  • 사이드카(.meta)는 재계산 가능한 메타데이터를 세그먼트 포맷 변경 없이 추가하는 패턴 — 힌트 전용이므로 손실·변조가 무결성에 영향 없음.
스스로 확인

① DB 카탈로그(pg_class 등)와 Quipu-Log 레이아웃의 공통점은 무엇인가요? "어떤 테이블이 있는지"를 Quipu-Log에서는 어떻게 알 수 있나요?
② 세그먼트 헤더에 base_index를 넣은 이유를 "별도 카운터 파일 접근법의 문제점"과 비교해 설명해 보세요.
③ v1→v2 포맷 단절이 필요했던 이유를 "retention + 무결성 증명"의 관점에서 설명해 보세요. per-frame chain-hash의 어떤 한계 때문이었나요?