Linux 시리즈 #09 — 디스크·블록 계층: 저널·복구·할당·I/O 스케줄러
이 글의 핵심
파일시스템 저널이 트랜잭션을 어떻게 기록·복구하는지, 블록 할당기가 공간을 어떻게 고르는지, blk-mq 위에서 스케줄러가 요청을 어떻게 재정렬하는지 연결해 설명합니다.
시리즈 안내
#09 | inode·익스텐트 구조: #03 파일·inode · 실무 트러블슈팅: 디스크 full vs inode · 개요: Linux 커널 내부 동작
블록 계층에서 파일시스템이 하는 일
#03에서 다룬 온디스크 inode와 익스텐트는 공간이 무엇을 뜻하는지를 정의합니다. 그다음 층에는 세 가지 질문이 있습니다. 여러 블록에 걸친 변경을 크래시에도 일관되게 디스크에 반영하려면 어떻게 해야 하는가(저널), 빈 블록을 어디서 가져올 것인가(할당기), 커널이 디바이스로 보내는 요청을 어떤 순서로 내보낼 것인가(I/O 스케줄러)입니다. 이 글은 이 세 층의 동작을 마운트 옵션과 커널 설정, 그리고 운영에서 보이는 증상과 연결해 설명합니다.
저널링과 복구: ext4(JBD2)
왜 저널이 필요한가
파일 하나를 새로 만드는 작업만 해도 inode 비트맵, inode 테이블, 디렉터리 블록, 블록 비트맵, 그룹 디스크립터 등 여러 블록을 함께 바꿔야 합니다. 디스크는 이 여러 블록을 한 번에 원자적으로 쓸 수 없으므로, 중간에 전원이 나가면 “비트맵에는 사용 중인데 어떤 inode도 가리키지 않는 블록” 같은 불일치가 남습니다. 저널이 없던 ext2에서는 이런 불일치를 찾으려고 비정상 종료 후 전체 파일시스템을 훑는 fsck를 돌려야 했고, 큰 볼륨에서는 이 과정이 몇 시간씩 걸렸습니다.
저널링은 바꿀 블록과 그 내용을 먼저 순차 로그 영역에 기록하고, 로그가 안전하게 남은 뒤에야 실제 위치를 갱신합니다. 크래시 후에는 로그만 다시 보면 되므로 복구 시간이 볼륨 크기가 아니라 저널 크기에 비례하게 됩니다.
JBD2 트랜잭션의 흐름
ext4는 JBD2라는 저널링 계층 위에서 동작합니다. 여러 파일시스템 작업의 메타데이터 변경은 하나의 실행 중 트랜잭션(running transaction)에 모입니다. 커밋 시점이 되면 JBD2는 변경된 블록 목록을 담은 디스크립터 블록과 변경 후 블록 이미지 전체를 저널에 쓰고, 마지막으로 커밋 블록을 써서 “이 트랜잭션은 완료됐다”고 표시합니다. 그 뒤 체크포인트 단계에서 각 블록을 파일시스템의 제자리에 쓰고, 저널 공간을 재사용할 수 있게 합니다.
JBD2는 블록의 변경 후 이미지를 통째로 기록하는 물리적 redo 로그입니다. 변경 전 이미지를 남기는 undo 로그는 없으므로 복구는 항상 “커밋된 것을 다시 쓰는” 한 방향입니다. 재부팅 후 커밋 블록이 없거나 체크섬이 맞지 않는 트랜잭션은 미완료로 보고 버리고, 커밋은 됐지만 체크포인트가 끝나지 않은 트랜잭션은 다시 써서 완료시킵니다. 이 과정이 저널 리플레이입니다.
이 순서가 의미를 가지려면 커밋 블록이 트랜잭션 본문보다 먼저 영구 매체에 도달하면 안 됩니다. 디스크의 휘발성 쓰기 캐시는 요청 순서와 다르게 매체에 기록할 수 있으므로, 파일시스템은 커밋 전후에 캐시 플러시와 FUA(Force Unit Access) 쓰기를 보내 순서를 강제합니다. 예전 문서에서 “배리어(barrier)“라고 부르던 것이 이 메커니즘입니다.
ext4 저널은 파일시스템 전체에 하나이고 트랜잭션도 한 번에 하나씩 커밋됩니다. 그래서 한 프로세스의 fsync가 다른 프로세스가 쌓아 둔 변경까지 함께 커밋해야 하는 상황이 생깁니다. 로그를 대량으로 쓰는 프로세스와 DB가 같은 ext4 볼륨에 있으면, DB의 짧은 fsync가 로그 쪽 더티 데이터를 기다리느라 수백 ms로 튀는 현상이 이런 이유로 나옵니다. 이 경우 스케줄러를 바꾸는 것보다 볼륨을 분리하는 것이 효과적입니다. iotop이나 pidstat -d에서 jbd2/sda1-8 같은 커널 스레드의 I/O 비중이 크게 보인다면 저널 커밋이 병목이라는 신호입니다.
저널 모드: journal, ordered, writeback
ext4의 data= 마운트 옵션은 파일 데이터를 저널과 어떤 관계로 다룰지 정합니다.
data=journal은 메타데이터뿐 아니라 파일 데이터까지 저널에 기록합니다. 모든 데이터가 저널과 제자리에 두 번 쓰이므로 쓰기량이 크게 늘고, 지연 할당도 쓸 수 없습니다. 대신 데이터와 메타데이터가 함께 원자적으로 반영됩니다.
data=ordered는 ext4의 기본값입니다. 데이터는 저널에 넣지 않지만, 어떤 트랜잭션의 메타데이터를 커밋하기 전에 그 메타데이터가 가리키는 데이터 블록을 먼저 디스크에 씁니다. 그래서 크래시 후 파일 크기가 늘어났는데 새 블록에 예전에 지워진 다른 파일의 내용이 보이는 일, 즉 오래된 데이터 노출이 생기지 않습니다.
data=writeback은 메타데이터만 저널링하고 데이터 쓰기 순서는 보장하지 않습니다. 쓰기 지연이 줄 수 있지만, 크래시 후 파일 끝부분에 쓰레기 데이터가 보일 수 있습니다. 기본값을 바꾸려면 이 차이를 감수할 수 있는지와 백업·DB 로그 같은 복구 수단을 함께 검토해야 합니다.
저널 모드와 무관하게 애플리케이션이 알아야 할 점은, 어떤 모드도 “write()가 끝났으니 디스크에 있다”를 보장하지 않는다는 것입니다. 저널은 파일시스템 구조의 일관성을 지킬 뿐이고, 애플리케이션 데이터의 내구성은 fsync를 호출해야 생깁니다. 설정 파일을 안전하게 교체하는 정석은 “임시 파일에 쓰기 → 임시 파일 fsync → rename으로 교체 → 디렉터리 fsync”입니다. ext4가 지연 할당을 도입했을 때 이 순서를 지키지 않고 write 후 바로 rename만 하던 애플리케이션들이 크래시 후 0바이트 파일을 남기는 문제가 널리 보고됐고, ext4는 이 패턴을 감지해 데이터를 먼저 내려 주는 auto_da_alloc 휴리스틱을 기본으로 켜게 됐습니다. 그래도 이것은 안전망일 뿐이고 다른 파일시스템에서는 동작하지 않으므로, 코드에서 fsync를 직접 호출하는 것이 맞습니다.
리플레이와 e2fsck
마운트할 때 커널은 저널을 스캔해 커밋됐지만 체크포인트가 끝나지 않은 트랜잭션을 재생합니다. 보통은 몇 초 안에 끝납니다. 구조적 손상이 의심될 때는 언마운트 상태에서 e2fsck를 돌리는데, 이것은 전체 비트맵과 디렉터리 구조를 검사하므로 볼륨 크기와 파일 수에 비례해 오래 걸립니다. “부팅할 때 fsck가 오래 걸렸다”는 증상은 대개 리플레이가 아니라 이 전체 검사가 돌았다는 뜻입니다.
orphan inode
파일을 지우거나 잘라 내는 작업은 여러 트랜잭션에 걸칠 수 있고, 삭제된 파일도 열어 둔 프로세스가 있으면 inode가 바로 해제되지 않습니다. ext 계열은 이런 inode를 orphan 목록에 올려 두고, 크래시 후 마운트할 때 목록을 따라가 해제나 잘라 내기를 마무리합니다. 덕분에 “삭제 도중 끊김”으로 공간이 영영 새는 일을 막습니다.
XFS 로그와 복구
논리 로그와 지연 로깅
XFS의 로그는 메타데이터 변경만 기록하고 파일 데이터는 저널링하지 않습니다. 내부 로그는 기본적으로 볼륨 가운데쯤의 할당 그룹에 만들어지고, mkfs.xfs -l logdev=...로 외부 디바이스에 둘 수도 있습니다. ext4의 JBD2가 블록 전체 이미지를 기록하는 것과 달리, XFS는 바뀐 객체 단위의 논리적 로그 항목을 기록하고, 여러 트랜잭션의 변경을 메모리에서 모았다가 한 번에 쓰는 지연 로깅(delayed logging)을 기본으로 씁니다. 같은 inode나 디렉터리를 반복해서 바꾸는 대량 파일 생성·삭제 작업에서 로그 쓰기량이 크게 줄어드는 이유입니다.
할당 그룹(AG)
XFS는 볼륨을 여러 할당 그룹(AG)으로 나누고, AG마다 자유 공간과 inode를 별도 B+트리로 관리하며 잠금도 따로 잡습니다. 여러 스레드가 서로 다른 디렉터리에 동시에 파일을 만들 때 XFS가 잘 확장되는 것은 서로 다른 AG에서 할당이 병렬로 진행되기 때문입니다. AG의 크기와 개수는 mkfs.xfs 시점에 정해지고, xfs_growfs는 같은 크기의 AG를 뒤에 덧붙이기만 합니다. 그래서 아주 작게 만든 뒤 크게 키운 볼륨은 작은 AG가 지나치게 많아지는 등, 처음부터 그 크기로 만든 볼륨과 배치가 달라집니다.
리플레이와 xfs_repair
정상 마운트 과정에서 로그 리플레이가 먼저 일어나고, 대부분은 이것으로 일관된 상태가 됩니다. 구조 손상이 의심될 때만 xfs_repair를 고려합니다.
xfs_repair에서 가장 조심할 옵션은 -L입니다. 로그가 손상되어 리플레이가 불가능할 때 로그를 지우고 수선을 진행하는 옵션인데, 로그에 남아 있던 커밋된 메타데이터 변경을 버리는 것이라 최근 작업한 파일이 lost+found로 옮겨지거나 사라질 수 있습니다. 먼저 한 번 마운트해 리플레이를 시도하고, 실패하면 xfs_repair -n(검사만)으로 손상 범위를 확인한 뒤, 가능하면 블록 디바이스 이미지를 떠 두고 나서 -L을 쓰는 순서가 안전합니다.
블록 할당
ext4 다중 블록 할당기
ext4는 블록 그룹마다 비트맵으로 빈 블록을 관리하고, 다중 블록 할당기(mballoc)가 여러 블록을 한 번에 연속 구간으로 잡으려 합니다. 내부적으로 버디(buddy) 비트맵을 만들어 2의 거듭제곱 크기의 빈 구간을 빠르게 찾습니다. 연속 할당이 잘 되면 파일 하나를 표현하는 익스텐트 수가 줄고 그만큼 메타데이터 쓰기도 줄어듭니다. 반대로 작은 랜덤 쓰기가 많거나 디스크가 거의 찼을 때는 적당한 빈 구간을 찾기 위해 여러 그룹을 뒤져야 해서 할당 비용이 커지고 단편화가 늘어납니다.
XFS 자유 공간 B+트리
XFS는 AG마다 자유 공간을 두 개의 B+트리로 관리합니다. 하나는 시작 블록 기준, 하나는 크기 기준이라 “특정 위치 근처의 빈 공간”과 “필요한 크기에 가장 잘 맞는 빈 공간”을 모두 빠르게 찾습니다.
지연 할당
XFS와 ext4(delalloc, 기본값)는 모두 지연 할당을 씁니다. write() 시점에는 공간만 예약하고 물리 블록은 정하지 않았다가, 페이지 캐시가 디스크로 내려갈 때 그동안 모인 크기를 보고 한 번에 연속 구간을 할당합니다. 작은 write를 여러 번 해도 파일이 조각나지 않는 이유입니다. 대신 쓰기가 페이지 캐시에 오래 머물다가 플러시 시점에 큰 배치로 몰려 나가므로, 쓰기 지연이 순간적으로 튀는 모습으로 관측되기도 합니다.
XFS 투기적 프리올로케이션
로그 파일처럼 계속 커지는 파일에 XFS는 파일 크기보다 큰 공간을 미리 잡아 둡니다. 그래서 du로 본 사용량이 ls -l의 파일 크기보다 훨씬 크게 나오곤 합니다. 이 여분은 파일이 닫히거나 백그라운드 정리 작업이 돌면 회수되므로 대부분 걱정할 일은 아니지만, 디스크가 거의 찼을 때 “파일 크기 합계는 여유가 있는데 No space left on device가 난다”는 증상으로 나타날 수 있습니다. 워크로드가 예측 가능하다면 allocsize= 마운트 옵션으로 프리올로케이션 크기를 고정할 수 있습니다.
I/O 스케줄러: blk-mq 이후
단일 큐에서 멀티 큐로
예전 블록 계층은 디바이스마다 요청 큐 하나를 두고 CFQ, deadline, noop 같은 스케줄러가 그 큐를 재정렬했습니다. 모든 CPU가 하나의 큐 잠금을 두고 경쟁하는 구조라, 초당 수십만 IOPS를 내는 NVMe에서는 이 잠금 자체가 병목이 됐습니다. blk-mq는 CPU별 소프트웨어 큐를 디바이스의 하드웨어 큐에 대응시켜 이 경쟁을 없앴고, 커널 5.0에서 레거시 단일 큐 경로와 CFQ·deadline·noop이 제거되면서 blk-mq만 남았습니다. 오래된 튜닝 문서의 elevator=deadline 부트 파라미터나 cfq 설정은 현재 커널에는 적용되지 않습니다. blk-mq에서 고를 수 있는 스케줄러는 none, mq-deadline, bfq, kyber입니다.
none
커널이 요청을 거의 재정렬하지 않고 디바이스로 넘깁니다. NVMe처럼 내부 병렬성이 크고 자체 큐가 깊은 디바이스에서는 커널이 순서를 바꾸고 인접 요청을 합치는 이득보다 그 비용이 더 클 수 있어, 흔히 기본값이 됩니다.
mq-deadline
읽기와 쓰기를 따로 모으고, 요청마다 만료 시각을 둡니다(기본값은 읽기 500ms, 쓰기 5초). 평소에는 섹터 순서로 모아서 내보내다가, 만료된 요청이 생기면 그것을 먼저 처리해 특정 요청이 계속 밀리는 기아를 막습니다. 읽기를 쓰기보다 우대하므로 쓰기가 많은 중에도 읽기 지연이 덜 튀는 경향이 있고, 회전 디스크와 SATA SSD의 기본값으로 많이 쓰입니다.
BFQ
프로세스와 cgroup마다 예산(budget)을 두고 대역폭을 공정하게 나누려는 스케줄러입니다. 대용량 복사가 도는 동안에도 대화형 프로그램의 응답성을 지키는 데 강해서 데스크톱에서 의미가 크지만, 요청당 CPU 비용이 다른 스케줄러보다 커서 고속 NVMe나 지연에 민감한 서버에서는 오히려 손해일 수 있습니다.
확인과 변경
cat /sys/block/sda/queue/scheduler
# 출력 예: [mq-deadline] kyber bfq none (대괄호가 현재 선택)
# 즉시 변경 (재부팅하면 원래대로)
echo none | sudo tee /sys/block/nvme0n1/queue/scheduler
# 영구 적용: udev 규칙 (/etc/udev/rules.d/60-ioscheduler.rules)
# ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none"
# ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="mq-deadline"
커널은 기본적으로 하드웨어 큐가 하나인 디바이스에는 mq-deadline을, 여러 개인 디바이스에는 none을 고르고, 배포판이 udev 규칙으로 이를 조정하기도 합니다. 가상 머신 안의 virtio 디스크(vda)는 하이퍼바이저 쪽 스토리지가 이미 재정렬을 하므로 게스트에서 none을 쓰는 경우가 많습니다. 스케줄러는 LVM이나 dm-crypt 같은 상위 가상 디바이스가 아니라 실제 하위 디바이스에 걸어야 의미가 있다는 점도 자주 놓칩니다.
변경 효과는 iostat -x의 r_await, w_await, aqu-sz와 /proc/diskstats, 필요하면 blktrace나 bpftrace의 지연 분포로 전후를 비교합니다. 디바이스 지표가 좋아져도 애플리케이션 지연이 그대로라면 병목이 다른 곳에 있다는 뜻이므로, 마지막에는 반드시 애플리케이션 쪽 지연 지표로 확인합니다.
운영 패턴
볼륨 분리
/, /var/log, DB 데이터, 백업 스테이징을 같은 볼륨에 두면 로그 폭주나 백업 쓰기가 앞에서 설명한 저널 공유 효과를 통해 DB의 fsync 지연으로 번집니다. 지연에 민감한 데이터는 별도 파일시스템에 두는 것이 가장 확실한 격리입니다. ext4는 저널을 외부 디바이스에 둘 수도 있지만(mke2fs -O journal_dev), 구성과 장애 처리가 복잡해져 볼륨 분리보다 먼저 고려할 일은 아닙니다.
마운트 옵션
noatime은 읽을 때마다 접근 시각을 갱신하는 메타데이터 쓰기를 없앱니다. 현재 커널의 기본값인 relatime은 이미 대부분의 갱신을 생략하므로 차이가 크지 않은 경우도 많고, 접근 시각이 감사 요건에 쓰인다면 정책과 충돌하지 않는지 확인해야 합니다.
nobarrier는 앞에서 설명한 캐시 플러시를 끄는 옵션입니다. ext4는 플러시가 기본(barrier=1)이고, XFS는 커널 4.19에서 barrier·nobarrier 옵션 자체를 제거했습니다. 오래된 튜닝 가이드의 nobarrier는 배터리 백업 캐시가 있는 RAID 컨트롤러를 전제로 한 설정이라, 일반 서버나 클라우드 디스크에서 따라 하면 전원 장애 시 파일시스템이 손상될 수 있습니다.
TRIM과 용량 관리
SSD에 삭제된 블록을 알려 주는 TRIM은 discard 마운트 옵션으로 삭제할 때마다 보낼 수도 있고, fstrim.timer로 주기적으로 모아서 보낼 수도 있습니다. 연속 discard는 디바이스에 따라 삭제 작업의 지연을 늘릴 수 있어, 많은 배포판이 주기적 fstrim을 기본으로 씁니다.
XFS는 xfs_growfs로 온라인 확장이 가능하지만 축소는 지원하지 않습니다. ext4는 resize2fs로 확장(온라인)과 축소(언마운트 상태)가 모두 가능하므로, 볼륨을 나중에 줄일 가능성이 있다면 이 차이가 파일시스템 선택 기준이 되기도 합니다. XFS라면 처음부터 크게 잡기보다 필요한 만큼 만들고 LVM으로 키워 가는 방식이 맞습니다.
모니터링
블록 여유는 node_filesystem_avail_bytes, inode 여유는 node_filesystem_files_free처럼 따로 봐야 합니다. 블록은 남았는데 inode가 고갈되는 경우가 있기 때문입니다. I/O는 디바이스별 대기 시간과 큐 길이를 보되, %util은 병렬 처리하는 SSD·NVMe에서 100%에 가까워도 포화가 아닐 수 있으므로 단독 지표로 쓰지 않습니다. 용량과 inode 고갈을 진단하는 순서는 트러블슈팅 글에서 다룹니다.