DB를 다루면서 "파일"은 당연히 있는 것으로 여겨왔을 겁니다. 그런데 Quipu-Log처럼 DB 엔진 없이 파일 위에서 직접 저장 엔진을 만들려면, OS가 파일을 어떻게 관리하는지부터 알아야 합니다. 이 챕터는 파일·디렉토리·inode·파일 디스크립터라는 네 가지 기본 개념을 다룹니다 — Quipu-Log 코드가 실제로 만드는 디렉토리와 파일을 보면서요.
DB는 "테이블"이라는 추상을 제공한다. 그 아래에는 OS가 관리하는 파일·디렉토리가 있다. Quipu-Log는 그 추상층을 직접 다룬다 — DB의 테이블/로우 대신, OS의 파일/바이트를 우리가 직접 다루고 그 위에 구조를 우리가 얹는다.
파일이란 무엇인가: 이름 붙은 바이트 열
OS 관점에서 파일은 단순합니다 — 연속된 바이트의 나열입니다. 관계형 DB가 "테이블 = 행들의 집합"이라는 추상을 제공하는 것처럼, 파일시스템은 "파일 = 바이트들의 집합"이라는 추상을 제공합니다. 그 위에 우리가 원하는 구조를 얹는 건 프로그래머의 몫입니다.
Quipu-Log는 이 바이트 나열에 프레임(frame)이라는 고정 틀을 씌워 레코드를 쌓습니다. 길이·체크섬·타임스탬프·본문을 순서대로 쓰는 것이 우리가 "구조를 얹는" 방식입니다. 9장에서 프레임 세부 사항을 자세히 다룹니다.
inode: 파일의 실제 정체
파일에는 두 가지 측면이 있습니다. 우리가 보는 이름(경로)과, OS 내부의 inode입니다.
inode는 파일의 실제 메타데이터 저장소입니다. 파일 크기, 권한, 타임스탬프, 그리고 실제 데이터가 디스크 어디에 있는지(블록 포인터)를 담습니다. 중요한 점은 — 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는 이 File을 BufWriter로 한 번 더 감쌉니다 — 그 이유는 5장 page cache와 fsync, 6장 std::fs 도구상자에서 다룹니다.
파일 디스크립터는 도서관의 "대출 카드"와 비슷합니다. 같은 책(파일)을 여러 사람이 동시에 빌릴 수 있고, 각자 자기 책갈피 위치(offset)를 독립적으로 유지합니다. 다만 책 내용(inode → 데이터)은 공유됩니다.
경로: 계층적 이름
OS에서 파일에 접근하는 방법은 경로(path)입니다. 절대 경로(/var/lib/myapp/audit/logs/seg-0000000000.log)나 상대 경로 모두 결국 디렉토리들의 이름→inode 체인을 따라 inode에 도달합니다.
Rust에서는 std::path::Path와 PathBuf가 경로를 다룹니다. Path는 빌린 참조(슬라이스처럼), PathBuf는 소유된 버전(String처럼)입니다. Quipu-Log에서 StoreConfig의 root 필드가 PathBuf인 것도 이 이유입니다.
crates/quipu-core/src/store.rspub struct StoreConfig {
pub root: PathBuf, // 루트 디렉토리 경로 (소유)
pub max_segment_bytes: u64,
pub sync_policy: SyncPolicy,
// ...
}
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에서 이에 해당하는 동작은?
③ Path와 PathBuf의 차이를 Rust 관점에서 설명해보세요 (&str과 String에 비유해서).