1바이트의 생애주기: 유저의 write()가 저장장치에 닿기까지
오늘의 질문
저장장치 쪽 일을 하다 보면 이런 장면을 자주 본다. 장치에는 크고 가지런한 순차 요청이 도착하는데, 정작 그 위에서 돌아가는 애플리케이션은 몇 바이트짜리 쓰기를 아무 때나 날리고 있다. 여기서 질문이 생긴다.
유저는 분명 제멋대로 쓰는데, 왜 장치에는 정돈된 요청이 도착하는가? 그 사이에서 누가 무엇을 하고 있는가?
그리고 이 질문은 실무적으로도 중요하다. 애플리케이션이 write()를 호출한 시점과 장치에 요청이 도착하는 시점은 같지 않다. 이 둘을 같은 것으로 놓고 성능을 분석하면 답이 안 나온다. 이 글은 그 사이에 놓인 계층들을 하나씩 따라간다.
먼저 답하기
두 문장으로 줄이면 이렇다.
유저의 요청은 커널을 지나면서 여러 번 다른 자료구조로 번역되고, 도중에 한참 머물렀다가, 이웃한 것들과 합쳐진 뒤에야 장치에 도착한다. 각 계층은 아래로 갈수록 파일이라는 개념을 벗고 하드웨어에 가까운 표현으로 바꾼다.
정돈된 요청이 도착하는 이유는 두 계층 덕분이다. 페이지 캐시가 시간을 벌어 여러 쓰기를 모아주고, I/O 스케줄러가 인접한 것들을 하나로 합친다. 펌웨어가 보는 깔끔한 순차 I/O는 이 두 계층이 만들어준 결과물이다.
주제에 맞는 핵심 설명
전체 조감도
먼저 지도를 펼쳐놓고 시작하자.
이 그림이 없으면 “계층이 여러 개”라는 말이 몇 개이고 각각 무엇을 바꾸는지 알 수 없다. 오른쪽 열을 세로로 훑어보면 핵심이 보인다. 같은 데이터가 계층마다 다른 이름으로 불린다. 위에서는 파일 디스크립터와 버퍼였다가, 중간에서 LBA가 되고, 아래에서는 장치 커맨드가 된다.
이제 하나씩 내려가 본다.
계층 1: 유저 스페이스와 VFS — 진입점
유저 프로세스는 디스크에 직접 접근할 수 없다. 접근하려면 커널 모드로 전환해서 커널에 일을 위임해야 하고, 그 전환점이 시스템 콜이다.
write(fd, buf, len)을 호출하면 세 가지가 커널로 넘어간다. 어느 파일인지(파일 디스크립터), 어디에 있는 데이터인지(버퍼 주소), 얼마나인지(길이)다. 여기서 넘어가는 것은 데이터 자체가 아니라 데이터가 있는 위치라는 점이 중요하다.
커널 쪽 첫 관문이 VFS(Virtual File System, 가상 파일 시스템)다. VFS는 실제 파일 시스템이 ext4든 F2FS든 FAT이든 같은 인터페이스로 다룰 수 있게 해주는 추상 계층이다. 애플리케이션이 파일 시스템 종류를 몰라도 되는 이유가 이것이다. VFS는 실제 처리를 아래 파일 시스템에 위임한다.
계층 2: 페이지 캐시 — 여기서 대부분 끝난다
여기가 이 글에서 가장 중요한 계층이다.
리눅스 커널 문서는 페이지 캐시를 유저와 커널 나머지 부분이 파일 시스템과 상호작용하는 기본 통로로 설명하며, O_DIRECT 같은 방법으로 우회할 수는 있지만 일반적인 읽기와 쓰기, mmap은 모두 페이지 캐시를 거친다고 명시한다.
무슨 일이 일어나는가. write()를 호출하면 커널은 유저 버퍼의 내용을 페이지 캐시의 페이지로 복사하고, 그 페이지를 더티(dirty)로 표시한 뒤 바로 반환한다. 더티란 “메모리의 내용이 디스크의 내용과 달라졌다”는 표시다. 이 시점에 디스크는 아무것도 모른다.
여기서 세 가지 결과가 따라 나온다.
첫째, write()는 대체로 빠르다. 디스크 속도가 아니라 메모리 복사 속도를 재고 있기 때문이다. 쓰기 성능 벤치마크가 실제 장치 성능보다 좋게 나오는 흔한 이유다.
둘째, 같은 자리를 여러 번 고쳐도 디스크 쓰기는 늘지 않는다. 페이지가 이미 더티 상태이므로 몇 번을 수정하든 나중에 한 번 내려보내면 된다. 앞선 글에서 다룬 파일 시스템 메타데이터처럼 같은 자리가 자주 갱신되는 데이터에서 이 효과가 크다. 페이지 캐시는 그 자체로 쓰기 감소 장치이기도 하다.
셋째, 이 상태에서 전원이 끊기면 내용이 사라진다. 애플리케이션은 write()가 성공했다고 받았는데 디스크에는 없는 상황이다. 앞선 글에서 저널링을 다룰 때 나온 문제가 여기서 시작된다. 그래서 정말 디스크에 남겨야 한다면 fsync() 같은 호출로 명시적으로 밀어붙여야 한다.
언제 실제로 내려가는가. 크게 세 가지 계기가 있다.
| 계기 | 무슨 일이 일어나는가 | 애플리케이션이 느끼는 것 |
|---|---|---|
| 더티 비율이 배경 임계치를 넘음 | 백그라운드 플러셔 스레드가 깨어나 쓰기 시작 | 영향 없음, 계속 쓸 수 있다 |
| 더티 비율이 상한을 넘음 | 커널이 쓰는 프로세스 자체를 붙잡는다 | write()가 갑자기 느려진다 |
| 시간 경과 또는 명시적 요청 | 오래된 더티 페이지부터 내려보냄, fsync는 즉시 | fsync는 완료까지 대기 |
두 번째 줄이 실무에서 자주 부딪히는 현상이다. 평소 빠르던 write()가 특정 순간부터 급격히 느려진다면, 대개 더티 페이지가 상한에 닿아 커널이 브레이크를 건 것이다.
저장장치 관점에서 이 계층이 뜻하는 바는 분명하다. 유저의 요청 시점과 장치의 요청 도착 시점 사이에 상당한 간격이 있다. 그리고 그 사이에 여러 쓰기가 뭉쳐진다. 장치에서 관측한 I/O 패턴을 애플리케이션 동작으로 곧바로 되짚으면 어긋나는 이유다.
계층 3: 파일 시스템 — 오프셋을 블록 번호로
페이지 캐시의 내용이 내려갈 때가 되면 파일 시스템이 나선다.
파일 시스템의 가장 핵심적인 역할은 매핑이다. 유저가 인식하는 “파일 안에서 몇 번째 바이트”를 디스크가 인식하는 LBA(Logical Block Address, 논리 블록 주소)로 바꾼다.
파일 오프셋 4096번 → [파일 시스템의 매핑 알고리즘] → LBA 12345번
앞선 글에서 FAT을 다룰 때 본 것이 정확히 이 일이다. 디렉터리 엔트리에서 시작 클러스터 번호를 얻고 FAT 사슬을 따라가 실제 위치를 찾는 과정이 이 매핑의 한 구현이다. ext4든 F2FS든 자료구조는 다르지만 하는 일은 같다.
여기서 짚어둘 대칭이 있다. 앞선 글에서 본 SSD 펌웨어의 FTL도 매핑을 한다. 다만 층이 다르다. 파일 시스템은 파일 오프셋 → LBA를 맡고, FTL은 LBA → 물리 위치를 맡는다. 같은 데이터가 두 번 번역되는 셈이고, 두 매핑이 서로를 모른다는 점이 앞선 글에서 다룬 TRIM이나 zoned storage 논의의 배경이 된다.
계층 4: 제네릭 블록 계층 — 파일이 사라지는 곳
파일 시스템을 거친 요청은 struct bio라는 커널 내부 구조체로 바뀐다. 모든 블록 장치에 공통으로 쓰이는 I/O 표현이다.
이 단계부터 “파일”이라는 개념이 완전히 사라진다. 남는 것은 세 가지뿐이다.
- 어느 LBA에
- 어느 메모리 주소의 데이터를
- 얼마만큼 쓸 것인가
파일 이름도, 디렉터리도, 권한도 여기엔 없다. 펌웨어 엔지니어가 보는 세계와 같은 어휘가 여기서부터 시작된다.
계층 5: I/O 스케줄러 — 합치고 줄 세우기
이 계층이 “왜 장치에는 정돈된 요청이 도착하는가”에 대한 직접적인 답이다.
리눅스 커널의 블록 계층 문서는 이 과정을 이렇게 설명한다. 요청들은 struct bio 형태로 블록 계층에 도착하고, 블록 계층은 이것으로 드라이버와 통신하기 위한 struct request를 만든다. 그리고 대기 큐에서 인접한 섹터에 대한 요청들을 병합한다. 문서가 드는 예시가 명확하다. 섹터 3–6, 6–7, 7–9에 대한 요청이 섹터 3–9에 대한 하나의 요청이 될 수 있다. 같은 문서는 이 병합 기법을 플러깅(plugging)이라 부른다고 밝힌다.
병합이 왜 이득인가. 같은 문서는 SSD나 NVM에서는 랜덤 접근과 순차 접근의 응답 시간이 같더라도 순차 접근으로 묶으면 개별 요청의 개수가 줄어든다는 점을 든다. 숫자로 보면 이렇다.
가정 — 4 KiB짜리 bio 8개가 연속된 섹터를 가리키는 상황.
| 항목 | 병합 전 | 병합 후 |
|---|---|---|
| 장치로 나가는 요청 수 | 8개 | 1개 |
| 전송되는 데이터 양 | 32 KiB | 32 KiB (동일) |
| 완료 인터럽트 | 8회 | 1회 |
| 요청당 크기 | 4 KiB | 32 KiB |
전송량은 그대로인데 부대 비용만 8분의 1로 줄어든다. 명령을 만들고, 큐에 넣고, 완료를 처리하는 비용이 요청 개수에 비례하기 때문이다.
적용 한계 — 병합은 요청이 실제로 인접할 때만 가능하다. 흩어진 쓰기는 합쳐지지 않는다. 그리고 병합 크기에는 하드웨어가 정한 상한이 있어서 무한정 커지지 않는다. 커널 문서는 또한 병합과 스케줄링이 같은 큐 안에서만 일어난다고 명시한다. 다른 큐에 들어간 요청끼리는 합쳐지지 않는다.
I/O 스케줄러는 병합 외에 재정렬도 한다. 커널 문서는 그 목적을 두 가지로 든다. 특정 애플리케이션이 굶지 않도록 자원 배분의 공정성을 지키는 것, 그리고 I/O 성능을 개선하는 것이다. 다만 문서는 아무 재정렬도 하지 않는 NONE 스케줄러도 선택지로 있다고 밝힌다. 회전 지연이 없는 장치에서는 재정렬의 이득이 작고 오히려 지연만 더할 수 있기 때문이다.
여기가 이 글의 핵심 대목이다. 펌웨어 엔지니어가 크고 순차적인 요청을 받는 것은 유저가 그렇게 써서가 아니다. 이 계층이 만들어준 것이다. 그리고 뒤집어 말하면, 이 계층이 합칠 수 없는 패턴으로 유저가 쓰면 장치에는 자잘한 요청이 그대로 도착한다.
계층 6: 드라이버와 인터럽트 — 요청과 완료의 분리
마지막 계층에서 표준 요청 구조체가 장치 종속적인 커맨드로 바뀐다. 같은 struct request라도 어떤 컨트롤러냐에 따라 다른 형태의 명령이 된다.
여기서 중요한 것은 비동기 처리다. 커널 문서는 submit_bio() 함수가 반환되었다고 해서 처리가 끝났다는 보장은 없다고 명시한다. 완료를 기다리려면 별도의 함수를 쓰거나, 구조체의 완료 콜백 필드에 함수를 지정해두어야 한다.
동작 방식은 이렇다. 드라이버는 요청 큐를 관리한다. 커널은 요청을 큐에 넣고 곧바로 돌아간다. 장치가 작업을 끝내면 인터럽트를 발생시키고, 그 인터럽트를 받은 드라이버가 큐에서 다음 보류 요청을 꺼내 내보낸다.
이 구조 덕분에 여러 요청이 동시에 진행될 수 있다. 하나가 끝나기를 기다리며 시스템 전체가 멈춰 있지 않다. 저장장치가 내부적으로 여러 채널을 병렬로 돌릴 수 있는 것도 위에서 요청이 이렇게 겹쳐 들어오기 때문이다.
종합: 1바이트가 겪는 일
지금까지의 계층을 한 줄로 이으면, 1바이트를 쓰겠다는 요청이 실제로는 얼마나 큰 일이 되는지가 보인다.
가정 — 페이지 캐시 페이지 4 KiB, 파일 시스템 블록 4 KiB, 논리 섹터 512 B, NAND 프로그램 단위 16 KiB. 해당 페이지가 캐시에 없어 새로 가져오는 상황.
| 계층 | 이 계층이 다루는 단위 | 1바이트 요청이 갖는 크기 |
|---|---|---|
| 유저 요청 | 바이트 | 1 B |
| 페이지 캐시 | 페이지 | 4 KiB (페이지 하나가 통째로 더티) |
| 파일 시스템 | 블록 | 4 KiB |
| 블록 계층 | 섹터 | 512 B × 8 = 4 KiB |
| NAND | 페이지 | 16 KiB |
논리 요청 1 B → 물리 프로그램 16,384 B
배율 = 16,384 배
적용 한계 — 위 배율은 “한 바이트만 쓰고 아무것도 더 안 쓴다”는 최악의 경우다. 실제로는 페이지 캐시가 인접한 쓰기를 흡수하므로 이 정도까지 가는 일이 흔하지는 않다. 각 단위 크기도 시스템 구성에 따라 다르다. 이 표의 의미는 정확한 배율이 아니라 계층마다 최소 단위가 있고, 그것이 곱해지며 커진다는 구조다.
그리고 이 표를 반대 방향으로 읽으면 최적화의 방향도 나온다. 각 계층의 단위에 맞춰 쓰면 이 증폭이 줄어든다. 앞선 글에서 다룬 정렬과 클러스터 크기 선택이 정확히 이 이야기였다.
정리하면 유저의 1바이트는 이런 여정을 거친다. 시스템 콜로 커널에 들어가고, 페이지 캐시에서 4 KiB 페이지의 일부가 되어 한참 머물고, 파일 시스템에서 LBA를 부여받고, struct bio가 되면서 파일이라는 신분을 잃고, 이웃들과 병합되어 더 큰 요청이 되고, 장치 커맨드로 번역되어 큐에 들어갔다가, 인터럽트와 함께 완료를 통보받는다.
펌웨어가 보는 것은 이 여정의 마지막 한 걸음뿐이다. 그 앞에서 무슨 일이 있었는지 알아야 장치에 도착한 패턴을 제대로 해석할 수 있다.
자주 묻는 질문
장치에서 관측한 I/O 패턴으로 애플리케이션 동작을 추정할 수 있나요? 직접적으로는 어렵다. 위에서 봤듯 사이에 페이지 캐시와 병합이 끼어 있기 때문이다. 애플리케이션이 4 KiB씩 여덟 번 썼는데 장치에는 32 KiB 한 번으로 보일 수 있고, 반대로 애플리케이션이 아무것도 안 하는 순간에 백그라운드 플러셔 때문에 대량 쓰기가 도착할 수도 있다. 읽기 쪽도 마찬가지다. 캐시에 있으면 장치까지 내려오지 않으므로 애플리케이션의 읽기 요청 중 일부만 관측된다. 상관관계를 보려면 애플리케이션 쪽과 장치 쪽을 함께 계측하고, 그 사이에 커널 계층의 추적을 끼워 맞추는 편이 정확하다.
write()가 갑자기 느려지는 현상은 왜 생기나요? 가장 흔한 원인은 더티 페이지가 상한에 닿은 경우다. 위 표의 두 번째 줄에 해당하며, 커널이 쓰는 프로세스를 직접 붙잡아 더티가 줄어들 때까지 대기시킨다. 이때 프로세스는 I/O 대기 상태로 보인다. 특징은 평소에는 아주 빠르다가 어느 순간 급격히 느려지는 패턴이라는 점이다. 지속적으로 대량 쓰기를 하는 워크로드에서 잘 나타난다. 확인하려면 시스템의 더티 페이지 양과 writeback 진행 상황을 함께 보면 되고, 근본적으로는 장치의 쓰기 처리량이 애플리케이션의 쓰기 속도를 못 따라간다는 신호다.
O_DIRECT를 쓰면 항상 빨라지나요? 아니다. 페이지 캐시를 건너뛰므로 복사가 줄고 메모리 압박도 줄지만, 캐시가 주던 이득이 함께 사라진다. 같은 자리를 여러 번 고쳐도 흡수해주지 않고, 읽을 때 캐시 히트도 없다. 그래서 일반적인 애플리케이션에는 오히려 손해인 경우가 많다. 유리한 쪽은 자체 캐싱 계층을 이미 갖춘 경우다. 데이터베이스가 대표적인데, 자기 버퍼 풀과 운영체제 캐시가 같은 내용을 두 벌 들고 있는 낭비를 피하려는 것이다. 또 O_DIRECT는 버퍼 주소와 크기에 정렬 제약이 있어서 아무 코드에나 붙일 수 있는 옵션도 아니다.
병합이 잘 안 되는 워크로드는 어떻게 알아볼 수 있나요?
장치에 도착하는 평균 요청 크기를 보면 대체로 드러난다. 애플리케이션이 쓰는 총량에 비해 요청 개수가 지나치게 많고 요청당 크기가 작다면 병합이 이루어지지 않고 있다는 뜻이다. 원인은 대개 두 가지다. 쓰는 위치가 물리적으로 흩어져 있어 인접 조건이 성립하지 않거나, fsync() 같은 호출을 너무 자주 불러 모을 시간을 주지 않는 경우다. 후자는 신뢰성을 위해 일부러 그러는 경우가 많으므로, 어디까지 모아도 되는지를 먼저 정하는 것이 순서다.
펌웨어 쪽에서 이 스택을 알아야 하는 실질적인 이유가 뭔가요? 장치에 도착한 요청의 성격을 해석할 수 있게 되기 때문이다. 예를 들어 크고 순차적인 쓰기가 몰려 들어온다면 그것은 유저가 그렇게 썼다기보다 writeback이 한꺼번에 내려온 것일 가능성이 높다. 반대로 자잘한 요청이 흩어져 도착한다면 병합이 실패했거나 캐시를 우회하는 경로일 수 있다. 그리고 앞선 글에서 다룬 정렬이나 TRIM 같은 최적화가 어느 계층에서 결정되는지도 이 지도를 갖고 있어야 짚을 수 있다. 장치 쪽 튜닝만으로는 닿지 않는 문제가 어디서 오는지 아는 것이, 위아래를 함께 보는 작업의 출발점이다.