Quipu-Log 교과서
파트 3 · 저장 엔진의 심장: append-only 로그

09 · 레코드 프레이밍: 길이·CRC32·magic/version

파일에 레코드를 줄줄이 이어 붙이면 한 가지 문제가 생깁니다 — "어디서 어디까지가 하나의 레코드인가?" 바이트 스트림만 보면 경계를 알 수 없습니다. 거기에 더해 디스크는 가끔 비트를 조용히 망가뜨립니다. 이 챕터는 Quipu-Log가 경계 문제를 프레임(frame)으로, 조용한 손상을 CRC32로 해결하는 방법을 설명합니다.

한 문장 요약

레코드 하나는 [u32 길이][u32 CRC32][u64 타임스탬프][payload] 프레임으로 감싸인다. CRC는 우연한 손상만 잡는다 — 의도적 변조를 잡는 건 머클 트리(파트 5)의 몫이다.

당신이 아는 것: DB의 페이지와 체크섬

PostgreSQL 같은 DB를 생각해봅시다. 데이터는 8 KB 단위 페이지에 저장됩니다. 각 페이지 헤더에는 체크섬이 있어서, 페이지를 읽을 때 다시 계산한 값과 비교합니다. 다르면? "이 페이지는 torn page(쓰다 만 페이지)거나 디스크 오류"로 판단합니다.

Quipu-Log도 같은 문제를 풀어야 합니다. 다만 여기선 고정 크기 페이지가 아니라 가변 길이 레코드를 다뤄야 합니다. 그래서 페이지 체크섬 대신 레코드 단위 프레임 헤더 + CRC를 씁니다.

DB ↔ 파일시스템

DB에서는 고정 크기 페이지 헤더에 체크섬을 넣어 torn page를 탐지한다. Quipu-Log에서는 가변 길이 레코드마다 프레임 헤더(길이 + CRC + 타임스탬프)를 앞에 붙여 경계와 손상을 동시에 처리한다.

파일 안을 뜯어보면: MAGIC + 헤더 + 프레임들

하나의 세그먼트 파일은 이 구조입니다.

ALOG 4 B v=2 1 B base index 8 B 세그먼트 헤더 (13 B) len u32 4B crc32 u32 4B ts u64 8B payload len bytes 레코드 프레임 #1 len crc32 ts FRAME_HEADER(16 B) + payload SEGMENT_HEADER = 13 B
세그먼트 파일 바이너리 레이아웃. 파일 첫 13바이트가 헤더(MAGIC 4 + version 1 + base_index 8), 그 뒤로 레코드 프레임이 이어진다. 프레임 헤더는 고정 16바이트(FRAME_HEADER).

코드에서 정확한 상수를 확인해봅시다.

crates/quipu-core/src/storage/segment.rspub const MAGIC: [u8; 4] = *b"ALOG";
pub const FORMAT_VERSION: u8 = 2;
pub const SEGMENT_HEADER: usize = MAGIC.len() + 1 + 8; // = 13

// 프레임 헤더: u32 길이 + u32 CRC + u64 타임스탬프
pub const FRAME_HEADER: usize = 4 + 4 + 8; // = 16
/// 한 레코드 최대 크기: 64 MiB
pub const MAX_RECORD: u32 = 64 * 1024 * 1024;

MAGIC과 FORMAT_VERSION: 파일 신원 확인

파일을 열 때 맨 먼저 하는 일은 신원 확인입니다. 처음 4바이트가 ALOG인지, 그다음 바이트가 2(현재 포맷 버전)인지 검사합니다.

crates/quipu-core/src/storage/segment.rs — read_headerif head[0..4] != MAGIC {
    return Err(Error::Corrupt { /* … */
        reason: "bad magic (not an audit segment file)".into(),
    });
}
let version = head[4];
if version != FORMAT_VERSION {
    return Err(Error::Corrupt { /* … */
        reason: format!("unsupported segment format version {version}"),
    });
}

왜 두 단계로 나뉠까요?

  • MAGIC(ALOG) — "이것은 Quipu-Log 세그먼트 파일이다"를 식별합니다. 실수로 다른 파일을 세그먼트 디렉토리에 두었거나, 파일이 완전히 다른 포맷일 때 빠르게 거부합니다.
  • FORMAT_VERSION — 같은 ALOG 파일이라도 구조가 바뀔 수 있습니다. 현재 구현은 버전 2만 읽습니다. 버전 1은 레코드마다 해시 체인이 있었는데, v2에서 머클 스파인으로 옮기며 제거됐습니다. 버전 불일치 시 "파싱을 잘못 시도해 데이터를 망가뜨리는" 대신 즉시 에러를 냅니다.
비유

MAGIC은 봉투 겉면의 "TO:" 주소, FORMAT_VERSION은 편지 서식 버전 번호입니다. 주소가 잘못된 봉투는 열지도 않고 돌려보내고, 서식이 다른 편지는 "이 양식은 구버전이라 못 읽겠습니다"라고 답장합니다.

프레임 헤더 세 필드: 길이, CRC, 타임스탬프

프레임 헤더의 세 필드가 각각 무슨 역할을 하는지 하나씩 살펴보겠습니다.

① u32 길이 (payload length)

이 레코드의 payload가 몇 바이트인지입니다. 읽는 쪽은 이 값을 보고 "여기서 N바이트를 읽으면 이 레코드 끝"임을 알고, 그 바로 다음 바이트부터 다음 프레임 헤더가 시작됨을 압니다. 가변 길이 레코드를 연속으로 담는 가장 기본적인 방법입니다.

하지만 길이 필드 하나만 있으면 위험합니다 — 크래시로 길이가 쓰레기값으로 남아 있으면 "1 GB짜리 레코드"를 할당하려다 OOM이 납니다. 그래서 MAX_RECORD 가드가 있습니다.

crates/quipu-core/src/storage/segment.rs — skim 함수 내let len = u32::from_le_bytes(header[0..4].try_into().unwrap());
if len > MAX_RECORD || total - valid - (FRAME_HEADER as u64) < len as u64 {
    break; // 길이가 비현실적이거나 파일 끝을 넘어가면 → torn tail
}

MAX_RECORD는 64 MiB입니다. 그보다 큰 "레코드"가 있다면 크래시로 망가진 길이 필드라고 판단하고 여기서 멈춥니다.

② u32 CRC32 (체크섬)

payload 바이트들의 CRC32 해시입니다. 레코드를 읽고 나서 payload를 다시 CRC32로 계산해, 저장된 값과 일치하지 않으면 손상으로 판단합니다.

crates/quipu-core/src/storage/segment.rs — appendlet crc = crc32fast::hash(payload);
self.writer.write_all(&(payload.len() as u32).to_le_bytes())?;
self.writer.write_all(&crc.to_le_bytes())?;
self.writer.write_all(&timestamp.to_le_bytes())?;
self.writer.write_all(payload)?;

읽을 때도 대칭입니다.

crates/quipu-core/src/storage/segment.rs — SegmentReader::next_recordself.reader.read_exact(&mut buf)?;
if crc32fast::hash(&buf) != crc {
    return Err(Error::Corrupt { /* … */ reason: "crc mismatch".into() });
}

③ u64 타임스탬프

레코드가 기록된 시각(microseconds since Unix epoch)입니다. 흥미로운 점은 이게 payload 안이 아니라 헤더에 있다는 것입니다. 덕분에 payload를 역직렬화하지 않고도 타임스탬프를 읽을 수 있습니다. 리텐션("90일 이전 세그먼트 삭제")이 세그먼트의 시간 범위를 파악할 때, 레코드를 하나하나 풀어볼 필요 없이 헤더만 훑으면 됩니다.

CRC32: 우연한 손상 탐지, 그 이상도 이하도 아닌

CRC32에 대해 한 가지 분명히 할 것이 있습니다.

주의 — CRC는 보안 도구가 아닙니다

CRC32는 우연한 손상(디스크 비트 플립, 전송 오류, torn write)을 잡는 도구입니다. 의도적 변조는 탐지하지 못합니다. 디스크에 직접 접근할 수 있는 공격자는 payload를 바꾸고 CRC도 다시 계산해 덮어쓸 수 있습니다. 의도적 변조를 탐지하는 건 머클 히스토리 트리(파트 5)가 하는 일입니다.

코드 주석도 같은 말을 하고 있습니다.

crates/quipu-core/src/storage/segment.rs — 세그먼트 문서// The segment CRC catches only accidental corruption.
// Tamper-evidence lives in the spine, not the segments.

CRC와 머클은 다른 위협을 다루는 도구입니다.

CRC32 (세그먼트 프레임)머클 트리 (스파인)
탐지 대상우연한 비트 손상, torn write의도적 payload 수정
공격자 가정없음 (물리 오류만)디스크 접근 가능 내부자
비용매우 빠름 (CRC32 하드웨어 가속)해시 트리 계산
위치각 세그먼트 프레임별도 스파인 파일

MAX_RECORD: 복구를 지키는 안전망

MAX_RECORD = 64 * 1024 * 1024(64 MiB)는 "한 레코드가 이보다 크면 망가진 것으로 간주"하는 한계선입니다.

크래시 직후 세그먼트를 열면 마지막 레코드가 중간에 잘릴 수 있습니다 — 길이 4바이트 중 2바이트만 쓰여 있을 수도 있고, 그러면 len 필드가 완전히 엉뚱한 값이 됩니다. 이 값을 믿고 메모리를 할당하면 OOM이 납니다. MAX_RECORD 가드가 이를 막습니다. 이 크기 이상의 len을 보면 "torn tail"로 취급하고 이전 유효 레코드까지만 복구합니다. 12장 크래시 복구에서 자세히 다룹니다.

쓰기 순서가 중요하다

append 함수에서 쓰기 순서를 다시 보겠습니다.

crates/quipu-core/src/storage/segment.rs — Segment::appendself.writer.write_all(&(payload.len() as u32).to_le_bytes())?; // 1. 길이
self.writer.write_all(&crc.to_le_bytes())?;                    // 2. CRC
self.writer.write_all(&timestamp.to_le_bytes())?;              // 3. 타임스탬프
self.writer.write_all(payload)?;                                // 4. payload

길이가 먼저 오기 때문에 파일을 앞에서 읽으면 "다음 레코드가 어디서 끝나는지"를 항상 알 수 있습니다. CRC가 payload 에 있기 때문에 payload를 다 읽고 나서 바로 검증할 수 있습니다. 타임스탬프도 헤더에 있어 payload를 파싱하지 않고도 시간 정보를 얻습니다.

스스로 확인

① CRC32가 "변조 탐지"가 아니라 "손상 탐지"인 이유는 무엇인가요? 공격자가 CRC를 무력화하려면 어떻게 하면 될까요?
② MAX_RECORD 가드 없이 torn write가 발생하면 어떤 문제가 생길 수 있나요?
③ 타임스탬프를 payload 안이 아니라 프레임 헤더에 둔 설계 의도를 리텐션(17장)과 연결해서 설명해보세요.