파일 시스템, 디스크에 데이터를 구조화하는 방법
매일 코드를 짜면서 수없이 많은 파일을 생성하고 저장한다. open(), write() 시스템 콜을 호출할 때마다 하드웨어 레벨에서 어떤 일이 벌어지는지 깊게 고민해 본 적이 드물다는 생각이 들었다.
이번 계기에 파일 시스템의 구현(File System Implementation)을 파고들면서, 우리가 당연하게 여기는 '파일 저장'이라는 행위가 실제 디스크 상에서 어떻게 구조화되는지 정리해보고자 한다(지난 시간을 조금 더 심화한 버전. 오늘은 가장 기본적인 형태인 VSFS(Very Simple File System) 모델을 통해 파일 시스템을 구축했던 과정을 정리해보겠다.
1. 디스크의 전체적인 구조 (Overall Organization)
파일 시스템을 설계할 때 가장 먼저 마주하는 문제는 "거대한 디스크 공간을 어떻게 관리할 것인가?"이다. 수업에서는 디스크를 블록(Block) 단위로 나누는 것부터 시작했다. 현대의 파일 시스템은 보통 4KB 크기의 블록을 사용한다. (여기서 disk는 SSD의 FTL이 논리적 주소를 OS에 제공하는 것처럼, 추상화되어있는 개념이다)
| Block |
가상의 디스크가 64개의 블록으로 이루어져 있다고 가정했을 때, 나는 이 공간을 다음과 같은 역할로 나누어 이해하기로 했다.
Data Region (데이터 영역): 실제 사용자의 파일 내용이 저장되는 곳이다. 가장 많은 공간을 차지한다.
Metadata (메타데이터): 데이터가 '어디에' 있는지, '누구의' 것인지에 대한 정보다.
Inode Table: 각 파일의 메타데이터(크기, 권한, 데이터 블록 위치 등)를 저장하는 아이노드(inode)들의 배열이다.
Bitmap (비트맵): 데이터 블록이나 아이노드가 사용 중인지(1), 비어 있는지(0)를 표시하는 지도다. (Inode Bitmap, Data Bitmap)
Superblock: 파일 시스템 전체의 정보(아이노드의 총개수, 아이노드 테이블의 시작 위치 등)를 담고 있다. 운영체제가 파일 시스템을 마운트 할 때 가장 먼저 읽는 곳이다.
"데이터 자체도 중요하지만, 그 데이터를 찾기 위한 '지도(Map)'를 디스크 어딘가에 체계적으로 심어두어야 한다."
2. 아이노드(Inode)
여기서 가장 중요한 부분은 아이노드(Index Node)의 구조다. 파일 시스템에서 '파일'이란 결국 이 아이노드 번호(inumber)로 식별된다. 아이노드는 파일의 이름(Name)을 제외한 모든 정보를 담고 있었다. (사실 지난 시간 주제로 다루긴 했으나, 내용이 꼬여버려서.... 여기서 추가로 설명한다)
파일 유형 (일반 파일, 디렉터리 등)
파일 크기
소유자 및 권한 (UID, GID, Mode)
시간 정보 (Access, Modify, Change time)
데이터 블록의 위치 (Data Block Pointers)
멀티 레벨 인덱스 (Multi-Level Index)
여기서 "큰 파일을 어떻게 지원할 것인가?"라는 문제에 봉착했다. 아이노드의 크기는 보통 256바이트로 고정되어 있는데, 수 기가바이트의 파일 위치를 어떻게 다 담을까?
해결책은 간접 포인터(Indirect Pointer)였다.
Direct Pointers: 아이노드 내에 직접 데이터 블록 주소를 저장 (보통 12개). 작은 파일은 이것만으로 충분하다. (그런데 용량이 48 KB (= 12 * 4 KB) 밖에 안됨...)
Indirect Pointer: 데이터 블록을 가리키는 대신, '포인터들로 가득 찬 블록'을 가리킨다.
Double/Triple Indirect Pointer: 포인터를 가리키는 블록을 가리키는 포인터.
이 구조 덕분에 고정된 크기의 아이노드로도 테라바이트 단위의 거대 파일을 지원할 수 있다. CS에서 복잡한 문제는 늘 'Indirection(간접 참조)' 계층을 추가함으로써 해결된다는 원칙을 다시 한번 확인했다.
3. 디렉터리의 구조 (Directory Organization)
디렉터리도 결국은 '파일'이라는 점을 명확히 해야 했다. 디렉터리의 데이터 블록에는 무엇이 들어갈까? 바로 (파일 이름, 아이노드 번호) 쌍의 리스트다.
| Inode Num | Reclen | Strlen | Name |
|-----------|--------|--------|--------|
| 5 | 4 | 2 | . |
| 2 | 4 | 3 | .. |
| 12 | 4 | 3 | foo |
| 13 | 4 | 3 | bar |
우리가 ls -l을 쳤을 때 보는 정보들은 디렉터리 파일 안에 있는 것이 아니라, 디렉터리가 가리키는 각 파일의 아이노드를 찾아가서 가져오는 정보들이다.
4. 실제 접근 경로와 비용 (Access Paths)
여기서 놀라운 점은 파일 하나를 읽기 위해 발생하는 디스크 I/O의 횟수였다. 예를 들어 /foo/bar라는 파일을 읽는다고 가정해 보자. (read /foo/bar)
루트 디렉터리(/) 읽기: 루트 아이노드 읽기 -> 루트 데이터 블록 읽기 (여기서 'foo'의 아이노드 번호를 찾음)
foo 디렉터리 읽기: foo 아이노드 읽기 -> foo 데이터 블록 읽기 (여기서 'bar'의 아이노드 번호를 찾음)
bar 파일 읽기: bar 아이노드 읽기 -> bar 데이터 블록 읽기
단순히 파일을 여는 과정(open)에서도 수많은 디스크 탐색이 발생한다.
쓰기(write) 작업은 더 복잡하다. 예를 들어, 새로운 데이터 블록을 할당하여 파일에 내용을 쓰는 경우를 생각해 보자. 무려 5번의 I/O가 발생한다.
아이노드 읽기 (Read inode): 파일의 메타데이터를 업데이트하기 위해 현재 상태를 읽어온다.
데이터 비트맵 읽기 (Read data bitmap): 비어 있는 데이터 블록을 찾기 위해 지도를 펼친다.
데이터 비트맵 쓰기 (Write data bitmap): 찾은 블록을 '사용 중'으로 표시하고 저장한다.
아이노드 쓰기 (Write inode): 새로 할당된 블록의 위치를 기록하고, 파일 크기를 업데이트하여 저장한다.
데이터 쓰기 (Write data): 드디어 실제 데이터를 디스크 블록에 쓴다.
파일 생성(create)까지 포함하면 디렉터리 아이노드와 데이터까지 수정해야 하므로 I/O 트래픽은 폭발적으로 증가한다. 이것이 파일 시스템 성능 최적화가 필요한 결정적인 이유다.
캐싱(Caching)의 필연성
디스크 접근은 메모리 접근보다 압도적으로 느리다. 위에서 살펴본 비효율적인 I/O 경로를 그대로 둔다면 시스템 성능은 처참할 것이다.
그래서 파일 시스템은 시스템 메모리(DRAM)를 공격적으로 캐시로 활용한다.
Read: 자주 읽는 블록(아이노드, 디렉터리 등)을 메모리에 캐싱하여 I/O를 획기적으로 줄인다.
Write: 쓰기 버퍼링(Write Buffering)을 통해 작은 쓰기 작업들을 메모리에 모아두었다가 한 번에 디스크에 반영(Batching)한다.
마치며
이번 학습을 통해 파일 시스템이 단순히 데이터를 저장하는 창고가 아니라, 복잡한 인덱싱 구조와 캐싱 전략이 결합된 고도의 소프트웨어라는 점을 깨달았다. 앞으로 대용량 트래픽을 처리하는 백엔드를 설계할 때, I/O 비용을 줄이기 위해 OS 레벨에서 어떤 노력을 하고 있는지 이해하고 접근할 수 있을 것 같다.
추천글:
[운영체제] Operating System 전체 포스팅 모음집
[운영체제] I/O 시스템, HDD, Disk Scheduling, RAID | CPU와 디스크의 물리적 속도 차이를 극복하는 설계
[시스템프로그래밍] Unix I/O & Signal - File I/O, Network I/O, Signal