리눅스 내부 구조 05 - 파일 시스템
모든 것은 파일이다
리눅스를 관통하는 설계 원칙 중 가장 근본적인 것은 "모든 것은 파일이다"라는 철학이다. 디스크에 저장된 문서뿐 아니라, 프로세스 정보(/proc), 디바이스(/dev), 네트워크 소켓까지 모두 파일이라는 동일한 인터페이스로 접근할 수 있다. 이 원칙이 왜 강력한가? open, read, write, close라는 네 가지 시스템 콜만 알면 디스크 파일이든 시리얼 포트든 커널 파라미터든 동일한 방식으로 다룰 수 있기 때문이다.
이 철학이 없었다면 디바이스마다 별도의 API를 만들어야 했을 것이고, 프로그래머는 새로운 하드웨어가 추가될 때마다 전혀 다른 인터페이스를 학습해야 했을 것이다. 파일이라는 단일 추상화 덕분에 cat /proc/cpuinfo로 CPU 정보를 읽는 것과 cat /etc/hostname으로 설정 파일을 읽는 것이 완전히 동일한 동작이 되는 셈이다.
VFS: 파일 시스템 위의 파일 시스템
리눅스는 ext4, XFS, Btrfs, tmpfs, procfs 등 수십 가지 파일 시스템을 지원한다. 그런데 사용자가 ls 명령을 실행할 때 현재 디렉터리가 어떤 파일 시스템 위에 있는지 신경 쓸 필요가 없다. 이것이 가능한 이유는 VFS(Virtual File System)라는 추상화 계층이 존재하기 때문이다.
VFS는 모든 파일 시스템이 구현해야 하는 공통 인터페이스를 정의한다. 각 파일 시스템은 이 인터페이스에 맞추어 자신만의 구현을 제공하고, 커널의 나머지 부분은 VFS 인터페이스만 호출하면 되는 것이다. 이는 객체 지향 설계에서의 다형성과 본질적으로 같은 개념이다.
┌─────────────────────────────────────┐
│ 유저 공간 프로세스 │
│ open() / read() / write() │
├─────────────────────────────────────┤
│ VFS (Virtual File System) │
│ superblock · inode · dentry · file │
├────────┬────────┬────────┬──────────┤
│ ext4 │ XFS │ tmpfs │ procfs │
├────────┴────────┴────────┴──────────┤
│ 블록 디바이스 계층 │
├─────────────────────────────────────┤
│ 물리 디스크 / SSD │
└─────────────────────────────────────┘
VFS가 관리하는 핵심 객체는 네 가지이다. superblock은 파일 시스템 전체의 메타데이터(블록 크기, 마운트 상태 등)를 담는다. inode는 개별 파일의 메타데이터를 나타낸다. dentry는 디렉터리 항목으로, 파일 이름과 inode 사이의 매핑을 제공한다. file 객체는 프로세스가 열어 놓은 파일의 상태(현재 오프셋, 접근 모드 등)를 추적한다.
inode: 파일의 정체성
파일 시스템에서 파일의 이름은 사실 본질이 아니다. 파일의 진짜 정체성은 inode 번호이다. inode에는 파일의 소유자, 권한, 크기, 타임스탬프, 그리고 실제 데이터가 저장된 디스크 블록의 위치가 기록된다. 흥미로운 것은 파일 이름이 inode에 저장되지 않는다는 점이다. 파일 이름은 디렉터리의 dentry에 존재하며, dentry가 inode를 가리키는 구조이다.
이 설계가 하드 링크를 가능하게 한다. 하나의 inode를 여러 dentry가 가리킬 수 있으므로, 하나의 파일이 여러 이름을 가질 수 있는 것이다. ls -i 명령으로 파일의 inode 번호를 확인할 수 있고, stat 명령으로 inode의 상세 정보를 볼 수 있다.
$ ls -i /etc/hostname
1234567 /etc/hostname
$ stat /etc/hostname
File: /etc/hostname
Size: 12 Blocks: 8 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1234567 Links: 1
Access: (0644/-rw-r--r--) Uid: (0/root) Gid: (0/root)
Access: 2026-01-15 10:30:00.000000000 +0900
Modify: 2026-01-10 08:00:00.000000000 +0900
Change: 2026-01-10 08:00:00.000000000 +0900
하드 링크와 심볼릭 링크
하드 링크는 동일한 inode를 가리키는 또 다른 dentry를 생성하는 것이다. 원본 파일과 하드 링크는 완전히 동등하며, 어느 쪽이 "원본"인지 구분할 수 없다. 파일의 inode에는 링크 카운트가 있어서, 모든 하드 링크가 삭제되어 링크 카운트가 0이 되어야 비로소 실제 데이터 블록이 해제된다.
그렇다면 심볼릭 링크(소프트 링크)는 왜 필요한가? 하드 링크에는 두 가지 제약이 있기 때문이다. 첫째, 하드 링크는 같은 파일 시스템 안에서만 생성할 수 있다. 서로 다른 파일 시스템은 inode 번호 공간이 독립적이기 때문에 교차 참조가 불가능한 것이다. 둘째, 디렉터리에 대한 하드 링크는 순환 참조 문제를 일으킬 수 있어 일반적으로 허용되지 않는다.
심볼릭 링크는 이러한 제약을 우회한다. 심볼릭 링크는 별도의 inode를 가지며, 그 내용은 대상 파일의 경로 문자열이다. 커널이 심볼릭 링크를 만나면 저장된 경로를 따라가서 실제 파일을 찾는다. 대상 파일이 삭제되면 심볼릭 링크는 끊어진 링크(dangling link)가 되는데, 하드 링크에서는 이런 일이 발생하지 않는다.
ext4 파일 시스템의 내부
ext4는 현재 리눅스에서 가장 널리 사용되는 파일 시스템이다. ext2에서 시작하여 ext3에서 저널링이 추가되고, ext4에서 대용량 파일과 대용량 파티션 지원이 강화된 진화의 산물이다.
ext4는 디스크를 블록 그룹이라는 단위로 나누어 관리한다. 각 블록 그룹에는 해당 그룹의 inode 테이블, 데이터 블록 비트맵, inode 비트맵이 포함된다. 이렇게 지역성을 유지하는 이유는 관련된 파일의 inode와 데이터를 물리적으로 가까운 위치에 배치하여 디스크 탐색 시간을 줄이기 위함이다.
┌──────────────────────────────────────────┐
│ ext4 디스크 레이아웃 │
├──────┬──────────────────────────────────┤
│ 슈퍼 │ 블록 그룹 0 │
│ 블록 │ GDT│비트맵│inode 테이블│데이터 블록 │
├──────┼──────────────────────────────────┤
│ │ 블록 그룹 1 │
│ │ GDT│비트맵│inode 테이블│데이터 블록 │
├──────┼──────────────────────────────────┤
│ │ 블록 그룹 N │
│ │ GDT│비트맵│inode 테이블│데이터 블록 │
└──────┴──────────────────────────────────┘
ext4의 중요한 혁신 중 하나는 extent 기반의 블록 매핑이다. 이전 세대인 ext2/ext3는 간접 블록 포인터를 사용하여 파일의 각 블록을 개별적으로 추적했다. 큰 파일의 경우 이중, 삼중 간접 포인터를 거쳐야 해서 성능이 저하되었다. ext4의 extent는 연속된 블록의 시작 위치와 길이만 기록하면 되므로, 대용량 파일의 메타데이터 오버헤드가 크게 줄어든 것이다.
저널링과 크래시 복구
파일 시스템에 데이터를 기록하는 것은 여러 단계의 디스크 쓰기를 수반한다. inode를 갱신하고, 데이터 블록을 쓰고, 비트맵을 업데이트해야 한다. 과연 이 과정 중간에 정전이 발생하면 어떻게 되는가? 저널링이 없다면 파일 시스템이 불일치 상태에 빠질 수 있다. inode는 데이터 블록을 가리키고 있지만 실제 데이터는 쓰여지지 않은 상태, 혹은 그 반대의 상태가 될 수 있는 것이다.
저널링은 이 문제를 해결한다. 실제 데이터를 기록하기 전에 "이런 변경을 할 것이다"라는 기록을 별도의 저널 영역에 먼저 쓰는 것이다. 변경이 완료되면 저널 항목을 커밋으로 표시하고, 크래시가 발생하면 저널을 재생하여 완료되지 않은 변경을 롤백하거나 재적용한다. 이를 write-ahead logging이라고 하며, 데이터베이스에서 오래전부터 사용해 온 기법과 동일한 원리이다.
ext4는 세 가지 저널링 모드를 제공한다. journal 모드는 메타데이터와 데이터 모두를 저널에 기록하여 가장 안전하지만 성능이 가장 낮다. ordered 모드는 메타데이터만 저널에 기록하되, 메타데이터 커밋 전에 데이터 블록이 먼저 디스크에 쓰여지도록 순서를 보장한다. writeback 모드는 메타데이터만 저널에 기록하고 데이터의 쓰기 순서는 보장하지 않아 가장 빠르지만 크래시 후 파일 내용이 훼손될 수 있다. 기본값은 ordered 모드로, 안전성과 성능의 균형을 잡은 선택인 것이다.
파일 디스크립터
프로세스가 파일을 열면 커널은 파일 디스크립터라는 정수값을 반환한다. 이 정수는 프로세스별 파일 디스크립터 테이블의 인덱스이며, 이 테이블의 각 항목은 커널의 파일 테이블 엔트리를 가리키고, 파일 테이블 엔트리는 다시 inode를 가리킨다.
모든 프로세스는 생성될 때 세 개의 파일 디스크립터를 기본으로 갖는다. 0번은 표준 입력(stdin), 1번은 표준 출력(stdout), 2번은 표준 에러(stderr)이다. 셸에서 > 리다이렉션이 동작하는 원리도 바로 이 파일 디스크립터에 기반한다. command > output.txt는 1번 파일 디스크립터가 output.txt 파일을 가리키도록 변경하는 것에 불과한 것이다.
$ ls -l /proc/self/fd
lrwx------ 1 user user 64 Jan 28 10:00 0 -> /dev/pts/0
lrwx------ 1 user user 64 Jan 28 10:00 1 -> /dev/pts/0
lrwx------ 1 user user 64 Jan 28 10:00 2 -> /dev/pts/0
파일 디스크립터는 단순한 정수이지만, 이 추상화가 유닉스 파이프라인의 근간이 된다. ls | grep txt에서 ls의 stdout(fd 1)이 파이프의 쓰기 끝에 연결되고, grep의 stdin(fd 0)이 파이프의 읽기 끝에 연결되는 방식으로 프로세스 간 데이터가 흐르는 것이다.
마운트 포인트
리눅스에서 파일 시스템을 사용하려면 먼저 디렉터리 트리의 특정 지점에 마운트해야 한다. 이 지점이 마운트 포인트이다. 윈도우에서 C:, D: 같은 드라이브 문자를 사용하는 것과 달리, 리눅스는 모든 파일 시스템을 단일 디렉터리 트리에 통합한다.
루트 파일 시스템(/)이 트리의 기반이 되고, 다른 파일 시스템들은 이 트리의 하위 디렉터리에 마운트된다. /home이 별도의 파티션일 수도 있고, /tmp가 메모리 기반의 tmpfs일 수도 있지만, 사용자에게는 하나의 연속된 디렉터리 트리로 보인다. 이것이 VFS의 마운트 추상화가 제공하는 투명성인 것이다.
$ mount
/dev/sda1 on / type ext4 (rw,relatime)
/dev/sda2 on /home type ext4 (rw,relatime)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev)
proc on /proc type proc (rw,nosuid,nodev,noexec)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec)
/proc이나 /sys처럼 실제 디스크가 아닌 가상 파일 시스템도 동일한 방식으로 마운트된다. 이들은 디스크에 데이터를 저장하지 않지만, 커널이 파일 시스템 인터페이스를 통해 정보를 노출하는 수단인 것이다. 프로세스 정보, 하드웨어 파라미터, 커널 통계 등이 파일처럼 읽을 수 있는 이유가 바로 여기에 있다.
다음 포스트에서는 시스템 콜과 커널의 내부 동작을 살펴본다.