Quipu-Log 전체 저장 엔진은 특별한 OS 기능이나 외부 라이브러리 없이 Rust 표준 라이브러리(std::fs, std::io)만으로 돌아갑니다. 이 챕터에서는 Quipu-Log 코드 전체에서 실제로 쓰이는 표준 API들을 모아서, "이게 우리가 가진 연장의 전부"라는 관점으로 살펴봅니다.
DB 엔진은 B-tree 관리, 캐시 풀, 잠금 관리자, WAL 엔진을 수백만 줄의 C 코드로 제공한다. Quipu-Log에서는 std::fs와 std::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::Read | read_exact(&mut [u8]) | 프레임 헤더·페이로드 읽기 |
std::io::Write | write_all(&[u8]) | 프레임 헤더·페이로드 쓰기 |
std::io::Seek | seek(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(×tamp.to_le_bytes())?;
self.writer.write_all(payload)?;
버퍼 I/O: BufReader와 BufWriter
File에 직접 write()를 호출하면 시스템 콜이 매번 발생합니다. 프레임 헤더 4바이트, CRC 4바이트, 타임스탬프 8바이트 — 이렇게 나눠 부르면 시스템 콜이 3번입니다. BufWriter는 내부 버퍼(256 KB)에 모았다가 한 번에 시스템 콜을 내보냅니다.
읽기도 마찬가지입니다. 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 함수들을 모았습니다. 이것이 우리가 가진 도구 전부입니다:
| API | Quipu-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만 쓰는 것의 장점과 단점을 각각 한 가지씩 말해보세요.