2장. select에서 epoll까지 — 커널이 답을 바꾼 20년

만화로 보는 요약 — 먼저 읽어보세요
“전부 확인해줘”를 “준비된 것만 줘”로 바꿨을 뿐인데, 커널의 대답이 5,712배 빨라졌습니다.
면접 실전 질문: ① select와 epoll의 시간복잡도 차이는 어디서 오나요? ②
FD_SETSIZE가 1024라는 게 실무에서 무슨 뜻인가요? ③ 레벨 트리거와 엣지 트리거는 무엇이 다르고, 엣지 트리거에서 흔히 나는 버그는?
배경 — 물어보는 방법은 처음부터 있었다
1장 끝에서 이런 질문을 남겼습니다. “‘준비된 연결만 알려달라’고 커널에 물어보려면, 커널은 무엇을 알고 있어야 할까요? 그 목록을 어떻게 관리해야 1만 개에서도 빠를까요?”
사실 물어보는 방법 자체는 C10K보다 16년 먼저 있었습니다. **select**입니다. 1983년 4.2BSD예요. 케겔이 1999년에 문제를 제기했을 때, 도구가 없었던 게 아닙니다.
문제는 물어보는 방식이었습니다.
select에게 묻는 방법은 이렇습니다. “내가 지켜보는 소켓 1만 개 목록을 통째로 줄 테니, 이 중에 준비된 게 있으면 알려줘.”(사실 select는 1만 개를 받지도 못합니다. 그 얘기는 곧 하겠습니다.) 커널은 1만 개를 처음부터 끝까지 훑어 확인하고, 결과를 돌려줍니다.
데이터가 실제로 도착한 소켓이 3개뿐이어도 마찬가지예요. 9,997번은 헛수고입니다.
이 장은 그 헛수고를 없애는 데 20년이 걸린 이야기입니다.
스토리 — 세 번의 세대교체
이 절의 연도·인명은 📄 문서 기반입니다(1차 출처: 각 OS 릴리스 노트와
kqueue/epoll원 논문·패치).
1세대: select (1983)
먼저 용어 하나. **파일 디스크립터(fd)**는 커널이 열린 소켓·파일마다 붙여주는 정수 번호예요. 0·1·2는 표준 입출력이 쓰고, 그다음부터 작은 수로 하나씩 배정됩니다.
select의 인터페이스는 시대를 반영합니다. 감시할 소켓 목록을 비트마스크(fd_set)로 넘겨요. fd 번호 7번을 감시하고 싶으면 7번 비트를 켜는 식입니다.
여기서 첫 번째 벽이 나옵니다. 비트마스크의 크기가 컴파일 시점에 고정돼 있어요. 그 크기가 **FD_SETSIZE**이고, 리눅스와 macOS의 기본값은 1024입니다.
이게 실무에서 무슨 뜻이냐면 — fd 번호가 1024 이상이면 select로는 감시할 수 없습니다. 연결을 1024개보다 적게 열어도 소용없어요. 프로세스가 로그 파일, DB 커넥션, 설정 파일을 열다 보면 fd 번호만 훌쩍 커집니다.
더 나쁜 경우도 있습니다. fd 번호 2,000을 FD_SET에 넣으면 에러가 아니라 배열 밖 메모리를 덮어씁니다. 조용히요.
두 번째 벽은 fd_set이 호출할 때마다 덮어써진다는 겁니다. 커널이 결과를 같은 자리에 써주기 때문에, 반복문을 돌 때마다 목록을 처음부터 다시 만들어야 합니다.
2세대: poll (1986)
System V Release 3에서 나온 poll은 첫 번째 벽을 치웠습니다. 비트마스크 대신 구조체 배열을 받아요. 배열이니 개수 제한이 없고, 입력(events)과 출력(revents) 필드가 분리돼 있어 목록을 재사용할 수 있습니다.
FD_SETSIZE 문제는 사라졌습니다. 그런데 헛수고는 그대로 남았어요.
poll도 호출할 때마다 전체 배열을 커널에 넘기고, 커널은 전체를 훑습니다. 방금 1만 개를 알려줬는데, 다음 호출에서 커널은 그걸 다 잊고 처음부터 다시 확인해요. 연결이 1만 개면 1만 번. 매번.
여기가 핵심입니다. 1·2세대의 진짜 문제는 자료구조가 아니라 “커널이 아무것도 기억하지 않는다”는 데 있었습니다.
3세대: kqueue(2000)와 epoll(2002)
3세대의 발상은 단순합니다. 커널이 기억하게 하자.
- 등록은 한 번만. “이 소켓들을 지켜봐 줘”라고 미리 등록해두면 커널이 목록을 들고 있습니다.
- 준비되면 커널이 목록에 담아둔다. 소켓에 데이터가 도착하는 순간, 커널 내부에서 이미 동작하던 “데이터 도착” 콜백이 그 소켓을 준비 완료 목록에 넣습니다.
- 물어볼 때는 그 목록만 받아온다. 감시 중인 소켓이 1만 개든 10만 개든, 돌려받는 건 준비된 몇 개뿐입니다.
이러면 비용이 감시 대상 수가 아니라 실제로 준비된 수에 비례합니다. 이게 O(감시 대상)과 O(준비된 수)를 가르는 지점이에요. 준비된 게 늘 몇 개뿐인 서버에서는 사실상 상수입니다.
FreeBSD가 2000년 kqueue로 먼저 도착했고(조너선 레먼, Jonathan Lemon), 리눅스는 2002년 epoll로 따라옵니다(다비데 리벤지, Davide Libenzi — 커널 2.5.44에 머지). 그 사이 솔라리스의 /dev/poll, 리눅스 2.4의 실시간 시그널 같은 중간 시도들도 있었지만, 살아남은 건 이 둘입니다.
리눅스 쪽 코드는 이렇게 생겼습니다.
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sock };
epoll_ctl(ep, EPOLL_CTL_ADD, sock, &ev); // 등록 — 소켓당 한 번만
struct epoll_event out[16];
int n = epoll_wait(ep, out, 16, -1); // 준비된 것만 n개 돌려받는다(epoll_create1은 2008년에 추가된 형태예요. 2002년엔 epoll_create(size)였고 지금 그 size는 무시됩니다.)
kqueue도 이름만 다르지 구조는 같습니다. epoll_ctl이 EV_SET+kevent(등록)로, epoll_wait가 kevent(수신)로 바뀔 뿐이에요.
윈도우는 다른 길로 갔습니다. IOCP(I/O Completion Ports)는 “준비됐다”가 아니라 **“다 끝났다”**를 알려주는 완료 통보 모델이에요. 리눅스도 2019년
io_uring(옌스 악스보에, Jens Axboe — 커널 5.1)으로 이 방향에 합류합니다. 준비 통보와 완료 통보의 차이는 7장에서 다시 다룹니다.
핵심 — 한 열은 자라고, 한 열은 평평했다
말로 하면 “O(n)이 상수가 됐다”로 끝나지만, 숫자로 보면 체감이 다릅니다. 직접 측정했습니다.
측정 설계
유휴 연결 N개와 활성 연결 1개를 만들어 둡니다. 연결은 프로세스 안에서 서로 이어진 소켓 한 쌍(socketpair)으로 만들었어요. 연결 하나가 fd를 2개 먹는다는 점을 기억해두세요 — 뒤에서 select를 판정할 때 이게 결정적입니다. 매 라운드마다 활성 연결에 1바이트를 쓰고, “준비된 게 있냐”를 물어 응답을 받는 데 걸리는 시간을 잽니다.
// poll(): 매 호출마다 배열 전체(n+1개)를 커널에 넘긴다
write(active_w, &c, 1);
poll(pfds, n + 1, -1); // ← 커널이 n+1개를 전부 훑는다
read(active_r, &c, 1);
// kqueue(): 등록은 루프 밖에서 딱 한 번
for (int i = 0; i <= n; i++)
EV_SET(&evs[i], pairs[i][0], EVFILT_READ, EV_ADD, 0, 0, NULL);
kevent(kq, evs, n + 1, NULL, 0, NULL); // 등록 — 한 번만
write(active_w, &c, 1);
kevent(kq, NULL, 0, out, 16, NULL); // ← 준비된 건 늘 1개지만 넉넉히 16개까지
read(active_r, &c, 1);발췌입니다. 타이머와 2,000라운드 루프는 생략했어요. 전체 소스는 docs/book/code/async/ch02-readiness.c에 있습니다.
공정성을 위해 두 가지를 맞췄습니다. 첫째, 두 경로 모두 write/read 시스템 콜을 똑같이 포함합니다. 그러니 둘의 차이는 “누가 준비됐나”를 묻는 비용의 차이로 봐도 됩니다. 둘째, poll 쪽에 유리하도록 pollfd 배열은 루프 밖에서 한 번만 만들었습니다(poll은 revents만 덮으니 재사용이 됩니다).
결과
한 번 실행할 때 2,000라운드를 돌려 평균을 내고, 그 실행을 3회 반복해 다시 평균한 값입니다.
| 유휴 연결 수 | poll() | kqueue() | 배율 |
|---|---|---|---|
| 100개 | 22.7 μs | 0.92 μs | 25× |
| 1,000개 | 220 μs | 0.97 μs | 227× |
| 5,000개 | 1,548 μs | 1.01 μs | 1,533× |
| 10,000개 | 5,255 μs | 0.92 μs | 5,712× |
✅ 실측 (Apple M1 8코어 / 램 16GB / macOS 26.5, cc -O2, 2026-08). 회차 편차는 poll 5% 이내, kqueue 15% 이내였습니다. 배율은 반올림한 좌측 두 열로 계산했습니다.
한계도 같이 적어둡니다. 이 측정은 macOS의 kqueue이고, 리눅스 epoll은 미측정입니다. 배율의 분자인 poll 구현도 OS마다 다릅니다. 그러니 아래 숫자는 macOS의 것으로 읽어주세요. 이 장의 주장은 ‘5,712’라는 값이 아니라 kqueue 열의 기울기가 0이라는 데 있습니다.
가로로 읽으면 함정이 있습니다. 100개에서의 25×는 스캔량이 아니라 poll 자체의 고정 비용이 대부분이에요. 이 표는 세로로 읽어야 합니다.
세로로 읽으면 이야기가 보입니다.
kqueue는 평평합니다. 유휴 연결이 100개든 10,000개든 약 0.92 μs예요. 100배가 늘어도 시간이 그대로입니다. 커널이 등록 목록을 기억하고 있으니, 물어볼 때는 준비된 하나만 꺼내주면 되거든요.
poll은 연결 수를 따라 자랍니다. 그것도 선형보다 나쁘게요. 연결이 100개에서 1,000개로 10배 늘 때 시간은 9.7배(선형)인데, 1,000개에서 10,000개로 다시 10배 늘 때는 23.9배가 됩니다.
왜 초선형인지는 이 측정만으로 단정할 수 없습니다. pollfd는 8바이트라 10,000개라도 80KB에 불과하니, 배열 자체보다는 커널이 fd마다 소켓 구조체를 흩어진 주소로 따라가며 확인하는 비용이 더 유력합니다(추정).
이 숫자가 서버에서 뜻하는 것
10,000 연결에서 poll 라운드 한 번이 5.25 ms입니다(write/read를 포함한 값이지만 사실상 전부 poll이에요). 이 라운드로 한 바퀴를 도는 루프라면, 산술적으로 초당 190바퀴가 천장입니다. 다른 걸 아무것도 안 하고 물어보기만 해도요.
같은 조건에서 kqueue는 0.92 μs, 초당 100만 번이 넘습니다.
1999년의 도구로는 C10K를 왜 풀 수 없었는지, 그리고 3세대 API가 나오자 왜 nginx 같은 서버가 곧바로 튀어나왔는지가 이 두 줄에 들어 있습니다.
select는 아예 출발선에 못 섰다
측정하면서 확인한 게 하나 더 있습니다.
유휴 연결 100개 | poll() 22490 ns | kqueue() 965 ns | ... | select(): 사용 가능 (FD_SETSIZE=1024)
유휴 연결 1000개 | poll() 219376 ns | kqueue() 891 ns | ... | select(): 사용 불가 (fd 번호가 FD_SETSIZE 초과) (FD_SETSIZE=1024)✅ 실측 (같은 환경, 2026-08. 가운데 배율 열은 이 절의 논점이 아니라 ...로 줄였습니다.)
이 벤치마크는 소켓쌍을 쓰므로 연결 하나가 fd를 2개 먹습니다. 1,000쌍이면 fd 번호가 이미 2,000대예요. 그래서 select는 1,000 연결에서 이미 못 씁니다.
그런데 이건 인위적인 조건이 아닙니다. 앞서 말했듯 실서버는 로그 파일·DB 커넥션·설정 파일로 fd 번호가 금방 커져요. 연결이 1,024개보다 적어도 select가 못 쓰이는 상황은 흔합니다.
레벨 트리거와 엣지 트리거
1장에서 케겔의 1번 전략이 두 갈래로 갈린다고 했던 것, 기억하시나요? 3세대 API를 쓸 때 반드시 고르게 되는 선택입니다.
- 레벨 트리거(기본) — “버퍼에 읽을 게 남아 있는 동안 계속” 알려줍니다. 100바이트 왔는데 30바이트만 읽고 나가면, 다음 호출에서 또 알려줘요. 안전합니다.
- 엣지 트리거(
epoll의EPOLLET,kqueue의EV_CLEAR) — “새로 도착한 순간 한 번만” 알려줍니다. 통보 횟수가 줄어 빠르지만,EAGAIN이 날 때까지 — 여기서EAGAIN은 1장에서 본 그것(스레드를 더 못 만든다)과 이름만 같고 “지금은 읽을 게 없다”는 뜻이에요 — 다 읽지 않으면, 새 데이터가 도착하기 전까지 통보가 다시 오지 않습니다. 상대가 응답을 기다리며 더 안 보내는 프로토콜이라면 그대로 멈춰요. 그게 “영영”이 되는 경로입니다.
엣지 트리거에서 가장 흔한 버그가 정확히 이겁니다. 조용히 멈춘 커넥션. 직접 다룬다면 읽기는 항상 EAGAIN까지 돌려야 합니다. 프레임워크를 쓴다면 어느 모드가 기본인지 확인하고 쓰세요 — 프레임워크마다 다르고, 성능을 위해 엣지를 기본으로 두는 쪽도 있습니다.
이 답이 안 통하는 곳
여기까지 읽으면 3세대 API가 만능처럼 보입니다. 아닙니다. epoll과 kqueue는 소켓에는 통하지만 일반 파일(regular file)에는 통하지 않습니다.
select/poll에게 디스크 파일 fd는 언제 물어봐도 “준비됨”입니다. 리눅스 epoll은 한술 더 떠 등록 자체를 거부해요 — epoll_ctl에 일반 파일을 넣으면 EPERM이 돌아옵니다. “이 파일 읽을 준비 됐어?”라는 질문이 성립하지 않으니까요. 디스크는 항상 읽을 수 있고, 다만 오래 걸릴 뿐입니다. 그래서 파일을 읽는 순간 스레드는 여전히 잠듭니다.
이 구멍이 뒤에 계속 따라옵니다. 3장에서 볼 Node.js가 “논블로킹”을 표방하면서도 뒤에 스레드 풀을 숨겨두고 있는 이유가 이거고, 리눅스가 io_uring을 만든 동기도 여기 걸려 있어요.
정리
select(1983)의 한계는 셋이었습니다.FD_SETSIZE1024라는 하드 리밋, 호출마다 목록을 다시 만들어야 하는 비용, 그리고 매번 전체를 훑는 비용. 실측에서 1,000 연결(fd 2,000대)부터 아예 사용 불가였습니다.poll(1986)은 개수 제한만 풀었습니다. 여전히 매번 전체를 넘기고 전체를 훑습니다. 10,000 연결에서 라운드 한 번에 5.25 ms — 초당 190바퀴가 천장입니다.kqueue(2000)·epoll(2002)이 바꾼 건 자료구조가 아니라 질문의 형태입니다. 커널이 등록 목록을 기억하고 준비된 것만 돌려줍니다. 10,000 연결에서도 0.92 μs로, 100개일 때와 같았습니다.- 비용이 “감시 대상 수”에서 “준비된 수”로 옮겨간 것 — 이 한 줄이 C10K의 실질적인 해답이었습니다.
- 다만 청구서가 있습니다. 엣지 트리거의 조용한 멈춤, 파일에는 안 통한다는 구멍, 그리고 가장 큰 것 — 코드를 쓰는 방식 자체가 바뀝니다.
read()를 부르고 기다리면 되던 코드가, “준비됐다는 통보를 받아 처리하는” 구조로 뒤집힙니다.
생각해볼 질문
kqueue가 10,000 연결에서도 0.92 μs로 평평하다면, 이벤트 루프 스레드는 몇 개나 필요할까요? 그 스레드가 하나라면 어떤 위험이 생길까요?- 커널이 “준비됐다”만 알려주고 콜백을 대신 실행해주지는 않습니다. 그럼 콜백은 누가 언제 실행할까요?
- 이 구조로 코드를 짜면
read()다음 줄에 결과가 오지 않습니다. 로직의 순서를 어떻게 표현해야 할까요?
3번이 다음 장의 질문입니다. 도구는 2002년에 완성됐는데, 이걸 쓰는 프로그래밍 모델이 웹 개발자 다수에게 기본값이 된 건 2009년이었어요. 개발자 한 명이 “블로킹이 문제다”라고 말하면서요.