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

04 · 읽고 쓰기, 그리고 rename의 원자성

파일을 읽고 쓰는 건 단순해 보입니다 — "바이트 쓰면 끝" 아닌가요? 하지만 "파일이 깨지지 않게 갱신하기"는 생각보다 까다롭습니다. 이 챕터에서는 Quipu-Log가 세그먼트를 여는 방법(OpenOptions)과, 안전한 파일 교체에 쓰는 rename(2)의 원자성을 다룹니다. DB에서는 엔진이 해주던 것을 여기선 우리가 직접 합니다.

DB ↔ 파일시스템

DB의 atomic commit은 "트랜잭션이 커밋되거나, 아니면 아무것도 안 일어난 것처럼 보이거나" 둘 중 하나다. Quipu-Log에서는 파일 수준 원자성이 필요할 때 rename(2)를 사용한다 — 새 파일을 임시 이름으로 다 쓴 다음, 한 번의 rename으로 교체. "다 완성됐을 때만 보이게 된다"는 원자성을.

파일 열기: OpenOptions의 옵션들

Rust에서 파일을 열 때는 std::fs::OpenOptions로 "어떻게 열 것인가"를 지정합니다. Quipu-Log 세그먼트를 여는 코드를 보면 각 옵션이 왜 필요한지 명확해집니다.

crates/quipu-core/src/storage/segment.rs — Segment::open()let file = OpenOptions::new()
    .create(true)         // 없으면 새로 만들어라
    .truncate(false)     // 있어도 비우지 마라 — 기존 내용 보존!
    .read(true)          // 읽기도 가능하게 (recovery 때 필요)
    .write(true)         // 쓰기 가능
    .open(path)?;

truncate(false)가 핵심입니다. truncate(true)로 열면 파일을 열자마자 내용을 지워버립니다. 세그먼트를 재시작할 때 이 옵션이 잘못되면 이전에 쌓인 감사 기록이 모두 날아갑니다. append-only 저장소에서는 절대 truncate(true)를 쓰면 안 됩니다.

OpenOptions 옵션Quipu-Log에서
create(true)파일이 없으면 새로 만든다첫 세그먼트 생성 시 사용
truncate(false)이미 있는 파일 내용 보존필수 — 기존 로그 보존
truncate(true)열자마자 파일 비우기절대 사용 금지
append(true)모든 write가 끝에 추가테스트 코드에서 사용
read(true)읽기 가능skim(복구)·snapshot 읽기
write(true)쓰기 가능append + set_len(복구)

seek: 파일 어디서든 읽고 쓰기

파일 디스크립터는 "현재 위치"를 기억합니다. seek()로 이 위치를 바꿀 수 있습니다. Quipu-Log에서 세그먼트를 열 때, 기존 파일이 있으면 유효한 끝 위치까지 seek해서 append를 거기서 시작합니다.

crates/quipu-core/src/storage/segment.rs — 기존 파일 재개let mut writer = BufWriter::with_capacity(256 * 1024, file);
writer.seek(SeekFrom::Start(s.valid_len))?;   // 유효한 끝으로 이동
// 이후 append는 이 위치부터 시작된다

SeekFrom::Start(n)은 파일 처음부터 n 바이트 위치. SeekFrom::End(0)은 파일 끝. SeekFrom::Current(n)은 현재 위치 기준 ±n. Quipu-Log는 항상 Start(valid_len)을 씁니다 — 크래시로 꼬리가 깨진 경우, 유효한 끝이 파일 크기와 다를 수 있으니까요.

torn write와 꼬리 truncate

append 중에 전원이 끊기면 어떻게 될까요? 마지막 레코드가 절반만 써진 채 파일에 남습니다. 이것을 torn write(찢어진 쓰기)라고 합니다.

Quipu-Log는 시작 시 skim() 함수로 세그먼트를 훑어서 CRC가 깨진 프레임을 찾습니다. 유효한 마지막 위치(valid_len)를 확인한 뒤, 파일을 거기까지 잘라냅니다:

crates/quipu-core/src/storage/segment.rs — 꼬리 truncateif file.metadata()?.len() > s.valid_len {
    file.set_len(s.valid_len)?;   // 깨진 꼬리를 잘라낸다
}
let mut writer = BufWriter::with_capacity(256 * 1024, file);
writer.seek(SeekFrom::Start(s.valid_len))?;

set_len(n)은 파일 크기를 n 바이트로 맞춥니다. 현재보다 작은 값을 주면 truncate(잘라내기), 큰 값을 주면 hole(빈 공간)이 생깁니다. 크래시 복구에서는 전자만 씁니다.

DB ↔ 파일시스템

DB의 WAL replay는 정전 후 재시작 시 WAL을 읽어 "완성된 트랜잭션"만 적용하고 미완성은 무시(undo)한다. Quipu-Log에서는 skim으로 CRC가 맞는 프레임까지만 valid_len으로 잡고, 나머지를 truncate한다 — append-only라 undo할 내용이 없고 redo만 있어서 이렇게 단순하다. 12장에서 자세히.

rename(2)의 원자성

이제 이 챕터의 핵심입니다. 파일을 "안전하게 교체"하는 패턴이 있습니다 — 임시 파일에 다 쓰고, rename으로 교체.

왜 직접 덮어쓰면 안 되나? 기존 파일을 열어서 새 내용을 쓰다가 전원이 끊기면, 절반만 쓰인 파일이 남습니다. 이것을 피하는 방법이 rename 패턴입니다:

  1. 새 내용을 임시 파일에 완전히 씁니다 (target.rewrite).
  2. 임시 파일을 fsync합니다.
  3. rename(tmp, target) — 이 연산은 POSIX에서 원자적(atomic)입니다. 완성되거나 아무것도 안 일어나거나 둘 중 하나. 중간 상태가 없습니다.
① 임시 파일에 새 내용 전체 쓰기 + fsync registry.rewrite/ 새 내용 완성됨 ✓ registry/patient/ 기존 내용 그대로 ② rename(tmp, target) — 원자적 registry.pre-rewrite/ 기존 내용 (백업) registry/patient/ ← 새 내용 rename으로 교체됨 ✓ 다른 프로세스 여기서만 새 파일을 봄
rename 패턴: 임시 파일에 완성한 뒤 한 번에 교체. 정전이 rename 전에 일어나면 기존 파일이 그대로 남는다.

Quipu-Log에서 re-key 시 테이블 전체를 재작성할 때 이 패턴을 씁니다:

crates/quipu-core/src/storage/table.rs — rewrite_table()let tmp = dir.with_file_name(format!("{name}.rewrite"));
let backup = dir.with_file_name(format!("{name}.pre-rewrite"));
// ... 새 테이블을 tmp에 다 씁니다 ...
fresh.sync()?;          // 먼저 fsync
drop(old); drop(fresh); // 핸들 반납
std::fs::rename(dir, &backup)?;   // 기존 → 백업
std::fs::rename(&tmp, dir)?;      // 새것 → 실제 경로
std::fs::remove_dir_all(&backup)?; // 백업 삭제
주의

rename(2)의 원자성은 같은 파일시스템 내에서만 보장됩니다. 임시 파일을 다른 파티션에 만든 다음 rename하면 OS가 복사+삭제로 대체하고, 이건 원자적이지 않습니다. Quipu-Log의 rewrite_table이 임시 디렉토리를 대상 디렉토리의 형제로 만드는 이유(같은 부모 = 같은 파일시스템)가 여기 있습니다.

스스로 확인

OpenOptions::truncate(false)가 없으면 어떤 문제가 생기나요?
② rename 패턴에서 임시 파일을 먼저 fsync한 뒤 rename하는 이유는 무엇인가요?
③ torn write는 어떻게 발생하고, Quipu-Log는 어떻게 탐지하고 회복하나요?