파일에 레코드를 줄줄이 이어 붙이면 한 가지 문제가 생깁니다 — "어디서 어디까지가 하나의 레코드인가?" 바이트 스트림만 보면 경계를 알 수 없습니다. 거기에 더해 디스크는 가끔 비트를 조용히 망가뜨립니다. 이 챕터는 Quipu-Log가 경계 문제를 프레임(frame)으로, 조용한 손상을 CRC32로 해결하는 방법을 설명합니다.
레코드 하나는 [u32 길이][u32 CRC32][u64 타임스탬프][payload] 프레임으로 감싸인다. CRC는 우연한 손상만 잡는다 — 의도적 변조를 잡는 건 머클 트리(파트 5)의 몫이다.
당신이 아는 것: DB의 페이지와 체크섬
PostgreSQL 같은 DB를 생각해봅시다. 데이터는 8 KB 단위 페이지에 저장됩니다. 각 페이지 헤더에는 체크섬이 있어서, 페이지를 읽을 때 다시 계산한 값과 비교합니다. 다르면? "이 페이지는 torn page(쓰다 만 페이지)거나 디스크 오류"로 판단합니다.
Quipu-Log도 같은 문제를 풀어야 합니다. 다만 여기선 고정 크기 페이지가 아니라 가변 길이 레코드를 다뤄야 합니다. 그래서 페이지 체크섬 대신 레코드 단위 프레임 헤더 + CRC를 씁니다.
DB에서는 고정 크기 페이지 헤더에 체크섬을 넣어 torn page를 탐지한다. Quipu-Log에서는 가변 길이 레코드마다 프레임 헤더(길이 + CRC + 타임스탬프)를 앞에 붙여 경계와 손상을 동시에 처리한다.
파일 안을 뜯어보면: MAGIC + 헤더 + 프레임들
하나의 세그먼트 파일은 이 구조입니다.
코드에서 정확한 상수를 확인해봅시다.
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(×tamp.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에 대해 한 가지 분명히 할 것이 있습니다.
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(×tamp.to_le_bytes())?; // 3. 타임스탬프
self.writer.write_all(payload)?; // 4. payload
길이가 먼저 오기 때문에 파일을 앞에서 읽으면 "다음 레코드가 어디서 끝나는지"를 항상 알 수 있습니다. CRC가 payload 앞에 있기 때문에 payload를 다 읽고 나서 바로 검증할 수 있습니다. 타임스탬프도 헤더에 있어 payload를 파싱하지 않고도 시간 정보를 얻습니다.
① CRC32가 "변조 탐지"가 아니라 "손상 탐지"인 이유는 무엇인가요? 공격자가 CRC를 무력화하려면 어떻게 하면 될까요?
② MAX_RECORD 가드 없이 torn write가 발생하면 어떤 문제가 생길 수 있나요?
③ 타임스탬프를 payload 안이 아니라 프레임 헤더에 둔 설계 의도를 리텐션(17장)과 연결해서 설명해보세요.