Skip to content

✍️ 필사 모드: Zero-Copy & Linux I/O 완전 가이드 2025: sendfile, splice, mmap, Page Cache, O_DIRECT — Kafka/Nginx/Netflix 실전 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며: 왜 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를 놀라울 정도로 낭비한다:

  1. 4번의 컨텍스트 스위치 (user ↔ kernel, 2번의 시스템콜)
  2. 4번의 데이터 복사 (disk → kernel, kernel → user, user → kernel, kernel → NIC)
  3. 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이 단지 성능 개선 수단만은 아니다:

  1. 공유 메모리: 여러 프로세스가 같은 파일을 매핑해 소통.
  2. 거대한 파일 랜덤 액세스: lseek + read 대신 포인터 연산.
  3. Copy-on-write: fork() 후 메모리 공유. 변경 시에만 실제 복사.
  4. 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는 대부분 유익하지만, 다음 경우엔 방해가 된다:

  1. 데이터베이스: 자체 버퍼 풀을 관리 (double caching 방지).
  2. 스트리밍: 한 번 읽고 끝 (캐시 의미 없음).
  3. 벤치마크: 진짜 디스크 성능 측정.
  4. 캐시 오염 방지: 거대한 파일이 다른 중요한 데이터를 밀어냄.

O_DIRECT 플래그

int fd = open("data.bin", O_RDONLY | O_DIRECT);

O_DIRECT로 열린 파일의 I/O는 page cache를 건너뛴다. DMA가 사용자 버퍼로 직접 전송.

엄격한 제약

O_DIRECT에는 까다로운 요구사항이 있다:

  1. 버퍼 정렬: 사용자 버퍼가 블록 크기(보통 4KB) 배수에 정렬.
  2. 길이 정렬: 읽기/쓰기 길이도 블록 크기 배수.
  3. 오프셋 정렬: 파일 오프셋도 블록 크기 배수.
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 경로

ProducerBroker:
  1. 네트워크로 메시지 수신
  2. page cache에 쓰기 (write-back)
  3. 정기적으로 디스크 flush (또는 flush 정책에 따라)

여기서도 가능한 복사를 최소화한다.

Consume 경로 (진짜 마법)

ConsumerBroker:
  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 스트리밍.

어떻게 가능한가?

  1. FreeBSD + sendfile(2): Netflix는 Linux가 아닌 FreeBSD를 사용. FreeBSD의 sendfile이 비동기(async) 지원.
  2. NUMA 최적화: 각 CPU 소켓이 자기 메모리와 NIC에만 접근.
  3. Kernel TLS: 하드웨어 가속 TLS (Chelsio NIC).
  4. 커널 우회 네트워킹: 일부 경로는 DPDK 스타일 처리.
  5. 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.8s4
mmap+write45%1.5s3
sendfile5%0.9s2 (DMA만)
sendfile + SG-DMA3%0.8s2
io_uring + send_zc2%0.7s2

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가 보낸 바이트가 그대로 디스크에 쓰이고, 소비자가 받는 바이트도 디스크의 바이트와 동일하다. 덕분에:

  1. Broker는 메시지를 파싱하지 않음 (키/타임스탬프 인덱스만 유지).
  2. Consume 요청 처리 시 sendfile 로 디스크 파일의 일부 범위를 바로 소켓으로 전달.
  3. JVM 힙을 한 번도 거치지 않음 — 가비지 컬렉션 압력 없음.
  4. 수천 컨슈머가 같은 파일을 읽어도 페이지 캐시가 공유되어 재사용.

반면 TLS를 켜면 이 최적화가 깨진다. Kafka가 TLS 활성화 시 처리량이 줄어드는 주된 이유다. KTLS/NIC offload가 해결책이지만 아직 완전 통합은 아니다.

Q5. io_uring이 epoll보다 왜 더 효율적인가?

A. epoll은 "어떤 fd가 준비되었나?"를 알려주는 레벨에서 동작한다. 그래서 전형적인 패턴은:

  1. epoll_wait() — 준비된 fd 목록 받기 (시스템콜 1)
  2. read()/write() — 각 fd에서 I/O (시스템콜 N)

매 I/O마다 시스템콜이 발생한다.

io_uring은 "이 I/O를 해줘"를 비동기로 제출한다:

  1. SQ에 여러 요청을 한 번에 넣음 (user space 메모리 쓰기)
  2. io_uring_enter() — 배치로 제출 (시스템콜 1, 여러 I/O)
  3. CQ에서 완료 결과 수집 (user space 메모리 읽기)

N개의 I/O가 있어도 시스템콜은 1번이면 충분하다. 심지어 SQPOLL 모드에선 커널 스레드가 SQ를 폴링해서 시스템콜 0번도 가능하다. 또한 io_uring은 zero-copy send, fixed buffers, linked operations 등 추가 기능을 제공한다. 결과적으로 높은 처리량과 낮은 CPU 사용을 동시에 달성한다.


마치며: 복사하지 않음의 미학

핵심 정리

  1. 전통 I/O는 낭비가 많다: 4번의 복사, 4번의 컨텍스트 스위치.
  2. mmap: page cache 직접 노출. 편리하지만 완전 zero-copy 아님.
  3. sendfile: 파일 → 소켓 zero-copy. Kafka/Netflix의 핵심.
  4. splice: 임의의 zero-copy. 파이프를 중간 매개체로.
  5. O_DIRECT: page cache 우회. DB에 적합.
  6. io_uring: 차세대 비동기 + zero-copy.
  7. KTLS/NIC offload: TLS와 zero-copy 양립.
  8. 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보다 크면, 줄일 방법이 있을 수 있다.


참고 자료

현재 단락 (1/397)

Apache Kafka는 **단일 브로커로 초당 2백만 메시지**를 처리할 수 있다. Netflix의 Open Connect Appliance(OCA)는 **단일 x86 서버에서 ...

작성 글자: 0원문 글자: 15,865작성 단락: 0/397