파일 시스템 — 디스크를 이름으로 다루다
디스크는 사실 번호가 매겨진 블록들의 거대한 배열일 뿐이다 — "3829번 섹터에 써라" 같은 식이다. 그런데 우리는 /home/user/report.txt라는 이름으로 읽고 쓴다. 이 블록 덩어리를 이름·디렉터리·권한을 가진 파일로 추상화하는 게 파일 시스템이다. OS의 추상화 중에서도 — *영구 저장(persistence)*을 다루기에, 전원이 갑자기 나가도 데이터가 망가지지 않게 하는 게 핵심 과제다. 그 추상과 무결성 보장을 본다.
파일은 무엇으로 이뤄지나 — inode와 디렉터리
파일 하나는 데이터만이 아니라 메타데이터도 갖는다 — 크기, 소유자, 권한, 수정 시각, 그리고 실제 데이터가 디스크의 어느 블록에 있는지. 이 메타데이터를 담는 게 **inode**다. 흥미롭게도 — 파일 이름은 inode에 없다. 이름은 디렉터리가 갖는데, 디렉터리란 사실 "이름 → inode 번호" 매핑의 표다.
이 분리 덕에 한 파일에 여러 이름(하드 링크)을 줄 수 있고, 이름을 바꿔도 데이터는 그대로다(inode는 안 변하니까).
전원이 갑자기 나가면 — 크래시 일관성과 저널링
여기서 영구 저장의 어려움이 드러난다. 파일 하나를 만드는 것조차 — 디렉터리에 항목 추가 + inode 초기화 + 빈 블록 비트맵 갱신, 즉 디스크의 여러 곳을 써야 한다. 그런데 그 중간에 전원이 나가면? — 디렉터리는 파일을 가리키는데 inode는 비어 있거나, 블록은 쓰였다고 표시됐는데 어느 파일도 안 가리키는 불일치가 남는다. (출처: Crash Consistency.)
해법이 **저널링(journaling)**이다 — *실제 위치에 쓰기 전에, "이러이러하게 바꾸겠다"를 먼저 *저널(로그)에 기록하고, 저널이 안전히 쓰인 뒤 실제 구조를 갱신한다. 만약 중간에 죽으면 — 재부팅 때 *저널을 다시 적용(redo)*해 일관성을 회복한다.
왜 이게 보편적인가 — "실제 변경 전에 의도를 로그에 먼저 기록한다"는 **선행 기록 로그(write-ahead log, WAL)**는 파일 시스템만의 것이 아니다. 데이터베이스의 트랜잭션 로그(그래서 DB가 죽어도 커밋된 트랜잭션을 복구)도, 로그 기반 메시지 시스템도 같은 원리다. 원자적으로 못 하는 여러 단계를, 로그에 먼저 한 줄로 기록해 *"전부 되거나 전부 안 되거나"*를 흉내 내는 것 — 이것이 영속성과 신뢰성의 보편적 기법이다.
디스크는 느리다 — 페이지 캐시
디스크는 메모리보다 수만 배 느리다. 그래서 OS는 읽은 디스크 블록을 메모리(페이지 캐시)에 보관해 — 다음에 같은 걸 읽으면 디스크 대신 메모리에서 준다. 쓰기도 일단 페이지 캐시에 모았다가 나중에 디스크로(write-back) 내보내 빠르게 응답한다. 다만 — write-back은 위험하다. 캐시에만 있고 디스크에 안 내려간 데이터는 전원이 나가면 사라진다. 그래서 반드시 보존돼야 할 데이터는 **fsync**로 *"지금 디스크에 강제로 내려라"*를 호출한다(DB가 커밋 시 fsync로 내구성을 보장하는 게 이것이다). 그리고 여러 종류의 파일 시스템(ext4·XFS·NFS…)을 *같은 인터페이스(open/read/write)*로 쓰게 해 주는 게 VFS(가상 파일 시스템) — 또 하나의 추상화 계층이다.
왜 빠른가 — 이 페이지 캐시가 고성능 저장·전송 시스템의 비결이다. 디스크에 순차로 쓰되, 읽는 쪽이 따라잡고 있으면 페이지 캐시에서 바로 내보내면(디스크를 안 읽음) 엄청나게 빨라진다 — 다음 편에서 볼 zero-copy가 이 페이지 캐시를 네트워크로 직행시키는 기법이다. 작은 I/O를 버퍼에 모아 시스템 콜·디스크 접근을 줄이는 버퍼링도 같은 발상이다.
실제로 눈으로 보기
inode 를 직접 본다
ls -li /etc/hosts
# 1049234 -rw-r--r-- 1 root root 220 Aug 17 03:12 /etc/hosts
# └ inode 번호
stat /etc/hosts
# File: /etc/hosts
# Size: 220 Blocks: 8 IO Block: 4096 regular file
# Device: 8,1 Inode: 1049234 Links: 1
# Access: 2026-08-17 03:12:44 Modify: 2026-08-01 09:20:11
이름은 inode 안에 없다. 이름은 디렉터리가 갖고 있고, 디렉터리는 이름 → inode 번호 표일 뿐이다. 그래서 하드 링크가 성립한다.
ln /etc/hosts /tmp/hosts-link
ls -li /etc/hosts /tmp/hosts-link
# 1049234 ... Links: 2 /etc/hosts ← inode 가 같다
# 1049234 ... Links: 2 /tmp/hosts-link
같은 inode 를 가리키는 이름이 둘이다. rm이 하는 일도 파일을 지우는 것이 아니라 이름을 지우고 링크 수를 하나 줄이는 것이다. 0이 되어야 실제로 회수된다.
inode 가 먼저 바닥날 수 있다
df -h / # 용량은 40% 만 썼는데
# Filesystem Size Used Avail Use% Mounted on
# /dev/sda1 50G 20G 28G 42% /
df -i / # inode 는 다 썼다
# Filesystem Inodes IUsed IFree IUse% Mounted on
# /dev/sda1 3.2M 3.2M 0 100% /
"디스크가 남았는데 No space left on device" 라는 혼란스러운 오류가 여기서 나온다. inode 는 포맷할 때 개수가 정해지므로, 아주 작은 파일을 수백만 개 만들면 용량보다 먼저 고갈된다. 세션 파일·캐시 조각을 안 지우는 시스템에서 흔하다.
지웠는데 공간이 안 늘어난다
lsof +L1
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
# myapp 8123 app 3w REG 8,1 4823102912 0 1049300 /var/log/app.log (deleted)
# └ NLINK 0 인데 열려 있다
이름은 지웠지만 누군가 파일을 열고 있으면 회수되지 않는다. 링크 수가 0이어도 열린 디스크립터가 남아 있으면 커널이 못 지운다. 로그 파일을 rm 했는데 디스크가 그대로인 전형적인 상황이고, 해법은 프로세스를 재시작하거나 로그를 잘라내는(truncate) 것이다.
페이지 캐시가 있어서 빠르다
echo 3 > /proc/sys/vm/drop_caches # 캐시를 비우고
time cat bigfile > /dev/null # real 2.481s ← 디스크에서 읽는다
time cat bigfile > /dev/null # real 0.092s ← 캐시에서 읽는다
27배 차이가 난다. free에서 buff/cache가 큰 것이 낭비가 아니라 정상인 이유다.
썼다고 디스크에 있는 것이 아니다
write(fd, buf, n); // 페이지 캐시까지만 갔다. 전원이 나가면 사라진다
fsync(fd); // 여기서야 디스크에 확정된다
strace -e trace=write,fsync,fdatasync ./myapp
write만 있고 fsync가 없다면 그 프로그램은 "썼다"고 믿지만 보장은 없다. DB의 WAL, 저널링 파일 시스템이 이 구분 위에 서 있다 — 먼저 기록하고 확정한 뒤에 본 데이터를 고친다.
정리하면, 파일 시스템은 번호 매겨진 블록을 이름·메타데이터를 가진 파일로 추상화한다 — *inode*가 메타데이터·블록 위치를, 디렉터리가 이름→inode 매핑을 갖는다. 영구 저장의 핵심 과제는 크래시 일관성인데, *저널링(WAL)*으로 실제 변경 전에 로그를 먼저 써 복구 가능하게 한다(DB 트랜잭션 로그와 같은 원리). 느린 디스크는 페이지 캐시로 가리되 내구성은 fsync로 보장하고(zero-copy의 토대), VFS로 여러 파일 시스템을 한 인터페이스로 묶는다. 파일이 저장된 데이터라면 — 장치와 데이터를 실제로 주고받는 법, I/O가 다음이다.