7장. 이벤트 루프는 OS에 없다 — epoll_wait를 도는 건 당신의 스레드다

만화로 보는 요약 — 먼저 읽어보세요
커널이 파는 API 목록에는 ‘콜백’이 없습니다. 콜백 호출 한 줄을 지웠더니 프로그램이 영원히 안 끝났어요.
면접 실전 질문: ① 이벤트 루프는 OS가 제공하나요? ② 커널이 “준비됐다”고 알려준 뒤, 콜백은 누가 실행하나요? ③ 이벤트 루프 스레드에서 블로킹 호출을 하면 왜 치명적인가요?
배경 — 계속 써온 말인데 정체를 말한 적이 없다
이 책은 1장부터 “이벤트 루프”라는 말을 써왔습니다. 3장에서는 Node의 이벤트 루프를 막아 타이머 넷을 516ms로 밀어냈고, 4장에서는 리액티브가 그 위에 섰다고 했어요.
그런데 정작 이벤트 루프가 어디서 도는 무엇인지는 말한 적이 없습니다.
6장이 남긴 질문이 그겁니다.
“커널이 ‘준비됐다’를 알려준다고 해서 우리 콜백을 실행해주는 건 아닙니다. 그럼 통보를 받고 콜백을 부르는 그 코드는 어디에 있을까요?”
흔한 오해가 하나 있습니다. 커널 안에 “이벤트 루프”라는 기능이 있고, 거기에 콜백을 등록해두면 커널이 알아서 불러준다는 그림이요. 면접에서 이 답을 자주 듣습니다.
아닙니다. 커널에는 콜백을 등록할 자리가 아예 없습니다. 한 화면짜리 프로그램으로 보여드릴게요.
스토리 — 커널이 파는 것과 팔지 않는 것
2장에서 본 커널 API를 다시 봅시다. 준비 통보 모델에서 커널이 파는 건 이 세 종류입니다.
| 커널이 주는 것 | 하는 일 |
|---|---|
epoll_create1 / kqueue | 감시 목록을 담을 그릇을 만든다 |
epoll_ctl / EV_SET+kevent | 그릇에 fd를 등록·수정·해제한다 |
epoll_wait / kevent | 준비된 fd 목록을 돌려준다. 없으면 부르는 스레드를 재운다 |
목록이 끝났습니다. 여기 “콜백”이라는 단어가 없습니다.
커널이 하는 일은 “누가 준비됐는지 알려주는 것”까지예요. 그 fd로 뭘 할지, 어떤 함수를 부를지, 어떤 순서로 처리할지는 전부 여러분 몫입니다. epoll·kqueue는 여러분의 함수 주소를 알지도 못합니다.
커널이 유저 함수를 직접 부르는 경로가 딱 하나 있긴 합니다 — 시그널 핸들러예요. 커널이 유저 스택에 프레임을 밀어넣고 여러분 함수로 점프합니다. 그런데 이벤트 루프에는 못 씁니다. 핸들러 안에서 부를 수 있는 함수가 극히 제한돼 있거든요.
그럼 이벤트 루프는 뭘까요? epoll_wait를 무한 반복하면서, 돌려받은 fd마다 우리 함수를 부르는 while문입니다. 그게 전부고, 그건 유저 공간 코드입니다.
while (안 끝났으면) {
준비된_목록 = 커널에게_물어본다(); // ← 여기서만 커널이 개입한다
for (fd : 준비된_목록)
내_함수를_부른다(fd); // ← 여기는 100% 내 코드
}Netty의 EventLoop, libuv의 루프, Node의 이벤트 루프 — 전부 이 while문의 정교한 버전입니다. 프레임워크가 대신 써줬을 뿐, 커널에서 내려온 게 아니에요.
그래서 “이벤트 루프 스레드”라는 게 존재합니다. 커널 기능이었다면 스레드가 필요 없었겠죠. 유저 공간 while문이니 그걸 돌 스레드가 하나 필요한 겁니다.
핵심 — 직접 만들어 보기
말로 하면 안 믿기니 만들어봤습니다. 주석·공백 빼고 70줄쯤 되는 진짜 이벤트 루프입니다.
측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5, cc -O2. 전체 소스는 docs/book/code/async/ch07-event-loop.c에 있습니다.
두 가지를 미리 밝혀둡니다. 첫째, 연결 8,000개는 socketpair라 fd를 16,000개 씁니다(2장에서 본 그 함정이죠). ulimit -n 20000으로 올리고 돌렸습니다. 둘째, 이 코드는 kqueue와 pthread_threadid_np를 쓰는 macOS/BSD 전용입니다. 리눅스에서는 epoll_create1/epoll_ctl/epoll_wait와 gettid()로 바꾸면 그대로 돕니다.
준비: 6장과 2장을 합친다
for (int i = 0; i < N; i++) {
socketpair(AF_UNIX, SOCK_STREAM, 0, pairs[i]);
fcntl(pairs[i][0], F_SETFL, O_NONBLOCK); // 6장: 재우지 마라
EV_SET(®[i], pairs[i][0], EVFILT_READ, EV_ADD, 0, 0, NULL);
}
kevent(kq, reg, N, NULL, 0, NULL); // 2장: 등록은 한 번만6장에서 “논블로킹 I/O = O_NONBLOCK + 준비 통보”라고 했죠. 그 두 줄이 여기 있습니다.
루프 본체 — 이게 전부입니다
while (1) {
int n = kevent(kq, NULL, 0, evs, 64, &wait); // 여기서만 잠든다
for (int i = 0; i < n; i++)
on_readable((int)evs[i].ident); // ← 우리가 직접 부른다
if (n == 0 && poker_done) break;
}그리고 “콜백”은 이렇게 생긴 평범한 함수입니다.
static void on_readable(int fd) {
char buf[64];
while (read(fd, buf, sizeof buf) > 0) ; // EAGAIN 까지 다 읽는다 (2장 엣지 규칙)
if (handler_tid_seen == 0) handler_tid_seen = tid(); // ← 누가 부르는지 기록
handled++;
}on_readable 안에서 실행 중인 스레드 ID를 찍어뒀습니다. 루프를 도는 스레드 ID와 비교하려고요.
결과 ① — 한 줄을 지우면 아무 일도 안 일어난다
커널이 콜백을 불러준다면, 우리 루프에서 on_readable(...) 호출 한 줄을 지워도 데이터는 처리돼야 합니다. 지워봤습니다.
$ timeout 8 ./ch07_콜백호출없음 2000 50000
(아무 출력 없음)
종료코드 = 124 ← 8초 타임아웃으로 강제 종료. 즉 영원히 안 끝난다.✅ 실측 (같은 환경, 2026-08)
처리 건수 0. 데이터는 커널 버퍼에 그대로 쌓여 있고, 레벨 트리거라 kevent는 같은 fd를 계속 다시 돌려주는데, 아무도 read()를 하지 않으니 아무 일도 일어나지 않습니다. 커널은 끝까지 우리 함수를 부르지 않아요.
한 줄을 되돌리면 즉시 정상 동작합니다. 그 한 줄이 “콜백 실행”의 전부였던 거죠.
덤으로, 그럼 그 한 줄을 실행한 건 누구였을까요? 스레드 ID를 찍어뒀습니다.
루프를 도는 스레드 ID : 550745
콜백을 실행한 스레드 ID : 550745✅ 실측 (같은 환경, 2026-08)
같은 스레드입니다. on_readable(evs[i].ident)가 우리가 쓴 평범한 함수 호출이니 당연한 결과예요 — 증명이라기보다 시연입니다. 커널은 kevent가 돌려준 배열을 채워줬을 뿐이고, 그 배열을 훑으며 함수를 부른 건 우리 스레드입니다.
“콜백이 오면 OS가 실행한다”는 표현은 틀렸어요. 정확히는 이렇습니다.
커널이 깨우는 건
epoll_wait/kevent에서 자고 있던 루프 스레드 하나뿐입니다. 그 스레드가 깨어나서, 자기 발로 애플리케이션 콜백을 호출합니다.
11장에서 볼 CompletableFuture.complete()도 마찬가지예요. 누군가의 스레드가 그걸 부르는 거지, 커널이 부르는 게 아닙니다.
결과 ② — 스레드 하나가 8,000 연결을 감당한다
연결 수를 바꿔가며 돌렸습니다. 전부 스레드 하나입니다.
| 연결 수 | 통보 건수 | 소요 | 초당 처리 |
|---|---|---|---|
| 100개 | 39,723 | 243 ms | 163,587건 |
| 2,000개 | 49,480 | 256 ms | 193,367건 |
| 8,000개 | 49,911 | 270 ms | 184,822건 |
✅ 실측 (같은 환경, 2026-08. 각 조건에서 별도 스레드가 무작위 연결에 총 50,000번 write. 통보 건수는 반복해도 안정적이지만, 처리량은 회차 편차가 커서 세 조건 모두 15만~20만 건/s 대에서 움직입니다.)
8,000개를 보세요. 1장에서 같은 기계의 JVM은 스레드 4,068개에서 멈췄습니다. 연결당 스레드였다면 8,000 연결은 서버가 뜨지도 못했을 조건이에요. 그걸 지금 스레드 하나가 처리하고 있습니다.
물론 이건 프로세스 안의 socketpair지 진짜 TCP 연결 8,000개가 아닙니다. 연결당 세션 상태가 붙으면 숫자는 달라져요. 그래도 스레드 축의 대비는 그대로입니다 — fd는 어느 모델이든 똑같이 들고, 갈리는 건 스레드뿐이니까요.
그리고 처리량을 세로로 읽으면 2장에서 본 평평한 kqueue 열이 다시 나옵니다. 연결이 80배 늘어도 처리량에 연결 수와의 상관관계가 안 보입니다. 커널이 준비된 것만 돌려주니, 연결이 몇 개든 루프가 하는 일의 양은 “실제로 도착한 이벤트 수”에만 비례하거든요.
결과 ③ — 덤으로 발견한 것: 통보는 합쳐진다
write는 세 조건 모두 50,000번인데, 통보는 39,723 / 49,480 / 49,911로 다릅니다. 1만 건이 어디로 샜을까요?
연결이 적을수록 통보가 적게 옵니다. 연결 100개에 write 5만 번이면 같은 소켓에 연달아 쓰는 일이 잦고, 루프가 한 번 깨어나 처리하는 사이에 도착한 것들이 하나의 통보로 합쳐지기 때문이에요. 6장에서 EAGAIN까지 다 읽으라고 한 게 이래서입니다 — 한 번의 통보가 여러 건을 담고 있을 수 있으니까요.
합쳐짐을 만드는 건 부하가 아니라 소켓당 쓰기가 얼마나 몰리느냐입니다. 연결 100개에 5만 번을 쓰면 소켓 하나에 500번씩 몰려 합쳐지고, 8,000개면 6번씩이라 거의 안 합쳐져요.
완료 통보 모델에서는 다를까
여기까지는 “준비 통보” 모델 얘기입니다. 2장에서 예고한 IOCP와 io_uring은 “준비됐다”가 아니라 **“다 끝났다”**를 알려주죠. 그럼 거기선 커널이 콜백을 부를까요?
아닙니다. 커널은 완료 큐에 결과를 넣어둘 뿐이고, 그 큐를 훑어 애플리케이션 함수를 부르는 while문은 여전히 유저 공간에 있습니다. 통보의 내용이 “읽을 수 있다”에서 “다 읽었다”로 바뀌었을 뿐, 루프의 주인은 안 바뀝니다. 📄 문서 기반
그래서 이 루프를 막으면
이제 3장의 그 장면을 다시 볼 수 있습니다. 위 코드에서 on_readable 안에 500ms짜리 동기 작업을 넣으면 무슨 일이 생길까요?
루프가 안 돕니다. kevent로 돌아가지 못하니 나머지 7,999개 연결은 준비됐든 말든 아무도 봐주지 않아요. 3장에서 타이머 넷이 516ms에 한꺼번에 터진 게 정확히 이 구조입니다.
스레드 풀 서버라면 느린 요청 하나가 스레드 하나만 잡아먹습니다. 이벤트 루프에서는 느린 요청 하나가 그 루프가 맡은 전부를 멈춥니다. 위 측정에서는 그게 8,000 연결이에요.
연결당 스레드 없이 수천 연결을 받는 대가가 이겁니다. 15장에서 WebFlux로 같은 장면을 봅니다.
정리
- 준비 통보 모델에서 커널이 파는 건 세 종류입니다. 그릇 만들기, fd 등록, 준비된 목록 돌려주기. “콜백”은 그 목록에 없습니다.
- 이벤트 루프는 유저 공간 while문입니다.
epoll_wait를 반복하며 돌려받은 fd마다 우리 함수를 부르는 코드예요. Netty·libuv·Node의 루프도 이것의 정교한 버전입니다. - 실측: 콜백 호출 한 줄을 지웠더니 처리 건수 0, 프로그램이 영원히 안 끝났습니다. 커널이 깨우는 건 자고 있던 루프 스레드 하나뿐이고, 콜백은 그 스레드가 자기 발로 부릅니다. 완료 통보 모델(IOCP·
io_uring)에서도 루프의 주인은 유저 공간이에요. - 실측: 스레드 하나가 8,000 연결을 처리했습니다. 같은 기계의 JVM은 1장에서 스레드를 4,068개까지밖에 못 만들었어요. 연결 수가 80배 늘어도 처리량에 상관관계가 안 보였습니다.
- 가격표도 분명합니다. 루프 스레드 하나가 멈추면 그 루프가 맡은 전부가 멈춥니다. 8,000 연결이 한 줄에 인질로 잡혀요.
생각해볼 질문
- 이벤트 루프가 유저 공간 while문이라면, 코어가 8개인 기계에서는 루프를 몇 개 돌려야 할까요? 그 답이 12장 프레임워크들의 기본 설정과 맞을까요?
- 이 장의 루프는 연결 8,000개를 스레드 하나로 처리했습니다. 그럼 1장에서 본 세 가지 값(개수 천장·자리값·깨우기 비용) 중 무엇이 사라지고 무엇이 남았을까요?
- 커널이 깨우는 게 루프 스레드 “하나”라면, 그 스레드가 깨어나는 데 드는 비용은 배경에 자는 스레드가 몇 개냐에 따라 달라질까요?
2번과 3번이 다음 장입니다. 1장에서 “자는 스레드 4,000개가 인계 지연을 22.5배로 올린다”고 재놓고 왜 그런지는 8장으로 미뤄뒀어요. 이제 그 빚을 갚을 차례입니다.