리눅스 네트워킹 스택의 구조

네트워크 패킷 하나가 원격 서버에서 출발하여 로컬 애플리케이션에 도달하기까지, 리눅스 커널 내부에서는 놀랍도록 복잡한 과정이 진행된다. 이 과정을 이해하려면 리눅스 네트워킹 스택의 계층 구조를 먼저 파악해야 한다.

리눅스 네트워킹 스택은 OSI 모델과 유사한 계층 구조를 따르지만, 실제 구현은 TCP/IP 4계층 모델에 더 가깝다. 최상위에 소켓 인터페이스가 있고, 그 아래로 전송 계층(TCP, UDP), 네트워크 계층(IP), 링크 계층(이더넷, 드라이버)이 위치한다. 각 계층은 자신의 헤더를 처리하고, 페이로드를 다음 계층으로 전달하는 방식으로 동작한다.

┌──────────────────────────────────┐
│        애플리케이션 (유저 공간)      │
├──────────────────────────────────┤
│          소켓 인터페이스             │
├──────────────────────────────────┤
│     전송 계층 (TCP / UDP)          │
├──────────────────────────────────┤
│   네트워크 계층 (IP / 라우팅)        │
├──────────────────────────────────┤
│      Netfilter (패킷 필터링)        │
├──────────────────────────────────┤
│   링크 계층 (이더넷 / 드라이버)       │
├──────────────────────────────────┤
│          NIC (하드웨어)             │
└──────────────────────────────────┘

소켓 API

유저 공간 프로그램이 네트워크와 상호작용하는 유일한 방법은 소켓 API이다. socket(), bind(), listen(), accept(), connect(), send(), recv() — 이 일련의 시스템 콜이 모든 네트워크 프로그래밍의 기반이 된다.

소켓이 흥미로운 이유는 파일 추상화와 통합되어 있다는 점이다. 소켓은 파일 디스크립터를 반환하며, 일반 파일처럼 read()write()로 데이터를 주고받을 수 있다. "모든 것은 파일이다"라는 유닉스 철학이 네트워크에도 적용된 것이다. 이 덕분에 파일 I/O를 위해 설계된 select(), poll(), epoll() 같은 다중화 메커니즘을 네트워크 소켓에도 동일하게 사용할 수 있다.

커널 내부에서 소켓은 struct socketstruct sock이라는 두 가지 구조체로 표현된다. struct socket은 VFS와의 인터페이스를 담당하고, struct sock은 실제 네트워크 프로토콜 상태를 관리한다. TCP 소켓의 경우 struct sock을 확장한 struct tcp_sock이 사용되며, 여기에 시퀀스 번호, 윈도우 크기, 혼잡 제어 상태 등 TCP에 고유한 모든 정보가 담겨 있다.

패킷의 수신 경로

네트워크 패킷이 NIC에서 애플리케이션까지 전달되는 과정을 따라가 보면 커널의 네트워킹 구현이 어떻게 동작하는지 구체적으로 이해할 수 있다.

패킷이 NIC에 도착하면 DMA(Direct Memory Access)를 통해 커널 메모리의 링 버퍼에 복사된다. NIC는 CPU에 인터럽트를 발생시켜 패킷의 도착을 알린다. 초기 리눅스에서는 모든 패킷마다 인터럽트가 발생했지만, 고속 네트워크에서는 초당 수백만 개의 패킷이 도착할 수 있어 인터럽트 처리 자체가 병목이 되었다.

이 문제를 해결하기 위해 NAPI(New API)가 도입되었다. NAPI는 인터럽트와 폴링을 결합한 방식이다. 첫 번째 패킷이 도착하면 인터럽트가 발생하지만, 이후 추가 인터럽트를 비활성화하고 폴링 모드로 전환하여 링 버퍼에서 패킷을 일괄 처리한다. 처리할 패킷이 없어지면 다시 인터럽트 모드로 돌아간다. 이 적응형 방식이 낮은 트래픽에서는 인터럽트의 낮은 지연을 제공하고, 높은 트래픽에서는 폴링의 높은 처리량을 제공하는 것이다.

NIC 드라이버가 패킷을 처리하면 sk_buff 구조체에 담겨 상위 계층으로 전달된다. 링크 계층에서 이더넷 헤더가 파싱되고, 네트워크 계층에서 IP 헤더가 처리되며 라우팅 결정이 내려진다. 이 패킷이 로컬 호스트를 향하는 것인지, 포워딩해야 하는 것인지가 여기서 결정된다. 로컬 패킷은 전송 계층으로 올라가 TCP 또는 UDP 처리를 거치고, 최종적으로 소켓의 수신 큐에 배치되어 recv() 시스템 콜을 통해 유저 공간으로 전달된다.

sk_buff: 패킷의 컨테이너

sk_buff(소켓 버퍼)는 리눅스 네트워킹 스택에서 가장 중요한 자료 구조이다. 모든 패킷은 sk_buff 구조체로 표현되며, 패킷 데이터뿐만 아니라 메타데이터(수신 인터페이스, 프로토콜 정보, 타임스탬프 등)도 함께 담고 있다.

sk_buff의 설계에서 주목할 점은 각 계층이 헤더를 추가하거나 제거할 때 데이터를 복사하지 않는다는 것이다. 대신 포인터를 조작하여 헤더의 시작 위치를 변경한다. 송신 시에는 각 계층이 자신의 헤더를 데이터 앞에 추가하고(headroom 사용), 수신 시에는 각 계층이 자신의 헤더를 건너뛰어 다음 계층의 페이로드를 가리킨다. 이 제로카피(zero-copy) 방식이 네트워킹 성능에 결정적인 기여를 하는 것이다.

sk_buff 구조:
┌─────────┬──────────┬──────────┬──────────┬─────────┐
│headroom │이더넷 헤더│  IP 헤더  │ TCP 헤더  │ 페이로드 │
└─────────┴──────────┴──────────┴──────────┴─────────┘
           ↑                                ↑
     수신 시 파싱 방향 →              송신 시 헤더 추가 ←

TCP/IP 구현

리눅스의 TCP 구현은 커널에서 가장 복잡한 코드 중 하나이다. 단순한 신뢰성 전송을 넘어서 혼잡 제어, 흐름 제어, 선택적 확인 응답(SACK), 타임스탬프, 윈도우 스케일링 등 수십 년간 추가된 최적화와 확장이 포함되어 있다.

TCP 연결 수립은 3-way 핸드셰이크로 이루어진다. 서버 측에서는 listen()을 호출하면 SYN 큐(반개방 연결 큐)와 Accept 큐(완료된 연결 큐)가 생성된다. SYN 패킷이 도착하면 SYN 큐에 들어가고 SYN+ACK가 전송된다. 클라이언트의 ACK가 도착하면 연결이 완료되어 Accept 큐로 이동하며, accept() 시스템 콜이 이 큐에서 연결을 꺼낸다.

과연 이 두 큐의 크기가 중요한가? 매우 중요하다. SYN 큐가 가득 차면 새로운 연결을 받을 수 없게 되고, 이는 SYN 플러드 공격의 표적이 되기도 한다. 리눅스는 SYN 쿠키(SYN cookies)라는 방어 메커니즘을 제공하여, SYN 큐를 사용하지 않고도 정상적인 연결을 수립할 수 있게 한다. Accept 큐의 크기는 listen()의 backlog 파라미터로 제어되며, 서버의 연결 수용 속도와 직결되는 것이다.

혼잡 제어는 TCP의 또 다른 핵심 영역이다. 리눅스는 CUBIC을 기본 혼잡 제어 알고리즘으로 사용하며, BBR 같은 최신 알고리즘도 지원한다. 이들 알고리즘은 패킷 손실과 RTT(왕복 지연 시간)를 기반으로 전송 속도를 동적으로 조절하여 네트워크 혼잡을 방지하면서도 가용 대역폭을 최대한 활용하려 한다.

Netfilter와 패킷 필터링

Netfilter는 리눅스 커널의 패킷 필터링 프레임워크이다. 패킷이 네트워크 스택을 통과하는 경로에 다섯 개의 훅 포인트를 제공하며, 각 훅에 규칙을 등록하여 패킷을 수락, 거부, 수정할 수 있다.

패킷 수신 경로:

  → PREROUTING → 라우팅 결정 → INPUT → 로컬 프로세스
                      ↓
                   FORWARD → POSTROUTING → 외부로 전송

패킷 송신 경로:

  로컬 프로세스 → OUTPUT → 라우팅 결정 → POSTROUTING → 외부로 전송

iptables는 오랫동안 Netfilter의 유저 공간 인터페이스로 사용되어 왔다. 테이블(filter, nat, mangle)과 체인(INPUT, OUTPUT, FORWARD 등)의 조합으로 규칙을 구성한다. 그러나 iptables는 규칙이 많아질수록 성능이 선형적으로 저하되는 구조적 한계가 있다. 모든 패킷이 규칙 목록을 순차적으로 탐색해야 하기 때문이다.

nftables는 iptables의 후속으로 설계되었다. 가장 큰 차이는 규칙을 가상 머신의 바이트코드로 컴파일하여 실행한다는 점이다. 집합(set)과 맵(map) 자료 구조를 지원하여 O(1) 탐색이 가능하며, 하나의 프레임워크로 IPv4, IPv6, ARP, 브릿지 필터링을 통합 처리한다. 커널 4.x 이후의 배포판에서는 nftables가 표준이 되어가고 있는 것이다.

네트워크 네임스페이스

네트워크 네임스페이스는 리눅스의 네트워킹 스택을 격리하는 메커니즘이다. 각 네임스페이스는 독립적인 네트워크 인터페이스, 라우팅 테이블, iptables 규칙, 소켓 목록을 가진다. 하나의 물리 호스트 위에서 완전히 분리된 여러 네트워크 환경을 운영할 수 있는 것이다.

네트워크 네임스페이스는 컨테이너 네트워킹의 기반이다. Docker 컨테이너를 실행하면 각 컨테이너는 자신만의 네트워크 네임스페이스를 갖게 되며, 호스트의 네트워크와는 격리된 환경에서 동작한다. 컨테이너가 외부와 통신하려면 네임스페이스 간의 연결이 필요한데, 이때 가상 네트워크 장치가 사용된다.

가상 네트워크 장치

리눅스는 소프트웨어로 구현된 다양한 가상 네트워크 장치를 제공한다. 이들은 물리적 네트워크 장치와 동일한 인터페이스를 가지지만, 하드웨어 대신 커널 내부에서 패킷을 처리한다.

veth(Virtual Ethernet) 쌍은 항상 두 개가 한 쌍으로 생성되며, 한쪽에 들어간 패킷은 다른 쪽으로 나온다. 네트워크 네임스페이스를 연결하는 가상 케이블 역할을 하며, 컨테이너 네트워킹에서 컨테이너와 호스트 브릿지를 연결하는 데 사용된다.

브릿지(bridge)는 가상 L2 스위치이다. 여러 네트워크 인터페이스를 하나의 브릿지에 연결하면, 이들 간에 이더넷 프레임이 전달된다. Docker의 기본 네트워킹 모드가 바로 이 브릿지를 사용한다. docker0 브릿지가 생성되고, 각 컨테이너의 veth 쌍 한쪽이 이 브릿지에 연결되는 구조이다.

tun/tap 장치는 유저 공간과 커널 네트워크 스택 사이의 터널을 제공한다. tun은 L3(IP) 수준에서, tap은 L2(이더넷) 수준에서 동작한다. VPN 소프트웨어가 이 장치를 사용하는 대표적인 예이다. OpenVPN은 tun/tap 장치를 통해 암호화된 터널 안에서 패킷을 주고받는 것이다.

컨테이너 네트워킹 구조:

┌─── 컨테이너 A ──┐   ┌─── 컨테이너 B ──┐
│  eth0 (veth)    │   │  eth0 (veth)    │
└──────┬──────────┘   └──────┬──────────┘
       │                     │
  ┌────┴─────────────────────┴────┐
  │         docker0 (bridge)       │
  └──────────────┬────────────────┘
                 │ NAT
  ┌──────────────┴────────────────┐
  │        eth0 (물리 NIC)         │
  └───────────────────────────────┘

이러한 가상 네트워크 장치들의 조합으로 물리 네트워크 장비 없이도 복잡한 네트워크 토폴로지를 소프트웨어만으로 구현할 수 있다. 이것이 SDN(Software-Defined Networking)과 클라우드 네트워킹의 기반이 되는 것이다.

리눅스 네트워킹의 성능 최적화

현대 리눅스는 고성능 네트워킹을 위한 다양한 최적화 기법을 포함하고 있다. GRO(Generic Receive Offload)는 여러 작은 패킷을 하나의 큰 패킷으로 합쳐 상위 계층의 처리 횟수를 줄인다. GSO(Generic Segmentation Offload)는 송신 시 큰 패킷을 가능한 한 늦게 분할하여 각 계층의 처리 오버헤드를 줄인다. RSS(Receive Side Scaling)는 수신 패킷을 여러 CPU 코어에 분산하여 병렬 처리한다.

더 나아가 XDP(eXpress Data Path)는 패킷이 네트워크 스택에 진입하기 전, NIC 드라이버 수준에서 eBPF 프로그램을 실행하여 패킷을 처리할 수 있게 한다. 전체 네트워크 스택을 거치지 않으므로 DDoS 방어나 로드 밸런싱 같은 작업에서 극단적인 성능을 달성할 수 있다. 커널의 유연성과 성능을 동시에 추구하는 현대 리눅스 네트워킹의 방향성을 보여주는 기술인 것이다.

다음 포스트에서는 리눅스의 컨테이너와 가상화를 살펴본다.