I/O 멀티플렉싱
이전 절에서 TCP 다중 클라이언트 처리를 위해 fork나 스레드를 사용했습니다. 하지만 클라이언트가 10,000개라면 10,000개의 스레드가 필요합니다. 각 스레드는 메모리를 소비하고, 컨텍스트 스위칭 비용이 누적됩니다. 이것이 바로 C10K 문제(10,000개의 동시 연결을 처리하는 문제)의 핵심입니다.
Blocking I/O의 한계
기본적으로 recv()는 블로킹(blocking) 호출입니다. 데이터가 도착할 때까지 스레드가 멈추고 기다립니다.
Blocking I/O (스레드 모델)
스레드 1: [recv() ■■■■■■■■] [처리] [send()]
스레드 2: [recv() ■■■] [처리] [send()]
스레드 3: [recv() ■■■■■■■■■■■■] [처리] [send()]
■ = 아무것도 안 하고 대기 (CPU 낭비)
→ 10,000 연결 = 10,000 스레드 ≈ 10GB 메모리
I/O 멀티플렉싱 (이벤트 모델)
단일 스레드: [감시] → 소켓A 준비 → [처리A] → [감시]
→ 소켓C 준비 → [처리C] → [감시]
→ 소켓A,B 준비 → [처리A] → [처리B]
→ 10,000 연결 = 1 스레드, 준비된 것만 처리해결 방법은 I/O 멀티플렉싱입니다. 하나의 스레드가 여러 소켓을 동시에 감시하면서, 데이터가 준비된 소켓만 골라서 처리합니다.
select
select()는 가장 오래된 I/O 멀티플렉싱 시스템 콜입니다. 감시할 소켓들의 집합을 등록하고, 그 중 하나 이상이 준비될 때까지 대기합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
int main() {
int server_fd, max_fd, client_fds[FD_SETSIZE];
fd_set read_fds;
struct sockaddr_in addr;
char buffer[1024];
int num_clients = 0;
server_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(8080);
bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(server_fd, 128);
while (1) {
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
max_fd = server_fd;
for (int i = 0; i < num_clients; i++) {
FD_SET(client_fds[i], &read_fds);
if (client_fds[i] > max_fd) max_fd = client_fds[i];
}
select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 새 연결 수락
if (FD_ISSET(server_fd, &read_fds)) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd >= 0) {
client_fds[num_clients++] = client_fd;
}
}
// 기존 클라이언트 처리
for (int i = 0; i < num_clients; i++) {
if (FD_ISSET(client_fds[i], &read_fds)) {
ssize_t n = recv(client_fds[i], buffer, sizeof(buffer) - 1, 0);
if (n <= 0) {
close(client_fds[i]);
client_fds[i] = client_fds[--num_clients];
i--;
} else {
buffer[n] = '\0';
send(client_fds[i], buffer, n, 0);
}
}
}
}
}하지만 select()에는 한계가 있습니다. 매 호출마다 전체 fd_set을 커널에 복사하고, 커널이 모든 소켓을 순회하며 검사합니다. FD_SETSIZE(보통 1024) 제한도 있습니다.
poll
poll()은 select()의 FD_SETSIZE 제한을 제거한 개선 버전입니다. 배열 기반이므로 감시할 소켓 수에 제한이 없습니다.
struct pollfd fds[MAX_CLIENTS];
fds[0].fd = server_fd;
fds[0].events = POLLIN;
int nfds = 1;
int ret = poll(fds, nfds, -1); // -1은 무한 대기
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
// 이 소켓에서 읽을 데이터가 있음
}
}select()보다 인터페이스가 깔끔하지만, 매 호출마다 전체 배열을 커널에 복사하고 순회하는 문제는 동일합니다.
epoll (Linux)
epoll은 Linux에서 대규모 동시 연결을 처리하기 위해 만들어진 시스템 콜입니다. select()와 poll()의 성능 문제를 근본적으로 해결합니다.
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
// events[i].data.fd에서 이벤트 발생
}소켓 수 select epoll
────────────────────────────────────────
100 빠름 빠름
1,000 약간 느림 빠름
10,000 매우 느림 빠름
100,000 사용 불가 빠름
select: O(n) — 매번 전체 소켓 순회
epoll: O(ready) — 준비된 소켓만 반환
select 매 호출
1. fd_set 전체 복사 (유저 → 커널)
2. 모든 fd 순회하며 상태 확인
3. fd_set 전체 복사 (커널 → 유저)
epoll
1. epoll_ctl()로 한 번 등록 (커널이 기억)
2. epoll_wait()는 준비된 것만 반환
3. 복사 없음, 순회 없음| 항목 | select | poll | epoll | kqueue |
|---|---|---|---|---|
| 플랫폼 | 모든 OS | 모든 OS | Linux | macOS/BSD |
| FD 제한 | 1024 | 없음 | 없음 | 없음 |
| 성능 | O(n) | O(n) | O(ready) | O(ready) |
| 등록 | 매번 복사 | 매번 복사 | 한 번 등록 | 한 번 등록 |
이벤트 루프의 원형
epoll(또는 kqueue)을 감싸는 무한 루프가 바로 이벤트 루프입니다. Node.js, Nginx, Redis 같은 고성능 서버의 핵심 구조입니다.
import selectors
import socket
sel = selectors.DefaultSelector()
def accept(server):
client, addr = server.accept()
client.setblocking(False)
sel.register(client, selectors.EVENT_READ, data=echo)
def echo(client):
data = client.recv(1024)
if data:
client.sendall(data)
else:
sel.unregister(client)
client.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 8080))
server.listen(128)
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, data=accept)
# 이벤트 루프
while True:
events = sel.select()
for key, mask in events:
callback = key.data
callback(key.fileobj)Python의 selectors 모듈은 플랫폼에 따라 자동으로 최적의 메커니즘을 선택합니다. Linux에서는 epoll, macOS에서는 kqueue, Windows에서는 select를 사용합니다.
서버/프레임워크 I/O 메커니즘 사용 언어
──────────────────────────────────────────────────
Nginx epoll/kqueue C
Node.js libuv(epoll/IOCP) JavaScript
Redis epoll C
Tornado epoll/kqueue Python
asyncio epoll/kqueue Python
Netty epoll/NIO Java
Node.js의 이벤트 루프:
libuv라는 라이브러리가 epoll/kqueue/IOCP를 추상화
JavaScript 코드가 콜백을 등록
app.get("/", handler)에서 handler가 콜백
요청이 들어오면 이벤트 루프가 handler 호출결국 네트워크 프로그래밍의 핵심은 기다리는 방법입니다. 블로킹으로 기다리면 스레드가 낭비되고, 이벤트 루프로 기다리면 하나의 스레드로 수만 개의 연결을 처리할 수 있습니다.
다음 장에서는 HTTP 프로토콜의 진화 — HTTP/2, HTTP/3, WebSocket — 을 다루겠습니다.