리눅스 커널의 TCP 제로-카피 기능이 송수신 경로에서 어떤 커널 기능과 API를 통해 동작하는지, 그리고 사용자 공간 메모리와 디바이스 메모리를 활용하는 방식까지 내부 메커니즘을 정리한다.
URL: https://blog.tohojo.dk/2026/02/the-inner-workings-of-tcp-zero-copy.html
TCP 제로-카피(zero-copy)는 리눅스 커널의 기능으로, 커널 메모리와 최종 데이터(사용자 공간(userspace) 또는 시스템 내 다른 디바이스의 메모리)에 저장될 버퍼 메모리 사이에서 추가적인 복사(copy) 를 발생시키지 않고 데이터를 송수신할 수 있게 해준다.
데이터 복사는 오버헤드를 추가하므로 이를 피하는 것은 매력적이다. 다만 이를 가능하게 하는 커널 기능들은 비교적 최근에 도입됐고, 내부에서 어떻게 동작하는지 정확히 파악하는 일은 만만치 않다. 그래서 이 글에서는 이러한 기능들을 사용할 때 내부적으로 실제로 무엇이 벌어지는지 정리해보려고 한다.
사실 여기에는 (서로 연관된) 기능이 몇 가지 섞여 있고, 제로-카피를 위한 커널 API도 여러 가지가 있다. 이 글에서는 커널에 추가된 순서에 대체로 맞춰서 다뤄보겠다. 먼저 비교적 단순한 송신(TX) 측 제로-카피 모드부터 시작한다.
송신 측 제로-카피 동작은 가장 오래된 기능으로, 2017년 8월 커밋 f214f915e7db (“tcp: enable MSG_ZEROCOPY”)에서 추가됐다. 이 기능은 TCP 소켓에서 sendmsg() 시스템 콜로 데이터를 보낼 때 MSG_ZEROCOPY 플래그를 지정할 수 있게 해준다. sendmsg()는 send()처럼 데이터 자체를 인자로 넘기는 대신, 데이터 버퍼 포인터(들)를 담은 iovec 구조체를 받는다.
이 플래그를 사용하면 커널은 사용자 공간에서 커널로 데이터를 복사하지 않는다. 대신 사용자 공간 데이터 버퍼를 직접 참조하는 skb 구조체를 구성해 네트워크 스택 아래로 전달한다. 커널 TCP 스택은 평소처럼 TCP 패킷(들)의 헤더를 생성하지만, 데이터를 담는 동일한 버퍼 앞에 헤더를 붙이는 대신 헤더는 별도의(커널 메모리) 버퍼에 놓인다.
이는 또한 네트워크 디바이스가 scatter-gather DMA(서로 다른 데이터 조각이 서로 다른 위치에서 오는 것을 허용하는 DMA) 작업을 지원하지 않는 경우, 제로-카피 송신이 동작하지 않고 커널이 결국 데이터를 복사하게 됨을 의미한다. 이 내용과 MSG_ZEROCOPY 사용 방법에 대한 더 자세한 설명은 커널 문서에 정리돼 있다.
메모리가 사용자 공간에서 네트워크 디바이스로 직접 복사되므로, 사용자 공간 애플리케이션은 송신이 완료될 때까지 해당 메모리를 수정하지 않은 채 유지해야 한다. sendmsg() 자체는 비동기(asynchronous)로 동작하므로, 이 시스템 콜은 송신 완료를 기다리지 않고 반환한다. 대신 메모리 버퍼가 더 이상 스택에서 필요 없어지면 커널이 사용자 공간에 “버퍼를 재사용해도 된다”는 알림을 돌려준다.
이 알림은 소켓 에러 큐(socket error queue)를 통해 전달되며(recvmsg()에 MSG_ERRQUEUE 플래그 지정), 사용자 공간 애플리케이션은 메모리를 해제하거나 재사용할 수 있는 시점을 알기 위해 이를 폴링해야 한다. 알림에는 또한 제로-카피가 성공하지 못해(예: 하드웨어 지원 부재) 커널이 어떤 이유로든 데이터를 복사해야 했는지 여부를 나타내는 플래그도 포함된다.
앞의 API를 보면, 사실상 비동기 데이터 전송 API라는 점을 눈치챘을 것이다. 커널에는 이미 다양한 작업을 위한 범용 비동기 API로 io_uring이 존재한다. 그리고 실제로 io_uring은 2022년에 TCP 제로-카피 지원을 추가했다.
io_uring에서 제로-카피 송신을 사용하는 것은 비교적 간단하다. 일반 send 작업 대신 사용할 수 있는 새로운 io_uring_prep_send_zc() 작업이 추가됐고, 비슷한 방식으로 동작한다. 또한 sendmsg() API에서 에러 큐에 올리던 것과 유사하게, 메모리 버퍼가 더 이상 필요 없어졌을 때 이를 알리는 새로운 알림(notification) 타입도 추가됐다. 자세한 내용은 위에 링크된 제출 메일(패치 시리즈)에서 확인할 수 있다.
송신 측 제로-카피가 비교적 직관적인 반면, 수신 측은 조금 더 복잡하다. 송신 측에서는 scatter-gather DMA를 이용해 패킷 헤더와 데이터 페이로드를 서로 다른 메모리 버퍼에서 조립할 수 있다. 그러나 수신에서는 그 반대가 필요하다. 즉 커널은 TCP 헤더를 처리해야 하지만, 페이로드는 목적지 버퍼로 곧장 들어가야 한다.
이를 위해서는 하드웨어의 도움이 필요하다. 구체적으로 NIC가 다음 기능들을 지원해야 한다:
TCP header split: 하드웨어가 패킷 헤더를 파싱해서 TCP 헤더와 데이터의 경계를 식별한 뒤, 헤더와 데이터를 서로 다른 두 메모리 위치로 보낼 수 있는 능력.
수신 큐 page_pool 메모리 바인딩 작업: 드라이버가 하드웨어에 메모리 버퍼를 공급하는 기반 메모리 제공자(allocator/backing provider)를 큐 단위(per-queue) 로 교체할 수 있게 해주는 드라이버 기능. 이는 네트워크 패킷 메모리를 위한 커널 추상화인 page_pool에 의존하며, 최근 몇 년간 네트워크 드라이버에서 채택이 늘고 있다.
이 전제 조건이 갖춰지면, 애플리케이션은 특정 NIC 수신 큐에 대해 제로-카피 수신에 사용할 메모리 영역을 등록(register)할 수 있다. 이를 위한 인터페이스로는 io_uring 작업 io_uring_register_ifq()와 netlink 명령 NETDEV_CMD_BIND_RX가 있는데, 후자는 실제로는 사용자 공간 메모리 버퍼와 함께 사용할 수 없다(아래의 디바이스 메모리 섹션 참고).
내부적으로는 두 방식이 비슷하게 동작한다. 두 경우 모두 커널은 해당 메모리 영역에 대한 scatter-gather 테이블을 할당하고, 메모리를 페이지 크기 단위로 쪼갠 뒤, netdevice RXQ가 사용하는 page_pool 인스턴스에 커스텀 메모리 제공자를 바인딩한다. 네트워크 디바이스에서 TCP data split이 활성화돼 있지 않으면 등록은 실패한다.
page_pool 구조체는 NIC 드라이버가 NIC 수신 링(receive ring)을 채우는 데 쓰이는 페이지를 할당할 때 사용된다. 커스텀 백킹 제공자를 사용하면, 이 페이지들은 사용자 공간 애플리케이션이 등록한 메모리 영역에서 할당된다. 이것이 패킷 데이터가 올바른 메모리 영역으로 직접 복사될 수 있게 해주는 핵심이다.
메모리 제공자 설정은 각 RXQ가 한 번만 바인딩되도록 보장하지만, 여러 RXQ가 동일한 바인딩(및 메모리 제공자)을 공유할 수는 있다.
이 설계의 한 가지 결과는, 해당 큐에 메모리 버퍼가 등록되면 그 큐로 들어오는 모든 트래픽이 바인딩된 메모리 어딘가에 들어가게 된다는 점이다. 즉 보통은 제로-카피 버퍼로 들어가야 하는 트래픽만 그 수신 큐로 향하도록 보장하기 위해 플로우 스티어링(flow steering)이 필요하다. 이를 지원하는 NIC는 대개 강력한 플로우 스티어링 기능을 내장하고 있지만, 커널이 이를 올바르게 설정했는지 보장해주지는 않는다. 제로-카피를 사용하는 애플리케이션이 모든 구성이 제대로 되어 있는지 스스로 보장해야 한다.
또 다른 결과는 애플리케이션이 등록된 메모리 내에서 패킷 데이터가 어디에 들어갈지를 조절할 방법이 없다는 점이다. 메모리 버퍼는 전체로 등록되고, 커널이 앞서 설명한 것처럼 페이지 크기 조각으로 나눈다. 데이터가 도착하면 애플리케이션은 데이터 버퍼가 준비되는 대로 알림을 받는데, 순서에 대한 보장은 없다. 즉 애플리케이션은 데이터가 임의의 메모리 조각에 흩어져 있을 수 있음을 처리할 수 있어야 한다(송신 측에서 하드웨어가 scatter-gather 데이터를 지원해야 하는 것과 유사하다).
TCP 제로-카피 그림에서 마지막으로 중요한 요소는 사용자 공간이 아닌 메모리(예: 스토리지 디바이스나 GPU 메모리)에 대해 제로-카피를 지원하는 것이다. 이런 방식으로 TCP와 디바이스 메모리를 함께 사용하는 지원은 2024년 9월에 추가됐다. 흥미롭게도 이는 일반적인 제로-카피 기능이 2025년 2월에 io_uring에 추가되기 이전이었다.
수신 측에서 디바이스 메모리를 사용하는 방식은 사용자 공간 메모리로 제로-카피 수신을 하는 방식과 유사하다. 데이터 버퍼를 NIC 수신 큐에 등록하고, 들어오는 패킷에 그것을 사용한다. 차이점은 등록 명령으로 제공되는 메모리가 사용자 공간 메모리 버퍼가 아니라, 디바이스 메모리 덩어리를 가리키는 dma-buf 파일 디스크립터라는 점이다. 이러한 디바이스 메모리 버퍼는 io_uring 또는 앞서 언급한 netlink API를 통해 등록할 수 있다. netlink API를 사용한다면, recvmsg() 호출 시 MSG_SOCK_DEVMEM 플래그를 전달해 들어오는 데이터에 대한 알림을 받을 수 있다.
송신 측에서 디바이스 메모리로부터의 제로-카피는 사용자 공간에서의 제로-카피와 비슷하지만, 디바이스 메모리를 사용하기 위해 한 단계가 추가로 필요하다. NETDEV_CMD_BIND_TX genl 명령을 이용해 메모리에 대한 바인딩을 생성하여, 메모리 영역을 네트워크 디바이스의 전송 큐에 바인딩한다. 바인딩을 생성할 때 커널은 RX 경우와 동일한 scatter-gather 테이블을 만들고, 추가로 바인딩된 영역 내 메모리 오프셋을 dmabuf 바인딩을 참조하는 iovec 구조체로 매핑하는 tx_vec 테이블도 채운다. 이 등록이 끝나면 위에서 설명한 방식과 동일하게 제로-카피 전송을 수행할 수 있는데, 차이점은 sendmsg()에 제공되는 메모리 주소가 절대 주소가 아니라 바인딩된 메모리 내 오프셋으로 해석된다는 것이다.
디바이스 메모리 제로-카피의 TX 경로는 2025년 5월 커밋 bd61848900bf (“net: devmem: Implement TX path”)에서 추가됐으며, 아직은 디바이스 드라이버 지원이 다소 제한적이다. 또한 아직 io_uring을 통해서는 지원되지 않는다. 다만 언젠가는 io_uring 지원도 추가될 가능성이 높고, 더 많은 드라이버가 이를 지원하게 될 것으로 보인다.
앞선 섹션들에서 보이듯, TCP 제로-카피 지원은 구성 요소가 꽤 많고, 실제로 동작하게 만들려면 비교적 복잡한 설정이 필요하다. 그렇다면 그만한 가치가 있을까?
io_uring 패치 시리즈의 커버 레터에서는 단일 CPU에서 단일 플로우에 대해 처리량이 30~40% 향상된다고 언급한다. 이는 내 테스트에서 본 개선폭과도 대체로 비슷한 수준이다. 다만 이 수치는 고속 NIC 환경에서, 많은 데이터를 전송해 설정 비용을 큰 전송량에 걸쳐 상쇄(amortise)할 수 있는 테스트에서의 결과임을 유의해야 한다. 그런 의미에서 제로-카피 지원은 낮은 지연시간(latency)을 위한 최적화라기보다는 벌크 처리량(throughput) 최적화에 가깝다. 노트북 환경에서는 큰 이득을 보기 어렵겠지만, 데이터센터에서는 특정 애플리케이션에 대해 분명한 이점이 있을 수 있다.
특히 나는 이것이 RDMA나 인피니밴드(Infiniband) 같은 특수 전송/패브릭의 대안이 될 가능성에 기대를 걸고 있다. 즉 이런 고가의 기술들에 필요한 특수 하드웨어 없이도, 더 많은 환경에서 매우 높은 데이터 전송률을 제공할 수 있게 해줄 수 있다.