icon

안동민 개발노트

11장 : 소켓 프로그래밍

I/O 멀티플렉싱


이전 절에서 TCP 다중 클라이언트 처리를 위해 fork나 스레드를 사용했습니다. 하지만 클라이언트가 10,000개라면 10,000개의 스레드가 필요합니다. 각 스레드는 메모리를 소비하고, 컨텍스트 스위칭 비용이 누적됩니다. 이것이 바로 C10K 문제(10,000개의 동시 연결을 처리하는 문제)의 핵심입니다.


Blocking I/O의 한계

기본적으로 recv()블로킹(blocking) 호출입니다. 데이터가 도착할 때까지 스레드가 멈추고 기다립니다.

I/O 모델 비교
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 멀티플렉싱 시스템 콜입니다. 감시할 소켓들의 집합을 등록하고, 그 중 하나 이상이 준비될 때까지 대기합니다.

select_server.c
#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 제한을 제거한 개선 버전입니다. 배열 기반이므로 감시할 소켓 수에 제한이 없습니다.

poll 기본 구조
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()의 성능 문제를 근본적으로 해결합니다.

epoll 기본 구조
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 vs epoll 성능 차이
소켓 수      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. 복사 없음, 순회 없음
항목selectpollepollkqueue
플랫폼모든 OS모든 OSLinuxmacOS/BSD
FD 제한1024없음없음없음
성능O(n)O(n)O(ready)O(ready)
등록매번 복사매번 복사한 번 등록한 번 등록

이벤트 루프의 원형

epoll(또는 kqueue)을 감싸는 무한 루프가 바로 이벤트 루프입니다. Node.js, Nginx, Redis 같은 고성능 서버의 핵심 구조입니다.

event_loop_concept.py
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 — 을 다루겠습니다.

목차