Quipu-Log 교과서
파트 2 · 파일시스템 기본기

05 · 데이터는 언제 진짜 디스크에 남나: page cache와 fsync

file.write_all(b"hello")를 호출했다고 해서 데이터가 바로 디스크에 기록되는 게 아닙니다. OS와 Rust 양쪽에 버퍼가 있어서, 실제 디스크에 닿기까지 두 단계를 거칩니다. 정전이 이 사이에 일어나면? 데이터가 사라집니다. 이 챕터에서는 "언제 진짜로 디스크에 남는가"를 이해하고, Quipu-Log가 이것을 어떻게 제어하는지 봅니다.

DB ↔ 파일시스템

DB에서는 COMMIT 시점에 WAL을 fsync해서 데이터 손실이 없음을 보장한다. Quipu-Log에서는 그 fsync를 우리가 직접 호출한다 — SyncPolicy로 "매번 fsync", "N번마다 fsync", "OS 맡김" 중 하나를 고른다.

2단 버퍼: 유저 공간 + 커널 page cache

write()를 호출하면 실제로는 두 개의 버퍼를 지나갑니다.

애플리케이션 seg.append(payload) BufWriter (유저 공간) 256 KB 버퍼 flush()로 커널로 전달 커널 page cache OS 메모리의 dirty page fsync()로 디스크로 강제 물리 디스크 (NVMe) 정전 후에도 살아남는다 write() flush() fsync() ⚡ 정전 시 손실 위험 구간
쓰기는 두 버퍼를 거친다. BufWriter(유저 공간) → page cache(커널) → 디스크(물리). flush()는 첫 버퍼를 비우고, fsync()는 page cache까지 디스크로 밀어낸다.

구체적으로 설명하면:

  1. BufWriter (유저 공간 버퍼): write()를 호출하면 데이터가 먼저 BufWriter의 내부 버퍼(Quipu-Log는 256 KB)에 쌓입니다. 버퍼가 꽉 차거나 flush()를 명시적으로 호출해야 커널로 넘어갑니다.
  2. 커널 page cache (OS 버퍼): flush()로 데이터가 커널에 넘어가면, OS는 그걸 즉시 디스크에 쓰지 않습니다. 메모리(RAM)의 "dirty page"로 보관하다가 나중에 한꺼번에 씁니다(write-back). 이 상태에서 정전이 나면 — 사라집니다.
  3. fsync(): fsync()를 호출하면 OS는 dirty page를 디스크에 강제로 씁니다. 이 호출이 반환된 후에는 정전이 나도 데이터가 살아있습니다. 단, 디스크 왕복 때문에 느립니다.

Quipu-Log에서: BufWriter, flush, sync

SegmentBufWriter<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/sOS write-back 주기 동안 (수십 ms ~ 수 s)
EveryN(64)~4,800 events/s최대 63 events
Always~750 events/s (추정)0 events (fsync 후 반환)

EveryN(64)가 기본값입니다 — 대부분의 감사 로그 워크로드에서 "63개 이하 유실 가능성"은 받아들일 수 있는 위험이고, 처리량은 충분합니다. HIPAA처럼 엄격한 환경에서는 Always를 검토하세요.

DB ↔ 파일시스템

PostgreSQL의 synchronous_commit = offOsManaged, on(기본값)은 Always에 해당한다. MySQL InnoDB의 innodb_flush_log_at_trx_commit = 2OsManaged와 비슷하다. DB 엔진이 내부적으로 하던 선택을 여기선 우리가 직접 SyncPolicy로 지정한다.

주의

OsManaged애플리케이션 크래시에는 안전합니다 — page cache는 OS가 관리하므로, 프로세스가 죽어도 OS가 살아있으면 page cache는 보존됩니다. 시스템 전체 전원 차단(정전, OS 크래시, 강제 리셋)에서만 유실 위험이 있습니다. 클라우드 VM에서는 하이퍼바이저가 대부분 write-back을 빠르게 처리하므로 실질적 위험이 낮을 수 있습니다 — 하지만 보장은 없습니다.

스스로 확인

flush()fsync()의 차이를 "어디까지 데이터가 전달되는가"로 설명해보세요.
SyncPolicy::EveryN(64)를 쓰다가 정전이 나면 최대 몇 개의 이벤트가 유실될 수 있나요?
③ "애플리케이션 크래시"와 "시스템 전원 차단" 시 OsManaged의 동작이 다른 이유는 무엇인가요?