- Published on
Zero-Copy & Linux I/O 완전 가이드 2025: sendfile, splice, mmap, Page Cache, O_DIRECT — Kafka/Nginx/Netflix 실전 분석
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 왜 Kafka는 그토록 빠른가?
놀라운 벤치마크
Apache Kafka는 단일 브로커로 초당 2백만 메시지를 처리할 수 있다. Netflix의 Open Connect Appliance(OCA)는 단일 x86 서버에서 200Gbps의 동영상 스트리밍을 뿜어낸다. Nginx는 단일 인스턴스로 수십만 동시 연결을 유지한다.
이들이 공유하는 비밀 병기가 있다: zero-copy I/O.
전통 I/O의 낭비
파일을 네트워크로 보내는 가장 단순한 코드를 보자:
while ((n = read(file_fd, buf, BUFSIZE)) > 0) {
write(socket_fd, buf, n);
}
이 단순해 보이는 코드는 CPU를 놀라울 정도로 낭비한다:
- 4번의 컨텍스트 스위치 (user ↔ kernel, 2번의 시스템콜)
- 4번의 데이터 복사 (disk → kernel, kernel → user, user → kernel, kernel → NIC)
- CPU 시간의 대부분이 복사에 소모
4GB 파일을 보낸다면? 16GB의 복사 작업이다. 대역폭이 있어도 CPU가 못 따라간다.
Zero-copy의 목표는 단순하다:
"데이터가 커널 공간을 벗어나지 않고 DMA로 NIC에 직접 전달된다."
결과: CPU 복사 0, 컨텍스트 스위치 최소, 메모리 대역폭 절반 이하.
이 글에서는 Linux의 zero-copy 메커니즘 전체를 파고든다. sendfile, splice, mmap, page cache, O_DIRECT, 그리고 이들을 활용하는 실전 시스템들.
1. 전통 I/O의 해부
read + write의 실제 동작
read(file_fd, buf, n); // 파일에서 읽기
write(socket_fd, buf, n); // 소켓으로 쓰기
내부적으로는:
1. read() 호출
- user → kernel 컨텍스트 스위치
- DMA: 디스크 → kernel buffer (page cache)
- CPU 복사: kernel buffer → user buffer
- kernel → user 컨텍스트 스위치
2. write() 호출
- user → kernel 컨텍스트 스위치
- CPU 복사: user buffer → kernel buffer (socket buffer)
- DMA: socket buffer → NIC
- kernel → user 컨텍스트 스위치
- 컨텍스트 스위치: 4번.
- 데이터 복사: 2번의 DMA + 2번의 CPU 복사 = 총 4번.
왜 이렇게 많은 복사가 필요한가?
- 페이지 캐시: 커널은 디스크 데이터를 페이지 캐시에 캐싱한다. 이는 재사용을 위해 필요.
- User space 격리: 프로세스마다 독립된 주소 공간. 커널 메모리를 직접 볼 수 없음.
- 프로토콜 스택: 네트워크 레이어가 자신의 버퍼를 관리.
즉, 보안과 캐싱을 위해 복사가 필요했던 것이다. 하지만 user space가 데이터를 건드리지 않는 경우(그냥 전달만 한다면)엔 낭비다.
메모리 대역폭의 병목
현대 서버:
- 메모리 대역폭: DDR4 ~25GB/s, DDR5 ~50GB/s.
- 네트워크: 100 Gbps = 12.5 GB/s.
- 디스크: NVMe ~7 GB/s.
100 Gbps NIC을 쓸 때 데이터를 두 번 복사하면 메모리 대역폭의 대부분이 복사에 소모된다. 다른 작업을 위한 여력이 없다.
2. mmap: 페이지 캐시 직접 접근
mmap이란?
mmap은 파일을 프로세스 주소 공간에 매핑한다. 그 후 해당 주소를 접근하면 OS가 필요한 페이지를 자동으로 로드한다.
void *mapped = mmap(NULL, file_size, PROT_READ, MAP_SHARED, file_fd, 0);
write(socket_fd, mapped, file_size);
munmap(mapped, file_size);
mmap + write의 동작
1. mmap() 호출
- 가상 주소 공간에 매핑 생성 (실제 복사 없음)
2. write(socket, mapped, size)
- user → kernel 컨텍스트 스위치
- 페이지 폴트 → DMA: 디스크 → page cache
- CPU 복사: page cache → socket buffer
- DMA: socket buffer → NIC
- kernel → user 컨텍스트 스위치
비교:
- 컨텍스트 스위치: 4 → 2 (개선).
- 복사: 4 → 3 (한 번 절약).
하지만 여전히 완전한 zero-copy는 아니다. 진짜 답은 sendfile이다.
mmap의 다른 용도
mmap이 단지 성능 개선 수단만은 아니다:
- 공유 메모리: 여러 프로세스가 같은 파일을 매핑해 소통.
- 거대한 파일 랜덤 액세스:
lseek+read대신 포인터 연산. - Copy-on-write:
fork()후 메모리 공유. 변경 시에만 실제 복사. - LMDB, SQLite mmap mode: 데이터베이스 파일을 매핑해 page cache 활용.
mmap의 함정
- SIGBUS: 매핑된 파일이 잘리면 접근 시 SIGBUS. 처리 안 하면 크래시.
- 페이지 폴트 비용: 큰 파일을 한 번에 접근하면 수많은 폴트 발생.
- TLB 압박: 큰 매핑은 TLB 항목을 많이 차지.
- 디스크 I/O 예측 불가: 접근 패턴이 커널에 숨겨져 readahead가 효율적이지 않을 수 있음.
MADV_* 힌트
madvise()로 커널에 접근 패턴을 알려줄 수 있다:
madvise(mapped, size, MADV_SEQUENTIAL); // 순차 접근 → readahead 크게
madvise(mapped, size, MADV_RANDOM); // 랜덤 접근 → readahead 끄기
madvise(mapped, size, MADV_WILLNEED); // 곧 접근할 것
madvise(mapped, size, MADV_DONTNEED); // 더 이상 필요 없음, 회수 가능
madvise(mapped, size, MADV_HUGEPAGE); // Transparent Huge Pages 사용
이 힌트 하나가 순차 읽기 성능을 수 배 향상시킬 수 있다.
3. sendfile: 진짜 Zero-Copy
sendfile의 등장
Linux 2.1에서 도입된 sendfile은 파일을 소켓으로 직접 전송한다:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
사용법:
sendfile(socket_fd, file_fd, &offset, file_size);
sendfile의 동작 (Linux 2.4+)
1. sendfile() 호출
- user → kernel 컨텍스트 스위치
2. 커널 내부:
- DMA: 디스크 → page cache
- page cache의 메타데이터만 socket buffer로 전달 (실제 복사 X)
- DMA (SG-DMA): page cache → NIC
3. kernel → user 컨텍스트 스위치
- 컨텍스트 스위치: 2번.
- CPU 복사: 0번.
- DMA: 2번 (디스크 → 페이지캐시, 페이지캐시 → NIC).
이것이 진정한 zero-copy다. CPU는 거의 관여하지 않는다.
Scatter-Gather DMA
진정한 zero-copy의 핵심은 Scatter-Gather DMA (SG-DMA) 지원 NIC이다:
- 전통 DMA: 하나의 연속 메모리 영역만 전송.
- SG-DMA: 여러 조각난 메모리 영역을 한 번에 전송 가능.
소켓 버퍼는 헤더+데이터로 나뉘어 있으며, SG-DMA가 이를 모아서 전송한다. 페이지 캐시의 페이지를 복사할 필요가 없어진다.
sendfile의 한계
- 파일 → 소켓만: 파일 → 파일, 소켓 → 파일은 불가능.
- 수정 불가: 데이터를 사용자가 변경할 수 없음 (그래서 zero-copy).
- 오프셋 관리: 큰 파일의 부분 전송 시 주의.
실전 코드
#include <sys/sendfile.h>
int serve_file(int socket_fd, const char *filename) {
int file_fd = open(filename, O_RDONLY);
struct stat st;
fstat(file_fd, &st);
off_t offset = 0;
while (offset < st.st_size) {
ssize_t sent = sendfile(socket_fd, file_fd, &offset, st.st_size - offset);
if (sent < 0) {
if (errno == EAGAIN) continue;
perror("sendfile");
close(file_fd);
return -1;
}
}
close(file_fd);
return 0;
}
이 한 줄(sendfile)이 수천 줄의 최적화된 I/O 코드를 대체한다.
4. splice: 파이프로 일반화된 Zero-Copy
splice란?
sendfile은 파일 → 소켓에 국한된다. splice (Linux 2.6.17+)는 임의의 두 파일 디스크립터 사이에 zero-copy를 제공한다:
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
제약: 적어도 한쪽은 파이프여야 한다.
두 splice로 일반화
파이프 + splice 두 번을 조합하면 임의의 zero-copy가 가능하다:
int pipefd[2];
pipe(pipefd);
// 파일 → 파이프 (zero-copy)
splice(file_fd, NULL, pipefd[1], NULL, len, SPLICE_F_MOVE | SPLICE_F_MORE);
// 파이프 → 소켓 (zero-copy)
splice(pipefd[0], NULL, socket_fd, NULL, len, SPLICE_F_MOVE | SPLICE_F_MORE);
파이프는 kernel 내부에 있는 순수 버퍼라서, 실제 데이터는 이동하지 않고 페이지 포인터만 이동한다.
사용 사례
- 프록시 서버: 한 소켓에서 다른 소켓으로 전달.
- 파일 복사:
cp명령어의 백엔드에서 사용. - HAProxy: 비활성 연결도 splice로 처리 가능.
tee: 하나의 파이프를 두 개로
tee 시스템콜은 파이프의 내용을 복사 없이 두 개의 파이프로 복제한다:
tee(pipe_in[0], pipe_out[1], len, SPLICE_F_NONBLOCK);
이는 실제 데이터 복사 없이 참조 카운트만 증가시킨다. "로그 수집 + 실제 전송"을 한 번에 처리할 때 유용.
vmsplice: 사용자 메모리 → 파이프
vmsplice는 user space 메모리를 파이프로 zero-copy 전달한다:
vmsplice(pipefd[1], iov, 1, SPLICE_F_GIFT);
SPLICE_F_GIFT 플래그로 "이 메모리는 다시 안 쓸 거야"라고 명시하면 진짜 zero-copy가 된다. 잘못 쓰면 데이터 손상 가능.
5. Linux Page Cache: 숨은 영웅
Page Cache란?
커널은 디스크에서 읽은 데이터를 메모리에 캐싱한다. 이것이 page cache다.
read()시: 먼저 page cache 확인. 있으면 복사, 없으면 디스크에서 읽어 캐싱.write()시: page cache에 쓰기 (write-back). 나중에 디스크로 flush.
Page Cache의 확인
# 파일의 캐싱 상태 확인
vmtouch filename
# 전체 page cache 크기
free -h # "buff/cache" 열
Read-ahead
커널은 순차 접근을 예측해서 미리 데이터를 읽는다:
// /proc/sys/vm/
readahead_kb // 기본 128 KB
실전에선 256KB ~ 4MB 사이 조정. 큰 파일 순차 읽기면 readahead를 크게 해서 디스크 대역폭 활용.
Write-back과 fsync
write()는 기본적으로 page cache까지만 쓴다 (non-durable). 실제 디스크 flush는:
- 백그라운드 writeback:
pdflush또는flush커널 스레드가 주기적으로. fsync(): 특정 파일의 모든 dirty 페이지 flush.sync(): 전체 dirty 페이지 flush.
이 차이가 durability와 성능의 핵심 트레이드오프다.
Dirty Ratio 조정
# /proc/sys/vm/
dirty_ratio # 전체 메모리의 이 비율까지 dirty 허용 (기본 20%)
dirty_background_ratio # 이 비율부터 백그라운드 writeback 시작 (기본 10%)
dirty_expire_centisecs # dirty 페이지의 최대 수명 (기본 3000 = 30초)
데이터베이스 서버는 이 값을 낮게 설정해서 지연된 writeback 폭발을 막는다. 기본값으로는 가끔 "갑자기 멈춤" 현상이 발생할 수 있다.
Page Cache 제거
// 특정 파일 제거
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
// 전체 제거 (개발/테스트용)
echo 1 > /proc/sys/vm/drop_caches
6. O_DIRECT: Page Cache 우회
언제 Page Cache를 피하는가?
Page cache는 대부분 유익하지만, 다음 경우엔 방해가 된다:
- 데이터베이스: 자체 버퍼 풀을 관리 (double caching 방지).
- 스트리밍: 한 번 읽고 끝 (캐시 의미 없음).
- 벤치마크: 진짜 디스크 성능 측정.
- 캐시 오염 방지: 거대한 파일이 다른 중요한 데이터를 밀어냄.
O_DIRECT 플래그
int fd = open("data.bin", O_RDONLY | O_DIRECT);
O_DIRECT로 열린 파일의 I/O는 page cache를 건너뛴다. DMA가 사용자 버퍼로 직접 전송.
엄격한 제약
O_DIRECT에는 까다로운 요구사항이 있다:
- 버퍼 정렬: 사용자 버퍼가 블록 크기(보통 4KB) 배수에 정렬.
- 길이 정렬: 읽기/쓰기 길이도 블록 크기 배수.
- 오프셋 정렬: 파일 오프셋도 블록 크기 배수.
void *buf;
posix_memalign(&buf, 4096, 4096 * 1024); // 4KB 정렬, 4MB 크기
pread(fd, buf, 4096 * 1024, 0);
정렬을 어기면 EINVAL 에러.
PostgreSQL의 O_DIRECT 논란
PostgreSQL은 전통적으로 page cache에 의존해왔다 (double buffering 문제). 그러나 최근 커뮤니티에서는 O_DIRECT 지원이 논의되고 있다:
- 장점: 커널 캐시 우회, 더 예측 가능한 성능, 잠재적 더 나은 latency.
- 단점: 커널 readahead와 writeback 활용 못 함, 자체 구현 필요.
PostgreSQL 18에서 직접 I/O 모드가 실험적으로 도입됐다.
ScyllaDB: 극단의 O_DIRECT
ScyllaDB는 완전히 O_DIRECT + async I/O로 동작한다. 자체적으로 모든 I/O를 관리하며 커널에 의존하지 않는다. 결과: 극도의 예측 가능한 성능과 수십만 IOPS 처리량.
7. io_uring: 차세대 비동기 I/O
왜 io_uring인가?
Linux의 전통 비동기 I/O API들은 문제가 있었다:
select/poll: O(N) 복잡도, 파일 디스크립터 수 제한.epoll: 훌륭하지만 여전히 시스템콜 많음 (준비됐는지 확인 + 실제 I/O 호출).AIO(libaio): 버그 많고, 블록 디바이스 O_DIRECT만 지원.
io_uring의 구조
io_uring (Linux 5.1+)은 두 개의 링 버퍼로 유저-커널 간 통신한다:
- SQ (Submission Queue): 유저가 I/O 요청을 넣음.
- CQ (Completion Queue): 커널이 완료 결과를 넣음.
시스템콜 없이 (또는 최소로) I/O를 주고받을 수 있다. 심지어 IORING_SETUP_SQPOLL로 커널 스레드가 SQ를 폴링하면 시스템콜 0번도 가능.
Zero-Copy와 io_uring
io_uring은 zero-copy 기능을 포함한다:
IORING_OP_SENDFILE: sendfile 비동기 버전.IORING_OP_SPLICE: splice 비동기 버전.IORING_OP_SEND_ZC(Linux 6.0+): zero-copy UDP/TCP send.
실전 성능
io_uring + zero-copy는 epoll + sendfile보다:
- 30~50% 더 높은 처리량.
- 50% 낮은 CPU 사용.
- 더 낮은 latency.
특히 다수의 작은 I/O가 있는 워크로드에서 효과적.
사용 예시 (liburing)
#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
// 파일 읽기 요청 등록
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, file_fd, buf, size, offset);
io_uring_submit(&ring);
// 결과 대기
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res > 0) {
// 성공, buf에 데이터 있음
}
io_uring_cqe_seen(&ring, cqe);
8. DMA와 MSI-X: 하드웨어의 협력
DMA (Direct Memory Access)
DMA는 CPU를 거치지 않고 디바이스가 메모리에 직접 접근하는 기술이다:
- 디스크 컨트롤러가 자기 버퍼를 메모리로 직접 전송.
- NIC이 메모리에서 패킷을 읽어 전송.
- GPU가 메모리와 직접 데이터 교환.
DMA 덕분에 CPU는 I/O 중에도 다른 일을 할 수 있다.
IOMMU와 DMA 보호
DMA가 임의의 메모리에 접근하면 보안 위험이다. IOMMU (Intel VT-d, AMD-Vi)는 DMA 요청을 가상화해서:
- 디바이스가 할당된 메모리만 접근 가능.
- 가상 주소를 물리 주소로 변환.
- 가상 머신 패스스루(passthrough) 지원.
MSI-X: 효율적인 인터럽트
전통 PCI 인터럽트는 라인이 제한적이고, 여러 디바이스가 공유했다. MSI-X (Message Signaled Interrupts Extended) 는:
- 디바이스당 수천 개의 인터럽트 라인.
- CPU 코어별로 인터럽트 라우팅 가능.
- NIC의 RX 큐마다 다른 코어로 처리 → 병렬성 극대화.
RPS (Receive Packet Steering), RSS (Receive Side Scaling), XPS (Transmit Packet Steering) 등의 커널 기능이 MSI-X를 활용한다.
9. 실전: Kafka의 Zero-Copy 아키텍처
Kafka의 성능 비밀
Apache Kafka는 zero-copy를 극한까지 활용한다.
저장 구조
Kafka는 각 파티션을 append-only log file로 저장:
topic/partition-0/
├── 00000000000000000000.log
├── 00000000000000000000.index
└── 00000000000000000000.timeindex
메시지는 바이너리 포맷 그대로 저장된다. 클라이언트가 보낸 그대로, 소비자가 받는 그대로.
Produce 경로
Producer → Broker:
1. 네트워크로 메시지 수신
2. page cache에 쓰기 (write-back)
3. 정기적으로 디스크 flush (또는 flush 정책에 따라)
여기서도 가능한 복사를 최소화한다.
Consume 경로 (진짜 마법)
Consumer → Broker:
1. FETCH 요청 수신
2. 인덱스 조회 → 파일 오프셋 결정
3. sendfile(socket_fd, log_file_fd, offset, length)
- page cache → NIC, CPU 복사 0
4. 응답 전송
메시지 데이터가 JVM 힙에 한 번도 들어가지 않는다. 디스크 → 페이지캐시 → NIC으로 바로 흘러간다. 이것이 Kafka가 극한의 처리량을 내는 비결이다.
Java의 FileChannel.transferTo
Java API도 sendfile을 래핑한다:
FileChannel source = FileChannel.open(path, StandardOpenOption.READ);
long transferred = source.transferTo(position, count, socketChannel);
// 내부적으로 sendfile 호출
Kafka의 LogSegment.writeTo()가 이를 사용한다.
TLS의 함정
TLS를 켜면 zero-copy가 깨진다. 암호화를 위해 데이터가 CPU를 거쳐야 하기 때문이다.
- Producer → Broker: 암호화 필요 → 복사
- Broker → Consumer: 암호화 필요 → 복사
결과: TLS 활성화 시 Kafka 처리량이 수십% 감소한다. 해결책:
- KTLS (Kernel TLS) (Linux 4.13+): TLS를 커널이 처리. sendfile과 결합 가능.
- NIC offload: Intel, Mellanox NIC이 TLS 가속 지원.
KTLS는 아직 Kafka에서 완전 지원되진 않지만, Netflix가 적극 활용 중.
10. Netflix Open Connect: 200Gbps의 비밀
Netflix CDN 아키텍처
Netflix의 Open Connect Appliance (OCA) 는 전 세계 ISP 내부에 배치된 동영상 캐시 서버다.
목표: 단일 1U 서버에서 200Gbps 스트리밍.
어떻게 가능한가?
- FreeBSD + sendfile(2): Netflix는 Linux가 아닌 FreeBSD를 사용. FreeBSD의 sendfile이 비동기(async) 지원.
- NUMA 최적화: 각 CPU 소켓이 자기 메모리와 NIC에만 접근.
- Kernel TLS: 하드웨어 가속 TLS (Chelsio NIC).
- 커널 우회 네트워킹: 일부 경로는 DPDK 스타일 처리.
- Tuned page cache: 인기 콘텐츠를 메모리에 고정.
sendfile의 진화
Netflix의 Scott Long은 FreeBSD sendfile을 전면 재작성했다:
- Pre-2015: 동기식, 블로킹.
- Post-2015: 완전 비동기, 여러 파일을 한 번에 처리.
이 변경으로 단일 서버 처리량이 40Gbps → 90Gbps → 200Gbps로 올라갔다.
TLS Offload
Netflix OCA는 대부분의 트래픽이 TLS(HTTPS)다. 일반 서버라면 TLS가 zero-copy를 깨지만, Netflix는 NIC 하드웨어 TLS offload를 활용:
- 커널이 TLS 레코드를 NIC에 전달.
- NIC이 암호화 후 전송.
- CPU는 컨트롤 플레인만 담당.
이로써 TLS 상태에서도 zero-copy를 유지한다.
11. Nginx의 I/O 전략
sendfile on
Nginx의 config:
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
...
}
sendfile on: 정적 파일을 sendfile로 전송.tcp_nopush: 헤더와 파일 본문을 함께 전송 (Nagle 알고리즘 관련).tcp_nodelay: 작은 패킷 즉시 전송.
AIO와 thread pool
기본 sendfile은 디스크 읽기가 블로킹이다. Nginx는 aio threads 옵션으로 I/O를 별도 스레드 풀로 분리할 수 있다:
aio threads;
aio_write on;
이는 특히 OS page cache에 없는 데이터(첫 접근)를 비동기로 처리할 때 유용.
directio
거대한 파일(캐시 밀어내는 위험)엔 directio를 사용:
location /videos/ {
directio 10m; # 10MB 이상 파일은 O_DIRECT
output_buffers 2 2m;
}
O_DIRECT로 page cache를 우회해서 핫 데이터가 밀려나지 않게 한다.
12. 커널 우회(Kernel Bypass)와 DPDK
극단의 성능 추구
Zero-copy도 부족한 경우가 있다. 초저지연 트레이딩, 100M+ PPS(packets per second) 같은 워크로드:
- 커널 네트워크 스택 자체가 병목.
- 컨텍스트 스위치마저 비싸다.
해결: 커널을 완전히 우회한다.
DPDK (Data Plane Development Kit)
Intel DPDK는 사용자 공간에서 직접 NIC을 제어한다:
- 폴링 모드 드라이버 (인터럽트 없음).
- 휴 페이지 기반 메모리 풀.
- 락프리 큐와 파이프라인.
- CPU 코어를 한 패킷 처리 전용으로.
성과: 100Gbps+ 라인 레이트 처리, 수십 나노초 latency.
XDP (eXpress Data Path)
Linux 커널 내부의 zero-copy 솔루션. eBPF 프로그램을 NIC 드라이버 수준에서 실행:
- 패킷이 커널 스택에 들어가기 전 처리.
- DDoS 필터링, 로드 밸런싱 등에 활용.
- Cloudflare, Facebook이 대규모로 사용.
AF_XDP
사용자 공간이 커널과 메모리 공유로 직접 패킷 접근:
int fd = socket(AF_XDP, SOCK_RAW, 0);
// 링 버퍼 설정, 메모리 매핑 ...
DPDK만큼 빠르면서 커널 네트워킹과 호환. Cilium, VPP 등이 활용.
13. 벤치마크: 진짜 차이를 측정하자
실험: 1GB 파일을 소켓으로 전송
같은 서버에서 각 방식을 비교 (숫자는 대략적):
| 방식 | CPU 사용 | 시간 | 복사 횟수 |
|---|---|---|---|
| read+write (naive) | 60% | 1.8s | 4 |
| mmap+write | 45% | 1.5s | 3 |
| sendfile | 5% | 0.9s | 2 (DMA만) |
| sendfile + SG-DMA | 3% | 0.8s | 2 |
| io_uring + send_zc | 2% | 0.7s | 2 |
CPU 사용이 10배 이상 감소하며, 처리량은 거의 네트워크 한계까지 도달한다.
측정 도구
# iostat: 디스크 I/O
iostat -x 1
# iperf3: 네트워크 처리량
iperf3 -c target_server
# perf: CPU 프로파일링
perf record -F 99 -g -- ./my_server
perf report
# bpftrace: sendfile 시스템콜 트레이싱
bpftrace -e 'tracepoint:syscalls:sys_enter_sendfile { @[comm] = count(); }'
14. 흔한 실수와 함정
함정 1: "sendfile을 켜면 다 빨라진다"
sendfile은 큰 파일을 네트워크로 보내는 경우에 가장 효과적이다. 작은 파일(1KB 미만)은 차이가 미미하고, 오히려 오버헤드가 클 수 있다.
함정 2: TLS와의 비호환
앞서 설명했듯, 일반 TLS는 zero-copy를 깨뜨린다. KTLS 또는 하드웨어 offload 없이는 HTTPS zero-copy는 불가능하다.
함정 3: mmap과 fsync의 관계
mmap 기반 쓰기에서 durability를 얻으려면 msync()가 필요하다:
// 데이터 변경 후
msync(mapped_addr, size, MS_SYNC);
그냥 fsync(fd)로는 mmap 변경이 반영되지 않을 수 있다.
함정 4: sendfile 부분 전송
sendfile은 요청한 전체 바이트를 한 번에 전송하지 않을 수 있다. 반드시 반환 값을 확인하고 남은 바이트를 재전송해야 한다.
while (remaining > 0) {
ssize_t sent = sendfile(out_fd, in_fd, &offset, remaining);
if (sent < 0) { /* error */ break; }
remaining -= sent;
}
함정 5: Page cache 오염
거대한 로그 파일을 한 번 읽으면 다른 중요한 데이터가 page cache에서 밀려난다. 해결:
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
이는 "이 파일 데이터는 캐시에서 빼도 돼"라고 알리는 힌트다. 로그 rotator나 백업 도구에서 필수.
퀴즈로 복습하기
Q1. 전통 read+write와 sendfile의 복사 횟수 차이는?
A. 전통 read+write는 총 4번의 복사가 발생한다: (1) 디스크 → kernel page cache (DMA), (2) page cache → user buffer (CPU), (3) user buffer → socket buffer (CPU), (4) socket buffer → NIC (DMA). sendfile은 CPU 복사를 완전히 제거한다: (1) 디스크 → page cache (DMA), (2) page cache → NIC (SG-DMA). SG-DMA 지원 NIC에선 page cache 데이터가 직접 NIC으로 가므로, CPU는 단 한 번도 데이터를 복사하지 않는다.
Q2. splice가 sendfile보다 더 범용적인 이유는?
A. sendfile은 파일 → 소켓만 지원한다 (디스크 파일을 네트워크로 보내는 특정 시나리오). splice는 임의의 파일 디스크립터 사이에서 zero-copy를 제공한다 (단, 한쪽은 파이프). 파이프와 두 번의 splice를 조합하면 파일 → 파일, 소켓 → 소켓 등 어떤 조합도 가능하다. 파이프는 사실상 커널 내부의 참조 카운팅된 메모리 페이지 목록이기 때문에, 실제 데이터 이동이 아닌 페이지 포인터 이동만 발생한다.
Q3. O_DIRECT가 page cache를 우회해서 얻는 이점과 대가는?
A. 이점: (1) Double buffering 방지 — 데이터베이스처럼 자체 버퍼 풀이 있을 때 커널 캐시와 중복 방지. (2) 예측 가능한 성능 — 커널 writeback의 "갑자기 멈춤" 현상 회피. (3) 캐시 오염 방지 — 거대한 순차 읽기가 다른 핫 데이터를 밀어내지 않음. (4) 벤치마크 정확도 — 진짜 디스크 성능 측정.
대가: (1) 정렬 제약 — 버퍼, 길이, 오프셋이 블록 크기(보통 4KB) 배수여야 함. (2) Readahead 포기 — 커널의 프리페치 이점 없음. (3) 직접 관리 필요 — 모든 I/O 스케줄링을 앱이 관리. (4) 첫 읽기 느림 — 캐시 miss 시 매번 디스크까지 감. ScyllaDB처럼 극단적으로 쓰면 뛰어난 성능을 내지만 상당한 구현 노력이 필요하다.
Q4. Kafka가 zero-copy를 극대화하기 위해 파일 포맷을 어떻게 설계했는가?
A. Kafka는 네트워크 포맷과 디스크 포맷을 동일하게 유지한다. Producer가 보낸 바이트가 그대로 디스크에 쓰이고, 소비자가 받는 바이트도 디스크의 바이트와 동일하다. 덕분에:
- Broker는 메시지를 파싱하지 않음 (키/타임스탬프 인덱스만 유지).
- Consume 요청 처리 시
sendfile로 디스크 파일의 일부 범위를 바로 소켓으로 전달. - JVM 힙을 한 번도 거치지 않음 — 가비지 컬렉션 압력 없음.
- 수천 컨슈머가 같은 파일을 읽어도 페이지 캐시가 공유되어 재사용.
반면 TLS를 켜면 이 최적화가 깨진다. Kafka가 TLS 활성화 시 처리량이 줄어드는 주된 이유다. KTLS/NIC offload가 해결책이지만 아직 완전 통합은 아니다.
Q5. io_uring이 epoll보다 왜 더 효율적인가?
A. epoll은 "어떤 fd가 준비되었나?"를 알려주는 레벨에서 동작한다. 그래서 전형적인 패턴은:
epoll_wait()— 준비된 fd 목록 받기 (시스템콜 1)read()/write()— 각 fd에서 I/O (시스템콜 N)
매 I/O마다 시스템콜이 발생한다.
io_uring은 "이 I/O를 해줘"를 비동기로 제출한다:
- SQ에 여러 요청을 한 번에 넣음 (user space 메모리 쓰기)
io_uring_enter()— 배치로 제출 (시스템콜 1, 여러 I/O)- CQ에서 완료 결과 수집 (user space 메모리 읽기)
N개의 I/O가 있어도 시스템콜은 1번이면 충분하다. 심지어 SQPOLL 모드에선 커널 스레드가 SQ를 폴링해서 시스템콜 0번도 가능하다. 또한 io_uring은 zero-copy send, fixed buffers, linked operations 등 추가 기능을 제공한다. 결과적으로 높은 처리량과 낮은 CPU 사용을 동시에 달성한다.
마치며: 복사하지 않음의 미학
핵심 정리
- 전통 I/O는 낭비가 많다: 4번의 복사, 4번의 컨텍스트 스위치.
- mmap: page cache 직접 노출. 편리하지만 완전 zero-copy 아님.
- sendfile: 파일 → 소켓 zero-copy. Kafka/Netflix의 핵심.
- splice: 임의의 zero-copy. 파이프를 중간 매개체로.
- O_DIRECT: page cache 우회. DB에 적합.
- io_uring: 차세대 비동기 + zero-copy.
- KTLS/NIC offload: TLS와 zero-copy 양립.
- Kernel bypass (DPDK, XDP): 극한 성능.
언제 무엇을 쓰는가?
| 상황 | 선택 |
|---|---|
| 큰 정적 파일 서빙 | sendfile |
| 작은 파일, 동적 컨텐츠 | 일반 read/write (차이 미미) |
| 데이터베이스 | O_DIRECT + 자체 버퍼 풀 |
| 프록시/터널링 | splice with pipe |
| 극한 저지연 | DPDK, XDP |
| 범용 고성능 서버 | io_uring |
| HTTPS 대규모 서빙 | KTLS + sendfile |
| 여러 프로세스 공유 | mmap (MAP_SHARED) |
마지막 교훈
현대 컴퓨팅의 핵심 통찰 중 하나는:
"가장 빠른 연산은 하지 않는 연산이다."
Zero-copy의 본질이 이것이다. 데이터를 옮기지 말고 움직이지 않게 두자. 포인터만 바꾸자. CPU를 해방시키자.
당신이 지금 보고 있는 이 웹사이트의 HTTPS 연결, Netflix의 동영상, Kafka의 이벤트 스트림 — 모두 이 원칙 위에서 동작한다. 복사하지 않음의 미학이 현대 인터넷의 토대다.
다음에 "성능이 왜 이러지?"라는 의문이 들면 먼저 물어보자: "나는 지금 데이터를 몇 번 복사하고 있는가?" 답이 2보다 크면, 줄일 방법이 있을 수 있다.
참고 자료
- Zero Copy I: User-Mode Perspective (IBM) - 고전적 설명
- The Linux Kernel Documentation: sendfile()
- io_uring: A New Beginning (Axboe, 2019) - io_uring 원 논문
- Efficient IO with io_uring
- Apache Kafka: A Distributed Streaming Platform
- Netflix: Optimizing FreeBSD for High Performance CDN
- Understanding Linux Page Cache (Sasha Goldshtein)
- DPDK Programmer's Guide
- XDP Tutorial
- The Secret Life of Page Cache