7장에서 "파일 끝에 덧붙이기만 한다"는 원칙을 배웠습니다. 그런데 한 파일만 계속 늘리면 어떻게 될까요? 수 GB짜리 파일 하나, 삭제는 어떻게 하나, 백업은? 이 챕터는 로그를 여러 파일(세그먼트)로 쪼개는 이유와 방법 — 그리고 쪼갤 때 생기는 롤오버·봉인·재구성 문제를 다룹니다.
하나의 로그는 여러 세그먼트 파일로 나뉜다. 가득 찬 파일은 봉인(sealed)되어 불변이 되고, 새 파일이 열려 쓰기를 이어받는다 — 이게 롤오버(rollover)다.
당신이 아는 것: SSTable과 데이터파일
관계형 DB를 쓴 적 있다면 데이터가 여러 파일로 나뉜다는 걸 어렴풋이 알고 있을 겁니다. PostgreSQL의 테이블은 1 GB 단위로 파일이 쪼개지고, LSM-Tree 기반 엔진(RocksDB 등)은 SSTable이라는 불변 파일 단위로 데이터를 관리합니다. 왜 한 파일이 아닐까요?
- 삭제가 가볍습니다. "이 기간 데이터 버려"를 파일 하나 unlink로 끝냅니다. 행을 찾아 지우는(vacuum) 무거운 작업이 필요 없죠.
- 백업·복제가 쉽습니다. 오래된 파일은 더 이상 바뀌지 않으니, 복사하는 동안 파일이 달라질 걱정이 없습니다.
- 손상 범위가 제한됩니다. 한 파일이 깨져도 다른 파일의 데이터는 온전합니다.
Quipu-Log도 같은 이유로 로그를 여러 세그먼트 파일로 나눕니다. 단, SSTable처럼 정렬·병합(compaction)은 없습니다 — append-only이므로 쓴 순서가 곧 파일 순서입니다.
DB에서는 LSM의 SSTable, PostgreSQL의 relation 파일처럼 데이터를 유한한 크기의 파일 단위로 쪼갠다. Quipu-Log에서는 그 단위가 세그먼트(.log)다. 봉인된 세그먼트는 SSTable처럼 불변이고, active 세그먼트는 끝이 열려 있다.
세그먼트의 두 상태: active vs sealed
세그먼트는 항상 둘 중 하나입니다.
max_segment_bytes를 넘으면 롤오버해 active가 교체된다.active 세그먼트는 현재 쓰고 있는 파일입니다. 레코드가 계속 append됩니다.
sealed 세그먼트는 max_segment_bytes를 초과해 "더 이상 쓰지 않기로 결정된" 파일입니다. 내용이 굳어집니다 — 수정이 없으므로 변조 탐지와 백업 관점에서 이상적입니다.
파일 이름과 시퀀스 번호
각 세그먼트 파일은 seg-NNNNNNNNNN.log 형식으로 이름 붙습니다. N은 10자리 0-패딩 정수, 즉 시퀀스 번호(sequence number)입니다. 코드에서는:
crates/quipu-core/src/storage/table.rsconst SEGMENT_PREFIX: &str = "seg-";
const SEGMENT_SUFFIX: &str = ".log";
fn segment_path(dir: &Path, seq: u64) -> PathBuf {
dir.join(format!("{SEGMENT_PREFIX}{seq:010}{SEGMENT_SUFFIX}"))
}
0-패딩 덕분에 파일 이름을 사전순으로 정렬하면 시간순이 됩니다. OS의 read_dir로 파일 목록을 받아 이름으로 정렬하면 곧바로 "과거 → 현재" 순서가 되죠. 가장 번호가 큰 파일이 active 세그먼트입니다.
롤오버: 새 세그먼트를 여는 순간
레코드를 append할 때마다 "이 파일이 충분히 찼는가?"를 확인합니다. 코드를 보겠습니다.
crates/quipu-core/src/storage/table.rs — Table::appendpub fn append(&mut self, row: &T, timestamp: u64) -> Result<()> {
let payload = bincode::serialize(row)?;
if !self.active.is_empty()
&& self.active.len() + payload.len() as u64 > self.max_segment_bytes
{
self.roll()?;
}
self.active.append(&payload, timestamp)?;
self.spine.append(leaf_hash(&payload))?;
Ok(())
}
active 세그먼트가 비어 있지 않고, 이 레코드를 더하면 max_segment_bytes를 넘을 것 같으면 roll()을 부릅니다. 기본값은 64 * 1024 * 1024 = 64 MiB(StoreConfig::new에서 설정)입니다.
roll() 안에서는 ① 현재 active를 fsync하고 ② 메타데이터 사이드카를 쓴 다음 ③ sealed로 이동, ④ 시퀀스 번호 +1로 새 파일을 엽니다. 이 순서가 중요합니다 — 새 파일이 열리기 전에 이전 파일이 완전히 기록되어야 하니까요.
사이드카(.meta)와 재시작 최적화
재시작할 때마다 sealed 세그먼트를 처음부터 읽어 시간 범위를 파악하면 느립니다. 그래서 Quipu-Log는 봉인 시점에 사이드카 파일(seg-NNNNNNNNNN.meta)을 씁니다. 여기에 최소·최대 타임스탬프, 레코드 수, base_index를 보관합니다.
crates/quipu-core/src/storage/table.rs#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct SegmentMeta {
min_timestamp: u64,
max_timestamp: u64,
records: u64,
base_index: u64,
}
사이드카는 힌트(hint)일 뿐, 진실의 원본이 아닙니다. 크래시로 사이드카 쓰기가 실패해도 재시작 시 세그먼트를 처음부터 skim(훑기)해서 재구성합니다. 무결성 증명(머클 트리)은 절대 사이드카에 의존하지 않습니다.
base_index: 세그먼트 파일을 넘어서도 유지되는 레코드 위치
각 세그먼트의 첫 번째 레코드가 전체 로그에서 몇 번째인지 기록하는 값이 base_index입니다. 예를 들어 세그먼트 0이 100개, 세그먼트 1이 80개 레코드를 담고 있다면 세그먼트 2의 base_index는 180입니다.
왜 필요할까요? 리텐션으로 세그먼트 0을 삭제해도, 세그먼트 2 안의 레코드들이 머클 트리에서 180번째부터임을 파일 헤더를 통해 알 수 있습니다. 세그먼트가 삭제된 후에도 머클 포함 증명(inclusion proof)이 유효하게 됩니다. 21장 포함 증명에서 자세히 다룹니다.
재시작: 디렉토리를 읽고 상태를 복원
프로세스가 재시작하면 Table::open이 로그 디렉토리를 읽어 상태를 재구성합니다.
read_dir로.log파일 목록을 수집하고 시퀀스 번호로 정렬합니다.- 가장 번호가 큰 파일을 active 세그먼트로 지정합니다.
- 나머지는 사이드카를 읽어 sealed 목록에 등록합니다. 사이드카가 없으면 skim으로 재구성합니다.
- 마지막으로
reconcile_spine()을 불러 머클 스파인과 세그먼트를 동기화합니다. 12장 크래시 복구
DB에서는 카탈로그 테이블(pg_class 등)이 "어떤 파일이 어느 테이블에 속하나"를 알고 있다. Quipu-Log에서는 카탈로그가 없다 — 파일 이름 패턴(seg-*.log)이 곧 카탈로그다. 재시작 시 디렉토리를 읽는 것만으로 완전히 복원할 수 있다.
삭제는 unlink 한 번
리텐션 정책("30일 지난 것은 삭제")이 실행될 때 Quipu-Log가 하는 일은 매우 단순합니다 — 대상 sealed 세그먼트 파일을 remove_file(OS의 unlink)로 지웁니다. 행을 찾아 삭제하고 남은 공간을 재사용하는 vacuum 같은 것이 없습니다.
crates/quipu-core/src/storage/table.rs — Table::purge_older_thanfor seq in &doomed {
if let Some(s) = self.sealed.remove(seq) {
std::fs::remove_file(s.path)?;
let _ = std::fs::remove_file(meta_path(&self.dir, *seq));
}
}
active 세그먼트는 절대 지우지 않습니다 — 가장 최근 기록은 항상 살아남습니다.
세그먼트 단위 삭제는 O(1) 파일시스템 연산입니다. "30일 전 레코드를 행 단위로 찾아 지우기"는 로그 전체를 훑어야 하는 O(n) 작업이고, 남은 공간을 재사용하려면 compaction까지 필요합니다. append-only 설계가 삭제도 단순하게 만들어줍니다. 단, 세그먼트 경계보다 정밀한 삭제는 불가능합니다 — 30.1일 전 레코드 하나만 지울 수는 없습니다. 17장 삭제와 보존
① active 세그먼트와 sealed 세그먼트의 차이를 한 문장으로 설명해 보세요. 왜 sealed는 "불변"이라고 할 수 있나요?
② 재시작 시 Quipu-Log가 카탈로그 테이블 없이 세그먼트를 재구성할 수 있는 이유는 무엇인가요?
③ base_index가 없다면 리텐션 후에 무슨 문제가 생길까요?