스토리지 I/O 최적화: 캐시 정책과 NVMe 운영
페이지 캐시와 Direct I/O, Write-Back·Write-Through, NVMe 큐 구조를 바탕으로 스토리지 I/O 병목을 분석하고 튜닝하는 방법을 정리한다.
2026-08-14 · 최초 발행 2026-01-16
CPU와 디스크 사이에 놓인 계층
스토리지 I/O는 느린 디스크와 빠른 CPU·메모리 사이의 격차를 다루는 문제다. 캐시는 자주 쓰는 데이터를 메모리에 남겨 디스크 접근을 줄이고, 쓰기 정책은 성능과 안정성의 균형을 결정한다. NVMe는 병렬성과 낮은 지연 시간을 제공해 이 경로의 하드웨어 제약도 바꿨다.
| 계층 | 접근 시간 | 용량 | 비용 |
|---|---|---|---|
| 레지스터 | 1 사이클 (0.3ns) | 수백 바이트 | 매우 높음 |
| L1/L2/L3 캐시 | 1-20ns | KB-MB | 높음 |
| 메인 메모리 (RAM) | 50-100ns | GB-TB | 중간 |
| SSD | 10-100μs | TB | 중간 |
| HDD | 5-10ms | TB | 낮음 |
캐시는 지역성에 기대어 작동한다. 시간 지역성은 최근에 접근한 데이터가 다시 필요할 가능성이 높다는 특성이고, 공간 지역성은 인접 데이터가 연달아 읽힐 가능성이 높다는 특성이다. 전자는 캐시 유지의 근거가 되고, 후자는 프리페칭과 Read-Ahead에 활용된다.
캐시를 통과하는 읽기 경로
페이지 캐시는 운영체제가 관리하는 디스크 캐시다. 읽기와 쓰기 데이터를 메모리에 보관하고, 남는 메모리를 캐시로 활용한다. 메모리가 부족해지면 LRU 정책으로 항목을 제거한다.
// 페이지 캐시 동작
읽기 요청 → 페이지 캐시 확인 (Cache Hit)
→ 캐시에 있으면 즉시 반환
→ 캐시에 없으면 디스크에서 읽기 (Cache Miss)
→ 페이지 캐시에 저장
→ 애플리케이션에 반환
버퍼 캐시는 블록 디바이스 메타데이터를 캐싱한다. 파일 시스템의 슈퍼블록, inode, 디렉터리 엔트리가 대상이며, 최신 Linux에서는 페이지 캐시에 통합돼 있다.
캐시가 가득 찼을 때는 교체 정책이 필요하다. LRU는 가장 오래 사용되지 않은 항목을 제거해 시간 지역성을 활용하지만 구현 복잡도가 높다. LFU는 접근 빈도가 낮은 항목을 제거해 장기적인 접근 패턴을 본다. Clock은 참조 비트를 쓰는 LRU 근사 알고리즘으로, 구현이 단순하고 오버헤드가 낮다. ARC는 최근성과 빈도를 함께 고려하며 ZFS와 PostgreSQL에서 사용된다.
순차 읽기에서는 다음에 필요할 데이터를 미리 캐시에 적재하는 Read-Ahead가 효과적이다. 이는 공간 지역성을 활용하며 Linux의 readahead 시스템에서 사용된다.
# Read-ahead 크기 설정
sudo blockdev --setra 256 /dev/sda
반대로 데이터베이스처럼 애플리케이션이 자체 캐시를 관리한다면 페이지 캐시를 우회하는 Direct I/O를 선택할 수 있다. 이때는 O_DIRECT 플래그를 사용한다.
// Direct I/O 예시
int fd = open("/path/to/file", O_RDWR | O_DIRECT);
파일을 가상 메모리에 매핑하는 mmap도 읽기와 쓰기 경로를 바꾼다. read()와 write() 대신 메모리 접근으로 처리하고, 페이지 폴트가 디스크 읽기를 수행한다. 대용량 파일 처리에 효율적이다.
// mmap 예시
void *addr = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
쓰기 응답 시점이 바꾸는 안정성
Write-Through는 데이터를 캐시와 디스크에 함께 기록한 뒤 쓰기 완료를 응답한다. 캐시와 디스크가 항상 동기화되고 전원 장애 시 데이터 손실이 없으며 구현도 단순하다. 다만 쓰기 성능은 디스크 속도에 제한되고, 디스크 수명이 단축될 수 있다.
Write-Back은 우선 캐시에만 쓰고 즉시 완료를 반환한다. 이후 Flush 과정에서 디스크에 비동기로 기록한다. 여러 쓰기를 묶어 처리하는 Write Coalescing이 가능하고 CPU 및 애플리케이션 지연을 줄일 수 있다. 대신 전원 장애 시 데이터 손실 위험이 있으며, 캐시와 디스크가 일시적으로 불일치하므로 관리가 복잡해진다.
Write-Back에서 수정된 페이지는 Dirty 상태로 표시된다. 시스템은 주기적으로 또는 임계값에 도달했을 때 이를 디스크로 Flush하며, Linux에서는 pdflush/kworker 스레드가 관련 작업을 수행한다.
# Dirty page 설정 확인
cat /proc/sys/vm/dirty_ratio # 전체 메모리 대비 dirty page 비율
cat /proc/sys/vm/dirty_background_ratio # 백그라운드 flush 시작 비율
cat /proc/sys/vm/dirty_writeback_centisecs # flush 간격 (1/100초)
쓰기 순서를 보장하는 배리어는 저널링 파일 시스템에서 특히 중요하다. 메타데이터의 일관성을 유지하기 위해 fsync()와 fdatasync() 시스템 콜을 사용한다.
// 파일 동기화
write(fd, buffer, size);
fsync(fd); // 커널 버퍼를 디스크로 강제 Flush
저널링은 기록 방식에 따라 특성이 다르다. 메타데이터 저널링은 메타데이터만 저널에 기록하며 ext3와 ext4의 기본 모드다. 데이터 저널링은 메타데이터와 데이터를 모두 기록하므로 가장 안전하지만 성능이 저하되고, ext4의 data=journal 모드가 이에 해당한다. Ordered 방식은 메타데이터만 저널링하되 데이터 쓰기 뒤에 메타데이터를 쓴다. 역시 ext4 기본 모드다.
NVMe가 바꾼 I/O 병렬성
SATA는 600MB/s 대역폭 제한, 단일 명령 큐, 레거시 프로토콜 오버헤드, 회전 디스크 기반 설계라는 제약을 갖는다. NVMe는 PCIe 인터페이스를 사용해 수 GB/s 대역폭을 제공하고, 최대 64K개의 I/O 큐와 큐당 64K개의 명령을 지원한다. 지연 시간은 10μs 이하로 낮다.
NVMe의 다중 큐 구조에서는 CPU 코어마다 독립적인 I/O 큐를 두고 Lock-free 병렬 처리를 수행한다. 컨텍스트 스위칭도 최소화한다. 프로토콜은 명령 오버헤드를 줄이고 하드웨어 가속과 인터럽트 감소를 지원한다. DMA는 CPU 개입을 최소화해 Zero-copy 데이터 전송을 가능하게 한다.
// NVMe 명령 흐름
애플리케이션 → I/O 요청
→ 큐 선택 (Per-CPU Queue)
→ Submission Queue에 명령 추가
→ Doorbell 레지스터 업데이트
→ NVMe 컨트롤러 처리
→ Completion Queue에 결과 추가
→ 인터럽트 또는 폴링으로 완료 확인
| 항목 | SATA SSD | NVMe SSD |
|---|---|---|
| IOPS | 10만 IOPS | 100만 IOPS 이상 |
| 지연 시간 | 50-100μs | 10-20μs |
| 대역폭 | 600MB/s | PCIe 3.0 x4: 3200MB/s |
| 대역폭 | 600MB/s | PCIe 4.0 x4: 7000MB/s |
NVMe SSD는 SATA SSD보다 10배 이상의 IOPS 성능 차이를 보이며, 지연 시간은 1/5 수준이다.
NVMe-oF는 네트워크를 통해 NVMe에 접근하는 방식이다. RDMA, TCP, Fibre Channel을 지원하며 로컬 NVMe와 유사한 성능으로 데이터센터 스토리지를 통합한다.
HDD에 맞춰진 전통적 스케줄러에는 CFQ(Completely Fair Queuing), Deadline, NOOP(No Operation)가 있다. NVMe에서는 none으로 스케줄링 없이 직접 전달하거나, 멀티 큐 데드라인을 지원하는 mq-deadline, QoS를 보장하는 BFQ(Budget Fair Queueing)를 선택할 수 있다.
# I/O 스케줄러 확인
cat /sys/block/nvme0n1/queue/scheduler
# 스케줄러 변경
echo none > /sys/block/nvme0n1/queue/scheduler
SSD와 비동기 I/O를 함께 다루기
TRIM/DISCARD는 삭제된 블록을 SSD에 알리고 가비지 컬렉션을 지원한다. 쓰기 증폭(Write Amplification)을 줄이고 SSD 수명을 늘리는 데 쓰인다.
# TRIM 지원 확인
sudo hdparm -I /dev/sda | grep TRIM
# 수동 TRIM 실행
sudo fstrim -v /
Over-Provisioning은 SSD 용량 일부를 예비 공간으로 유지하는 방식이다. 가비지 컬렉션 성능과 쓰기 증폭에 영향을 주며, 일반적으로 7-28%의 예비 공간을 둔다.
Wear Leveling은 특정 셀의 과도한 사용을 막도록 SSD 셀의 마모를 고르게 분산한다. 컨트롤러가 자동으로 관리한다.
io_uring은 Submission/Completion Queue 기반의 최신 Linux 비동기 I/O 인터페이스다. 시스템 콜을 최소화하며 NVMe와 궁합이 좋다.
// io_uring 개념
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// I/O 요청 제출
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buffer, size, offset);
io_uring_submit(&ring);
// 완료 확인
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
파일 시스템도 I/O 특성에 영향을 준다. ext4는 익스텐트(Extent) 기반 할당, 지연 할당(Delayed Allocation), 멀티 블록 할당을 사용한다. XFS는 대용량 파일 시스템으로 병렬 I/O와 온라인 조각 모음을 지원한다. Btrfs는 Copy-on-Write(CoW), 스냅샷 및 클론, 데이터 무결성 검증을 제공한다. ZFS에는 ARC/L2ARC 캐싱, 압축 및 중복 제거, RAID-Z가 있다.
관찰한 뒤 조정할 파라미터
I/O 상태는 장치별 지표와 지연 시간을 함께 봐야 한다. 다음 도구로 통계, 디스크 활동, 블록 I/O, 지연 시간, 벤치마크를 확인할 수 있다.
# I/O 통계 확인
iostat -x 1
# 디스크 활동 모니터링
iotop
# 블록 I/O 추적
blktrace /dev/nvme0n1
# I/O 지연 시간 분석
ioping /dev/nvme0n1
# fio 벤치마크
fio --name=randread --ioengine=libaio --iodepth=16 --rw=randread --bs=4k --direct=1 --size=1G --numjobs=4 --runtime=60 --group_reporting
스케줄러, Read-Ahead 크기, 큐 깊이, Dirty Page 정책은 조정 대상이다.
# I/O 스케줄러 최적화
echo none > /sys/block/nvme0n1/queue/scheduler
# Read-ahead 크기 조정
blockdev --setra 8192 /dev/nvme0n1
# 큐 깊이 증가
echo 1024 > /sys/block/nvme0n1/queue/nr_requests
# Dirty page 정책 조정
sysctl -w vm.dirty_ratio=40
sysctl -w vm.dirty_background_ratio=10
병목 분석은 IOPS와 지연 시간을 기준으로 이어갈 수 있다.
워크로드별로 달라지는 저장소 선택
데이터베이스에서는 Direct I/O로 데이터 파일에 접근하고, Write-Ahead Logging(WAL)을 사용하며, 테이블스페이스를 NVMe에 배치할 수 있다. 버퍼 풀 크기도 조정 대상이다.
가상화 환경에서는 VM 디스크를 NVMe에 저장하고 Virtio-blk/Virtio-scsi 드라이버를 사용한다. I/O 스케줄러는 none으로 설정하고 TRIM/DISCARD 전달을 활성화할 수 있다.
컨테이너 환경에서는 Overlay2 파일 시스템을 사용하고, 볼륨을 NVMe에 마운트할 수 있다. 임시 데이터는 tmpfs에 저장하며 로그는 별도 볼륨으로 분리한다.