포스트

파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계

파일 시스템의 실체 — inode와 디렉터리 엔트리, 직접·간접 블록 포인터, 하드/심볼릭 링크, VFS 추상화를 그림과 실습으로 이해합니다.

파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계

“이 파일의 진짜 이름은 무엇인가”

report.pdf를 final.pdf로 바꿔도 내용은 그대로입니다. 같은 파일에 하드링크를 걸면 이름이 두 개가 됩니다. rm으로 이름을 지워도 누군가 그 파일을 열고 있으면 디스크에서 안 사라집니다. 이 세 가지 현상은 전부 한 가지 사실에서 나옵니다 — 이름은 파일이 아니다.

파일의 실체는 inode라는 자료구조이고, 우리가 보는 파일명은 그 inode를 가리키는 포인터(라벨) 일 뿐입니다. 디렉터리는 폴더라는 물건이 아니라 “이름 → inode 번호” 매핑을 담은 특수한 파일입니다. 이 글은 파일 시스템을 “디렉터리 트리”로 보는 익숙한 그림을 깨고, 이름·inode·데이터 블록·파일 디스크립터가 어떻게 분리되어 연결되는지를 끝까지 따라갑니다.

디렉터리의 정체: 이름 → inode 번호 매핑표

리눅스에서 모든 파일은 정수 하나, inode 번호로 식별됩니다. inode에는 파일에 대한 거의 모든 것이 들어 있습니다 — 단 하나, 이름만 빼고.

  • inode가 담는 것: 파일 종류(일반/디렉터리/심볼릭/장치), 크기, 소유자(uid/gid), 권한(mode), 타임스탬프(atime/mtime/ctime), 링크 수(nlink), 그리고 실제 데이터가 어느 디스크 블록에 있는지 가리키는 포인터들.
  • inode가 담지 않는 것: 파일 이름. 이름은 오직 디렉터리 엔트리에만 존재합니다.

디렉터리도 결국 하나의 파일인데, 그 내용이 (이름, inode 번호) 쌍의 목록입니다. 그래서 /etc/hosts를 여는 건 사실 이름으로 inode를 찾아가는 연속 조회입니다.

현실 체크 — rm은 파일을 지우지 않는다. unlink()는 디렉터리에서 이름 하나를 떼고 inode의 nlink를 1 줄일 뿐입니다. nlink가 0이 되고, 그 파일을 연 프로세스도 0개일 때 비로소 데이터 블록이 회수됩니다. 그래서 “디스크 꽉 찼다고 큰 로그를 rm 했는데 용량이 안 줄어드는” 고전 함정이 생깁니다 — 어떤 프로세스가 그 파일을 아직 열고 있으면(nlink=0이어도 열린 fd가 있으면) 공간은 그대로입니다. lsof +L1로 “이름은 없는데 열려 있는” 파일을 찾습니다.

경로 해석: 한 칸씩 inode를 점프한다

/a/b/c를 여는 과정은 마법이 아니라 디렉터리 엔트리를 따라가는 반복 조회입니다. 루트 inode(항상 2번)에서 출발해, 디렉터리 내용에서 다음 이름의 inode 번호를 찾고, 그 inode로 점프하고, 다시 그 디렉터리에서 다음 이름을 찾고… 목적지에 닿을 때까지 반복합니다.

아래 애니메이션에서 토큰이 /(inode 2)에서 출발해 a의 inode를 찾아 점프하고, 그 디렉터리에서 b를, 다시 c를 찾아 최종 파일 inode에 도착합니다. 각 단계가 별도의 디렉터리 조회라는 점을 보세요 — 경로가 깊을수록 조회가 늘어, 그래서 커널은 이 결과를 dentry 캐시에 담아둡니다.

경로 해석: /a/b/c 를 연다 / (inode 2) 디렉터리 a → 12 inode 12 디렉터리 a/ b → 45 inode 45 디렉터리 b/ c → 78 inode 78 일반 파일 c → 데이터 블록 도착!

이 반복 조회가 ..(상위 디렉터리)가 가능한 이유이기도 합니다. 모든 디렉터리에는 .(자기 inode)과 ..(부모 inode) 엔트리가 기본으로 들어 있어, 트리를 위아래로 오갈 수 있습니다. 그리고 매번 디스크를 긁지 않도록, 커널은 최근 해석 결과를 dentry 캐시(이름↔inode 연결)에 보관합니다.

큰 파일을 가리키는 법: 직접·간접 블록 포인터

inode는 크기가 고정(예: ext4에서 256바이트)인데, 파일은 수 GB가 될 수 있습니다. 어떻게 작은 inode가 거대한 파일의 모든 블록을 가리킬까요? 답은 다단계 간접 포인터입니다.

  • 직접 포인터(direct): inode 안에 데이터 블록 주소를 직접 몇 개(전통 ext에선 12개) 담습니다. 작은 파일은 이걸로 끝 — 한 번에 블록에 닿습니다.
  • 단일 간접(single indirect): 포인터가 “데이터 블록”이 아니라 “포인터들이 든 블록“을 가리킵니다. 한 단계 더 거치지만 수천 개를 추가로 가리킵니다.
  • 이중·삼중 간접: 포인터 블록이 또 포인터 블록을 가리킵니다. 단계가 깊어질수록 가리킬 수 있는 파일 크기가 기하급수로 커집니다.

아래에서 작은 파일은 직접 포인터로 곧장 데이터에 닿고(빠름), 큰 파일일수록 간접 블록을 한 겹씩 더 거쳐 도달하는 것을 보세요. 깊은 간접일수록 한 블록을 읽는 데 디스크 접근이 더 필요합니다.

inode 직접 ×12 단일 간접 이중 간접 데이터 블록 (한 번에 도달 · 작은 파일) 간접 +1단계 (수천 블록) 간접 간접 간접 +2단계 (수 GB · 깊을수록 디스크 접근↑)

이 구조의 묘미는 작은 파일에 페널티가 없다는 점입니다. 대부분의 파일은 작아서 직접 포인터로 끝나고, 드물게 거대한 파일만 간접 단계의 비용을 치릅니다. 한편 ext4·xfs 같은 현대 파일 시스템은 연속된 블록 범위를 하나로 표현하는 익스텐트(extent) 로 이 포인터 트리를 더 효율화합니다 — 하지만 “메타데이터가 데이터 위치를 가리킨다”는 본질은 같습니다.

VFS: 하나의 인터페이스, 여러 파일 시스템

내 코드는 ext4든 xfs든 btrfs든 네트워크 파일 시스템(NFS)이든 똑같이 open/read/write로 다룹니다. 이 다형성을 만드는 게 VFS(가상 파일 시스템) — 커널이 모든 파일 시스템에 강제하는 공통 추상 계층입니다.

flowchart TB
  app["유저: open / read / write"]
  vfs["VFS 공통 객체: superblock · inode · dentry · file"]
  e4["ext4 드라이버"]
  xfs["xfs 드라이버"]
  btr["btrfs 드라이버"]
  nfs["NFS (네트워크)"]
  app -->|시스템콜| vfs
  vfs --> e4
  vfs --> xfs
  vfs --> btr
  vfs --> nfs

VFS의 네 객체가 핵심입니다 — superblock(마운트된 파일 시스템 전체 정보), inode(파일 메타데이터, 위에서 본 그것), dentry(이름↔inode 연결, 경로 해석 캐시), file(열린 파일 인스턴스: 오프셋·플래그). 각 파일 시스템은 이 객체들의 함수 포인터(예: read_inode)를 자기 방식으로 채워 끼웁니다 — 객체지향의 인터페이스/구현 분리를 C로 구현한 셈입니다.

fd → 열린 파일 → inode: 세 단계의 분리

open()이 돌려주는 작은 정수, 파일 디스크립터(fd) 의 뒤에는 세 단계 구조가 있습니다. 이 분리를 이해하면 dup, 파이프, fork 후 오프셋 공유가 전부 설명됩니다.

단계무엇프로세스별/공유
fd 테이블프로세스마다 가진 정수→포인터 배열 (0·1·2 = stdin/out/err)프로세스별
열린 파일 테이블(open file table)파일 오프셋·접근 모드. 한 open()당 하나fork/dup 시 공유
inode (in-core)실제 파일 메타데이터·블록 위치시스템 전역 1개

그래서 fork() 후 부모·자식이 같은 오프셋을 공유해 한쪽이 읽으면 다른 쪽 위치도 함께 전진합니다(같은 열린 파일 테이블 엔트리). 반대로 같은 파일을 두 번 open() 하면 inode는 하나지만 열린 파일 엔트리가 둘이라 오프셋이 독립입니다.

직접 들여다보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# inode 번호와 메타데이터 보기 — 이름이 아니라 inode가 본체
ls -i report.pdf            # 앞의 정수가 inode 번호
stat report.pdf             # Inode, Links(nlink), 크기, atime/mtime/ctime

# 하드링크 vs 심볼릭 링크
ln  report.pdf same.pdf     # 같은 inode에 이름 추가 → nlink 2, stat 동일
ln -s report.pdf short.pdf  # 경로를 담은 별도 inode(특수 파일) → 원본 지우면 깨짐
ls -li *.pdf                # 하드링크는 inode 번호가 같다

# inode 고갈: 용량은 남는데 "No space left" — 작은 파일이 너무 많을 때
df -i                       # IUse% 가 100%면 inode 소진(블록은 남아도 생성 불가)

# 열린 fd → 어떤 파일을 가리키나 (rm 했는데 공간 안 주는 범인 찾기)
ls -l /proc/<pid>/fd        # fd가 가리키는 실제 경로(삭제된 파일은 (deleted) 표시)
lsof +L1                    # nlink 0인데 열려 있는 파일 추적

면접/리뷰 단골 질문

  • Q. 파일명은 어디에 저장되나? → inode가 아니라 디렉터리 엔트리에. 디렉터리는 (이름, inode 번호) 목록을 담은 파일이고, inode에는 이름이 없다.
  • Q. 하드링크와 심볼릭 링크의 차이는? → 하드링크는 같은 inode에 이름을 하나 더 다는 것(nlink 증가, 원본 삭제해도 유효). 심볼릭 링크는 대상 경로 문자열을 담은 별도 파일(원본 삭제 시 깨짐, 파일 시스템 경계·디렉터리도 가능).
  • Q. rm 했는데 디스크 용량이 안 줄어든다. 왜? → unlink는 이름만 떼고 nlink를 줄인다. 그 파일을 연 프로세스가 있으면(열린 fd) nlink가 0이어도 블록이 회수되지 않는다. lsof +L1로 찾아 해당 프로세스를 재시작/종료한다.
  • Q. 작은 inode가 어떻게 거대한 파일을 가리키나? → 직접 포인터 + 단일·이중·삼중 간접 포인터의 다단계 트리. 작은 파일은 직접 포인터로 즉시, 큰 파일만 간접 단계 비용을 치른다(현대 FS는 익스텐트로 최적화).
  • Q. VFS가 왜 필요한가? → ext4·xfs·NFS 등 서로 다른 파일 시스템을 동일한 open/read/write로 다루기 위한 공통 추상(superblock/inode/dentry/file). 관심사 분리 + 교체 가능성.
  • Q. df는 공간이 남았다는데 파일 생성이 실패한다? → inode 고갈(df -i). 블록은 남아도 inode 풀이 비면 새 파일을 못 만든다.

정리

  • 이름 ≠ 파일. 파일의 실체는 inode(메타데이터 + 블록 포인터). 이름은 디렉터리 엔트리에만 존재하는 라벨이다.
  • 경로 해석은 루트 inode에서 디렉터리 엔트리를 따라 inode를 한 칸씩 점프하는 반복 조회 — dentry 캐시가 이를 가속한다.
  • inode는 직접·간접 블록 포인터의 다단계 트리로 작은 파일부터 수 GB 파일까지 가리킨다.
  • VFS가 superblock·inode·dentry·file 추상으로 모든 파일 시스템을 한 인터페이스로 묶는다.
  • fd → 열린 파일 테이블(오프셋) → inode의 3단 분리가 dup·fork 오프셋 공유·중복 open의 동작을 설명한다. nlink와 열린 fd가 0이어야 공간이 회수된다.

다음 글: 파일을 쓰는 도중 전원이 꺼지면 메타데이터가 절반만 갱신돼 파일 시스템이 깨집니다. 이 크래시 일관성을 지키는 저널링으로 이어집니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계 지금 보는 글
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)