파일 시스템 구현(vsfs) 한 장으로 정리: 디스크 구조 → inode → 디렉터리 → I/O 비용 → 캐시/내구성
0) 이 챕터의 목표(진짜 핵심)
파일 시스템을 이해할 때는 “암기”보다 정신 모델(mental model)이 중요합니다. 즉, 디스크에 어떤 구조가 있고, open/read/write가 그 구조를 어떤 순서로 접근하는지를 머릿속에서 시뮬레이션할 수 있어야 해요.
- 현업 장애/성능 이슈의 70%는 “구조”보다 접근 경로(Access Path)와 I/O 증폭에서 터집니다.
- 그래서 이 문서도 “구조(데이터/메타데이터)” + “접근 방법(open/read/write가 무엇을 읽고/쓰는가)” 두 축을 강조합니다.
1) vsfs 디스크 온-디스크 구조(Overall Organization)
vsfs는 전형적인 유닉스 계열 파일시스템을 단순화한 케이스 스터디입니다. 기본 아이디어는 디스크를 고정 크기 블록(예: 4KB)으로 나누고, 그 블록들을 다음 영역으로 분할하는 것:
- Superblock(S): 파일시스템 전체 메타정보(총 inode/데이터블록 수, inode table 시작 위치, 매직넘버 등)
- Inode bitmap(i-bmap): inode 할당 여부(0 free / 1 used)
- Data bitmap(d-bmap): 데이터 블록 할당 여부
- Inode table(I): inode들의 배열(메타데이터 저장소
- Data region(D): 실제 파일 내용(유저 데이터)
[디스크 블록 레이아웃(개념)]
| S | i-bmap | d-bmap | inode table (I...) | data region (D...) |
참고로 예시에서는 작은 디스크(64 blocks)와 4KB 블록을 가정합니다. inode 크기를 256B로 두면 4KB 블록 하나에 inode 16개가 들어가고, inode table 5블록이면 총 80개 inode(=최대 파일 수 상한)가 됩니다.
2) inode: 파일의 메타데이터 “원장”
inode는 파일 길이/권한/블록 위치 같은 메타데이터를 담는 핵심 구조입니다.
파일은 보통 inode 번호(i-number)로 “저수준 이름”이 정해지고, i-number로 inode table에서 위치를 계산해 곧바로 찾아갑니다.
2-1) i-number로 inode 위치 계산(“주소 계산”이 된다)
vsfs에서는 inode table 시작 주소를 알고 있으니, (inumber * inode_size)로 오프셋을 만들어 inode가 들어있는 블록/섹터를 계산할 수 있습니다. 문서의 예시에서는 inode table이 12KB 지점에서 시작하고, inode 32의 오프셋을 계산해 해당 섹터를 읽는 흐름을 보여줍니다
blk = (inumber * sizeof(inode_t)) / blockSize;
sector = ((blk * blockSize) + inodeStartAddr) / sectorSize;
2-2) inode 안에는 뭐가 있나? (ext2 예시)
inode에 들어가는 전형적 필드(권한, uid/gid, 크기, 시간, 블록 포인터 등)를 ext2 예시로 보여줍니다.
2-3) 데이터 블록을 가리키는 방법: direct / indirect / multi-level index
direct pointer만 있으면 큰 파일을 못 담습니다(포인터 개수 한계). 그래서 indirect block을 도입합니다: inode의 “indirect pointer”가 포인터 배열 블록을 가리키고, 그 배열이 실제 데이터 블록들을 가리키는 방식.
- Single indirect: 4KB 블록에 4B 포인터면 1024개 포인터 추가 → 파일 크기 확장
- Double indirect: “포인터→포인터블록→데이터”를 한 단계 더 → 수 GB까지
- Triple indirect: 더 큰 파일(매우 큼)
왜 이런 비대칭 트리(imbalanced tree)를 쓰냐? “대부분 파일은 작다”는 관찰을 반영해 작은 파일을 direct로 빠르게 처리하려는 최적화 때문입니다.
2-4) (팁) Extent 기반 접근
포인터를 블록마다 하나씩 두는 대신, (시작 블록 + 길이)로 연속 구간을 표현하는 extent 방식도 소개합니다. 포인터 기반은 유연하지만 메타데이터가 커지고, extent는 더 압축적이지만 연속 공간을 찾기 어려울 수 있습니다.
3) 디렉터리: “이름 → inode 번호” 매핑 테이블
vsfs에서 디렉터리는 단순히 (엔트리 이름, inode 번호) 쌍들의 리스트로 저장됩니다.
예시로 reclen/strlen/name을 두어 가변 길이 이름과 삭제로 생긴 빈 공간 재사용을 처리합니다.
inum | reclen | strlen | name
5 | 12 | 2 | .
2 | 12 | 3 | ..
12 | 12 | 4 | foo
13 | 12 | 4 | bar
24 | 36 | 28 | foobar_is_a_pretty_longname
디렉터리는 “특수한 파일”처럼 취급되며, inode(타입=directory) + 데이터 블록(엔트리 목록)로 구성될 수 있습니다.
또한 디렉터리 구현도 리스트만이 답은 아니고, 큰 디렉터리를 위해 B-tree 형태(XFS 등)도 가능하다고 언급합니다.
4) Free Space Management: 비트맵(할당 추적)
파일시스템은 inode/데이터블록의 free/used를 반드시 추적해야 하고, vsfs는 inode bitmap + data bitmap을 씁니다.
단순 “읽기(read)”는 보통 비트맵을 보지 않습니다. 이미 inode/디렉터리/간접블록에 “어느 블록이 파일에 속하는지” 정보가 있으므로, “이 블록이 할당된 게 맞나?”를 다시 확인할 필요가 없어요.
다만 “할당이 필요한 순간”(파일 생성/확장 등)에는 비트맵을 읽고/갱신해야 합니다. 그리고 성능을 위해 ext2/ext3 같은 시스템이 “연속 블록(예: 8개)”을 미리 찾아 일부를 contiguous하게 배치하는 휴리스틱도 언급합니다.
5) Access Path: open/read/write가 실제로 읽고/쓰는 것들
5-1) open("/foo/bar")의 실제 동작(경로 탐색)
open은 “파일 내용을 읽는” 호출이 아니라, inode를 찾기 위한 디렉터리 탐색이 핵심입니다. 탐색은 루트(/)부터 시작하고, 루트 inode 번호는 마운트 시 “well-known”으로 알고 있어야 합니다(유닉스에서는 흔히 2)
루트 inode를 읽고 → 루트 디렉터리 데이터에서 "foo" 엔트리를 찾고 → foo inode를 읽고 → foo 디렉터리 데이터에서 "bar"를 찾아 bar inode를 읽는 식으로 진행됩니다.
5-2) read(): inode를 보고 데이터 블록을 읽는다
파일이 열리면 read는 inode의 블록 포인터를 따라 첫 블록부터 읽고(오프셋 관리), 접근 시간(atime) 같은 메타데이터를 갱신할 수도 있습니다.
중요한 건, 경로가 길거나 디렉터리가 크면 open 단계에서 I/O가 폭발할 수 있다는 점입니다.
5-3) write(): “데이터”만 쓰는 게 아니라 “메타데이터 갱신”이 같이 온다
덮어쓰기가 아니라 파일 확장/새 블록 할당이 필요하면, write는 데이터 블록 쓰기 전에 비트맵 갱신 + inode 갱신이 추가로 필요해 I/O가 늘어납니다.
문서의 단순 모델에서는 “할당이 동반되는 write 1번”이 논리적으로 대략 5 I/O를 만들 수 있다고 설명합니다.
5-4) “파일 생성(create)”은 더 비싸다 (메타데이터 폭풍)
파일 생성은 inode 할당뿐 아니라, 부모 디렉터리 엔트리 추가, 디렉터리 inode 갱신(크기/블록 등)까지 필요합니다.
그래서 “파일 하나 만든다”가 생각보다 무거운 작업이 됩니다.
6) 성능의 구원: Caching & Buffering
6-1) 캐시 없으면 open이 지옥이 된다
캐시가 없으면 디렉터리 깊이가 깊은 경로에서 open은 레벨마다 “inode 읽기 + 디렉터리 데이터 읽기”가 반복돼 수백 번의 read로 터질 수 있습니다.
그래서 파일시스템은 DRAM을 적극 활용해 중요한 블록을 캐싱합니다.
6-2) 정적 캐시 vs 통합 페이지 캐시(현대 OS)
예전엔 부팅 때 메모리의 일정 비율(예: 10%)을 파일 캐시로 고정했지만(정적 분할), 요즘은 VM 페이지와 FS 페이지를 통합한 unified page cache처럼 더 동적으로 운영합니다.
6-3) write buffering: 빠르지만 “crash 시 유실” 트레이드오프
read는 캐시로 I/O를 “없앨” 수 있지만, write는 영속성을 위해 결국 디스크로 가야 합니다. 다만, 쓰기를 잠깐 메모리에 모아(batch) 한 번에 처리하면 성능이 좋아질 수 있고(쓰기 병합/스케줄링/불필요 쓰기 회피), 대신 크래시가 나면 아직 디스크에 안 내려간 내용은 날아갑니다.
DB 같은 일부 애플리케이션은 이런 트레이드오프를 싫어해서 fsync()로 강제 플러시를 하거나, direct I/O / raw disk를 쓰기도 한다고 설명합니다.
- “빠름”과 “즉시 내구성”은 동시에 얻기 어렵다: 내구성 올리면 느려지고, 빠르게 보이게 하면 유실 위험이 생긴다.
- 서비스에서 “유저 결제/재고/정산” 같이 중요한 데이터는 fsync/트랜잭션 레벨에서 확실히 보장해야 합니다(그래서 DB가 존재).
- 반대로 캐시/썸네일/임시파일은 유실 허용을 전제로 튜닝할 수 있어요.
7) 이 챕터를 공부할 때 “반드시” 가져가야 하는 체크리스트
- 디스크 구조를 그릴 수 있다: S, i-bmap, d-bmap, inode table, data region.
- inode가 파일의 “지도”라는 걸 설명할 수 있다: 메타데이터 + 데이터블록 위치.
- direct/indirect/double의 의도를 안다: 작은 파일 최적화 + 큰 파일 확장.
- 디렉터리는 name→inode 매핑 파일이고, 삭제로 hole이 생길 수 있다.
- 읽기는 비트맵을 안 본다(할당이 없으면).
- write/create는 메타데이터 I/O가 붙어서 비싸다 (I/O 증폭).
- 캐시/버퍼링이 없으면 현실 성능은 불가능, 대신 내구성 트레이드오프가 있다.
9) 빠른 퀴즈(면접/시험 대비)
- Q. read()는 왜 비트맵을 확인하지 않아도 되나?
A. inode/디렉터리(및 간접블록)에 이미 파일이 사용하는 블록 위치가 기록돼 있고, 할당이 필요 없으면 allocation 구조를 consult할 이유가 없기 때문. - Q. open("/foo/bar")에서 디스크 I/O는 경로 길이에 어떤 영향을 받나?
A. 경로의 각 디렉터리 레벨마다 “디렉터리 inode 읽기 + 디렉터리 데이터 읽기”가 반복되므로 길수록 I/O가 증가. - Q. write buffering의 장점/단점은?
A. 장점: 배치/스케줄링/불필요 쓰기 회피로 성능↑. 단점: 크래시 시 아직 디스크에 안 내려간 쓰기 유실. - Q. direct pointer만 쓰면 왜 큰 파일이 힘든가?
A. inode 내부 포인터 수가 고정이라 blockSize * 포인터개수 이상의 파일을 못 담음 → indirect(다단계) 필요.