I/O 멀티플렉싱 비교: select·poll·epoll·kqueue·IOCP와 C10K 문제

이 글의 핵심

하나의 스레드로 많은 소켓을 다루는 I/O 멀티플렉싱의 원리와 select·poll·epoll·kqueue·IOCP 각각의 동작 방식, 운영체제별 선택 기준을 코드 예제로 정리합니다.

이 글의 핵심

연결마다 스레드를 하나씩 붙이는 블로킹 서버는 동시 연결이 수천 개로 늘면 스레드 메모리와 컨텍스트 스위칭 비용에 먼저 막힙니다. 이 글은 그 한계(C10K 문제)에서 출발해, 한 스레드가 여러 소켓의 준비 상태를 감시하는 select·poll부터 epoll·kqueue·IOCP까지 각 API의 동작 방식과 코드, 시간 복잡도 차이, 운영체제별 선택 기준을 정리합니다. 각 API마다 실제로 자주 부딪히는 함정(select의 1024 한계를 넘었을 때의 메모리 오염, epoll Edge-Triggered의 이벤트 유실, IOCP의 버퍼 수명)도 함께 다룹니다.


사전 지식 (초보자를 위한 기초)

소켓(Socket)이란?

소켓은 네트워크 통신의 양 끝점입니다. 유닉스 계열에서는 소켓도 파일처럼 정수 번호인 파일 디스크립터(fd)로 다루며, 연결·송신·수신·종료 같은 동작은 전부 이 fd를 통해 이뤄집니다. 간단한 서버 예제:

// 서버 소켓 생성
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 주소 바인딩
bind(server_fd, ...);
// 연결 대기
listen(server_fd, 10);
// 클라이언트 연결 수락
int client_fd = accept(server_fd, ...);
// 데이터 받기
char buffer[1024];
recv(client_fd, buffer, sizeof(buffer), 0);
// 데이터 보내기
send(client_fd, "Hello", 5, 0);
// 연결 종료
close(client_fd);

Blocking vs Non-blocking I/O

Blocking I/O (블로킹)

// recv()는 데이터가 올 때까지 대기 (멈춤!)
char buffer[1024];
int n = recv(client_fd, buffer, sizeof(buffer), 0);
// ↑ 데이터가 오기 전까지 여기서 멈춤
printf("Received: %s\n", buffer);
타임라인:
시간 →
0초: recv() 호출
1초: (대기 중...)
2초: (대기 중...)
3초: 데이터 도착! recv() 반환
3초: printf() 실행
문제: 데이터를 기다리는 동안 아무것도 못함!

Non-blocking I/O (논블로킹)

// 소켓을 Non-blocking으로 설정
fcntl(client_fd, F_SETFL, O_NONBLOCK);
// recv()가 즉시 반환
int n = recv(client_fd, buffer, sizeof(buffer), 0);
if (n == -1 && errno == EAGAIN) {
    // 데이터가 없음, 다른 일 할 수 있음
    printf("No data yet, doing other work...\n");
}

동시 접속 처리 방법

방법 1: 프로세스/스레드 per 연결

// 클라이언트마다 스레드 생성
while (1) {
    int client_fd = accept(server_fd, ...);
    pthread_create(&thread, NULL, handle_client, &client_fd);
}
// 문제: 클라이언트 10,000명 = 스레드 10,000개 (메모리 부족!)

방법 2: I/O 멀티플렉싱 (이 글의 주제!)

// 하나의 스레드로 여러 소켓 동시 처리
while (1) {
    // 여러 소켓 중 준비된 것만 처리
    int ready = select(max_fd, &read_fds, ...);

    for (int fd = 0; fd < max_fd; fd++) {
        if (FD_ISSET(fd, &read_fds)) {
            // 이 소켓에 데이터 있음
            handle_client(fd);
        }
    }
}
// 장점: 스레드 1개로 10,000개 연결 처리 가능!

I/O 멀티플렉싱이란?

정의

I/O 멀티플렉싱은 하나의 스레드로 여러 소켓의 준비 상태를 한 번에 기다렸다가, 준비된 것만 처리하는 기법입니다. 블로킹 호출 하나에 스레드 전체가 묶이지 않게 하는 것이 목적입니다.

중요한 점은 멀티플렉싱이 I/O 자체를 빠르게 만들지는 않는다는 것입니다. recv()가 데이터를 복사하는 비용은 그대로이고, 달라지는 것은 “어느 소켓을 지금 읽으면 막히지 않는가”를 알아내는 방법입니다. 그래서 준비된 소켓만 골라 호출하는 대신 한 소켓에서 오래 걸리는 작업(무거운 계산, 동기 DB 호출)을 이벤트 루프 안에서 하면, 그동안 나머지 모든 연결이 멈춥니다. 이벤트 루프 기반 서버가 느려졌을 때 가장 먼저 의심해야 하는 부분이 이것입니다.

동작 원리

┌──────────────────────────────────────┐
│         애플리케이션 (1개 스레드)      │
└──────────────┬───────────────────────┘
               │
               │ select/epoll/kqueue
               │ "어떤 소켓이 준비됐나요?"
               │
┌──────────────▼───────────────────────┐
│            커널 (OS)                  │
│  Socket 1: 데이터 있음                │
│  Socket 2: 대기 중                    │
│  Socket 3: 데이터 있음                │
│  Socket 4: 대기 중                    │
└──────────────┬───────────────────────┘
               │
               │ "Socket 1, 3이 준비됐어요"
               │
┌──────────────▼───────────────────────┐
│         애플리케이션                   │
│  Socket 1 처리 → recv()               │
│  Socket 3 처리 → recv()               │
└──────────────────────────────────────┘

Blocking I/O와 스레드당 연결 방식의 한계

단일 클라이언트 처리

// 간단한 에코 서버
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(server_fd, ...);
listen(server_fd, 10);
while (1) {
    int client_fd = accept(server_fd, ...);  // 연결 대기 (Blocking)

    char buffer[1024];
    int n = recv(client_fd, buffer, sizeof(buffer), 0);  // 데이터 대기 (Blocking)

    send(client_fd, buffer, n, 0);  // 에코
    close(client_fd);
}
// 문제: 한 번에 1명만 처리 가능!
// 클라이언트 A가 recv()에서 대기 중이면
// 클라이언트 B는 accept()조차 못함

멀티 스레드 방식

void* handle_client(void* arg) {
    int client_fd = *(int*)arg;

    char buffer[1024];
    int n = recv(client_fd, buffer, sizeof(buffer), 0);
    send(client_fd, buffer, n, 0);

    close(client_fd);
    return NULL;
}
int main() {
    while (1) {
        int client_fd = accept(server_fd, ...);

        pthread_t thread;
        pthread_create(&thread, NULL, handle_client, &client_fd);
        pthread_detach(thread);
    }
}
// 문제:
// - 클라이언트 10,000명 = 스레드 10,000개
// - 메모리: 10,000 × 8MB = 80GB (스택 크기)
// - 컨텍스트 스위칭 오버헤드

select - 기본 멀티플렉싱

select란?

select는 가장 오래된 I/O 멀티플렉싱 방법입니다. (1983년 BSD Unix)

int select(
    int nfds,                  // 최대 fd + 1
    fd_set *readfds,           // 읽기 준비 확인할 fd 집합
    fd_set *writefds,          // 쓰기 준비 확인할 fd 집합
    fd_set *exceptfds,         // 예외 확인할 fd 집합
    struct timeval *timeout    // 타임아웃
);

select 예제

#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#define MAX_CLIENTS 1024
#define PORT 8080
int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 10);

    fd_set master_fds;  // 모든 소켓 저장
    FD_ZERO(&master_fds);
    FD_SET(server_fd, &master_fds);

    int max_fd = server_fd;

    printf("Server listening on port %d\n", PORT);

    while (1) {
        fd_set read_fds = master_fds;  // 복사 (select가 수정함)

        // 준비된 소켓 대기
        int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);

        if (ready < 0) {
            perror("select");
            break;
        }

        // 모든 소켓 확인
        for (int fd = 0; fd <= max_fd; fd++) {
            if (!FD_ISSET(fd, &read_fds)) {
                continue;  // 준비 안 됨
            }

            if (fd == server_fd) {
                // 새 연결
                int client_fd = accept(server_fd, NULL, NULL);
                FD_SET(client_fd, &master_fds);

                if (client_fd > max_fd) {
                    max_fd = client_fd;
                }

                printf("New client: %d\n", client_fd);
            } else {
                // 기존 클라이언트 데이터
                char buffer[1024];
                int n = recv(fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    // 연결 종료
                    printf("Client %d disconnected\n", fd);
                    close(fd);
                    FD_CLR(fd, &master_fds);
                } else {
                    // 에코
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

select의 장단점

장점: POSIX 계열 어디에나 있고(Windows Winsock에도 있음) API가 단순하며 타임아웃을 걸기 쉽습니다.

단점: select()는 호출이 끝나면 fd_set을 “준비된 fd만 남은 상태”로 덮어쓰므로, 매 호출 전에 집합을 다시 만들어야 합니다. 그리고 가장 위험한 한계가 FD_SETSIZE(리눅스 glibc에서 1024)입니다. fd_set은 크기가 고정된 비트 배열이라서, fd 번호가 1024 이상인 소켓을 FD_SET에 넣으면 에러가 나는 것이 아니라 배열 밖 메모리를 덮어씁니다. 연결 수가 1024개가 안 되더라도 프로세스가 파일·로그·DB 연결로 fd를 많이 열어 두었다면 새 소켓의 번호가 1024를 넘을 수 있고, 이때 스택이 조용히 오염되어 전혀 상관없는 곳에서 크래시가 납니다. glibc에서 -D_FORTIFY_SOURCE=2로 빌드하면 *** bit out of range 0 - FD_SETSIZE on fd_set ***: terminated로 즉시 중단시켜 주므로 원인을 찾기 쉬워집니다. 리눅스에서는 FD_SETSIZE를 재정의해도 커널·glibc가 이를 따르지 않으므로, fd가 많아질 가능성이 있다면 처음부터 poll이나 epoll을 쓰는 것이 맞습니다.

select 성능 문제

클라이언트 1,000명 연결:
매 select() 호출마다:
1. fd_set 복사 (1,000개)
2. 커널이 1,000개 모두 확인
3. 애플리케이션이 1,000개 순회
→ O(n) 복잡도, 비효율적!

poll - select 개선

poll이란?

poll은 select의 FD_SETSIZE 제한을 없앤 개선 버전입니다. (1986년 SVR3)

int poll(
    struct pollfd *fds,  // fd 배열
    nfds_t nfds,         // 배열 크기
    int timeout          // 타임아웃 (ms)
);
struct pollfd {
    int fd;         // 파일 디스크립터
    short events;   // 확인할 이벤트 (POLLIN, POLLOUT)
    short revents;  // 발생한 이벤트
};

poll 예제

#include <poll.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#define MAX_CLIENTS 10000
#define PORT 8080
int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 10);

    struct pollfd fds[MAX_CLIENTS];
    int nfds = 1;

    // 서버 소켓 추가
    fds[0].fd = server_fd;
    fds[0].events = POLLIN;

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 준비된 소켓 대기
        int ready = poll(fds, nfds, -1);

        if (ready < 0) {
            perror("poll");
            break;
        }

        // 모든 소켓 확인
        for (int i = 0; i < nfds; i++) {
            if (fds[i].revents == 0) {
                continue;  // 이벤트 없음
            }

            if (fds[i].fd == server_fd) {
                // 새 연결
                int client_fd = accept(server_fd, NULL, NULL);

                fds[nfds].fd = client_fd;
                fds[nfds].events = POLLIN;
                nfds++;

                printf("New client: %d (total: %d)\n", client_fd, nfds - 1);
            } else {
                // 기존 클라이언트 데이터
                char buffer[1024];
                int n = recv(fds[i].fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    // 연결 종료
                    printf("Client %d disconnected\n", fds[i].fd);
                    close(fds[i].fd);

                    // 배열에서 제거 (마지막 요소와 교체)
                    fds[i] = fds[nfds - 1];
                    nfds--;
                    i--;  // 다시 확인
                } else {
                    // 에코
                    send(fds[i].fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

poll vs select

항목selectpoll
fd 개수 제한1024 (FD_SETSIZE)무제한
fd_set 복사필요불필요
성능O(n)O(n)
이식성높음높음

poll의 개선점: FD_SETSIZE 제한이 없고, 입력(events)과 출력(revents)이 분리되어 있어 호출할 때마다 집합을 다시 만들 필요가 없습니다.

여전한 문제: 매 호출마다 pollfd 배열 전체를 커널로 복사하고, 커널과 애플리케이션 모두 모든 fd를 순회해야 하므로 비용은 여전히 O(n)입니다. 연결 수가 수천 개인데 그중 몇 개만 활발한 전형적인 서버 워크로드에서는 대부분의 순회가 헛수고가 됩니다. 위 예제처럼 끊긴 연결을 마지막 원소와 바꿔 지우는 방식은 배열을 빽빽하게 유지하는 흔한 기법이지만, i--를 빠뜨리면 바꿔 들어온 원소의 이벤트를 이번 루프에서 건너뛰게 됩니다.


epoll - Linux 고성능

epoll이란?

epoll은 Linux의 고성능 I/O 멀티플렉싱 API입니다. (Linux 2.5.44, 2002년)

epoll 커널 내부 메커니즘:

epoll의 핵심: 이벤트 기반 알림 (Event Notification)

커널 자료구조:

1. epoll 인스턴스 (struct eventpoll):

   epoll_create1(0)
   ↓
   커널이 eventpoll 구조체 할당:

   struct eventpoll {
       wait_queue_head_t wq;       // 대기 큐
       struct list_head rdllist;   // Ready List (준비된 fd)
       struct rb_root rbr;         // Red-Black Tree (모든 fd)
       ...
   };

   ┌─────────────────────────────────┐
   │       epoll 인스턴스            │
   │                                 │
   │ Red-Black Tree (모든 fd):      │
   │        [fd=5]                   │
   │       /      \                  │
   │   [fd=3]    [fd=10]             │
   │                                 │
   │ Ready List (준비된 fd):         │
   │ → [fd=3] → [fd=10] → NULL       │
   └─────────────────────────────────┘

2. fd 등록 (epoll_ctl):

   epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)
   ↓
   커널 동작:

   a. Red-Black Tree에 fd 추가 (O(log n)):
      struct epitem 할당:
      - fd
      - event (EPOLLIN, EPOLLOUT)
      - callback 함수 등록

   b. 해당 fd의 wait queue에 콜백 등록:
      socket->wq.add(epoll_callback)

      socket의 recv 버퍼에 데이터 도착 시:
      → epoll_callback() 자동 호출
      → fd를 Ready List에 추가

   시간 복잡도: O(log n)

3. 이벤트 대기 (epoll_wait):

   epoll_wait(epfd, events, MAX_EVENTS, timeout)
   ↓
   커널 동작:

   a. Ready List 확인:
      비어있음? → sleep (timeout까지 또는 이벤트 발생까지)
      있음? → 즉시 반환

   b. Ready List에서 이벤트 복사:
      Ready List → [fd=3, fd=10]
      ↓
      events[0].data.fd = 3
      events[0].events = EPOLLIN
      events[1].data.fd = 10
      events[1].events = EPOLLIN
      ↓
      return 2  // 준비된 fd 개수

   시간 복잡도: O(k) (k = 준비된 fd 개수)

4. 데이터 도착 시 (커널 내부):

   네트워크 카드 → 인터럽트
   ↓
   커널: TCP 스택 처리
   ↓
   socket recv 버퍼에 데이터 복사
   ↓
   wait queue 순회:
   for callback in socket->wq:
       callback()  ← epoll_callback 호출!
   ↓
   epoll_callback:
       if fd not in Ready List:
           Ready List에 fd 추가
           epoll_wait()에 sleep 중이면 깨우기
   ↓
   epoll_wait() 반환
   ↓
   애플리케이션이 recv() 호출

Level-Triggered vs Edge-Triggered:

Level-Triggered (LT, 기본):
- 데이터가 있는 동안 계속 알림
- epoll_wait() 호출마다 Ready List에 유지
- 데이터 일부만 읽어도 다음에도 알림

예시:
1. 데이터 100바이트 도착
2. epoll_wait() → fd 반환
3. recv() → 50바이트만 읽음
4. epoll_wait() → fd 또 반환! (50바이트 남음)

Edge-Triggered (ET, EPOLLET):
- 상태 변화 시에만 알림
- 한 번 알림 후 Ready List에서 제거
- 데이터 전부 읽어야 함 (EAGAIN까지)

예시:
1. 데이터 100바이트 도착
2. epoll_wait() → fd 반환
3. recv() → 50바이트만 읽음
4. epoll_wait() → fd 반환 안 됨!
5. while (recv() != EAGAIN) 필수

장단점:
LT:
  - 사용 쉬움
  - 데이터 일부 읽기 가능
  - 성능 약간 낮음

ET:
  - 성능 높음 (알림 최소화)
  - 반드시 Non-blocking + while 루프
  - 실수하기 쉬움

핵심 차이:

select/poll:
- 매번 모든 fd를 커널에 전달
- 커널이 모든 fd 확인 (O(n))
- 애플리케이션이 모든 fd 순회 (O(n))

epoll:
- fd를 커널에 한 번만 등록
- 커널이 준비된 fd만 반환 (O(1))
- 애플리케이션이 준비된 fd만 처리 (O(k), k = 준비된 개수)

성능 차이:
10,000개 연결, 10개만 활성:
select/poll: 10,000번 확인
epoll: 10번만 처리

→ 확인 횟수가 10,000번에서 10번으로 줄어듦

epoll을 처음 쓸 때 흔히 걸리는 함정이 몇 가지 있습니다. 첫째, epoll이 추적하는 대상은 fd 번호가 아니라 그 fd가 가리키는 커널의 열린 파일 객체입니다. fork()나 dup()으로 같은 소켓을 가리키는 fd가 하나 더 있으면, close(fd)만 해서는 epoll에서 빠지지 않아 이미 닫았다고 생각한 소켓의 이벤트가 계속 올라옵니다. 닫기 전에 EPOLL_CTL_DEL을 먼저 호출하는 습관이 안전합니다. 둘째, 여러 스레드가 같은 epoll 인스턴스에서 epoll_wait를 하면 한 소켓의 이벤트가 두 스레드에 동시에 전달될 수 있어, 같은 연결을 두 스레드가 동시에 읽는 경쟁이 생깁니다. EPOLLONESHOT을 쓰면 이벤트를 한 번 전달한 뒤 해당 fd를 비활성화하므로, 처리가 끝난 스레드가 EPOLL_CTL_MOD로 다시 켜 주는 방식으로 한 연결을 한 스레드만 다루게 할 수 있습니다. 셋째, 여러 프로세스가 같은 리스닝 소켓을 각자의 epoll에 등록하면 연결 하나에 모든 프로세스가 깨어나는 thundering herd 현상이 생기는데, 리눅스 4.5부터는 EPOLLEXCLUSIVE로, 3.9부터는 SO_REUSEPORT로 소켓 자체를 나눠 이를 줄일 수 있습니다.

epoll API

// 1. epoll 인스턴스 생성
int epoll_fd = epoll_create1(0);
// 2. fd 등록
struct epoll_event event;
event.events = EPOLLIN;  // 읽기 이벤트
event.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);
// 3. 이벤트 대기
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
// 4. 준비된 fd만 처리
for (int i = 0; i < n; i++) {
    int fd = events[i].data.fd;
    // 처리
}

epoll 예제

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#define MAX_EVENTS 1024
#define PORT 8080
// Non-blocking 설정
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 서버 소켓 생성
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);

    // epoll 생성
    int epoll_fd = epoll_create1(0);

    // 서버 소켓 등록
    struct epoll_event event;
    event.events = EPOLLIN | EPOLLET;  // Edge-Triggered
    event.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);

    struct epoll_event events[MAX_EVENTS];

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 이벤트 대기
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;

            if (fd == server_fd) {
                // 새 연결
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 더 이상 연결 없음
                        }
                        perror("accept");
                        break;
                    }

                    set_nonblocking(client_fd);

                    // 클라이언트 소켓 등록
                    event.events = EPOLLIN | EPOLLET;
                    event.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);

                    printf("New client: %d\n", client_fd);
                }
            } else {
                // 클라이언트 데이터
                char buffer[1024];

                while (1) {
                    int n = recv(fd, buffer, sizeof(buffer), 0);

                    if (n < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 더 이상 데이터 없음
                        }
                        perror("recv");
                        break;
                    }

                    if (n == 0) {
                        // 연결 종료
                        printf("Client %d disconnected\n", fd);
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    }

                    // 에코
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    close(epoll_fd);
    return 0;
}

Edge-Triggered vs Level-Triggered

Level-Triggered (LT, 기본값)

데이터가 있는 동안 계속 알림
시간 →
0초: 데이터 100바이트 도착
0초: epoll_wait() 반환 → 이벤트 알림
1초: 50바이트만 읽음
1초: epoll_wait() 반환 → 이벤트 알림 (아직 50바이트 남음)
2초: 50바이트 읽음
2초: epoll_wait() 대기 (데이터 없음)

Edge-Triggered (ET, 고성능)

데이터 상태가 변할 때만 알림
시간 →
0초: 데이터 100바이트 도착
0초: epoll_wait() 반환 → 이벤트 알림
1초: 50바이트만 읽음
1초: epoll_wait() 대기 (알림 없음! 주의!)
→ 반드시 EAGAIN까지 읽어야 함!

ET 모드 사용 시 주의:

// ❌ 잘못된 ET 사용
int n = recv(fd, buffer, sizeof(buffer), 0);
send(fd, buffer, n, 0);
// 문제: 데이터가 더 있어도 알림 안옴!
// ✅ 올바른 ET 사용
while (1) {
    int n = recv(fd, buffer, sizeof(buffer), 0);
    if (n < 0) {
        if (errno == EAGAIN) break;  // 더 이상 없음
        // 에러 처리
    }
    if (n == 0) break;  // 연결 종료
    send(fd, buffer, n, 0);
}

ET 모드에는 반대 방향의 함정도 있습니다. “EAGAIN까지 읽기” 규칙을 지키려고 한 연결에서 끝까지 읽다 보면, 데이터를 쉬지 않고 보내는 클라이언트 하나가 이벤트 루프를 독점해 다른 연결이 굶는(starvation) 문제가 생깁니다. 실무에서는 한 번에 읽을 양에 상한을 두고, 상한에 걸리면 “아직 남았음” 목록에 넣어 다음 루프에서 이어 읽는 방식을 씁니다. 또 위 코드는 send()가 요청한 만큼 다 보냈다고 가정하는데, 논블로킹 소켓의 send()는 송신 버퍼가 차면 일부만 보내거나 EAGAIN을 반환합니다. 남은 데이터는 버퍼에 보관하고 EPOLLOUT을 등록해 쓰기 가능해졌을 때 이어 보내야 하며, 이를 생략하면 부하가 걸렸을 때만 응답이 잘리는 재현하기 어려운 버그가 됩니다. LT와 ET 중 무엇이 “빠른가”보다 이런 처리를 빠짐없이 할 수 있는가가 선택 기준이고, 확신이 없다면 기본값인 LT로 시작하는 것이 안전합니다.


kqueue - BSD/macOS

kqueue란?

kqueue는 FreeBSD·macOS의 고성능 이벤트 알림 메커니즘입니다. (FreeBSD 4.1, 2000년)

epoll과의 차이:

  • epoll: fd로 표현되는 것만 감시. 타이머·시그널·파일 변경은 timerfd, signalfd, inotify 같은 별도 fd를 만들어 등록해야 함
  • kqueue: I/O, 파일 변경(EVFILT_VNODE), 시그널, 타이머, 프로세스 종료(EVFILT_PROC)를 필터 종류로 직접 지원
  • kqueue는 등록 변경과 이벤트 대기를 kevent() 한 번의 호출로 묶을 수 있어, 많은 fd의 설정을 바꿀 때 시스템 콜 횟수가 줄어듦

kqueue도 기본은 level-triggered처럼 동작하고, EV_CLEAR를 주면 epoll의 ET와 비슷하게 상태가 바뀔 때만 알립니다. 또 EVFILT_READ 이벤트의 data 필드에는 읽을 수 있는 바이트 수가, flags에 EV_EOF가 담겨 오므로 상대가 연결을 닫았는지를 recv() 호출 전에 알 수 있습니다.

kqueue API

// 1. kqueue 생성
int kq = kqueue();
// 2. 이벤트 등록
struct kevent change;
EV_SET(&change, fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
// 3. 이벤트 대기
struct kevent events[MAX_EVENTS];
int n = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);
// 4. 이벤트 처리
for (int i = 0; i < n; i++) {
    int fd = events[i].ident;
    // 처리
}

kqueue 예제

#include <sys/event.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#define MAX_EVENTS 1024
#define PORT 8080
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 서버 소켓 생성
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);

    // kqueue 생성
    int kq = kqueue();

    // 서버 소켓 등록
    struct kevent change;
    EV_SET(&change, server_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
    kevent(kq, &change, 1, NULL, 0, NULL);

    struct kevent events[MAX_EVENTS];

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 이벤트 대기
        int n = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);

        for (int i = 0; i < n; i++) {
            int fd = events[i].ident;

            if (fd == server_fd) {
                // 새 연결
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd < 0) break;

                    set_nonblocking(client_fd);

                    // 클라이언트 소켓 등록
                    EV_SET(&change, client_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
                    kevent(kq, &change, 1, NULL, 0, NULL);

                    printf("New client: %d\n", client_fd);
                }
            } else if (events[i].filter == EVFILT_READ) {
                // 클라이언트 데이터
                char buffer[1024];
                int n = recv(fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    // 연결 종료
                    printf("Client %d disconnected\n", fd);
                    close(fd);
                } else {
                    // 에코
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    close(kq);
    return 0;
}

kqueue 고급 기능

파일 변경 감지:

// 파일 변경 감지
int fd = open("config.txt", O_RDONLY);
struct kevent change;
EV_SET(&change, fd, EVFILT_VNODE, EV_ADD | EV_CLEAR,
       NOTE_WRITE | NOTE_DELETE, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
// 파일이 수정되면 이벤트 발생

타이머:

// 1초 타이머
struct kevent change;
EV_SET(&change, 1, EVFILT_TIMER, EV_ADD, 0, 1000, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
// 1초마다 이벤트 발생

IOCP - Windows 고성능

IOCP란?

IOCP (I/O Completion Port)는 Windows의 고성능 비동기 I/O 모델입니다. 핵심 차이:

select/epoll/kqueue (Readiness Notification):
- "이 소켓에 데이터가 있어요" 알림
- 애플리케이션이 recv() 호출
- 동기적 처리
IOCP (Completion Notification):
- 애플리케이션이 비동기 recv() 요청
- 커널이 데이터를 버퍼에 복사
- "recv() 완료됐어요" 알림
- 완전히 비동기적

IOCP 동작 원리

1. 애플리케이션: "데이터 받아줘" (WSARecv 호출)
   ↓
2. 커널: "알았어, 나중에 알려줄게" (즉시 반환)
   ↓
3. 애플리케이션: 다른 일 함
   ↓
4. 커널: 데이터 도착! 버퍼에 복사
   ↓
5. 커널: Completion Port에 알림
   ↓
6. 애플리케이션: GetQueuedCompletionStatus() → "완료됐어요!"

완료 기반 모델에서 가장 조심해야 할 것은 버퍼와 OVERLAPPED 구조체의 수명입니다. WSARecv에 넘긴 버퍼는 호출이 반환된 뒤에도 커널이 나중에 데이터를 써 넣을 자리이므로, 완료 통지를 받기 전에 해제하거나 스택 변수로 두면 이미 다른 용도로 쓰이는 메모리에 커널이 데이터를 쓰는 사고가 납니다. 그래서 IOCP 코드에서는 연결마다 OVERLAPPED를 첫 멤버로 가진 구조체를 힙에 할당하고, 완료 통지에서 받은 OVERLAPPED*를 원래 구조체로 되돌려 쓰는 패턴이 표준입니다. 소켓을 닫을 때도 진행 중인 I/O가 ERROR_OPERATION_ABORTED로 완료 통지될 때까지 그 구조체를 살려 둬야 합니다. readiness 모델에서는 “읽을 수 있을 때 버퍼를 준비”하면 되지만, 완료 모델에서는 “미리 버퍼를 맡겨 두는” 구조라 연결 수만큼 버퍼 메모리가 묶인다는 점도 설계 시 고려해야 합니다.

IOCP 예제

#include <winsock2.h>
#include <windows.h>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib")
#define PORT 8080
#define BUFFER_SIZE 1024
// 소켓별 데이터
struct SocketData {
    SOCKET socket;
    WSAOVERLAPPED overlapped;
    WSABUF wsaBuf;
    char buffer[BUFFER_SIZE];
    DWORD flags;
};
int main() {
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    // 서버 소켓 생성
    SOCKET server_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_socket, (sockaddr*)&addr, sizeof(addr));
    listen(server_socket, SOMAXCONN);

    // IOCP 생성
    HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

    // 서버 소켓을 IOCP에 연결
    CreateIoCompletionPort((HANDLE)server_socket, iocp, (ULONG_PTR)server_socket, 0);

    printf("Server listening on port %d\n", PORT);

    // Accept 시작
    SOCKET client_socket = accept(server_socket, NULL, NULL);

    while (1) {
        DWORD bytes_transferred;
        ULONG_PTR completion_key;
        LPOVERLAPPED overlapped;

        // 완료된 I/O 대기
        BOOL result = GetQueuedCompletionStatus(
            iocp,
            &bytes_transferred,
            &completion_key,
            &overlapped,
            INFINITE
        );

        if (!result) {
            printf("GetQueuedCompletionStatus failed\n");
            continue;
        }

        SocketData* socket_data = CONTAINING_RECORD(overlapped, SocketData, overlapped);

        if (bytes_transferred == 0) {
            // 연결 종료
            printf("Client disconnected\n");
            closesocket(socket_data->socket);
            delete socket_data;
            continue;
        }

        // 데이터 처리 (에코)
        socket_data->wsaBuf.len = bytes_transferred;
        WSASend(
            socket_data->socket,
            &socket_data->wsaBuf,
            1,
            NULL,
            0,
            &socket_data->overlapped,
            NULL
        );

        // 다음 recv 요청
        ZeroMemory(&socket_data->overlapped, sizeof(OVERLAPPED));
        socket_data->wsaBuf.len = BUFFER_SIZE;
        socket_data->flags = 0;

        WSARecv(
            socket_data->socket,
            &socket_data->wsaBuf,
            1,
            NULL,
            &socket_data->flags,
            &socket_data->overlapped,
            NULL
        );
    }

    closesocket(server_socket);
    CloseHandle(iocp);
    WSACleanup();
    return 0;
}

IOCP 스레드 풀

// Worker 스레드
DWORD WINAPI WorkerThread(LPVOID param) {
    HANDLE iocp = (HANDLE)param;

    while (1) {
        DWORD bytes_transferred;
        ULONG_PTR completion_key;
        LPOVERLAPPED overlapped;

        GetQueuedCompletionStatus(iocp, &bytes_transferred,
                                  &completion_key, &overlapped, INFINITE);

        // I/O 처리
        // ...
    }

    return 0;
}
int main() {
    HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

    // CPU 코어 수만큼 스레드 생성
    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);
    int num_threads = sysInfo.dwNumberOfProcessors * 2;

    for (int i = 0; i < num_threads; i++) {
        CreateThread(NULL, 0, WorkerThread, iocp, 0, NULL);
    }

    // 메인 스레드는 accept만 처리
    while (1) {
        SOCKET client = accept(server_socket, NULL, NULL);
        CreateIoCompletionPort((HANDLE)client, iocp, (ULONG_PTR)client, 0);

        // 비동기 recv 시작
        // ...
    }
}

연결 수에 따른 성능 비교

성능은 무엇에 좌우되는가

인터넷에서 “IOCP가 epoll보다 몇 퍼센트 빠르다”는 식의 숫자를 자주 보지만, 운영체제·하드웨어·커널 버전이 모두 다른 조건에서 측정한 값이라 서로 비교할 수 있는 근거가 되지 못합니다. 실제로 차이를 만드는 요인은 다음과 같습니다.

  • 활성 비율: 연결 1만 개 중 대부분이 유휴 상태라면 select·poll은 매번 1만 개를 훑는 비용이 지배적이고, epoll·kqueue는 활성 연결 수에만 비례합니다. 반대로 모든 연결이 계속 바쁘다면 어느 API든 준비된 fd가 n개에 가까워져 차이가 줄어듭니다.
  • 시스템 콜 횟수: readiness 모델은 “대기 → 읽기 → 쓰기”마다 시스템 콜이 필요하고, IOCP·io_uring 같은 완료 모델은 요청과 완료를 묶어 처리할 수 있습니다. 작은 메시지를 많이 주고받는 워크로드에서 이 차이가 커집니다.
  • 애플리케이션 코드: 대부분의 실서비스에서 병목은 멀티플렉싱 API가 아니라 파싱·직렬화·DB 호출입니다.

자기 워크로드에 대한 숫자가 필요하다면 같은 머신에서 wrk나 h2load 같은 부하 도구로 직접 측정하는 것이 유일하게 믿을 수 있는 방법입니다.

복잡도 비교

메커니즘등록대기처리전체
selectO(n)O(n)O(n)O(n)
pollO(1)O(n)O(n)O(n)
epollO(1)O(1)O(k)O(k)
kqueueO(1)O(1)O(k)O(k)
IOCPO(1)O(1)O(k)O(k)

k = 준비된 이벤트 수

연결 수에 따른 성능

연결 수: 100개
- select:  충분히 빠름
- epoll:   약간 더 빠름
- 차이:    거의 없음
연결 수: 1,000개
- select:  느려지기 시작
- epoll:   여전히 빠름
- 차이:    fd 순회 비용이 눈에 띄기 시작
연결 수: 10,000개
- select:  사용 불가 (fd 번호가 FD_SETSIZE=1024를 넘으면 메모리 오염)
- epoll:   여전히 빠름
- 차이:    리눅스에서는 FD_SETSIZE를 재정의해도 해결되지 않음
연결 수: 100,000개
- select:  불가능
- epoll:   가능 (C10K 문제 해결)

플랫폼별 선택 기준과 라이브러리

선택 기준

Linux:
✅ epoll (최고 성능)
- 대규모 연결 (10,000+)
- 프로덕션 서버
Windows:
✅ IOCP (최고 성능)
- 완전 비동기
- 스레드 풀 활용
macOS/FreeBSD:
✅ kqueue (최고 성능)
- epoll과 유사한 성능
- 다양한 이벤트 지원
크로스 플랫폼:
✅ libuv, libevent, Boost.Asio
- 플랫폼별 최적 메커니즘 자동 선택
- Node.js는 libuv 사용 (Nginx는 자체 이벤트 모듈로 epoll·kqueue 등을 직접 사용)

라이브러리 추천

libuv (Node.js 사용)

#include <uv.h>
void on_read(uv_stream_t* client, ssize_t nread, const uv_buf_t* buf) {
    if (nread < 0) {
        uv_close((uv_handle_t*)client, NULL);
        return;
    }

    // 에코
    uv_write_t* req = malloc(sizeof(uv_write_t));
    uv_buf_t wrbuf = uv_buf_init(buf->base, nread);
    uv_write(req, client, &wrbuf, 1, NULL);
}
void on_connection(uv_stream_t* server, int status) {
    uv_tcp_t* client = malloc(sizeof(uv_tcp_t));
    uv_tcp_init(uv_default_loop(), client);

    if (uv_accept(server, (uv_stream_t*)client) == 0) {
        uv_read_start((uv_stream_t*)client, alloc_buffer, on_read);
    }
}
int main() {
    uv_tcp_t server;
    uv_tcp_init(uv_default_loop(), &server);

    struct sockaddr_in addr;
    uv_ip4_addr("0.0.0.0", 8080, &addr);

    uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0);
    uv_listen((uv_stream_t*)&server, 128, on_connection);

    printf("Server listening on port 8080\n");
    uv_run(uv_default_loop(), UV_RUN_DEFAULT);

    return 0;
}

Boost.Asio (C++)

#include <boost/asio.hpp>
#include <iostream>
using boost::asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
    Session(tcp::socket socket) : socket_(std::move(socket)) {}

    void start() {
        do_read();
    }

private:
    void do_read() {
        auto self(shared_from_this());
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            [this, self](boost::system::error_code ec, std::size_t length) {
                if (!ec) {
                    do_write(length);
                }
            }
        );
    }

    void do_write(std::size_t length) {
        auto self(shared_from_this());
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(buffer_, length),
            [this, self](boost::system::error_code ec, std::size_t) {
                if (!ec) {
                    do_read();
                }
            }
        );
    }

    tcp::socket socket_;
    char buffer_[1024];
};
class Server {
public:
    Server(boost::asio::io_context& io_context, short port)
        : acceptor_(io_context, tcp::endpoint(tcp::v4(), port)) {
        do_accept();
    }

private:
    void do_accept() {
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::make_shared<Session>(std::move(socket))->start();
                }
                do_accept();
            }
        );
    }

    tcp::acceptor acceptor_;
};
int main() {
    boost::asio::io_context io_context;
    Server server(io_context, 8080);

    std::cout << "Server listening on port 8080\n";
    io_context.run();  // 내부적으로 epoll/kqueue/IOCP 사용

    return 0;
}

Reactor 패턴과 Proactor 패턴

Reactor 패턴 (epoll, kqueue)

Reactor 패턴:
- 이벤트 루프
- 이벤트 발생 시 핸들러 호출
- 동기적 처리
┌─────────────────────────────────┐
│        Event Loop               │
│  while (1) {                    │
│    events = epoll_wait()        │
│    for event in events:         │
│      handler(event)             │
│  }                              │
└─────────────────────────────────┘

구현:

class Reactor {
public:
    void register_handler(int fd, std::function<void()> handler) {
        handlers_[fd] = handler;

        struct epoll_event event;
        event.events = EPOLLIN | EPOLLET;
        event.data.fd = fd;
        epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &event);
    }

    void run() {
        struct epoll_event events[MAX_EVENTS];

        while (1) {
            int n = epoll_wait(epoll_fd_, events, MAX_EVENTS, -1);

            for (int i = 0; i < n; i++) {
                int fd = events[i].data.fd;
                handlers_[fd]();  // 핸들러 호출
            }
        }
    }

private:
    int epoll_fd_ = epoll_create1(0);
    std::unordered_map<int, std::function<void()>> handlers_;
};
// 사용
Reactor reactor;
reactor.register_handler(server_fd, [&]() {
    int client_fd = accept(server_fd, NULL, NULL);

    reactor.register_handler(client_fd, [client_fd]() {
        char buffer[1024];
        int n = recv(client_fd, buffer, sizeof(buffer), 0);
        if (n > 0) {
            send(client_fd, buffer, n, 0);
        }
    });
});
reactor.run();

이 Reactor 예제는 구조를 보여 주기 위한 최소 코드라 그대로 쓰면 문제가 있습니다. EPOLLET로 등록했는데 핸들러가 accept()와 recv()를 한 번씩만 호출하므로, 연결 요청이 동시에 여러 개 들어오거나 데이터가 버퍼 크기보다 많이 오면 나머지는 다시 알림이 오지 않아 처리되지 않습니다. 소켓을 논블로킹으로 설정하지도 않았고, 연결이 끊겼을 때(n <= 0) 핸들러와 epoll 등록을 지우고 소켓을 닫는 코드도 없습니다. 실제 구현에서는 앞의 ET 규칙(논블로킹 + EAGAIN까지 반복)과 연결 정리를 모두 넣어야 합니다.

Proactor 패턴 (IOCP)

Proactor 패턴:
- 비동기 I/O 요청
- 완료 시 핸들러 호출
- 완전히 비동기적
┌─────────────────────────────────┐
│        Proactor                 │
│  1. async_read(handler)         │
│  2. 커널이 읽기 수행             │
│  3. 완료 시 handler 호출         │
└─────────────────────────────────┘

C10K 문제와 Nginx의 해법

C10K 문제란?

C10K (Concurrent 10,000 connections): 동시 접속 10,000명 처리 문제

2000년대 초반:
- 서버 1대로 동시 접속 10,000명 처리 어려움
- select/poll의 O(n) 성능 문제
- 스레드 per 연결 방식의 메모리 문제
해결책:
- epoll (Linux, 2002)
- kqueue (FreeBSD, 2000)
- IOCP (Windows NT 3.5, 1994)
현재:
- 커널 튜닝과 충분한 메모리가 있으면 서버 1대로 수십만~100만 연결 유지도 가능 (C1M)
- 이 경우 병목은 API가 아니라 연결당 메모리(소켓 버퍼, 애플리케이션 상태)

C10K 해결 사례: Nginx

Nginx 아키텍처:
- Worker 프로세스: CPU 코어 수만큼
- 각 Worker: epoll로 수만 개 연결 처리
- 비동기 I/O + 이벤트 루프
- Worker끼리 메모리를 공유하지 않아 락이 거의 없음

Zero-Copy·SO_REUSEPORT·TCP_NODELAY

Zero-Copy

일반적인 데이터 전송:

1. 커널 → 애플리케이션 버퍼 (복사 1)
2. 애플리케이션 버퍼 → 커널 (복사 2)
총 2번 복사!

Zero-Copy (sendfile, splice):

// Linux sendfile
#include <sys/sendfile.h>
// 파일을 소켓으로 직접 전송 (복사 없음)
off_t offset = 0;
sendfile(client_fd, file_fd, &offset, file_size);
// 커널 내부에서만 처리, 애플리케이션 버퍼 거치지 않음

SO_REUSEPORT (Linux 3.9+)

// 여러 프로세스가 같은 포트 listen
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
// 커널이 자동으로 로드 밸런싱
// Nginx, HAProxy가 사용

TCP_NODELAY

// Nagle 알고리즘 비활성화 (지연 감소)
int flag = 1;
setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
// 작은 패킷도 즉시 전송
// 실시간 애플리케이션 (게임, 채팅)에 필수

이벤트 루프·멀티 프로세스·스레드 풀 서버 구조

단일 스레드 이벤트 루프 (Node.js, Redis)

┌─────────────────────────────────┐
│      Main Thread                │
│  ┌───────────────────────────┐  │
│  │     Event Loop            │  │
│  │  - epoll_wait()           │  │
│  │  - 이벤트 처리            │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘
장점:
- 간단한 구조
- 락 불필요
- 컨텍스트 스위칭 없음
단점:
- CPU 1개만 사용
- CPU 집약적 작업에 부적합

멀티 프로세스 (Nginx)

┌─────────────────────────────────┐
│      Master Process             │
│  - 설정 관리                     │
│  - Worker 관리                   │
└──────────┬──────────────────────┘
           │
    ┌──────┼──────┐
    │      │      │
┌───▼──┐ ┌─▼───┐ ┌─▼───┐
│Worker│ │Worker│ │Worker│
│epoll │ │epoll │ │epoll │
└──────┘ └──────┘ └──────┘
장점:
- 멀티 코어 활용
- 프로세스 격리 (안정성)
단점:
- 메모리 사용 증가
- 프로세스 간 통신 필요

스레드 풀 (IOCP, Boost.Asio)

┌─────────────────────────────────┐
│      Main Thread                │
│  - accept() 처리                 │
└──────────┬──────────────────────┘
           │
    ┌──────┼──────┐
    │      │      │
┌───▼──┐ ┌─▼───┐ ┌─▼───┐
│Worker│ │Worker│ │Worker│
│Thread│ │Thread│ │Thread│
│ IOCP │ │ IOCP │ │ IOCP │
└──────┘ └──────┘ └──────┘
장점:
- 멀티 코어 활용
- 메모리 공유 (효율적)
단점:
- 동기화 필요 (락)
- 디버깅 어려움

epoll 기반 HTTP 서버 구현

epoll 기반 HTTP 서버

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define MAX_EVENTS 1024
#define PORT 8080
const char* HTTP_RESPONSE =
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: text/html\r\n"
    "Content-Length: 13\r\n"
    "\r\n"
    "Hello, World!";
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 서버 소켓 생성
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);

    // epoll 생성
    int epoll_fd = epoll_create1(0);

    struct epoll_event event;
    event.events = EPOLLIN | EPOLLET;
    event.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);

    struct epoll_event events[MAX_EVENTS];

    printf("HTTP Server listening on http://localhost:%d\n", PORT);

    while (1) {
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;

            if (fd == server_fd) {
                // 새 연결
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd < 0) break;

                    set_nonblocking(client_fd);

                    event.events = EPOLLIN | EPOLLET;
                    event.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);
                }
            } else {
                // HTTP 요청 처리
                char buffer[4096];
                int n = recv(fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else {
                    // HTTP 응답 전송
                    send(fd, HTTP_RESPONSE, strlen(HTTP_RESPONSE), 0);

                    // Keep-Alive 미지원, 즉시 종료
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                }
            }
        }
    }

    close(server_fd);
    close(epoll_fd);
    return 0;
}

컴파일 및 실행:

gcc -o http_server http_server.c
./http_server
# 테스트
curl http://localhost:8080
# Hello, World!
# 벤치마크
ab -n 100000 -c 1000 http://localhost:8080/

커널 파라미터와 애플리케이션 튜닝

커널 파라미터 최적화

Linux:

# /etc/sysctl.conf
# 최대 파일 디스크립터
fs.file-max = 1000000
# TCP 버퍼 크기
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# SYN backlog 크기
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 8192
# TIME_WAIT 소켓 재사용
net.ipv4.tcp_tw_reuse = 1
# 적용
sudo sysctl -p

프로세스 제한:

# /etc/security/limits.conf
* soft nofile 1000000
* hard nofile 1000000
# 확인
ulimit -n

애플리케이션 최적화

1) 버퍼 크기 조정

// 소켓 버퍼 크기 증가
int buffer_size = 1024 * 1024;  // 1MB
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buffer_size, sizeof(buffer_size));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buffer_size, sizeof(buffer_size));

2) 타임아웃 설정

// 읽기 타임아웃
struct timeval timeout;
timeout.tv_sec = 30;
timeout.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));

SO_RCVTIMEO는 블로킹 소켓의 recv()에만 의미가 있습니다. 이 글의 이벤트 루프처럼 논블로킹 소켓을 쓰면 recv()는 기다리지 않고 바로 EAGAIN을 반환하므로 이 옵션은 아무 효과가 없고, 유휴 연결 정리는 연결마다 마지막 활동 시각을 기록해 두고 epoll_wait의 타임아웃이나 타이머 휠로 주기적으로 검사하는 방식으로 구현해야 합니다. SO_RCVBUF를 직접 지정하는 것도 주의가 필요한데, 리눅스에서는 이 값을 설정하는 순간 커널의 자동 버퍼 튜닝이 꺼지고 값도 net.core.rmem_max로 잘립니다. 측정 없이 크게 잡는 것보다 기본 자동 튜닝을 믿는 편이 대부분의 경우 낫습니다. 3) Keep-Alive

// TCP Keep-Alive 활성화
int keepalive = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
// Keep-Alive 간격
int keepidle = 60;   // 60초 후 첫 probe
int keepintvl = 10;  // 10초 간격
int keepcnt = 3;     // 3번 실패 시 종료
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));

Nginx·Node.js·Redis는 어떻게 쓰는가

Nginx (epoll)

Nginx 설정:
worker_processes auto;  # CPU 코어 수
worker_connections 10000;  # Worker당 최대 연결
events {
    use epoll;
    multi_accept on;
}
worker_connections × worker_processes가
최대 동시 연결 수의 상한 (프록시라면 연결 하나에
클라이언트·업스트림 2개의 fd를 쓰므로 절반으로 계산)

Node.js (libuv)

// Node.js는 내부적으로 libuv 사용
// libuv가 플랫폼별 최적 메커니즘 선택
// - Linux: epoll
// - macOS: kqueue
// - Windows: IOCP
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello World');
});
server.listen(8080);
// 단일 스레드로 수만 개 연결 처리

Redis (epoll/kqueue)

// Redis 이벤트 루프 (ae.c)
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
    }
}
// 내부적으로 epoll/kqueue 사용
// 명령 실행은 단일 스레드 (6.0부터 네트워크 I/O만 선택적으로 멀티스레드)

Redis가 단일 스레드로도 빠른 이유는 멀티플렉싱 자체보다 명령 하나하나가 메모리 안에서 짧게 끝난다는 데 있습니다. 같은 구조에서 KEYS *처럼 오래 걸리는 명령 하나가 실행되면 그동안 모든 클라이언트가 멈추는데, 이것이 이벤트 루프 모델의 본질적인 한계이고 운영 중인 Redis에서 KEYS 사용을 금지하는 이유이기도 합니다.


select·poll·epoll·kqueue·IOCP 종합 비교

종합 비교

항목selectpollepollkqueueIOCP
플랫폼Unix/Linux/WindowsUnix/LinuxLinuxBSD/macOSWindows
최대 fd1024무제한무제한무제한무제한
복잡도O(n)O(n)O(k)O(k)O(k)
대규모 연결 성능낮음낮음높음높음높음
이벤트 종류I/OI/Ofd (timerfd·signalfd 포함)I/O+파일+시그널+타이머+프로세스I/O
알림 방식ReadinessReadinessReadinessReadinessCompletion

선택 가이드

연결 수 < 100:
→ select/poll (충분히 빠름, 간단함)
연결 수 100~1,000:
→ epoll/kqueue (권장)
연결 수 10,000+:
→ epoll/kqueue/IOCP (필수)
크로스 플랫폼:
→ libuv, libevent, Boost.Asio
Windows:
→ IOCP (최고 성능)
Linux:
→ epoll (최고 성능)
macOS:
→ kqueue (최고 성능)

Too many open files·epoll_wait 멈춤 같은 문제

자주 만나는 증상과 원인

1) “Too many open files” 에러

# 원인: 파일 디스크립터 제한 초과 (accept()가 EMFILE 반환)
# 해결:
ulimit -n 100000
# 영구 설정 (로그인 세션용)
echo "* soft nofile 100000" >> /etc/security/limits.conf
echo "* hard nofile 100000" >> /etc/security/limits.conf
# systemd 서비스는 limits.conf를 읽지 않음 → 유닛 파일에 지정
# [Service]
# LimitNOFILE=100000

limits.conf를 고쳤는데도 서비스에서 Too many open files가 계속 난다면 대부분 systemd로 띄운 서비스이기 때문입니다. limits.conf는 PAM을 통해 로그인 세션에만 적용되므로, 서비스는 유닛 파일의 LimitNOFILE로 설정하고 cat /proc/<PID>/limits로 실제 적용값을 확인해야 합니다. 또 이벤트 루프에서 accept()가 EMFILE을 반환했을 때 그냥 넘어가면, 리스닝 소켓은 여전히 “읽을 수 있음” 상태라 LT 모드에서는 epoll_wait가 즉시 계속 반환되며 CPU를 100% 쓰는 바쁜 루프가 됩니다. 여분의 fd 하나를 미리 열어 두었다가 EMFILE 때 닫고 accept 후 바로 닫아 연결을 거절하는 기법이 libuv 등에서 쓰입니다. 2) epoll_wait() 반환 안 됨

// 원인: Edge-Triggered 모드에서 데이터 남음
// 해결: EAGAIN까지 읽기
while (1) {
    int n = recv(fd, buffer, sizeof(buffer), 0);
    if (n < 0) {
        if (errno == EAGAIN) break;  // 필수!
    }
    // 처리
}

3) 메모리 누수

// 원인: 연결 종료 시 리소스 미해제
// 해결:
if (n <= 0) {
    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);  // epoll에서 제거
    close(fd);                                      // 소켓 닫기
    free(client_data);                              // 메모리 해제
}

4) 성능 저하

# 원인: 커널 파라미터 미조정
# 해결:
# 1. TCP 버퍼 크기 증가
# 2. backlog 크기 증가
# 3. TIME_WAIT 재사용 활성화

net.ipv4.tcp_tw_reuse는 이 서버가 밖으로 여는(outgoing) 연결에서만 TIME_WAIT 소켓 재사용을 허용합니다. 클라이언트 연결을 받는 쪽의 TIME_WAIT는 줄여 주지 않으므로, 업스트림으로 연결을 많이 여는 프록시에서 로컬 포트가 고갈될 때(connect: Cannot assign requested address) 효과가 있는 옵션입니다. 비슷해 보이는 tcp_tw_recycle은 NAT 뒤 클라이언트의 연결을 끊는 문제로 리눅스 4.12에서 제거되었으니 오래된 튜닝 글을 그대로 따라 하지 않도록 주의하세요.


io_uring과 eBPF·XDP

io_uring (Linux 5.1+, 2019)

io_uring은 Linux의 차세대 비동기 I/O 인터페이스입니다. epoll이 “준비됐다”를 알려 주는 readiness 모델이라면, io_uring은 IOCP처럼 “완료됐다”를 알려 주는 completion 모델에 가깝습니다. 다만 보안 문제가 여러 차례 보고되어 일부 컨테이너 런타임과 배포판은 기본 seccomp 정책에서 io_uring을 막아 두므로, 도입 전에 실행 환경에서 쓸 수 있는지 먼저 확인해야 합니다.

기존 (epoll):
- 시스템 콜 필요 (커널 ↔ 유저 전환)
- epoll_wait() → recv() → send()
io_uring:
- 공유 메모리 링 버퍼
- 시스템 콜 최소화
- 배치 처리 가능
성능:
- 시스템 콜 횟수가 줄어드는 만큼 고부하에서 이점이 큼
- 실제 이득은 워크로드마다 달라 측정이 필요

io_uring 예제:

#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
// 비동기 read 요청
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buffer, sizeof(buffer), 0);
io_uring_submit(&ring);
// 완료 대기
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// 결과 처리
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);

eBPF + XDP (초고성능)

eBPF (Extended Berkeley Packet Filter):
- 커널 내부에서 패킷 처리
- 유저 공간 전환 없음
- DDoS 방어, 로드 밸런싱
XDP (eXpress Data Path):
- 네트워크 드라이버 단계에서 처리
- 초당 수천만 패킷 처리
- Cloudflare, Facebook이 사용

프로덕션 배포 전 설정과 모니터링

필수 설정

논블로킹 소켓, epoll/kqueue에서 ET 사용 시 읽기 루프 처리, ulimit과 systemd LimitNOFILE 등 fd 상한, SO_REUSEADDR, 실시간 트래픽이면 TCP_NODELAY, Keep-Alive, EAGAIN/EINTR 처리, 부분 send() 처리, 연결 종료 시 리소스 해제를 확인합니다. 로그·메트릭은 운영 단계에서 빠지기 쉬우니 미리 자리를 잡아 두는 것이 좋습니다.

모니터링

# 연결 수 확인
netstat -an | grep ESTABLISHED | wc -l
# 소켓 상태 확인
ss -s
# epoll 사용 확인
strace -e epoll_wait ./server
# 성능 프로파일링
perf record -g ./server
perf report

FAQ

Q1. select는 이제 쓰지 않나요?

연결이 적고 이식성이 최우선이면 select로도 충분한 경우가 있습니다. 다만 프로세스의 fd 번호가 1024를 넘을 가능성이 조금이라도 있다면 poll 이상을 쓰는 것이 안전하고, 동시 연결이 커지면 epoll·kqueue 쪽이 부담이 적습니다.

Q2. epoll과 IOCP 중 더 빠른 쪽은?

워크로드와 측정 환경마다 달라서 숫자만으로 단정하기 어렵습니다. 두 API는 서로 다른 운영체제에서만 동작하므로, Windows면 IOCP, Linux면 epoll처럼 플랫폼에 맞추는 것이 먼저입니다.

Q3. Node.js는 내부적으로 무엇을 쓰나요?

libuv가 OS별로 epoll·kqueue·IOCP를 고릅니다. 파일 I/O처럼 readiness 모델로 다루기 어려운 작업은 libuv가 내부 스레드 풀(기본 4개)에서 처리하므로, 파일 작업이 몰리면 네트워크와 무관하게 대기가 생길 수 있습니다.

Q4. 멀티스레드와 멀티플렉싱을 같이 쓸 수 있나요?

흔한 구성입니다. accept만 전담하는 스레드가 연결을 워커에 나눠 주고 워커마다 자기 epoll 루프를 돌리거나, SO_REUSEPORT로 워커마다 리스닝 소켓을 따로 두는 식으로 설계합니다. 한 연결은 한 워커만 다루게 해야 락 없이 단순하게 유지됩니다.


같이 보면 좋은 글