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

06 · std::fs 도구상자: Rust로 파일 다루기

Quipu-Log 전체 저장 엔진은 특별한 OS 기능이나 외부 라이브러리 없이 Rust 표준 라이브러리(std::fs, std::io)만으로 돌아갑니다. 이 챕터에서는 Quipu-Log 코드 전체에서 실제로 쓰이는 표준 API들을 모아서, "이게 우리가 가진 연장의 전부"라는 관점으로 살펴봅니다.

DB ↔ 파일시스템

DB 엔진은 B-tree 관리, 캐시 풀, 잠금 관리자, WAL 엔진을 수백만 줄의 C 코드로 제공한다. Quipu-Log에서는 std::fsstd::io가 그 역할을 하는 도구 전부다 — 이것만으로 WAL·인덱스·락·스냅샷을 만든다. DB 엔진이 없어도 된다는 게 얼마나 큰 도약인지, 이 연장 목록을 보면 실감이 납니다.

파일 열기: File과 OpenOptions

가장 기본적인 파일 연산은 열기(open)와 닫기(close)입니다. Rust에서는 std::fs::File이 파일 핸들이고, OpenOptions로 어떻게 열지 지정합니다.

crates/quipu-core/src/storage/segment.rs — 파일 열기use std::fs::{File, OpenOptions};

// 쓰기 전용 열기 (세그먼트 쓰기 전용)
let file = OpenOptions::new()
    .create(true).truncate(false).read(true).write(true)
    .open(path)?;

// 읽기 전용 열기 (skim, snapshot 읽기)
let file = File::open(path)?;  // 읽기 전용 단축형

File은 drop되면 자동으로 닫힙니다. 명시적으로 close()를 부를 필요가 없습니다 — Rust의 RAII 덕분입니다.

읽기·쓰기 트레이트: Read, Write, Seek

실제 데이터를 주고받는 세 가지 핵심 트레이트입니다.

트레이트핵심 메서드Quipu-Log에서의 쓰임
std::io::Readread_exact(&mut [u8])프레임 헤더·페이로드 읽기
std::io::Writewrite_all(&[u8])프레임 헤더·페이로드 쓰기
std::io::Seekseek(SeekFrom::Start(n))append 위치로 이동, skim 후 복구 위치 이동
crates/quipu-core/src/storage/segment.rs — 프레임 쓰기use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};

// 프레임 헤더 4+4+8 = 16 bytes, 그 다음 페이로드
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)?;

버퍼 I/O: BufReader와 BufWriter

File에 직접 write()를 호출하면 시스템 콜이 매번 발생합니다. 프레임 헤더 4바이트, CRC 4바이트, 타임스탬프 8바이트 — 이렇게 나눠 부르면 시스템 콜이 3번입니다. BufWriter는 내부 버퍼(256 KB)에 모았다가 한 번에 시스템 콜을 내보냅니다.

직접 쓰기 (File) write(4B) → syscall write(4B) → syscall write(8B) → syscall write(nB) → syscall 레코드당 4번의 시스템 콜 BufWriter<File> (256 KB 버퍼) write(4B) → 버퍼에 누적 write(4B) → 버퍼에 누적 write(8B) → 버퍼에 누적 write(nB) → 버퍼에 누적 flush() → 한 번의 syscall로 전부
BufWriter는 여러 번의 write()를 모았다가 한 번의 시스템 콜로 처리한다. 시스템 콜 오버헤드를 크게 줄여준다.

읽기도 마찬가지입니다. BufReader는 파일에서 한 번에 큰 덩어리를 읽어두고, read_exact()는 그 버퍼에서 꺼냅니다:

crates/quipu-core/src/storage/segment.rs — skim() 읽기let mut reader = BufReader::with_capacity(256 * 1024, file);
let mut header = [0u8; FRAME_HEADER];
reader.read_exact(&mut header)?;   // 버퍼에서 16바이트 꺼내기

파일·디렉토리 조작: 실제 사용 목록

Quipu-Log 저장소 모듈에서 쓰이는 std::fs 함수들을 모았습니다. 이것이 우리가 가진 도구 전부입니다:

APIQuipu-Log에서 쓰는 곳
std::fs::create_dir_all()스토어 루트, 테이블 디렉토리 초기화
std::fs::read_dir()테이블 열 때 세그먼트 파일 목록 수집
std::fs::remove_file()리텐션 — 오래된 세그먼트 삭제 (unlink)
std::fs::rename()테이블 재작성 후 원자적 교체
std::fs::metadata().len()세그먼트 크기 확인, 롤오버 판단
File::set_len()크래시 복구 — 깨진 꼬리 truncate
File::try_lock()루트 디렉토리 advisory 락 (MSRV 1.89)
File::sync_data()SyncPolicy::Always/EveryN — fsync

파일 락: File::try_lock (MSRV 1.89)

Quipu-Log는 단일 writer 원칙을 지킵니다. 같은 루트 디렉토리에 두 번째 프로세스가 스토어를 열면, 인메모리 인덱스가 엉키고 세그먼트 쓰기가 충돌합니다. 이를 방지하는 게 OS advisory 파일 락입니다.

crates/quipu-core/src/store.rs — AuditStore::open()let lock = std::fs::OpenOptions::new()
    .create(true).truncate(false).write(true)
    .open(cfg.root.join("LOCK"))?;

lock.try_lock().map_err(|e| match e {
    std::fs::TryLockError::WouldBlock => Error::Locked(cfg.root.display().to_string()),
    std::fs::TryLockError::Error(e) => Error::Io(e),
})?;

File::try_lock()은 Rust 1.89에서 안정화된 API입니다. 이것이 Quipu-Log의 MSRV(최소 지원 러스트 버전)가 1.89인 이유입니다. Cargo.toml에 그 이유가 명시되어 있습니다:

Cargo.toml# MSRV floor: the store guards its root with `std::fs::File::try_lock` and
# matches on `std::fs::TryLockError`, both stabilized in Rust 1.89.
rust-version = "1.89"

advisory 락의 특성: 같은 프로세스 내에서는 효과가 없고 프로세스 간 충돌을 막습니다. 프로세스가 죽으면 OS가 자동으로 락을 해제합니다(fd가 닫히므로). "파일 내용이 없어도 되는 락 파일"이라는 점이 흥미롭습니다 — 1바이트도 쓸 필요 없이, 파일을 열고 try_lock만 하면 됩니다.

왜 이렇게?

Quipu-Log가 외부 DB나 특수 OS API(mmap, io_uring 등) 없이 std::fs만 쓰는 이유는 — 이식성입니다. 코드 주석에도 적혀 있습니다: "Only std::fs/std::io are used, so behaviour is identical on every OS Rust targets." Windows·Linux·macOS·FreeBSD 어디서든 같은 코드가 돌아갑니다.

스스로 확인

① BufWriter를 쓰지 않고 File에 직접 write()를 수십 번 부르면 어떤 문제가 생기나요? 어떻게 다르게 동작하나요?
File::try_lock()이 MSRV 1.89를 요구하는 이유는 무엇인가요? MSRV를 낮추려면 어떤 대안이 있을까요?
std::fs만 쓰는 것의 장점과 단점을 각각 한 가지씩 말해보세요.