Zero Copy

sendfile on은 커널 공간에서 데이터를 바로 복사하여 CPU 부하를 줄이는 기술이다.
- Legacy: Disk → Kernel Buffer → User Buffer(Nginx) → Kernel Socket Buffer → NIC (복사 4회, Context Switch 2회)
- Zero Copy: Disk → Kernel Buffer → Kernel Socket Buffer → NIC (복사 2~3회, Context Switch 감소)
제로 카피에 대한 대표적인 IBM 블로그가 있다.
https://developer.ibm.com/articles/j-zerocopy/
리눅스 듀얼 모드(커널 스페이스, 유저 스페이스)에 대한 것인데, 듀얼 모드에 대한 개념은 예전에 작성한 OS글을 참고하면 된다.
https://konkukcodekat.tistory.com/246
[운영체제] OS의 듀얼모드와 작업(Operations)의 종류와 관리 기능 소개
OS의 OperationsInterrupt-driven운영체제는 비동기적인 이벤트를 감지하고 처리하기 위해 interrupt-driven 방식으로 동작한다.하드웨어 인터럽트: I/O 디바이스 작업 완료 시 발생소프트웨어 인터럽트 (trap,
konkukcodekat.tistory.com
1. 코드 레벨 매핑(Java vs C/Linux)
Legacy 방식(read + write)


- Java: File.read() → 버퍼 → Socket.send()
- C/Linux: read() 시스템 콜 → write() 시스템 콜
- 문제점: 유저 영역(User Space)을 거치면서 불필요한 데이터 복사와 컨텍스트 스위치가 4번 발생한다.
Zero Copy 방식(transferTo)


- Java: FileChannel.transferTo() 메서드를 사용한다.
- C/Linux: sendfile() 시스템 콜로 동작한다.
- 핵심: sendfile(socket, file, len) 형식으로 호출하면 데이터는 유저 영역을 거치지 않고 커널 내에서만 이동한다.
2. True Zero Copy를 가능하게 하는 Scatter-Gather
단순히 sendfile만 써선 진짜 제로카피가 완성되지 않는다.
일반 sendfile (커널 2.4 이전):
- 유저 영역 복사는 없지만 커널 내부적으로 Read Buffer → Socket Buffer로 복사해야 했다. (CPU 관여, 복사 3회)
Scatter-Gather(sendfile with DMA Gather):

- 리눅스 커널 2.4 이상, NIC가 "Gather"를 지원하면, 소켓 버퍼에 데이터를 복사하지 않는다.
- 데이터의 위치 정보(Descriptor)만 소켓 버퍼에 기록한다.
- DMA 엔진이 이 정보를 읽어 Read Buffer에서 NIC로 직접 데이터를 보낸다.
- 결과: CPU가 데이터 복사하는 횟수 = 0회(True Zero Copy)
넷플릭스의 sendfile 개조 사례
sendfile을 사용할때 IT기업들은 큰 문제가 없을까 고민되어 흥미로운 사례를 하나 찾아봤다.
https://netflixtechblog.com/protecting-netflix-viewing-privacy-at-scale-39c675d88f45
Protecting Netflix Viewing Privacy at Scale
increasing Open Connect Appliance efficiency
netflixtechblog.com
1. 문제 상황

넷플릭스는 대용량 비디오 데이터를 빠르게 전송하기 위해 sendfile을 적극적으로 사용해왔다.
하지만 HTTPS(TLS 암호화)를 적용하려 하자 큰 문제가 발생했다.
- 암호화 처리를 위해 데이터를 User Space(Nginx)로 불러와야 한다.
- 이 순간 zero copy가 깨지고, 불필요한 복사, context switch 폭증, CPU 부하 증가로 성능이 크게 저하됐다.
2. 해결책
넷플릭스는 "암호화도 커널에서 해버리자"는 방식으로 해결했다.

- 기존: Disk → Kernel Buffer → (Copy) → User Space(Encrypt) → (Copy) → Kernel Socket → NIC
- 개선: Disk → Kernel Buffer → 커널에서 직접 Encrypt → NIC → HTTPS를 사용해도 zero copy가 유지된다.
추가적으로 개선 한 것
- kTLS(Kernel-side TLS):
- Handshake는 기존처럼 User Space(Nginx)에서 처리한다.
- Bulk 데이터 전송은 커널이 직접 암호화하도록 sendfile 시스템 콜을 개조했다.
- 하드웨어 가속(AES-NI, AES-GCM):
- CBC 대신 AES-GCM을 쓰고, AES-NI 등 하드웨어 가속 명령어, 최적화 라이브러리(ISAL)로 성능을 높인다.
- 비동기 Sendfile:
- 단순 sendfile만이 아니라, 디스크 I/O가 막히지 않도록 비동기화하여 Nginx와 FreeBSD 커널을 최적화했다.
Zero Copy를 비활성화 했을때 시스템 콜

기본 페이지 오류
상황
sudo dd if=/dev/zero of=/var/www/html/test.dat bs=1M count=100
제공된 명령어로 html 파일을 만들고 접속해보니 not found가 떴다.
원인

에러 로그를 보면 파일을 찾지 못한 문제였다.
설정 디렉토리가 /usr/share/nginx/html로, 파일을 잘못된 위치(예: /var/www/html/)에 생성했다.

위와 같이 nginx 기본 설정파일에서 root 경로의 위치를 확인할 수 있다.
sudo dd if=/dev/zero of=/usr/share/nginx/html/test.dat bs=1M count=100


명령어를 수정하여 파일을 올바른 경로에 재생성하고 정상적으로 다운로드되는 것을 확인했다.
캐싱 테스트
요청을 했더니 시스템 콜이 아래와 같이 응답이 심심하게 나온다. 유저/커널 스페이스 스위칭도 보이지 않는다.

1단계: 첫 요청(200 OK)
- Nginx: "파일 받아라. 이 파일은 2026년 1월 15일 10시 마지막 수정 파일이다."
- Header: Last-Modified: Thu, 15 Jan 2026 10:00:00 GMT
2단계: 두번째 요청(304 Check)
- 클라이언트: "이거 이후로 바뀐 거 있나?" (If-Modified-Since 헤더)
- Nginx: 파일이 그대로라 응답코드 304(Not Modified)로 본문 없이 돌려준다
강제로 매번 파일을 읽게 하려면 url 쿼리를 바꿔서 요청한다.

시스템 콜 출력

1. 노가다의 현장(Read → Write 반복)
pread64(5, "\\\\0\\\\0...", 32768, 104366080) = 32768
pread64(5, "\\\\0\\\\0...", 32768, 104398848) = 32768
- 파일 디스크립터 5(test.dat 파일)에서 32KB씩 읽는다.
- 디스크에서 커널 버퍼 → 유저 공간(Nginx 메모리)로 복사 (Copy #1)
- \0\0...로 채워진 데이터가 맞다.
writev(4, [{iov_base="\\\\0\\\\0...", iov_len=32768}, {iov_base="\\\\0\\\\0...", iov_len=32768}], 2) = 65536
- 읽은 32KB 2덩어리를 합쳐서 64KB를 파일 디스크립터 4(클라이언트 소켓)에 쓴다.
- 유저 공간 데이터가 다시 커널 소켓 버퍼로 복사됨 (Copy #2)
- sendfile을 쓰면 이 과정이 사라진다.
(이 pread 2번 → writev 1번 패턴이 파일 끝까지 반복된다)
2. 병목 현상(Socket Buffer Full)
writev(4, [{...}, iov_len=15873], 1) = -1 EAGAIN (Resource temporarily unavailable)
- 마지막 15KB를 보내려다 실패(-1), 에러는 EAGAIN.
- 소켓 버퍼가 꽉 차서 더 받은 건 없다.
3. 대기 및 재개(Event Loop)
epoll_pwait(10, [{events=EPOLLOUT, ...}], ...) = 1
- Nginx가 "소켓 4번 버퍼 비우면 깨워줘"라 잠시 쉬고 있다가, 다시 신호받고 전송.
writev(4, [{...}, iov_len=15873], 1) = 15873
- 실패했던 데이터를 다시 제대로 전송함.
4. 마무리(Log & Close)
write(7, "210.106.232.106 - - [15/Jan/2026...", 242) = 242
- access.log에 로그를 남긴다.
close(5)
close(4)
- 파일(5) 닫고, 클라이언트(4) 연결을 끊는다.
핵심 요약:
- 100MB 파일을 보내기 위해 read/write 시스템 콜이 수천 번 호출되어 context switch 비용이 크다.
- Disk → Kernel → Nginx(User) → Kernel → NIC 경로로 복사 비용이 많이 들어간다.
- pread64, writev가 찍히는 게 바로 legacy mode 증거다.
버퍼
중요하게 봐야 하는 이 로그이다.

Non-blocking(Nginx) + EAGAIN
버퍼가 가득 차면 그냥 멍하니 있지 않고 "아, 꽉 찼네(EAGAIN)? 일단 딴 일 하고 오겠다"는 식으로 동작한다. 그리고 나중에 버퍼 공간이 확보되면 다시 와서 전송한다.
1. 32KB의 비밀: output_buffers
- sendfile off 상태일 때, 디스크에서 파일을 읽기 위해 유저 영역(User Space)에 메모리 버퍼를 할당한다.
- Nginx의 기본 디스크 버퍼는 64비트 리눅스 기준 보통 32KB이다.
- 설정 이름은 output_buffers
- 동작: 32KB씩 파일을 읽는다. (pread64가 32768로 잡힌 이유)
2. 64KB의 비밀: writev(Vector Write)
- 쓰기는 64KB씩 잡힌다. 로그를 보면 write 대신 writev를 쓴다.
- writev란 여러 버퍼 조각을 한 번에 보내는 시스템 콜이다. 호출 수를 줄이려고 사용한다.
- 상황:
- 32KB 읽기(Buffer 1)
- 32KB 추가 읽기(Buffer 2)
- 도합 64KB가 되어 writev로 한 번에 커널 소켓에 쏜다.
- 그래서 65536(64KB) 바이트가 전송된다.
3. 왜 더 크게 못 잡는가?
예를 들면"10MB씩 읽으면 더 빠를 것 같다"라고 생각할 수 있으나, 트레이드오프가 있다.
- 메모리 효율:
- 접속자 수천 명 기준 1인당 버퍼를 10MB 씩 잡으면 바로 수십 GB RAM이 필요하다.
- 그래서 32KB~64KB로 쪼개는 것.
- 반응성:
- 큰 데이터 블록을 읽다 멈추면(Blocking) 전체 시스템 반응성이 떨어진다.
- 작은 단위(Time slicing)로 처리해서 전체 반응 속도를 높인다

sendfile on 했을때 시스템 콜
sendfile on 상태로 요청을 보내보면 read/write 시스템 콜 없이 바로 zero copy(sendfile)로 전송되는 것을 확인할 수 있다.

댓글