file.write_all(b"hello")를 호출했다고 해서 데이터가 바로 디스크에 기록되는 게 아닙니다. OS와 Rust 양쪽에 버퍼가 있어서, 실제 디스크에 닿기까지 두 단계를 거칩니다. 정전이 이 사이에 일어나면? 데이터가 사라집니다. 이 챕터에서는 "언제 진짜로 디스크에 남는가"를 이해하고, Quipu-Log가 이것을 어떻게 제어하는지 봅니다.
DB에서는 COMMIT 시점에 WAL을 fsync해서 데이터 손실이 없음을 보장한다. Quipu-Log에서는 그 fsync를 우리가 직접 호출한다 — SyncPolicy로 "매번 fsync", "N번마다 fsync", "OS 맡김" 중 하나를 고른다.
2단 버퍼: 유저 공간 + 커널 page cache
write()를 호출하면 실제로는 두 개의 버퍼를 지나갑니다.
구체적으로 설명하면:
- BufWriter (유저 공간 버퍼):
write()를 호출하면 데이터가 먼저 BufWriter의 내부 버퍼(Quipu-Log는 256 KB)에 쌓입니다. 버퍼가 꽉 차거나flush()를 명시적으로 호출해야 커널로 넘어갑니다. - 커널 page cache (OS 버퍼):
flush()로 데이터가 커널에 넘어가면, OS는 그걸 즉시 디스크에 쓰지 않습니다. 메모리(RAM)의 "dirty page"로 보관하다가 나중에 한꺼번에 씁니다(write-back). 이 상태에서 정전이 나면 — 사라집니다. - fsync():
fsync()를 호출하면 OS는 dirty page를 디스크에 강제로 씁니다. 이 호출이 반환된 후에는 정전이 나도 데이터가 살아있습니다. 단, 디스크 왕복 때문에 느립니다.
Quipu-Log에서: BufWriter, flush, sync
Segment는 BufWriter<File>을 씁니다. append()는 BufWriter에 쓰고, flush()는 page cache까지, sync()는 디스크까지 보냅니다.
crates/quipu-core/src/storage/segment.rspub fn flush(&mut self) -> Result<()> {
self.writer.flush()?; // BufWriter → page cache (OS 메모리)
Ok(())
}
pub fn sync(&mut self) -> Result<()> {
self.writer.flush()?;
self.writer.get_ref().sync_data()?; // page cache → 물리 디스크
Ok(())
}
sync_data()는 Rust의 fdatasync(2) 래퍼입니다. fsync(2)와 비슷하지만 메타데이터(접근 시각 등)는 업데이트하지 않아 약간 빠릅니다. 데이터 내구성에는 충분합니다.
SyncPolicy: 내구성 vs 처리량 트레이드오프
매 append마다 fsync를 하면 안전하지만 느립니다. OS에 맡기면 빠르지만 정전 시 최근 기록이 날아갑니다. Quipu-Log는 SyncPolicy로 이 트레이드오프를 선택할 수 있게 합니다.
crates/quipu-core/src/store.rspub enum SyncPolicy {
Always, // 매 append 후 fsync. 가장 안전, 가장 느림.
EveryN(u32), // N번마다 한 번 fsync. 중간 트레이드오프.
OsManaged, // fsync 없음. OS에 맡김. 가장 빠름.
}
이 세 옵션이 적용되는 코드를 보면 명확합니다:
crates/quipu-core/src/store.rs — apply_sync_policy()match self.cfg.sync_policy {
SyncPolicy::Always => self.sync_all()?,
SyncPolicy::EveryN(n) => {
self.appends_since_sync += 1;
if self.appends_since_sync >= n {
self.sync_all()?; // N번째마다 fsync
} else {
self.logs.flush()?; // 나머지는 page cache까지만
self.relations.flush()?;
}
}
SyncPolicy::OsManaged => {
self.logs.flush()?; // BufWriter만 비움, fsync 없음
}
}
성능 수치로 보는 트레이드오프
README의 벤치마크 수치가 이 차이를 보여줍니다 (Apple M4, NVMe SSD):
| SyncPolicy | 내구 처리량 | 정전 시 유실 가능량 |
|---|---|---|
OsManaged | ~56,000 events/s | OS write-back 주기 동안 (수십 ms ~ 수 s) |
EveryN(64) | ~4,800 events/s | 최대 63 events |
Always | ~750 events/s (추정) | 0 events (fsync 후 반환) |
EveryN(64)가 기본값입니다 — 대부분의 감사 로그 워크로드에서 "63개 이하 유실 가능성"은 받아들일 수 있는 위험이고, 처리량은 충분합니다. HIPAA처럼 엄격한 환경에서는 Always를 검토하세요.
PostgreSQL의 synchronous_commit = off는 OsManaged, on(기본값)은 Always에 해당한다. MySQL InnoDB의 innodb_flush_log_at_trx_commit = 2는 OsManaged와 비슷하다. DB 엔진이 내부적으로 하던 선택을 여기선 우리가 직접 SyncPolicy로 지정한다.
OsManaged는 애플리케이션 크래시에는 안전합니다 — page cache는 OS가 관리하므로, 프로세스가 죽어도 OS가 살아있으면 page cache는 보존됩니다. 시스템 전체 전원 차단(정전, OS 크래시, 강제 리셋)에서만 유실 위험이 있습니다. 클라우드 VM에서는 하이퍼바이저가 대부분 write-back을 빠르게 처리하므로 실질적 위험이 낮을 수 있습니다 — 하지만 보장은 없습니다.
① flush()와 fsync()의 차이를 "어디까지 데이터가 전달되는가"로 설명해보세요.
② SyncPolicy::EveryN(64)를 쓰다가 정전이 나면 최대 몇 개의 이벤트가 유실될 수 있나요?
③ "애플리케이션 크래시"와 "시스템 전원 차단" 시 OsManaged의 동작이 다른 이유는 무엇인가요?