배치, 순서, 그리고 표현: ext2에서 ext4까지
저장장치가 강요한 세 가지 숙제
앞선 글에서 유저의 요청이 커널의 여러 계층을 거쳐 장치에 닿는 경로를 따라갔다. 이번에는 그 계층 중 하나인 파일 시스템 안으로 들어간다.
파일 시스템을 설계할 때 던지게 되는 질문은 결국 세 개다.
데이터를 디스크의 어디에 놓을 것인가? 여러 곳을 고쳐야 할 때 어떤 순서로 쓸 것인가? 그리고 그 위치를 어떻게 기록해둘 것인가?
첫 질문은 성능 문제, 둘째는 신뢰성 문제, 셋째는 표현 방식의 문제다. ext2는 첫 질문에, ext3는 둘째 질문에, ext4는 셋째 질문에 답한 파일 시스템이다. 이 글은 세 답이 각각 어떤 한계에서 출발했는지를 따라간다.
먼저 답하기
세 문장으로 줄이면 이렇다.
배치: 메타데이터와 데이터를 디스크 반대편에 두면 파일 하나 읽을 때마다 헤드가 왕복한다. 디스크를 여러 구역으로 나누고 각 구역이 자급자족하게 만들면 이 왕복이 짧은 이동으로 바뀐다.
순서: 여러 곳을 고치는 도중에 전원이 끊기면 파일 시스템이 어긋난다. 고치기 전에 무엇을 고칠지 로그에 먼저 적어두면, 깨어났을 때 되돌릴지 밀어붙일지 판단할 수 있다.
표현: 블록 주소를 하나씩 적으면 큰 파일에서 주소 목록 자체가 짐이 된다. “어디서부터 몇 개”라는 구간으로 묶으면 이 짐이 사라진다.
세 답 모두 성격이 같다. 하드웨어의 물리적 한계를 소프트웨어의 배치, 순서, 표현으로 우회한다.
주제에 맞는 핵심 설명
▎왜 배치가 성능을 결정하는가
회전하는 디스크에서 데이터를 읽으려면 두 가지 기계적 동작이 먼저 일어나야 한다. 헤드를 목표 트랙으로 옮기는 탐색(seek)과, 원판이 돌아 목표 섹터가 헤드 밑으로 올 때까지의 회전 대기다. 둘 다 밀리초 단위이고, 그 사이 전송은 한 바이트도 일어나지 않는다.
이 비용이 얼마나 지배적인지 계산해보자.
가정 — 100 MiB를 4 KiB 블록 단위로 읽는다. 탐색 10 ms, 회전 대기 4.17 ms(7200 rpm의 절반 회전), 전송 대역폭 100 MiB/s.
| 배치 | 계산식 | 걸리는 시간 | 유효 대역폭 |
|---|---|---|---|
| 블록들이 이어져 있음 | 탐색 1회 + 전송 100 MiB ÷ 100 MiB/s | 약 1.01 s | 약 99 MiB/s |
| 블록마다 흩어져 있음 | 25,600 × 14.17 ms + 전송 1.00 s | 약 363.8 s | 약 0.27 MiB/s |
약 359배 차이다. 그리고 흩어진 경우의 유효 대역폭은 장치가 낼 수 있는 성능의 0.27%에 불과하다. 같은 장치, 같은 데이터량인데 배치만 달라진 결과다.
적용 한계 — 위 수치는 최악의 경우를 가정한 상한이다. 실제로는 읽기 선행(read-ahead), 장치 내부 캐시, 앞선 글에서 다룬 I/O 스케줄러의 병합이 개입해 이만큼 나빠지지는 않는다. 탐색 시간도 이동 거리에 따라 다르다. SSD에서는 탐색과 회전 대기가 없으므로 이 표가 그대로 적용되지 않는다.
실제로 이 문제는 심각했다. 4.2BSD의 새 파일 시스템을 소개한 McKusick 외의 논문 A Fast File System for UNIX에 따르면, 512바이트 블록을 쓰던 초기 UNIX 파일 시스템은 디스크가 낼 수 있는 대역폭의 2% 정도밖에 활용하지 못했다. 블록 크기를 1024바이트로 키운 뒤에도 4% 수준이었다.
▎파일 하나를 읽는 데 몇 곳을 건드리는가
이 그림이 없으면 “메타데이터 접근이 반복된다”는 말이 몇 번을 뜻하는지 알 수 없다. 핵심은 보라색과 파란색이 계속 번갈아 나온다는 점이다.
파일 하나를 열려면 경로를 따라 내려가야 한다. 각 단계마다 inode를 읽고, 그 inode가 가리키는 디렉터리 내용을 읽어 다음 단계의 inode 번호를 알아내야 한다. 여기서 inode란 파일의 크기, 소유자, 시각, 그리고 데이터가 어느 블록에 있는지를 담은 메타데이터 구조체다.
경로가 세 단계면 최소 8곳을 건드린다. 그리고 결정적인 문제가 생긴다. inode 영역과 데이터 영역이 디스크의 반대편에 있다면, 이 번갈아 접근이 그대로 앞뒤 왕복이 된다.
쓰기는 더 복잡하다. 데이터 블록을 쓰고, inode를 갱신하고, 비트맵에 표시해야 한다. 서로 떨어진 세 곳을 고쳐야 하고, 셋이 함께 반영되지 않으면 파일 시스템이 어긋난다.
▎극단적 분리: 앞에는 inode, 뒤에는 데이터
초기 UNIX 파일 시스템과 그 계열인 UFS(UNIX File System)는 단순한 배치를 택했다. 디스크 앞단에 모든 inode를 몰아넣고, 뒷단의 넓은 영역에 데이터를 두는 방식이다.
구현은 간단하다. inode 번호만 알면 위치를 곧바로 계산할 수 있다. 하지만 파일 하나를 읽을 때마다 앞뒤를 왕복하게 된다. 앞서 인용한 논문도 구형 파일 시스템의 문제로 inode 정보가 데이터와 분리되어 있어 파일의 inode에서 데이터까지 긴 탐색이 발생한다는 점을 지적한다.
문제는 하나 더 있었다. 같은 논문은 처음에는 빈 블록 목록이 최적의 접근 순서로 정렬되어 있지만, 파일이 생성되고 삭제되면서 금방 뒤엉킨다고 서술한다.
▎FFS의 답: 디스크의 생김새를 아는 파일 시스템
4.2BSD의 FFS(Fast File System)가 내놓은 해법은 파일 시스템이 디스크의 물리적 구조를 알고 그에 맞춰 배치하자는 것이었다.
핵심 장치가 실린더 그룹(cylinder group)이다. 디스크를 여러 구역으로 나누고, 각 구역 안에 슈퍼블록 사본, 빈 공간 비트맵, inode 테이블, 데이터 블록을 모두 갖춘다.
배치 원칙은 단순하다. 같은 디렉터리에 속한 파일들은 같은 그룹에 두고, 파일의 inode와 데이터도 같은 그룹에 둔다. 논문은 이 배치로 inode 접근에 필요한 디스크 접근 횟수가 상황에 따라 2배에서 8배까지 줄었다고 보고한다.
FFS는 두 가지를 더 바꿨다. 블록 크기를 키웠다. 최소 4096바이트로 하고 그 이상은 2의 거듭제곱으로 지정할 수 있게 했다. 그런데 큰 블록은 작은 파일에서 낭비가 크다. 논문은 4096바이트 블록에서 낭비가 45.6%에 이른다고 계산했다. 그래서 조각(fragment) 개념을 도입해 블록 하나를 2, 4, 8개로 나눠 쓸 수 있게 했다. 조각의 하한은 섹터 크기, 보통 512바이트다.
슈퍼블록도 복제했다. 이게 망가지면 전체를 잃기 때문에 그룹마다 사본을 두었다. 성능 설계인 실린더 그룹이 신뢰성 개선까지 함께 가져온 셈이다.
▎그래서 실제 파일들은 어떻게 나뉘는가
“같은 그룹에 둔다”는 원칙이 실제 디렉터리 트리에 적용되면 어떤 모습이 되는지 예시로 따라가 보자.
먼저 각 그룹이 무엇을 갖고 있는지 짚어야 한다. 그룹마다 inode 테이블이 따로 있고, 그 크기는 파일 시스템을 만들 때 고정된다. 앞서 인용한 논문도 각 실린더 그룹에 파일 시스템 생성 시점에 정해진 개수의 inode가 할당된다고 서술한다. 즉 그룹 0은 1번부터 2048번까지, 그룹 1은 2049번부터 4096번까지 하는 식으로 inode 번호 구간이 그룹별로 나뉘어 있다.
그래서 거꾸로도 성립한다. inode 번호만 보면 그것이 어느 그룹에 있는지 계산으로 나온다.
그룹 번호 = (inode 번호 − 1) ÷ 그룹당 inode 수
그룹당 inode가 2,048개라면 inode 4100은 (4100−1) ÷ 2048 = 2, 즉 그룹 2에 있다. 디렉터리 엔트리에 적힌 inode 번호 하나만 알면 어디로 가야 할지 곧바로 정해진다. 별도의 조회 테이블이 필요 없다.
이제 파일들이 어떻게 배치되는지 보자. 규칙은 네 가지다.
첫째, 새 디렉터리는 일부러 흩뿌린다. /home을 만들 때 /와 같은 그룹에 두지 않는다. 논문의 정책은 할당된 디렉터리 수가 적고 빈 inode가 많은 그룹을 고르는 것이다. 이유는 앞을 내다본 것이다. 새 디렉터리에는 앞으로 파일들이 들어올 텐데, 그 파일들을 담을 자리를 미리 확보해두려는 것이다. 처음부터 한 그룹에 디렉터리를 몰아넣으면 나중에 파일을 넣을 공간이 없어진다.
둘째, 파일 inode는 부모 디렉터리와 같은 그룹에 만든다. /home/tom 아래의 report.txt, notes.md, movie.mp4는 전부 tom이 있는 그룹 2에 inode를 받는다. 위 그림에서 inode 4099부터 4102까지가 나란히 붙어 있는 이유다.
이 규칙의 효과가 논문에 수치로 나온다. ls 같은 명령으로 한 디렉터리의 여러 파일 inode를 한꺼번에 읽을 때, 구형 파일 시스템은 파일마다 별도의 디스크 전송이 필요했다. 그런데 inode들이 한 그룹에 모여 있으면 한 번의 전송으로 여러 개를 가져올 수 있다. 논문은 이 개선으로 inode 접근에 필요한 디스크 접근이 상황에 따라 2배에서 8배까지 줄었다고 보고한다.
셋째, 파일의 데이터 블록은 그 파일의 inode와 같은 그룹에 잡는다. 논문은 파일의 데이터 블록들이 대체로 함께 접근되므로 모두 같은 실린더 그룹에 두려 한다고 서술한다. 앞의 읽기 경로 그림에서 봤던 “inode 읽고 → 데이터로 이동”이 그룹 안에서의 짧은 이동이 되는 지점이 여기다.
넷째, 큰 파일만 예외로 쪼갠다. 앞의 세 규칙을 그대로 밀어붙이면 문제가 생긴다. 큰 파일 하나가 그룹 하나를 통째로 채워버리면, 같은 디렉터리의 다른 파일들이 갈 곳을 잃는다. 논문은 이 상황을 명시적으로 지적하며 어떤 실린더 그룹도 완전히 꽉 차서는 안 된다고 말한다. 그래서 택한 해법이 파일이 48 KiB를 넘으면 블록 배정을 다른 그룹으로 돌리고, 그 뒤로는 1 MiB마다 다시 돌리는 것이다. 위 그림에서 movie.mp4만 그룹 2와 3에 걸쳐 있는 이유다.
이 네 규칙을 한 문장으로 줄이면 이렇게 된다.
관련 있는 것은 모으고, 관련 없는 것은 떼어놓되, 한 곳이 꽉 차지 않게 한다.
그리고 이 정책에는 전제가 있다. 각 그룹에 빈자리가 남아 있어야 한다는 것이다. 디스크가 꽉 차면 “같은 그룹에 두기”가 아예 불가능해지고, 배치 정책이 무력해진다. 그래서 FFS는 디스크의 일정 비율(기본 10%)을 예약해두고 그 아래로는 일반 사용자가 쓰지 못하게 했다. 성능을 위해 용량을 내어준 셈이다.
빈 공간을 찾는 방식도 바뀌었다. 구형 파일 시스템은 빈 블록들을 연결 리스트로 관리했는데, 앞서 봤듯 이 목록이 금방 뒤엉켰다. FFS는 이를 그룹마다의 비트맵으로 대체했다. 비트 하나가 블록 하나의 사용 여부를 나타내므로, 특정 위치 근처에 빈자리가 있는지를 곧바로 확인할 수 있다. “가까운 자리에 두기”라는 정책이 실제로 실행 가능해진 것은 이 자료구조 덕분이다.
원하는 자리가 이미 차 있으면 논문의 지역 할당 루틴이 차선책을 순서대로 시도한다. 회전상 가장 가까운 블록, 같은 실린더 그룹 안의 다른 블록, 그래도 없으면 다른 그룹, 마지막으로 전체 탐색이다. 가까운 곳부터 차례로 양보하는 구조다.
▎리눅스로의 적용: ext2
리눅스 초기의 ext는 구조가 단순해 성능과 확장성에 한계가 있었다. ext2는 FFS의 아이디어를 리눅스에 들여온 것으로, 실린더 그룹에 해당하는 블록 그룹 개념을 채택했다.
| 축 | ext | ext2 |
|---|---|---|
| 공간 배치 | 단순한 분리 구조 | 블록 그룹으로 지역성 확보 |
| 메타데이터와 데이터 | 떨어져 있음 | 같은 그룹에 배치 |
| 슈퍼블록 | 단일 | 그룹마다 사본 |
| 설계 관점 | 하드웨어를 고려하지 않음 | 디스크 특성을 반영한 배치 정책 |
| 크래시 대응 | 사후 검사 | 사후 검사 (여전히) |
마지막 줄이 다음 절의 주제다. ext2는 배치 문제를 풀었지만 순서 문제는 손대지 않았다.
▎크래시 일관성: 검사에서 예방으로
파일 하나를 쓰려면 데이터 블록, inode, 비트맵 세 곳을 고쳐야 한다. 이 중간에 전원이 끊기면 이런 상태가 남는다.
- 데이터만 쓰인 경우: 아무도 참조하지 않는 블록이 남는다.
- 메타데이터만 쓰인 경우: inode는 파일이 커졌다고 하는데 그 자리에는 쓰레기 값이 있다. 더 위험한 쪽이다.
- 비트맵만 쓰인 경우: 실제로는 안 쓰는 블록이 사용 중으로 표시된다.
ext2까지의 대응은 사후 검사였다. 부팅할 때 검사 도구가 디스크 전체를 훑으며 어긋난 곳을 고친다. 문제는 이 방식이 디스크 용량에 비례해 오래 걸린다는 점이다. 그리고 구조는 복구해도 내용까지 되살려주지는 않는다.
그래서 발상이 바뀐다. 깨진 뒤에 고치는 대신, 애초에 깨지지 않게 순서를 강제하자는 것이다.
▎선행 기록: 고치기 전에 적어둔다
저널(journal)이란 실제 위치를 고치기 전에 “무엇을 어떻게 고칠 것인지”를 먼저 적어두는 별도의 기록 구역이다. 이 방식의 핵심 원칙이 선행 기록(write-ahead logging)이다.
최종 위치에 데이터를 쓰기 전에, 반드시 로그 구역에 트랜잭션의 의도를 먼저 기록한다.
여기서 트랜잭션(transaction)은 함께 반영되어야 하는 변경들의 묶음이다. 리눅스에서 이 기능은 JBD(Journaling Block Device) 계층이 담당한다.
처리는 두 단계로 나뉜다. 오라클의 JBD 설명 문서는 트랜잭션을 저널 로그에 기록하는 것을 커밋(committing), 커밋된 트랜잭션을 최종 위치에 반영하는 것을 체크포인팅(checkpointing)이라 구분한다. 그리고 트랜잭션에 포함된 모든 변경이 저널에 기록되어야만 커밋된 것으로 간주된다고 밝힌다.
왜 이 순서가 안전한가. 커밋 블록 하나가 판단의 분기점이 되기 때문이다.
- 커밋 전에 끊겼다면 커밋 블록이 없다. 저널 내용을 버린다(언두). 최종 위치는 손대지 않았으니 이전 상태 그대로 안전하다.
- 커밋 후 반영 중에 끊겼다면 커밋 블록이 있다. 저널을 다시 반영한다(리도).
그리고 복구가 빨라진다. 디스크 전체가 아니라 저널만 훑으면 되므로, 복구 시간이 저널 크기에 비례한다.
저장장치 관점에서 짚어둘 점이 있다. 저널 영역은 순차로 채워진다. 앞선 글에서 다룬 NAND 관점에서 보면 다루기 좋은 패턴이다. 반면 체크포인트는 흩어진 최종 위치로 가므로 성격이 다르다.
▎무엇까지 저널에 넣을 것인가
여기서 딜레마가 생긴다. 데이터까지 저널에 넣으면 모든 내용을 두 번 쓴다. ext3의 해법은 하나를 고르지 않고 선택지를 여는 것이었다.
writeback 모드. ext3가 데이터를 전혀 저널링하지 않는다. 커널 문서는 이 모드로 마운트하면 정상 종료가 아닌 경우 최근에 쓴 파일에 예전 데이터가 노출될 수 있으며, 상황에 따라 보안 문제가 될 수 있다고 경고한다.
ordered 모드. 기본값이다. 메타데이터만 저널링하지만 순서를 강제한다. 커널 문서의 표현대로 새 메타데이터를 쓸 때가 되면 관련된 데이터 블록을 먼저 쓴다. 같은 문서는 이 모드가 writeback보다 약간 느리지만 journal 모드보다는 훨씬 빠르다고 정리한다.
journal 모드. 데이터와 메타데이터를 모두 저널링한다. 가장 안전하지만 모든 내용을 두 번 쓴다.
| 축 | writeback | ordered (기본값) | journal |
|---|---|---|---|
| 저널에 들어가는 것 | 메타데이터만 | 메타데이터만 | 데이터까지 전부 |
| 데이터 쓰기 순서 | 정하지 않음 | 메타데이터보다 먼저 | 저널을 거쳐서 |
| 쓰기량 | 가장 적음 | 중간 | 가장 많음 |
| 구조 일관성 | 보장 | 보장 | 보장 |
| 내용 일관성 | 보장 안 됨 | 대체로 보장 | 보장 |
| 크래시 후 위험 | 옛 내용 노출 가능 | 최근 데이터 유실 가능 | 가장 작음 |
적용 한계 — 성능 순서는 커널 문서의 서술을 정리한 것이며, 실제 차이는 워크로드에 크게 좌우된다. 커널 문서 자체도 모드를 바꿔보는 것이 가치가 있으며 일부 워크로드에서는 writeback이 더 빠를 수 있다고 표현한다.
▎남은 문제: 주소 목록 자체가 짐이 된다
ext3는 순서 문제를 풀었지만, ext2에서 물려받은 것 중 손대지 않은 부분이 있었다. 블록의 위치를 기록하는 방식이다.
ext2와 ext3는 블록 포인터 방식을 쓴다. inode에 직접 포인터 12개를 두고, 그것으로 부족하면 단일 간접, 이중 간접, 삼중 간접 블록을 거친다. 간접 블록은 포인터를 잔뜩 담은 블록이다.
문제는 파일이 커질수록 드러난다. 블록 하나마다 주소 하나가 필요하기 때문이다.
가정 — 4 KiB 블록, 4바이트 포인터, 1 GiB 파일. 데이터 블록은 262,144개이고, 간접 블록 하나에 포인터 1,024개가 들어간다.
| 항목 | 블록 포인터 방식 | 익스텐트 방식 (완전히 이어져 있을 때) |
|---|---|---|
| 데이터 블록 수 | 262,144개 | 262,144개 (동일) |
| 위치 기록에 필요한 것 | 간접 블록 257개 | 익스텐트 8개 |
| 메타데이터 크기 | 약 1 MiB | 약 4 KiB |
| 임의 위치 접근 시 추가 읽기 | 간접 블록 2회 | 0~1회 |
약 253배 차이다. 그리고 접근 비용에서도 갈린다. 이중 간접 영역의 블록 하나를 읽으려면 이중 간접 블록과 간접 블록을 먼저 읽어야 하므로 디스크 접근이 3회(메타데이터 2 + 데이터 1)가 된다.
적용 한계 — 위 익스텐트 쪽 수치는 파일이 완전히 연속으로 배치되었다는 최선의 가정이다. 파일이 잘게 쪼개져 있으면 익스텐트 개수가 조각 수만큼 늘어나고, 극단적으로 파편화되면 이점이 크게 줄어든다. 반대로 블록 포인터 방식의 간접 블록 수는 파일 크기로 결정되므로 배치와 무관하다. 즉 익스텐트의 이득은 연속 배치를 전제로 한다.
▎ext4의 답: 구간으로 묶기
익스텐트(extent)란 “파일의 어느 위치부터, 디스크의 어디부터, 몇 개”를 한 줄로 적은 것이다. 이어진 블록들을 하나씩 적는 대신 구간으로 묶는다.
구현이 우아한 부분은 예전 블록 포인터가 있던 자리를 그대로 쓴다는 점이다. 리눅스 커널의 ext4 디스크 레이아웃 문서와 오라클의 해설에 따르면, inode 안의 60바이트 영역에 12바이트짜리 헤더 하나와 12바이트짜리 항목 네 개가 들어간다. 12 × 5 = 60으로 딱 맞는다.
핵심은 헤더에 들어 있는 깊이(depth) 값이 항목의 정체를 결정한다는 것이다.
- 깊이가 0이면 항목은 데이터 구간을 직접 가리키는 익스텐트다. 추가 디스크 접근 없이 곧바로 위치를 안다.
- 깊이가 0보다 크면 항목은 아래 노드를 가리키는 인덱스다. 한 단계 더 내려가야 한다.
그래서 작은 파일이나 조각이 적은 파일은 inode 안에서 끝난다. 오라클 문서의 설명대로 다섯 번째 익스텐트가 필요해지는 순간 깊이가 1로 올라가고, inode의 항목이 리프 블록을 가리키는 인덱스로 바뀐다. 리프 블록도 같은 12바이트 헤더로 시작하고 그 뒤에 익스텐트들이 늘어선다.
익스텐트 하나가 담는 내용도 봐두면 뒤의 이야기가 이어진다. 커널 문서와 관련 자료에 따르면 익스텐트는 파일 안에서의 시작 위치(32비트), 디스크의 물리 블록 번호(48비트), 길이를 담는다. 길이의 최상위 비트는 아직 기록되지 않은 구간임을 표시하는 데 쓰인다. 이 표시가 뒤에 나올 사전 할당을 가능하게 한다.
▎배정을 언제 할 것인가
익스텐트는 연속 배치를 전제로 이득을 본다. 그러면 연속 배치를 어떻게 만들 것인가. ext4의 답은 배정 시점을 미루는 것이었다.
지연 할당(delayed allocation). 앞선 글에서 봤듯 write()는 페이지 캐시에 데이터를 넣고 바로 반환한다. 그런데 블록 배정까지 그 시점에 해버리면, 파일이 최종적으로 얼마나 커질지 모르는 상태에서 자리를 정하게 된다. 4 KiB씩 여덟 번 쓰면 여덟 번 따로 배정되고, 그때그때 비어 있던 자리로 흩어진다.
지연 할당은 배정을 실제로 디스크에 내려보낼 때까지 미룬다. 그 시점에는 총 크기를 알고 있으므로 한 번에 연속된 자리를 고를 수 있다. 결과적으로 익스텐트 하나로 표현되는 파일이 나온다. 덤으로 만들었다가 곧바로 지운 임시 파일은 배정 자체가 일어나지 않아 디스크에 닿지도 않는다.
다중 블록 할당(multiblock allocation). 배정을 미룬 덕에 여러 블록을 한꺼번에 요청할 수 있게 됐다. 예전에는 블록 하나씩 할당기를 호출했지만, 이제는 필요한 만큼을 한 번에 요청해 할당기가 연속 구간을 찾도록 한다. 호출 횟수도 줄고 결과도 좋아진다.
사전 할당(preallocation). 애플리케이션이 “이 파일은 결국 1 GiB가 될 것”이라고 미리 알려주면, 파일 시스템이 그만큼의 연속 구간을 미리 확보해둘 수 있다. 앞서 본 미기록 표시가 여기서 쓰인다. 자리는 잡아뒀지만 아직 내용이 없는 구간이라고 표시해두는 것이다. 이 구간을 읽으면 0이 반환되고, 나중에 실제로 쓸 때 표시만 바꾸면 된다. 자리를 확보하면서도 미리 0을 채워 쓰는 비용은 내지 않는 방식이다.
세 가지가 서로 맞물려 있다는 점이 중요하다. 지연 할당이 정보를 모아주고, 다중 블록 할당이 그 정보로 연속 구간을 잡고, 익스텐트가 그 결과를 적은 비용으로 표현한다.
▎64비트로의 전환
ext3의 또 다른 한계는 블록 번호가 32비트라는 점이었다. 4 KiB 블록 기준으로 계산해보면 이렇다.
| 항목 | ext3 | ext4 |
|---|---|---|
| 물리 블록 번호 폭 | 32비트 | 48비트 |
| 4 KiB 블록 기준 최대 볼륨 | 2³² × 4 KiB = 16 TiB |
2⁴⁸ × 4 KiB = 1,024 PiB |
익스텐트 구조체가 물리 블록 번호를 48비트로 나눠 담는 이유가 이것이다. 12바이트 안에 32비트 하위와 16비트 상위로 쪼개 넣는다.
적용 한계 — 위 최대 볼륨은 블록 번호 폭만으로 계산한 이론값이다. 실제 지원 한계는 다른 구조체의 필드 폭, 구현, 도구의 제약에 따라 더 작을 수 있다.
▎ext3와 ext4를 한자리에 놓으면
| 축 | ext3 | ext4 |
|---|---|---|
| 블록 위치 표현 | 블록 포인터 + 간접 블록 | 익스텐트 트리 |
| 1 GiB 파일의 위치 메타데이터 | 약 1 MiB | 약 4 KiB (연속 시) |
| 임의 위치 접근 | 간접 블록 여러 번 읽기 | 0~1회 |
| 블록 배정 시점 | 쓰기 시점 | 디스크에 내려보낼 때 |
| 배정 단위 | 블록 하나씩 | 여러 블록 한꺼번에 |
| 사전 할당 | 실제로 0을 채워야 함 | 미기록 표시로 자리만 확보 |
| 물리 블록 번호 | 32비트 | 48비트 |
| 저널링 | JBD | JBD2 (체크섬 등 확장) |
| 큰 파일에서의 성격 | 메타데이터가 짐이 됨 | 연속 배치를 유도하고 저렴하게 표현 |
주의할 점 하나. 위 표의 이점은 대체로 파일이 연속으로 배치되었을 때 성립한다. 파편화가 심하면 익스텐트 개수가 늘어 이점이 줄어들고, 극단적인 경우 트리 깊이가 늘어 접근 비용도 커진다. 그래서 ext4의 익스텐트와 할당 최적화는 따로 떼어 볼 수 없는 한 묶음이다.
▎세 이야기를 잇는 선
ext2, ext3, ext4가 푼 문제는 달랐지만 방식은 같았다. 하드웨어가 강요하는 한계를 소프트웨어의 배치, 순서, 표현으로 우회한다.
- ext2는 “헤드 이동이 비싸다”는 한계를 공간적 인접성으로 우회했다.
- ext3는 “여러 쓰기가 원자적이지 않다”는 한계를 쓰기 순서로 우회했다.
- ext4는 “블록마다 주소가 필요하다”는 한계를 구간 표현과 배정 시점 조절로 우회했다.
그리고 이 구도는 지금도 반복된다. 앞선 글에서 다룬 SSD 펌웨어를 떠올려보자. 덮어쓰기가 안 된다는 한계를 매핑 테이블로 우회했고, 그 대가로 생긴 무효 페이지를 가비지 컬렉션으로 정리한다.
다만 이 대응 관계에는 뒤틀림도 있다. ext2가 애써 확보한 물리적 인접성은 SSD의 FTL을 거치면서 사라진다. 파일 시스템이 인접한 LBA에 배치해도, FTL은 그것을 전혀 다른 물리 위치에 쓸 수 있다. 앞선 글에서 다룬 zoned storage가 이 이중 관리를 걷어내려는 시도인 이유가 여기에 있다.
흥미로운 것은 ext4의 최적화 중 일부는 SSD에서도 여전히 유효하다는 점이다. 익스텐트가 줄이는 것은 헤드 이동이 아니라 메타데이터 읽기 횟수이고, 이건 매체와 무관하게 이득이다. 지연 할당이 만드는 큰 연속 쓰기도 앞선 글에서 봤듯 병합과 가비지 컬렉션 양쪽에 유리하다. 동기는 회전 디스크에서 나왔지만 효과는 그보다 넓다.
자주 묻는 질문
SSD를 쓰면 실린더 그룹 같은 배치 최적화는 의미가 없나요? 탐색과 회전 대기가 없으므로 원래의 동기는 사라진다. 다만 인접성 자체가 무의미해지지는 않는다. 첫째, 인접한 요청은 I/O 스케줄러에서 병합되어 요청 개수를 줄인다. 둘째, 순차 쓰기는 SSD 내부에서 블록을 통째로 채우고 통째로 무효화하는 패턴을 만들어 정리 비용을 낮춘다. 셋째, 읽기 선행이 유효하게 동작한다. 이유는 바뀌었지만 결론은 비슷하게 남아 있다. 반면 회전 지연을 계산에 넣는 종류의 세밀한 배치 최적화는 확실히 의미를 잃었다.
저널링을 쓰면 쓰기가 두 배가 되나요? 모드에 따라 다르다. journal 모드는 데이터까지 두 번 쓰므로 대체로 그렇다. ordered와 writeback은 메타데이터만 저널에 넣으므로 증가분이 훨씬 작다. 그리고 저널 쓰기는 순차 패턴이라 흩어진 쓰기보다 장치가 처리하기 유리하다. 총 바이트 수만 세면 손해로 보이지만 실제 소요 시간은 그 비율만큼 늘지 않는 경우가 많다.
ext3에서 ext4로 바꾸면 기존 파일도 익스텐트로 바뀌나요? 자동으로 바뀌지는 않는다. 익스텐트는 파일 단위 속성이라, 이미 블록 포인터 방식으로 기록된 파일은 그 방식을 유지한다. 새로 만드는 파일부터 익스텐트가 적용되는 것이 일반적이다. 기존 파일까지 전환하려면 별도의 변환 작업이 필요하고, 그 과정에서 데이터가 재배치될 수 있으므로 백업 후 진행하는 것이 안전하다. 실무적으로는 성능 개선을 기대한다면 새로 쓰는 데이터부터 효과가 나타난다고 보는 편이 정확하다.
지연 할당 때문에 데이터가 더 잘 유실되나요?
배정과 실제 쓰기가 모두 뒤로 미뤄지므로, 크래시 시점에 아직 디스크에 닿지 않은 데이터의 양이 늘어날 수 있다. 커널 문서도 지연 할당 때문에 오래된 데이터도 전원 손실 시 유실될 수 있으며, 그 시점은 더티 페이지 만료 설정에 좌우된다고 언급한다. 다만 이는 성능과 내구성의 교환이지 결함이 아니다. 반드시 살아남아야 하는 데이터라면 fsync()로 명시적으로 밀어붙여야 하고, 이건 지연 할당 유무와 무관하게 원래 필요한 절차다.
파편화가 심해지면 ext4의 이점이 사라지나요? 줄어든다. 익스텐트 개수가 조각 수만큼 늘어나고, 네 개를 넘으면 트리 깊이가 생기며, 더 늘면 리프 블록을 여러 개 읽어야 한다. 극단적으로는 블록 포인터 방식과 비슷한 접근 비용에 근접할 수 있다. 다만 이 상황은 지연 할당과 다중 블록 할당이 막으려는 바로 그 상황이기도 하다. 실무적으로 확인하려면 파일별 익스텐트 개수를 재보면 되고, 크기에 비해 개수가 지나치게 많다면 파편화가 진행된 것이다. 여유 공간이 부족하면 연속 구간을 찾기 어려워지므로, 이 경우도 여유 공간 확보가 우선이다.
펌웨어 쪽에서 이 내용을 알아야 하는 실질적인 이유가 뭔가요? 장치에 도착하는 쓰기의 성격을 해석할 수 있게 된다. 저널링 파일 시스템이 올라가 있다면 좁은 구역에 반복해서 순차 쓰기가 몰리는 패턴이 관측되는데, 이게 저널 영역이다. 커밋 시점마다 순서를 강제하기 위한 요청이 끼어들어 병합을 방해할 수도 있다. 반대로 체크포인트는 흩어진 위치로 가는 쓰기가 된다. 그리고 ext4의 지연 할당이 켜져 있으면 작은 쓰기가 모여 큰 연속 쓰기로 도착하므로, 애플리케이션이 실제로 어떻게 썼는지와 장치가 본 패턴이 더 크게 달라진다. 이 사정을 알고 보면 관측한 워크로드를 훨씬 정확하게 읽을 수 있다.