안동민 개발노트

본문 시작

I/O 멀티플렉싱

select·poll·epoll이 여러 소켓의 준비 상태를 한 대기 루프에서 감시하는 방식과 이벤트 루프 구조를 이해합니다.

이전 절에서 TCP 다중 클라이언트 처리를 위해 fork나 스레드를 사용했습니다.

단순한 blocking-per-connection 모델로 클라이언트 10,000개를 처리하려면 연결 수에 가까운 실행 단위가 필요해집니다.

각 스레드는 메모리를 소비하고, 컨텍스트 스위칭 비용이 누적됩니다.

이것이 바로 C10K 문제(10,000개의 동시 연결을 처리하는 문제)의 핵심입니다.


Blocking I/O의 한계

기본적으로 recv()는 블로킹(blocking) 호출입니다.

블로킹 소켓에서 읽을 데이터가 없으면 보통 대기하며, EOF·오류·timeout으로도 호출이 끝날 수 있습니다.

해결 방법은 I/O 멀티플렉싱입니다.

하나의 대기 루프가 여러 소켓을 동시에 감시하면서, 데이터가 준비된 소켓만 골라서 처리합니다.

실제 서버에서는 이 대기 루프와 함께 non-blocking I/O, worker thread pool, backpressure 같은 장치를 조합합니다.


select

select()는 가장 오래된 I/O 멀티플렉싱 시스템 콜입니다.

호출마다 감시할 소켓 집합을 넘기고 준비 상태를 기다립니다. 다음 C 코드는 읽기 집합을 다루는 예시입니다. fd 범위는 검사하지만 초기화 실패 처리 일부, 비블로킹 설정, 부분 송신·SIGPIPE·쓰기 준비 처리는 생략되어 있습니다. 읽기 준비만 확인한 뒤 send한다고 전체 응답이 처리되는 것은 아닙니다.

select_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.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);
    if (server_fd < 0 || server_fd >= FD_SETSIZE) {
        fprintf(stderr, "server fd outside select range\n");
        if (server_fd >= 0) close(server_fd);
        return EXIT_FAILURE;
    }
    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];
        }

        int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (ready < 0) {
            if (errno == EINTR) continue;
            perror("select failed");
            return EXIT_FAILURE;
        }

        // 새 연결 수락
        if (FD_ISSET(server_fd, &read_fds)) {
            int client_fd = accept(server_fd, NULL, NULL);
            if (client_fd >= 0) {
                if (client_fd >= FD_SETSIZE || num_clients >= FD_SETSIZE) {
                    close(client_fd);
                } else {
                    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 제한을 제거한 개선 버전입니다.

고정 fd_set의 번호 제한은 없지만, 프로세스의 파일 디스크립터 한도와 메모리·배열 크기 등의 제한은 남습니다.

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()보다 인터페이스가 깔끔하고 FD_SETSIZE 같은 고정 비트셋 제한은 없지만, 매 호출마다 관심 fd 배열을 커널에 전달하고 준비 여부를 순회하는 문제는 동일합니다.


epoll (Linux)

epoll은 Linux에서 대규모 동시 연결을 처리하기 위해 만들어진 시스템 콜입니다.

관심 fd를 커널에 등록해 유지하고 대기 시 준비된 이벤트 목록을 돌려받습니다. 관심 상태가 바뀌거나 소켓을 닫을 때는 MOD·DEL 등으로 갱신합니다. 모든 부하에서 전체 처리 비용이 일정하다는 뜻은 아닙니다.

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에서 이벤트 발생
}
I/O 감시 집합을 관리하는 방식

I/O 감시 집합을 관리하는 방식

I/O 감시 집합을 관리하는 방식
API관심 집합과 반환환경과 한계
select호출마다 fd_set을 넘기고 반환된 집합에서 준비 fd를 찾습니다.POSIX와 Windows Winsock 등에서 제공하지만 세부 규칙은 다릅니다. Linux fd_set은 FD_SETSIZE 미만 번호만 허용합니다.
poll호출마다 fd 배열을 넘기고 항목별 반환 이벤트를 찾습니다.POSIX 계열 API입니다. 고정 비트셋 제한은 없지만 OS fd 한도·메모리·배열 크기는 제한됩니다.
epoll커널에 관심을 유지하고 준비 이벤트 목록을 받습니다.Linux API입니다. ADD·MOD·DEL로 갱신하며 등록/처리 비용과 리소스 한도는 남습니다.
kqueue필터별 관심을 등록·변경하고 발생 이벤트를 받습니다.BSD·macOS API입니다. 준비 이벤트 중심이며 필터 의미와 자원 한도를 확인해야 합니다.
API: select
관심 집합과 반환: 호출마다 fd_set을 넘기고 반환된 집합에서 준비 fd를 찾습니다.
환경과 한계: POSIX와 Windows Winsock 등에서 제공하지만 세부 규칙은 다릅니다. Linux fd_set은 FD_SETSIZE 미만 번호만 허용합니다.
API: poll
관심 집합과 반환: 호출마다 fd 배열을 넘기고 항목별 반환 이벤트를 찾습니다.
환경과 한계: POSIX 계열 API입니다. 고정 비트셋 제한은 없지만 OS fd 한도·메모리·배열 크기는 제한됩니다.
API: epoll
관심 집합과 반환: 커널에 관심을 유지하고 준비 이벤트 목록을 받습니다.
환경과 한계: Linux API입니다. ADD·MOD·DEL로 갱신하며 등록/처리 비용과 리소스 한도는 남습니다.
API: kqueue
관심 집합과 반환: 필터별 관심을 등록·변경하고 발생 이벤트를 받습니다.
환경과 한계: BSD·macOS API입니다. 준비 이벤트 중심이며 필터 의미와 자원 한도를 확인해야 합니다.

이벤트 루프의 원형

이벤트 루프는 I/O 준비, 완료 통지, 타이머와 콜백 등을 반복해서 처리하는 구조입니다. epoll이나 kqueue는 그 대기 수단의 예이며 이벤트 루프 자체와 같은 API는 아닙니다.

Node.js, Nginx, Redis 같은 고성능 서버의 핵심 구조입니다.

다음은 callback 등록과 dispatch를 보여 주는 축약 예제입니다. non-blocking 소켓의 recv·accept는 BlockingIOError를 낼 수 있고 sendall도 일부를 보낸 뒤 실패할 수 있습니다. 현재 코드는 이 경우의 출력 큐·쓰기 이벤트·오류 정리를 구현하지 않아 완전한 에코 서버로 사용할 수 없습니다.

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)
읽기와 쓰기 준비를 나누는 이벤트 루프

읽기와 쓰기 준비를 나누는 이벤트 루프

출력 버퍼를 둔 이벤트 루프읽기 이벤트 처리 후 출력 데이터를 보관하고 쓰기 이벤트 때 일부를 전송한 뒤 다시 기다리는 정상 경로이다.준비 이벤트 대기읽기 또는 쓰기읽기 가능: recv받은 내용을 출력 큐에쓰기 관심 등록보낼 데이터 있음쓰기 가능: send보낸 만큼 큐에서 제거남은 출력은 보관다음 쓰기 이벤트
출력 버퍼를 둔 이벤트 루프쓰기까지 즉시 끝난다고 가정하지 않고 남은 데이터를 보관한 채 다음 준비 이벤트로 돌아간다.준비 이벤트 대기읽기 또는 쓰기읽기 가능: recv받은 내용을 출력 큐에쓰기 관심 등록보낼 데이터 있음쓰기 가능: send보낸 만큼 큐에서 제거

원문의 짧은 callback 예제에 추가로 필요한 송신 경로입니다. 빈 출력 큐는 쓰기 관심을 끄고, BlockingIOError는 다음 준비 이벤트를 기다립니다. EOF·오류·큐 크기 제한은 별도 분기로 처리합니다.

Python의 DefaultSelector는 플랫폼에서 지원하는 구현 중 우선순위가 높은 메커니즘을 선택합니다. 실제 작업의 성능 최적화를 자동으로 보장하지는 않습니다.

Linux에서는 epoll, macOS에서는 kqueue, Windows에서는 select를 사용합니다.

이벤트 루프 기반 서버들
플랫폼과 빌드에 따라 달라지는 대표 구성

Nginx (C): epoll / kqueue
Node.js (JavaScript): libuv가 epoll / kqueue / IOCP 추상화
Redis (C): Linux에서는 epoll
Tornado (Python): 이벤트 루프의 플랫폼별 대기 구현 사용
asyncio (Python): Unix는 epoll / kqueue 등
  Windows 기본: ProactorEventLoop / IOCP
Netty (Java): epoll 전송 구현 / NIO 등

Node.js의 이벤트 루프:
  libuv가 플랫폼별 I/O를 처리
  JavaScript 코드가 콜백을 등록
  app.get("/", handler)는 Express의 경로 핸들러 등록 예
  요청은 HTTP 처리와 라우팅을 거쳐 handler에 전달

결국 네트워크 프로그래밍의 핵심은 기다리는 방법입니다.

이벤트 루프는 많은 대기 연결을 적은 실행 단위로 감시할 수 있습니다. 콜백에서 긴 CPU 작업이나 블로킹 호출을 수행하면 다른 연결 처리가 지연되므로 작업 분리와 출력 큐 제한도 필요합니다.

Linux epoll의 edge-triggered 모드에서는 논블로킹 처리를 진행해 EAGAIN 등으로 준비 상태가 소진되었음을 확인해야 합니다. 이 원문의 epoll 예시는 기본 level-triggered 등록입니다.

다음 장에서는 HTTP 프로토콜의 진화 — HTTP/2, HTTP/3, WebSocket — 을 다루겠습니다.