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

03 · 파일시스템의 기본: 파일·디렉토리·inode·디스크립터

DB를 다루면서 "파일"은 당연히 있는 것으로 여겨왔을 겁니다. 그런데 Quipu-Log처럼 DB 엔진 없이 파일 위에서 직접 저장 엔진을 만들려면, OS가 파일을 어떻게 관리하는지부터 알아야 합니다. 이 챕터는 파일·디렉토리·inode·파일 디스크립터라는 네 가지 기본 개념을 다룹니다 — Quipu-Log 코드가 실제로 만드는 디렉토리와 파일을 보면서요.

DB ↔ 파일시스템

DB는 "테이블"이라는 추상을 제공한다. 그 아래에는 OS가 관리하는 파일·디렉토리가 있다. Quipu-Log는 그 추상층을 직접 다룬다 — DB의 테이블/로우 대신, OS의 파일/바이트를 우리가 직접 다루고 그 위에 구조를 우리가 얹는다.

파일이란 무엇인가: 이름 붙은 바이트 열

OS 관점에서 파일은 단순합니다 — 연속된 바이트의 나열입니다. 관계형 DB가 "테이블 = 행들의 집합"이라는 추상을 제공하는 것처럼, 파일시스템은 "파일 = 바이트들의 집합"이라는 추상을 제공합니다. 그 위에 우리가 원하는 구조를 얹는 건 프로그래머의 몫입니다.

Quipu-Log는 이 바이트 나열에 프레임(frame)이라는 고정 틀을 씌워 레코드를 쌓습니다. 길이·체크섬·타임스탬프·본문을 순서대로 쓰는 것이 우리가 "구조를 얹는" 방식입니다. 9장에서 프레임 세부 사항을 자세히 다룹니다.

inode: 파일의 실제 정체

파일에는 두 가지 측면이 있습니다. 우리가 보는 이름(경로)과, OS 내부의 inode입니다.

inode는 파일의 실제 메타데이터 저장소입니다. 파일 크기, 권한, 타임스탬프, 그리고 실제 데이터가 디스크 어디에 있는지(블록 포인터)를 담습니다. 중요한 점은 — inode에 파일 이름이 없습니다. 이름은 디렉토리가 관리하는 별개의 것입니다.

디렉토리 (logs/) 이름 → inode 번호 매핑 "seg-0000000000.log" → 4829 "seg-0000000001.log" → 4830 "seg-0000000002.log" → 4831 inode #4829 크기: 67,108,864 bytes 권한: rw-r--r-- 수정 시각: 2026-06-14 ↓ 데이터 블록 포인터 블록 1287, 1288, 1289... 디스크 데이터 블록 ALOG 0x02 [base_index] [len][crc][ts][payload...] [len][crc][ts][payload...] [len][crc][ts]...
디렉토리는 이름→inode 번호의 매핑이다. inode가 실제 데이터 블록을 가리킨다. 이름이 바뀌어도(rename) inode는 그대로다.

이 구조가 왜 중요한가? 이름(경로)과 데이터(inode)가 분리되어 있기 때문에, 파일 이름을 바꿔도 데이터는 그대로입니다. 이 성질을 rename(2)의 원자성으로 활용하는 방법은 4장에서 봅니다.

디렉토리: 이름 → inode 매핑

디렉토리는 특별한 파일입니다. 내용물이 바이트가 아니라 "이름 → inode 번호"의 목록입니다. Quipu-Log의 logs/ 디렉토리는 이렇게 생겼습니다 (논리적으로):

root/logs/ 디렉토리 내용 (논리적 표현)// "seg-0000000000.log" → inode 4829
// "seg-0000000001.log" → inode 4830
// "seg-0000000002.log" → inode 4831  ← 현재 쓰는 중

Quipu-Log 코드는 시작할 때 read_dir()로 이 디렉토리를 읽어서 세그먼트 번호 목록을 만듭니다. 숫자가 가장 큰 것이 현재 쓰는 중인 "active" 세그먼트입니다.

crates/quipu-core/src/storage/table.rsfor entry in std::fs::read_dir(dir)? {
    let name = entry?.file_name();
    let name = name.to_string_lossy();
    if let Some(num) = name
        .strip_prefix("seg-")
        .and_then(|s| s.strip_suffix(".log"))
        .and_then(|s| s.parse::<u64>().ok())
    {
        seqs.push(num);
    }
}

파일 디스크립터: 열린 파일의 핸들

파일을 열면 OS는 파일 디스크립터(fd, file descriptor)를 돌려줍니다. 이것은 "나는 지금 이 파일을 이 위치부터 읽겠다"는 상태를 OS가 관리하는 핸들입니다. 정수 하나지만, 내부적으로는 현재 읽기/쓰기 위치(offset), 열기 모드, inode 참조 등을 담고 있습니다.

Rust에서는 std::fs::File이 파일 디스크립터를 감싼 타입입니다. File이 drop되면 자동으로 닫힙니다. Quipu-Log의 Segment는 이 FileBufWriter로 한 번 더 감쌉니다 — 그 이유는 5장 page cache와 fsync, 6장 std::fs 도구상자에서 다룹니다.

비유

파일 디스크립터는 도서관의 "대출 카드"와 비슷합니다. 같은 책(파일)을 여러 사람이 동시에 빌릴 수 있고, 각자 자기 책갈피 위치(offset)를 독립적으로 유지합니다. 다만 책 내용(inode → 데이터)은 공유됩니다.

경로: 계층적 이름

OS에서 파일에 접근하는 방법은 경로(path)입니다. 절대 경로(/var/lib/myapp/audit/logs/seg-0000000000.log)나 상대 경로 모두 결국 디렉토리들의 이름→inode 체인을 따라 inode에 도달합니다.

Rust에서는 std::path::PathPathBuf가 경로를 다룹니다. Path는 빌린 참조(슬라이스처럼), PathBuf는 소유된 버전(String처럼)입니다. Quipu-Log에서 StoreConfigroot 필드가 PathBuf인 것도 이 이유입니다.

crates/quipu-core/src/store.rspub struct StoreConfig {
    pub root: PathBuf,   // 루트 디렉토리 경로 (소유)
    pub max_segment_bytes: u64,
    pub sync_policy: SyncPolicy,
    // ...
}
DB ↔ 파일시스템

DB에서는 테이블 이름이 논리적 식별자다 — 물리적으로 어떤 파일에 어떻게 저장되는지는 DB 엔진이 관리한다. 파일시스템에서는 그 물리적 구조를 직접 본다 — 디렉토리·파일 이름·inode. Quipu-Log의 logs/, registry/patient/ 같은 이름들은 DB의 테이블명을 우리가 직접 디렉토리 이름으로 표현한 것이다.

실제 Quipu-Log 저장소 레이아웃 다시 보기

이제 1장의 디렉토리 구조를 다시 보면 다르게 읽힙니다.

실제 디렉토리 레이아웃root/
  meta/                     ← Table<MetaEvent> : 스키마 정의 로그
    seg-0000000000.log      ← inode: 바이트 배열, 내부는 프레임들
    seg-0000000000.meta     ← inode: JSON 사이드카 (시간 범위, 레코드 수)
  logs/                     ← Table<AuditLog>  : 감사 이벤트 로그
    seg-0000000000.log
    seg-0000000001.log      ← active: 현재 쓰는 중
  registry/patient/         ← Table<RegistryRecord> : patient 엔티티 버전 이력
    seg-0000000000.log
  LOCK                      ← 단 1바이트도 필요 없는 파일, 락 용도만

.log 파일은 inode 하나 — 연속된 바이트 배열입니다. Quipu-Log가 그 위에 프레임 구조를 얹어서 의미 있는 레코드들로 읽습니다.

스스로 확인

① 파일을 삭제하지 않고 이름만 바꿔도 데이터가 그대로인 이유는 inode 구조로 어떻게 설명할 수 있나요?
② Quipu-Log가 시작할 때 read_dir()로 세그먼트 번호 목록을 만드는 이유는 무엇인가요? DB에서 이에 해당하는 동작은?
PathPathBuf의 차이를 Rust 관점에서 설명해보세요 (&strString에 비유해서).