100만 커넥션을 버티는 이벤트 루프 — C10K 문제부터 epoll과 io_uring까지
1999년 컴퓨터 과학자 댄 케겔(Dan Kegel)은 웹 엔지니어링 역사에 거대한 이정표가 된 화두를 던졌습니다. 바로 C10K 문제(Concurrently handling 10,000 connections) 였습니다. 단일 서버에서 1만 개의 동시 클라이언트 연결을 효율적으로 처리할 수 있는가에 대한 질문이었습니다. 당시 펜티엄급 고성능 하드웨어와 기가비트 이더넷 카드를 장착하고도 서버들은 1만 개의 연결 근처에 도달하기만 하면 극심한 메모리 고갈과 CPU 과열로 다운되었습니다.
동시 접속자 수백만 명이 상시 연결을 유지해야 하는 메신저와 채팅 서버 엔지니어들에게 C10K는 생존이 걸린 벽이었습니다. 하드웨어 스펙이 부족해서가 아니었습니다. 문제는 운영체제 커널이 수만 개의 네트워크 소켓을 감시하고 스레드를 스케줄링하는 기본 아키텍처 에 있었습니다.
1. 1 커넥션 1 스레드 모델의 파산
1990년대 후반의 전형적인 멀티스레드/멀티프로세스 서버 아키텍처는 클라이언트가 접속할 때마다 전담 작업자를 하나씩 붙여주는 구조였습니다.
// Traditional Thread-per-Connection Loop
while (1) {
int client_fd = accept(server_fd, ...);
pthread_create(&thread_id, NULL, handle_client, (void*)&client_fd);
}
이 모델은 동시 접속자가 수백 명 수준일 때는 직관적이고 훌륭하게 작동했습니다. 각 스레드는 블로킹 I/O를 사용해 read() 시스템 콜에서 상대방이 메시지를 보낼 때까지 잠들어 있으면 그만이었습니다.
그러나 동시 접속자가 1만 개를 넘어서는 순간 두 가지 물리적 장벽에 부딪혔습니다.
flowchart TD
subgraph Clients["10,000 Concurrent Client Connections"]
C1["Client 1"]
C2["Client 2"]
CD["..."]
CN["Client 10,000"]
end
subgraph Threads["Thread-per-Connection Model"]
T1["Thread 1 (8MB Stack)"]
T2["Thread 2 (8MB Stack)"]
TD["..."]
TN["Thread 10,000 (8MB Stack)"]
end
C1 --> T1
C2 --> T2
CD --> TD
CN --> TN
Threads --> BOT["<b>Physical Bottlenecks</b><br/>• 80GB Stack Memory Consumption<br/>• Massive Context Switching & CPU Thrashing"]
- 스택 메모리의 고갈 : 당시 리눅스 스레드의 기본 스택 크기는 8MB(현재도 2~8MB 수준)였습니다. 1만 개의 스레드를 띄우는 것만으로 가상 메모리 수십 기가바이트가 증발했습니다. 스택 크기를 수백 킬로바이트로 줄인다 해도 커널 내부의 스레드 제어 블록(TCB)과 페이지 테이블 오버헤드는 감당할 수 없었습니다.
- 컨텍스트 스위칭(Context Switching) 쓰래싱 : CPU 코어는 기껏해야 1~4개에 불과한데 스레드가 1만 개라면, 운영체제 스케줄러는 코어 위에서 스레드를 뺏고 끼워 넣느라 대부분의 사이클을 소비합니다. 레지스터 복원, TLB 플러시, L1/L2 캐시 미스가 폭발하면서 CPU 사용률은 100%를 찍지만 정작 패킷 처리는 단 한 건도 하지 못하는 ‘쓰래싱(Thrashing)’ 상태에 빠졌습니다.
메신저 연결의 본질은 “대부분의 연결이 99%의 시간 동안 아무런 말을 하지 않는 유휴 상태” 라는 점입니다. 말을 하지도 않는 1만 개의 연결을 위해 1만 개의 스레드를 유지하는 것은 순수한 낭비였습니다.
2. I/O 멀티플렉싱의 여명: select와 poll의 O(N) 한계
이에 대한 해법으로 등장한 것이 단일 스레드가 수천 개의 파일 디스크립터(FD)를 한꺼번에 감시하는 I/O 멀티플렉싱(Multiplexing) 이었습니다. 대표적인 시스템 콜이 select()와 poll()이었습니다.
하지만 select()는 치명적인 구조적 결함을 안고 있었습니다.
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
첫째, FD_SETSIZE의 1024 하드 리밋 이었습니다. select의 fd_set은 고정된 비트마스크 배열로 구현되어 있어 기본적으로 프로세스당 1,024개 이상의 소켓을 감시할 수 없었습니다.
둘째, 선형 탐색의 병목 이었습니다. poll()은 비트마스크 대신 pollfd 구조체 배열을 사용하여 1024 한계를 풀었지만, 근본적인 탐색 복잡도는 여전히 이었습니다.
sequenceDiagram
autonumber
actor App as User Space App
participant Kernel as Kernel Space
App->>Kernel: select() / poll(): Copy array of 10,000 FDs (Memory Copy Overhead)
Note over Kernel: O(N) Linear Scan: Check FD 0, 1, 2 ... 9999<br/>(Only FD 42 is ready)
Kernel-->>App: Return 10,000 FDs with bitmask/events (Memory Copy Overhead)
Note over App: O(N) User Scan: Loop 10,000 times to locate FD 42
1만 개의 소켓을 감시할 때 단 1개의 소켓에 메시지가 도착해도 일어나는 일:
- 애플리케이션은 1만 개짜리 소켓 배열을 커널 메모리로 복사해 넘깁니다.
- 커널은 1만 개의 소켓을 0번부터 9999번까지 하나하나 순회하며() 데이터가 있는지 검사합니다.
- 커널이 결과를 반환하면, 유저 프로세스 역시 1만 번 루프를 돌며 어떤 놈에 불이 켜졌는지 뒤져야 합니다.
연결 수가 늘어날수록 유효한 데이터 처리 시간보다 소켓 배열을 복사하고 순회하는 데 드는 시간이 지수적으로 폭증했습니다. 10만 개, 100만 개의 연결은 꿈도 꿀 수 없었습니다.
3. epoll의 혁신: 커널 레벨의 O(1) 이벤트 통지
2002년 리눅스 2.6 커널에 도입된 epoll (FreeBSD의 kqueue, Windows의 IOCP)은 I/O 멀티플렉싱의 계산 복잡도를 에서 ****로 끌어내리며 C10K를 단숨에 과거의 역사로 만들었습니다.
epoll의 핵심 철학은 “소켓 등록과 이벤트 대기를 완벽하게 분리하자” 는 것이었습니다.
flowchart TD
subgraph KernelSpace["Kernel Space epoll Architecture"]
RBT["<b>Red-Black Tree</b><br/>Stores all monitored FDs (100,000+ sockets)<br/><i>O(log N) insertion/deletion</i>"]
RL["<b>Ready List (Doubly Linked List)</b><br/>Only active FDs with pending I/O events"]
CB["NIC Hardware Interrupt & Socket Callback"]
end
APP["User Application"] -->|"1. epoll_ctl(ADD)"| RBT
NIC["NIC Network Card Packet Arrival"] --> CB
CB -->|"2. Append active FD (e.g. FD 42)"| RL
APP <-->|"3. epoll_wait(): O(1) Copy Active Only"| RL
epoll은 커널 내부에 두 개의 핵심 자료구조를 유지합니다.
- 레드-블랙 트리 (Red-Black Tree) :
epoll_create로 생성된 커널 공간에epoll_ctl을 통해 감시할 소켓 FD들을 등록합니다. 균형 이진 트리이므로 10만 개의 소켓이 등록되어도 추가·삭제·조회 비용이 에 불과하며, 매번 유저 공간에서 소켓 목록을 복사해 넘길 필요가 없습니다. - 준비 큐 (Ready List) : 네트워크 카드(NIC)에 패킷이 도착해 하드웨어 인터럽트가 발생하면, 소켓에 등록된 커널 콜백 함수가 작동하여 데이터가 도착한 소켓만을 즉시 ‘Ready List(이중 연결 리스트)‘에 밀어 넣습니다.
그리고 애플리케이션이 epoll_wait()를 호출하면, 커널은 전체 소켓 트리를 뒤지는 것이 아니라 오직 Ready List에 담긴 ‘진짜 이벤트가 발생한 소켓들’만 유저 버퍼로 복사 해 줍니다. 100만 개의 소켓 중 3개에만 데이터가 들어왔다면, epoll_wait는 정확히 3개의 FD만 반환하며 복잡도는 입니다.
레벨 트리거(LT) vs 에지 트리거(ET)
epoll은 이벤트를 감지하는 두 가지 방식을 제공하며, 이는 고성능 네트워크 엔진의 설계 패턴을 결정짓습니다.
- 레벨 트리거 (Level Triggered, 기본값) : 소켓 수신 버퍼에 읽지 않은 데이터가 남아 있는 한
epoll_wait가 호출될 때마다 계속해서 이벤트를 알려줍니다. 안전하고 실수가 적지만 불필요한 이벤트 호출이 잦습니다. - 에지 트리거 (Edge Triggered, EPOLLET) : 소켓의 상태가 변화한 그 순간(버퍼가 비어 있다가 데이터가 들어온 시점)에 단 한 번만 이벤트를 쏩니다. 따라서 애플리케이션은
read()시스템 콜이EAGAIN이나EWOULDBLOCK에러를 반환할 때까지 논블로킹(Non-blocking) 루프로 버퍼의 데이터를 남김없이 긁어와야 합니다. 제대로 구현하면 시스템 콜 호출 빈도를 극적으로 줄일 수 있어 Nginx나 고성능 채팅 엔진에서 애용됩니다.
이 epoll 아키텍처 덕분에 왓츠앱(WhatsApp)은 단일 머신에서 200만 개가 넘는 TCP 연결을 단 수십 개의 워커 프로세스로 거뜬히 유지할 수 있었습니다.
4. 차세대 비동기 I/O: io_uring의 무복사 혁명
epoll이 C10K를 해결했지만, 현대의 100Gbps 고속 네트워크와 NVMe SSD 환경에서 또 다른 병목이 드러났습니다. 바로 시스템 콜(System Call) 자체의 오버헤드 입니다.
epoll_wait로 이벤트가 발생한 소켓을 알아내도, 실제 데이터를 읽고 쓰려면 소켓마다 read()와 write() 시스템 콜을 별도로 날려야 했습니다. 시스템 콜이 발생할 때마다 CPU는 유저 모드와 커널 모드를 오가며 레지스터를 저장하고 복원해야 합니다. 2018년 인텔 CPU의 스펙터(Spectre)와 멜트다운(Meltdown) 보안 취약점 패치 이후 유저-커널 전환 비용은 더욱 비싸졌습니다.
2019년 리눅스 5.1 커널에 젠스 악스보(Jens Axboe)가 도입한 **io_uring**은 이 시스템 콜 오버헤드마저 제로(Zero)로 수렴시켰습니다.
flowchart LR
subgraph User["User Space"]
UA["Application Process"]
end
subgraph Shared["Shared Memory (Lockless Ring Buffers)"]
direction TB
SQ["<b>Submission Queue (SQ)</b><br/>App writes I/O requests directly (No syscall)"]
CQ["<b>Completion Queue (CQ)</b><br/>Kernel posts I/O results directly"]
end
subgraph Kernel["Kernel Space"]
KTH["Kernel Worker / Polling Thread"]
end
UA -->|"Enqueue Requests"| SQ
SQ -->|"Read Requests"| KTH
KTH -->|"Post Completed Results"| CQ
CQ -->|"Reap Results"| UA
io_uring은 유저 공간과 커널 공간이 공유 메모리(Shared Memory) 기반의 두 가지 링 버퍼(Ring Buffer) 를 함께 바라보도록 만듭니다.
- 제출 큐 (Submission Queue, SQ) : 유저가 읽거나 쓸 소켓 요청을 링 버퍼에 그냥 밀어 넣습니다.
- 완료 큐 (Completion Queue, CQ) : 커널이 백그라운드에서 I/O를 비동기로 수행하고 결과를 완료 큐에 기록합니다.
커널 폴링 모드(IORING_SETUP_SQPOLL)를 켜면 커널 전용 스레드가 SQ를 계속 감시하므로, 애플리케이션은 단 한 번의 시스템 콜도 호출하지 않고 메모리 버퍼에 읽기/쓰기 작업을 밀어 넣는 것만으로 수백만 건의 네트워크 I/O를 초당 처리 할 수 있습니다.
5. 이벤트 루프의 본질과 시스템 설계의 트레이드오프
select의 1024 한계에서 시작해 epoll의 이벤트 큐, 그리고 io_uring의 제로 시스템 콜로 이어지는 25년의 진화는 한 가지 명확한 엔지니어링 원리로 귀결됩니다. “느린 하드웨어와 무거운 커널을 기다리느라 스레드를 세워두지 말고, 데이터가 준비된 순간에만 일꾼을 깨워 연속으로 처리하라.”
Node.js의 libuv, Nginx의 코어 루프, Netty의 EventLoopGroup, Redis의 싱글 스레드 아키텍처는 모두 이 비동기 이벤트 루프 철학 위에서 탄생했습니다.
그러나 이벤트 드리븐 모델 역시 만능은 아닙니다. 워커 스레드가 소켓 이벤트 핸들러 내부에서 무거운 암호화 연산이나 CPU 바운드 작업을 처리하는 순간, 전체 이벤트 루프가 멈춰 서서 수만 개의 다른 소켓들이 함께 얼어붙는 블로킹 전염(Blocking Contagion) 위험을 안고 있습니다.
따라서 현대의 고성능 실시간 시스템은 I/O를 전담하는 비동기 이벤트 루프(I/O Threads)와 비즈니스 로직 및 연산을 전담하는 작업 풀(Worker Pool)을 철저히 분리하여, 커널의 네트워크 파이프라인이 1마이크로초도 쉬지 않고 돌 수 있도록 격리 설계해야 합니다.