파일 시스템 구현
파일 시스템이 디스크 블록을 파일에 나누어 주는 연속·연결·인덱스 할당과 빈 공간 관리를 모델로 비교하고, FAT·inode·익스텐트·NTFS가 파일의 블록 위치를 기록하는 방식을 살펴봅니다. 쓰는 도중 전원이 꺼져도 구조가 망가지지 않게 하는 저널링과 쓰기 시점 복사도 정리합니다.
파일을 저장하면 그 내용은 디스크의 블록(block) 여러 개에 나뉘어 기록됩니다. 블록은 파일 시스템이 공간을 나누어 주는 단위로, 요즘은 4KiB가 흔합니다. 파일 시스템은 빈 블록을 골라 파일에 나누어 주고, 어느 블록이 어느 파일의 몇 번째 부분인지 기록해 두어야 합니다. 이 글에서는 그 할당과 기록의 방법, 그리고 쓰는 도중에 전원이 꺼져도 그 기록이 망가지지 않게 하는 방법을 살펴봅니다.
디스크 위에 놓이는 구조
Unix 계열 파일 시스템은 디스크를 대략 다음 영역으로 나누어 씁니다.
| 영역 | 담는 것 |
|---|---|
| 슈퍼블록(superblock) | 블록 크기, 전체 블록과 inode의 수, 빈 블록 수, 마운트 상태 같은 파일 시스템 전체의 정보 |
| 비트맵(bitmap) | 블록과 inode 하나하나가 사용 중인지를 1비트씩 표시 |
| inode 테이블 | 파일마다 하나씩, 메타데이터와 데이터 블록의 위치 |
| 데이터 블록 | 파일의 내용과 디렉터리의 내용 |
| 저널(journal) | 제자리에 반영하기 전의 변경 기록 |
ext4는 디스크를 블록 그룹(block group)으로 나누고, 그룹마다 비트맵과 inode 테이블을 두어 파일의 inode와 데이터 블록이 가까이 놓이게 합니다. 4KiB 블록이라면 한 그룹은 32,768블록, 곧 128MiB입니다. 슈퍼블록이 망가지면 파일 시스템 전체를 읽을 수 없으므로, 여러 블록 그룹에 슈퍼블록의 사본을 둡니다.
블록을 나누어 주는 세 가지 방법
연속 할당
연속 할당(contiguous allocation)은 파일의 블록을 디스크에서 연속된 자리에 둡니다. 파일마다 시작 블록과 길이만 기록하면 되고, n번째 블록이 시작 + n에 있으므로 어느 위치든 계산 한 번으로 찾습니다. 순서대로 읽을 때도 HDD의 헤드가 한 방향으로 움직이기만 하면 됩니다.
문제는 파일이 생기고 지워지면서 빈 공간이 여기저기 조각나는 외부 단편화(external fragmentation)입니다. 빈 블록을 모두 합치면 충분해도 연속된 자리가 없으면 큰 파일을 만들 수 없고, 파일 바로 뒤에 다른 파일이 있으면 파일을 늘릴 수도 없습니다. 그래서 크기가 정해진 채로 한 번 기록하고 고치지 않는 CD-ROM 같은 매체에 알맞습니다.
연결 할당
연결 할당(linked allocation)은 각 블록의 끝에 다음 블록의 번호를 적어, 파일을 블록의 연결 리스트로 만듭니다. 빈 블록이면 어디든 쓸 수 있어 외부 단편화가 없고, 파일을 늘리기도 쉽습니다. 대신 n번째 블록을 찾으려면 첫 블록부터 차례로 읽으며 번호를 따라가야 하므로 임의 접근이 느립니다. 중간의 번호 하나가 망가지면 그 뒤의 블록을 모두 잃고, 블록마다 번호가 자리를 차지해 데이터를 담을 공간도 조금 줄어듭니다.
인덱스 할당
인덱스 할당(indexed allocation)은 파일마다 인덱스 블록(index block)을 하나 두고, 파일의 데이터 블록 번호를 모두 거기에 모읍니다. n번째 블록의 번호는 인덱스 블록의 n번째 칸에 있으므로 임의 접근이 빠르고, 외부 단편화도 없습니다. 대신 아주 작은 파일도 인덱스 블록 하나를 차지하며, 인덱스 블록 하나에 번호를 다 담을 수 없을 만큼 큰 파일은 인덱스를 여러 단계로 두어야 합니다.
다음 코드는 블록 5개짜리 파일을 세 방식으로 저장해 두고, 논리 블록(파일 안에서의 블록 순번)을 찾을 때 디스크 블록을 몇 번 읽는지 셉니다. 시작 블록이나 인덱스 블록의 번호처럼 디렉터리 항목과 inode에 있는 정보는 이미 메모리에 있다고 보았습니다.
# 블록 5개짜리 파일을 세 방식으로 저장했을 때, 논리 블록 n을 찾느라 읽는 디스크 블록 수
contiguous = {"start": 20, "length": 5} # 시작 블록과 길이만 기록
linked = {"head": 7, "next": {7: 3, 3: 12, 12: 5, 5: 9, 9: None}} # 각 블록 끝에 다음 블록 번호
indexed = {"index_block": 30, "blocks": [7, 3, 12, 5, 9]} # 인덱스 블록에 주소를 모은다
def find_contiguous(n):
return contiguous["start"] + n, 1 # 계산만으로 위치를 안다: 데이터 블록 1번
def find_linked(n):
block, reads = linked["head"], 1
for _ in range(n): # 블록을 읽어야 다음 블록 번호를 안다
block = linked["next"][block]
reads += 1
return block, reads
def find_indexed(n):
return indexed["blocks"][n], 2 # 인덱스 블록 1번 + 데이터 블록 1번
for n in (0, 4):
parts = []
for name, find in (("연속", find_contiguous), ("연결", find_linked), ("인덱스", find_indexed)):
block, reads = find(n)
parts.append(f"{name} 블록 {block}(읽기 {reads}번)")
print(f"논리 블록 {n}: " + ", ".join(parts))논리 블록 0: 연속 블록 20(읽기 1번), 연결 블록 7(읽기 1번), 인덱스 블록 7(읽기 2번)
논리 블록 4: 연속 블록 24(읽기 1번), 연결 블록 9(읽기 5번), 인덱스 블록 9(읽기 2번)연속 할당은 어느 블록이든 한 번이면 되지만, 연결 할당은 뒤쪽 블록일수록 읽는 횟수가 늘어납니다. 블록이 수십만 개인 파일의 끝부분을 읽으려면 수십만 번을 읽어야 한다는 뜻입니다. 인덱스 할당은 인덱스 블록을 한 번 더 읽지만 어느 위치든 횟수가 같고, 인덱스 블록이 캐시에 있으면 그 한 번도 줄어듭니다. 실제 파일 시스템들은 이 방식들을 고쳐 가며 조합해 씁니다.
빈 공간을 관리하는 방법
블록을 나누어 주려면 어느 블록이 비어 있는지 알아야 하고, 파일을 지우면 그 블록들을 다시 빈 블록으로 표시해야 합니다. 가장 널리 쓰이는 방법은 비트맵으로, 블록마다 1비트를 두어 사용 중이면 1, 비어 있으면 0으로 표시합니다. 1TiB 디스크를 4KiB 블록으로 나누면 블록이 2^28개이므로 비트맵은 2^28비트, 곧 32MiB입니다. 디스크 크기에 비해 작아 메모리에 올려 두기 쉽고, 0이 이어진 구간을 찾으면 연속된 빈 블록을 바로 찾을 수 있습니다.
bitmap = "1110011000111101" # 블록 0부터 15까지, 1은 사용 중이고 0은 비어 있다
def find_free_run(bitmap, count):
run_start, run_len = None, 0
for i, bit in enumerate(bitmap):
if bit == "1":
run_len = 0
continue
if run_len == 0:
run_start = i
run_len += 1
if run_len == count:
return run_start
return None
print("빈 블록 수:", bitmap.count("0"))
for count in (2, 3, 4):
start = find_free_run(bitmap, count)
print(f"빈 블록 {count}개가 이어진 첫 위치:", "없음" if start is None else start)빈 블록 수: 6
빈 블록 2개가 이어진 첫 위치: 3
빈 블록 3개가 이어진 첫 위치: 7
빈 블록 4개가 이어진 첫 위치: 없음빈 블록은 6개나 있지만 4개가 이어진 자리는 없습니다. 연속 할당이라면 블록 4개짜리 파일을 만들 수 없는 상황으로, 앞에서 말한 외부 단편화입니다. 연결 할당이나 인덱스 할당이라면 떨어져 있는 빈 블록을 모아 쓸 수 있습니다.
비트맵 말고도 몇 가지 방법이 있습니다. 빈 블록끼리 연결 리스트로 이으면 따로 공간이 들지 않지만, 연속된 빈 공간을 찾으려면 리스트를 따라가야 합니다. 그룹화(grouping)는 첫 빈 블록에 다른 빈 블록 여러 개의 번호를 적고 그중 마지막이 다음 묶음을 가리키게 해서, 블록 하나를 읽어 빈 블록 여러 개를 한꺼번에 알아냅니다. 연속된 빈 블록을 (시작, 길이) 쌍으로 기록하는 계수(counting) 방식도 있으며, XFS는 이런 빈 구간들을 B+ 트리로 관리합니다.
FAT: 다음 블록 번호를 표 하나에 모으기
FAT(file allocation table)는 연결 할당을 고친 방식입니다. 다음 블록 번호를 각 블록 안에 적는 대신, 디스크 앞쪽의 표 하나에 모아 둡니다. 표의 n번 칸에는 n번 클러스터 다음에 오는 클러스터 번호가 들어 있고, 디렉터리 항목에는 파일의 시작 클러스터만 기록합니다. 클러스터(cluster)는 FAT가 공간을 나누어 주는 단위로, 디스크의 섹터 여러 개를 묶은 것입니다.
EOC = "끝" # 체인의 끝(end of chain) 표시
fat = {2: 5, 5: 6, 6: 11, 11: EOC, 3: 4, 4: EOC} # 클러스터 번호 -> 다음 클러스터 번호
directory = {"photo.jpg": 2, "memo.txt": 3} # 디렉터리 항목에는 시작 클러스터만 있다
CLUSTER = 4096 # 클러스터 크기(바이트)
def chain(start):
clusters = [start]
while fat[clusters[-1]] != EOC:
clusters.append(fat[clusters[-1]])
return clusters
def cluster_for(name, offset):
cluster = directory[name]
for _ in range(offset // CLUSTER): # 앞쪽 클러스터 수만큼 표를 따라간다
cluster = fat[cluster]
return cluster
for name, start in directory.items():
print(f"{name}의 클러스터:", chain(start))
print("photo.jpg의 오프셋 10000이 든 클러스터:", cluster_for("photo.jpg", 10000))photo.jpg의 클러스터: [2, 5, 6, 11]
memo.txt의 클러스터: [3, 4]
photo.jpg의 오프셋 10000이 든 클러스터: 6오프셋 10000을 클러스터 크기 4096으로 나눈 몫은 2이므로, 그 바이트는 파일의 세 번째 클러스터에 있습니다. 그래서 시작 클러스터 2에서 표를 두 번 따라가 6을 찾았습니다. 표 전체를 메모리에 올려 두면 디스크를 읽지 않고 체인을 따라갈 수 있어 순수한 연결 할당보다 훨씬 빠르지만, 큰 파일의 뒤쪽으로 갈수록 따라가는 횟수가 늘어나는 것은 같습니다. 실제 FAT에서 클러스터 번호는 2부터 시작하며, FAT32에서는 0x0FFFFFF8 이상의 값이 체인의 끝을 뜻합니다. 표가 망가지면 모든 파일을 잃으므로 보통 표를 두 벌 저장합니다.
| 종류 | 특징 |
|---|---|
| FAT12 | 표의 칸이 12비트입니다. 플로피 디스크처럼 작은 볼륨에 쓰였습니다. |
| FAT16 | 표의 칸이 16비트입니다. 초기의 하드 디스크에 쓰였습니다. |
| FAT32 | 칸은 32비트이지만 그중 28비트만 클러스터 번호로 씁니다. 파일 하나는 4GiB에서 1바이트 모자란 크기까지만 담을 수 있습니다. |
| exFAT | 파일 크기를 64비트로 기록해 큰 파일을 담을 수 있습니다. SD 카드와 USB 메모리에 많이 쓰입니다. |
FAT32의 파일 크기 한도 때문에 4GiB가 넘는 영상 파일은 FAT32로 포맷한 USB 메모리에 복사할 수 없고, exFAT이나 NTFS로 포맷해야 합니다. FAT 계열은 구조가 단순해 거의 모든 운영체제와 카메라, 자동차 같은 기기가 읽을 수 있으므로, 기기 사이에 파일을 옮기는 이동식 저장 장치에 여전히 많이 쓰입니다. 대신 권한, 링크, 저널링 같은 기능은 없습니다.
inode와 다단계 인덱스
Unix 계열 파일 시스템은 파일마다 inode를 하나씩 두고, 거기에 파일의 메타데이터와 데이터 블록의 위치를 기록합니다. 앞 글에서 본 것처럼 inode에는 파일 이름이 없으며, 이름은 디렉터리 항목에 있습니다. ext2와 ext3가 쓰던 전통적인 inode는 인덱스 할당을 여러 단계로 확장한 구조로, 블록 주소 15개를 담습니다.
파일 시스템 · 블록 주소
간접 주소는 데이터에 도달하기 전에 포인터 블록을 거칩니다
전통적인 모델의 직접 주소 12개와 단일·이중·삼중 간접 주소를 구분합니다. 그림은 직접 경로와 단일 간접 경로만 펼쳐 보이며, 현대 ext4의 extent 구조를 뜻하지 않습니다.
4KiB 블록·4바이트 포인터 모델입니다. 이중·삼중 간접 경로는 각각 포인터 블록 2·3단계를 거칩니다. 그림의 블록은 크기 비례가 아닙니다.
처음 12개는 직접 주소(direct pointer)로 데이터 블록을 바로 가리킵니다. 13번째인 단일 간접 주소(single indirect pointer)는 데이터 블록 주소들을 담은 포인터 블록을 가리킵니다. 14번째 이중 간접 주소는 포인터 블록을 가리키는 포인터 블록을, 15번째 삼중 간접 주소는 그것을 한 단계 더 거쳐 데이터 블록에 이릅니다. 4KiB 블록과 4바이트 주소라면 포인터 블록 하나에 주소가 1,024개 들어갑니다.
BLOCK = 4096 # 블록 크기 4KiB
PTRS = BLOCK // 4 # 주소가 4바이트라면 포인터 블록 하나에 주소 1024개
levels = [("직접 주소 12개", 12), ("단일 간접", PTRS), ("이중 간접", PTRS**2), ("삼중 간접", PTRS**3)]
total = 0
for name, blocks in levels:
total += blocks
print(f"{name}: 데이터 블록 {blocks:,}개, 여기까지 {total * BLOCK:,}바이트")
print(f"최대 크기: 약 {total * BLOCK / 2**40:.3f}TiB")직접 주소 12개: 데이터 블록 12개, 여기까지 49,152바이트
단일 간접: 데이터 블록 1,024개, 여기까지 4,243,456바이트
이중 간접: 데이터 블록 1,048,576개, 여기까지 4,299,210,752바이트
삼중 간접: 데이터 블록 1,073,741,824개, 여기까지 4,402,345,721,856바이트
최대 크기: 약 4.004TiB48KiB까지의 작은 파일은 직접 주소만으로 충분해, inode 하나만 읽으면 데이터 블록의 위치를 모두 압니다. 파일이 커질수록 간접 단계를 거치며 포인터 블록을 더 읽어야 하지만, 대부분의 파일은 작으므로 흔한 경우를 빠르게 하는 설계입니다. 이 방식이 담을 수 있는 최대 크기는 약 4TiB이고, ext4 문서도 이 블록 맵 방식 파일의 한도를 4TiB로 적고 있습니다.
포인터 블록도 디스크를 차지합니다. 데이터 블록 13개를 쓰는 파일은 직접 주소로 처음 12개를 찾고, 13번째 블록은 단일 간접 포인터 블록의 첫 칸으로 찾습니다. 데이터는 13 × 4KiB = 52KiB이지만, 포인터 블록 4KiB가 더 필요해 디스크에서는 모두 56KiB를 차지합니다.
익스텐트: 연속된 구간을 한 번에 적기
블록 맵 방식은 파일이 디스크에 연속으로 놓여 있어도 블록마다 주소를 하나씩 적어야 합니다. ext4는 이것을 익스텐트(extent)로 바꾸었습니다. 익스텐트는 파일 안의 시작 블록, 디스크의 시작 블록, 길이의 세 값으로 연속된 구간 하나를 나타냅니다. 블록 1,000개가 연속으로 놓인 파일이라면 블록 맵 방식은 주소 1,000개를 적어야 하지만, 익스텐트로는 하나면 됩니다.
# 익스텐트 하나 = (논리 시작 블록, 물리 시작 블록, 길이)
extents = [(0, 1000, 100), (100, 5000, 50), (150, 2300, 200)]
def physical(logical):
for start, phys, length in extents:
if start <= logical < start + length:
return phys + (logical - start)
return None # 어느 익스텐트에도 없다: 할당되지 않은 구간
for n in (0, 99, 100, 349, 400):
p = physical(n)
print(f"논리 블록 {n}:", "할당되지 않음" if p is None else f"물리 블록 {p}")
blocks = sum(length for _, _, length in extents)
print(f"블록마다 주소를 적으면 {blocks}개, 익스텐트로는 {len(extents)}개")논리 블록 0: 물리 블록 1000
논리 블록 99: 물리 블록 1099
논리 블록 100: 물리 블록 5000
논리 블록 349: 물리 블록 2499
논리 블록 400: 할당되지 않음
블록마다 주소를 적으면 350개, 익스텐트로는 3개논리 블록 100부터는 두 번째 익스텐트에 속하므로 물리 블록 5000에서 다시 시작합니다. 어느 익스텐트에도 속하지 않는 논리 블록 400은 할당되지 않은 구간입니다. 쓴 적 없는 부분이 디스크를 차지하지 않는 희소 파일(sparse file)이 이렇게 표현됩니다.
ext4 문서에 따르면 inode 안에는 익스텐트를 네 개까지 바로 담을 수 있고, 그보다 많아지면 익스텐트들을 트리로 구성해 별도의 블록에 둡니다. 익스텐트 하나는 최대 32,768블록, 4KiB 블록이라면 128MiB를 나타내며, 익스텐트를 쓰는 파일은 4KiB 블록에서 최대 16TiB까지 커질 수 있습니다. Linux에서는 filefrag로 파일이 익스텐트 몇 개로 이루어져 있는지 볼 수 있습니다.
filefrag -v big.iso # 익스텐트마다 논리 위치, 물리 위치, 길이를 출력
stat big.iso # 크기, 할당된 블록 수, inode 번호NTFS와 MFT
Windows의 기본 파일 시스템인 NTFS는 모든 파일의 정보를 MFT(master file table)라는 표에 모읍니다. 파일마다 보통 1KiB 크기의 MFT 레코드가 하나 있고, 파일 이름, 시각, 보안 정보, 데이터가 모두 속성(attribute)으로 그 안에 들어갑니다. 데이터가 작아 레코드의 남은 자리에 들어가면 데이터를 레코드 안에 그대로 두는데, 이것을 상주 속성(resident attribute)이라고 합니다. 이런 파일은 MFT 레코드만 읽으면 내용까지 얻습니다. 데이터가 크면 레코드 밖의 클러스터에 두고, 연속된 클러스터 구간을 (시작, 길이)로 적은 런(run) 목록으로 위치를 기록하는데, ext4의 익스텐트와 같은 생각입니다.
NTFS는 사용자와 그룹마다 세밀한 권한을 주는 ACL, 파일 단위 암호화(EFS), 투명 압축을 지원합니다. 한 파일에 데이터 흐름을 여러 개 담는 대체 데이터 스트림(alternate data stream)도 있어서, 인터넷에서 내려받은 파일에 붙는 Zone.Identifier 스트림이 그 예입니다. 또 메타데이터의 변경을 $LogFile에 먼저 기록하는 저널링 파일 시스템입니다. 파일 연산을 트랜잭션으로 묶는 TxF라는 기능도 있었지만, 마이크로소프트는 이제 쓰지 말라고 권합니다.
저널링: 쓰다 멈춘 변경을 복구하기
쓰기 하나가 여러 곳을 바꾼다
파일 끝에 블록 하나를 덧붙이는 간단한 쓰기도 실제로는 디스크의 세 곳을 바꿉니다. 블록 비트맵에서 새 블록을 사용 중으로 표시하고, inode에 새 블록의 주소와 늘어난 크기를 적고, 데이터 블록에 내용을 씁니다. 디스크는 이 세 곳을 한꺼번에 쓸 수 없으므로, 그 사이에 전원이 꺼지면 일부만 바뀐 상태가 남습니다. 비트맵만 바뀌었다면 어느 파일도 쓰지 않는 블록이 사용 중으로 남아 공간이 새고, inode만 바뀌었다면 파일이 아직 내용을 쓰지 않은 블록, 곧 예전에 다른 파일이 쓰던 내용을 가리키게 됩니다.
저널링이 없던 시절에는 다시 부팅할 때 fsck(file system check) 같은 도구로 파일 시스템 전체를 검사해 이런 불일치를 찾아 고쳤습니다. 모든 inode와 비트맵을 맞춰 보아야 하므로 디스크가 클수록 오래 걸려, 큰 디스크에서는 몇 시간이 걸리기도 했습니다.
저널에 먼저 쓰고 커밋한다
저널링(journaling)은 데이터베이스 교재의 로그·체크포인트와 장애 회복에서 다루는 쓰기 전 로그(write-ahead logging, WAL)와 같은 생각입니다. 바꿀 내용을 제자리에 쓰기 전에, 디스크에 따로 마련한 영역인 저널에 먼저 기록합니다.
- 바꿀 내용(비트맵, inode 등)을 저널에 씁니다.
- 그것이 모두 기록되면, 트랜잭션이 완성되었다는 커밋 블록(commit block)을 씁니다.
- 그 뒤에 변경을 제자리에 반영합니다. 이것을 체크포인트(checkpoint)라고 합니다.
- 반영이 끝나면 저널의 그 영역을 다시 쓸 수 있게 비웁니다.
다시 부팅할 때는 저널만 확인하면 됩니다. 커밋 블록까지 있는 트랜잭션은 내용을 제자리에 다시 반영하고, 커밋 블록이 없는 트랜잭션은 버립니다. 다음 코드는 두 경우를 흉내 냅니다.
def recover(disk, journal):
for tx in journal:
if tx.get("commit"): # 커밋 기록까지 남은 트랜잭션만
for key, value in tx["changes"]:
disk[key] = value # 제자리에 다시 반영한다(재생)
journal.clear()
# 파일에 블록 하나를 덧붙이는 트랜잭션: 세 곳을 함께 바꿔야 한다
changes = [("bitmap[7]", 1), ("inode12.size", 8192), ("inode12.block[1]", 7)]
# 경우 1: 저널에 변경을 쓰다가, 커밋 기록을 쓰기 전에 전원이 꺼졌다
disk = {"bitmap[7]": 0, "inode12.size": 4096, "inode12.block[1]": None}
journal = [{"changes": changes}]
recover(disk, journal)
print("커밋 전 중단:", disk)
# 경우 2: 커밋한 뒤 제자리에 반영하던 중, 비트맵만 바꾸고 전원이 꺼졌다
disk = {"bitmap[7]": 1, "inode12.size": 4096, "inode12.block[1]": None}
journal = [{"changes": changes, "commit": True}]
recover(disk, journal)
print("커밋 후 중단:", disk)커밋 전 중단: {'bitmap[7]': 0, 'inode12.size': 4096, 'inode12.block[1]': None}
커밋 후 중단: {'bitmap[7]': 1, 'inode12.size': 8192, 'inode12.block[1]': 7}커밋 전에 멈춘 트랜잭션은 버려져 쓰기 전 상태가 그대로 남고, 커밋 후에 멈춘 트랜잭션은 재생(replay)되어 세 곳이 모두 바뀐 상태가 됩니다. 어느 쪽이든 일부만 바뀐 상태는 남지 않습니다. 재생은 기록된 값을 다시 쓰는 것이므로, 이미 반영된 비트맵을 한 번 더 써도 결과가 같습니다. 그래서 복구 도중에 또 전원이 꺼져도 처음부터 다시 재생하면 되고, 확인할 범위가 디스크 전체에서 저널로 줄어든 덕분에 디스크가 커도 복구가 금방 끝납니다.
순서도 지켜야 합니다. 커밋 블록이 앞의 기록보다 먼저 디스크에 닿으면 완성되지 않은 트랜잭션이 완성된 것처럼 보이므로, 파일 시스템은 그 사이에 저장 장치의 쓰기 캐시를 비우게 하는 명령을 보냅니다. ext4는 기본적으로 쌓인 변경을 5초마다 하나의 트랜잭션으로 커밋합니다. ext4 문서의 설명대로, 전원이 꺼지면 최근 몇 초의 메타데이터 변경은 잃을 수 있지만 파일 시스템의 구조는 망가지지 않습니다.
데이터도 저널에 쓸까: 세 가지 모드
메타데이터와 함께 데이터까지 저널에 쓰면 모든 데이터를 두 번 쓰게 되어 느려집니다. 그래서 ext4는 데이터를 어떻게 다룰지 세 가지 모드를 둡니다.
| 모드 | 저널에 쓰는 것 | 데이터와 메타데이터의 순서 |
|---|---|---|
data=ordered (기본값) | 메타데이터 | 데이터를 제자리에 먼저 쓴 뒤 메타데이터를 커밋 |
data=writeback | 메타데이터 | 순서를 지키지 않아, 메타데이터가 먼저 커밋될 수 있음 |
data=journal | 데이터와 메타데이터 | 둘 다 저널에 먼저 쓴 뒤 제자리에 반영 |
ordered 모드에서는 데이터가 제자리에 쓰인 뒤에야 그 블록을 가리키는 메타데이터가 커밋되므로, 파일이 아직 쓰지 않은 블록을 가리키는 일이 없습니다. writeback 모드는 이 순서를 지키지 않아 조금 빠를 수 있지만, ext4 문서는 비정상 종료 뒤 최근에 쓴 파일에서 예전 데이터가 드러날 수 있다고 경고합니다. journal 모드는 데이터까지 저널로 보호하는 대신 모든 데이터를 두 번 쓰고, 지연 할당과 O_DIRECT를 쓸 수 없게 합니다. 최근 커널 문서는 이 모드를 폐기 예정으로 표시하고, 2028년 1월에 지원을 없앤다고 밝히고 있습니다.
findmnt -t ext4 -o TARGET,SOURCE,OPTIONS # ext4로 마운트된 곳과 옵션옵션 목록에 data=writeback이나 data=journal이 보이면 그 모드로 마운트된 것입니다. 커널은 기본값과 다른 data= 옵션만 목록에 보여 주므로, 기본값인 ordered로 마운트되었다면 아무것도 나타나지 않습니다.
지연 할당과 fsync
write()가 돌아왔다고 데이터가 디스크에 있는 것은 아닙니다. 쓴 데이터는 먼저 메모리의 페이지 캐시에 머물고, ext4는 기본으로 켜진 지연 할당(delayed allocation) 때문에 디스크 블록을 고르는 일조차 데이터를 실제로 내보내기 직전으로 미룹니다. 미루는 동안 쓰기가 더 모이면 연속된 블록을 한꺼번에 고를 수 있어 단편화가 줄어듭니다.
데이터가 디스크에 확실히 기록되어야 하는 순간이 있다면 fsync()를 불러야 합니다. fsync(2)는 그 파일의 바뀐 데이터와 메타데이터를 저장 장치로 보내고, 장치가 기록을 마쳤다고 알릴 때까지 기다립니다. 저널링 파일 시스템에서는 이때 그 파일의 메타데이터 변경도 커밋됩니다. 페이지 캐시와 fsync를 실제로 쓰는 방법은 다음 글에서 더 다룹니다.
더 깊이: 쓰기 시점 복사 파일 시스템
Btrfs와 ZFS는 데이터를 제자리에서 고치지 않는 쓰기 시 복사(copy-on-write, COW) 방식을 씁니다. 블록을 고칠 때 고친 내용을 새 자리에 쓰고, 그 블록을 가리키던 상위 블록도 새 자리에 고쳐 쓰는 일을 트리의 뿌리까지 반복한 뒤, 마지막에 뿌리를 가리키는 포인터 하나만 바꿉니다. 그 포인터가 바뀌기 전에 전원이 꺼지면 옛 트리가, 바뀐 뒤라면 새 트리가 온전히 남으므로 일부만 바뀐 상태가 생기지 않습니다. 옛 블록을 바로 지우지 않고 남겨 두면, 그 시점의 뿌리를 기억하는 것만으로 스냅샷(snapshot)을 만들 수 있습니다.
COW 파일 시스템도 로그를 함께 씁니다. 뿌리를 바꾸는 일은 여러 변경을 모아 가끔 하므로, fsync처럼 당장 기록을 끝내야 하는 요청을 위해 ZFS는 ZIL(ZFS intent log)을, Btrfs는 로그 트리(log tree)를 둡니다. 두 파일 시스템은 블록마다 체크섬도 기록해, 디스크가 오류 없이 잘못된 데이터를 돌려주는 경우까지 찾아냅니다.
정리
- 파일 시스템은 디스크를 슈퍼블록, 비트맵, inode 테이블, 데이터 블록, 저널로 나누어 씁니다.
- 연속 할당은 빠르지만 외부 단편화에 약하고, 연결 할당은 임의 접근이 느리며, 인덱스 할당은 주소를 한곳에 모아 어느 위치든 빠르게 찾습니다.
- 빈 공간은 주로 비트맵으로 관리하며, 빈 블록이 충분해도 이어진 자리는 없을 수 있습니다.
- FAT는 다음 클러스터 번호를 표에 모은 연결 할당이고, 전통적인 inode는 직접 주소와 단일·이중·삼중 간접 주소로 이루어진 다단계 인덱스입니다. ext4의 익스텐트와 NTFS의 런은 연속된 구간을 (시작, 길이)로 적어 메타데이터를 줄입니다.
- 저널링은 변경을 저널에 먼저 쓰고 커밋한 뒤 반영해, 비정상 종료 뒤에도 트랜잭션이 전부 반영되거나 전혀 반영되지 않게 합니다. ext4의 기본 모드인 ordered는 데이터를 먼저 쓴 뒤 메타데이터를 커밋합니다.
write()는 페이지 캐시에 쓰고 돌아오므로, 디스크에 기록되었음을 확인하려면fsync()가 필요합니다.- COW 파일 시스템은 제자리를 고치지 않고 새 자리에 쓴 뒤 뿌리 포인터를 바꾸어 일관성을 지키고, 스냅샷을 쉽게 만듭니다.
연습 문제
1. 비트맵의 크기
64GiB 볼륨을 8KiB 블록으로 나누고, 블록마다 1비트를 쓰는 비트맵으로 빈 공간을 관리합니다. 블록 수와 비트맵의 크기를 구하세요. 비트맵의 1바이트는 블록 몇 개의 상태를 나타내나요?
해설 보기
64GiB는 2^36바이트, 8KiB는 2^13바이트이므로 블록은 2^36 ÷ 2^13 = 2^23개, 약 839만 개입니다. 비트맵은 2^23비트이고 이를 8로 나누면 2^20바이트, 곧 1MiB입니다. 비트맵의 1바이트는 8비트이므로 블록 8개의 상태를 나타냅니다. 비트맵의 크기가 볼륨의 65,536분의 1에 불과해 메모리에 올려 두기 쉽습니다.
2. 블록 1,037개짜리 파일
전통적인 inode 모델(4KiB 블록, 4바이트 주소, 직접 주소 12개)에서 데이터 블록 1,037개를 모두 쓰는 파일이 있습니다. 직접 주소, 단일 간접, 이중 간접이 각각 몇 블록을 맡는지, 포인터 블록이 몇 개 필요한지, 데이터 블록과 포인터 블록을 합쳐 몇 바이트를 차지하는지 구하세요.
해설 보기
직접 주소 12개가 처음 12블록을, 단일 간접이 다음 1,024블록을 맡아 1,036블록까지 찾습니다. 마지막 1블록은 이중 간접으로 찾아야 합니다. 포인터 블록은 단일 간접에 1개, 이중 간접에 상위 포인터 블록 1개와 그 아래 포인터 블록 1개가 필요해 모두 3개입니다. 데이터 1,037블록과 포인터 3블록을 합한 1,040블록은 1,040 × 4,096 = 4,259,840바이트입니다. 데이터 블록 하나가 이중 간접의 범위로 넘어가는 바람에 포인터 블록이 둘 늘었습니다. 같은 파일이 디스크에 연속으로 놓였다면 ext4에서는 inode 안의 익스텐트 하나로 충분해, 포인터 블록이 필요 없습니다.
3. 전원이 꺼진 뒤 파일 끝에 보이는 것
ext4에서 로그 파일 끝에 4KiB를 덧붙이는 쓰기를 하고 몇 초 뒤 전원이 꺼졌습니다. 새 블록의 주소와 늘어난 크기를 담은 메타데이터는 커밋되었지만, 데이터 블록에는 아직 새 내용이 쓰이지 않았다고 합시다. writeback 모드라면 다시 부팅한 뒤 파일 끝에서 무엇을 읽게 될 수 있나요? ordered 모드에서는 왜 이런 상태가 생기지 않나요?
해설 보기
writeback 모드는 데이터와 메타데이터의 순서를 지키지 않으므로, 메타데이터만 커밋된 상태가 생길 수 있습니다. 그러면 재생 뒤 inode는 늘어난 크기와 새 블록을 가리키지만, 그 블록에는 새 데이터 대신 예전에 그 블록을 쓰던 파일의 내용이 남아 있어 그것이 파일 끝에서 읽힙니다. ext4 문서가 경고하는 예전 데이터의 노출이 이것이며, 다른 사용자의 데이터가 드러날 수 있어 보안 문제도 됩니다. ordered 모드에서는 데이터를 제자리에 쓴 뒤에야 메타데이터를 커밋하므로, 메타데이터가 커밋되었다면 데이터도 이미 쓰여 있습니다. 데이터를 쓰던 중에 전원이 꺼졌다면 커밋도 없으므로 파일은 늘어나기 전 크기로 남습니다. 어느 모드든 마지막 몇 초의 쓰기를 잃지 않으려면 응용 프로그램이 fsync로 기록을 확인해야 합니다.
다음 글 파일 시스템 실무에서는 파일 디스크립터와 커널의 파일 테이블, 페이지 캐시와 fsync, 디스크 관리 명령, RAID와 SSD처럼 파일 시스템을 실제로 쓰고 운영할 때 만나는 주제들을 다룹니다.