이벤트 루프 아키텍처 정리
이벤트 루프라는 게 뭔지 막연하게 알고 있었지만 설명하기는 어려워 정리해보았다.
목차
- 배경: C10K 문제와 동시성 모델
- 이벤트 루프의 본질
- 기반 기술: fd와 epoll
- Node.js 아키텍처
- 다른 시스템과의 비교
- 결론
- 시각화
배경: C10K 문제와 동시성 모델
문제 정의
서버는 본질적으로 "동시에 들어오는 수천 개의 연결을 어떻게 처리하느냐"는 문제를 푼다. 1999년 Dan Kegel이 제기한 C10K 문제(Concurrent 10,000 connections)는 "동시 연결 1만 개를 한 대의 서버가 감당할 수 있는가"를 묻는다.
지금은 1만 개가 작아 보이지만, 당시 일반적인 서버는 수백 연결만 처리해도 비명을 질렀다. C10K는 이후 C10M(천만 연결)으로까지 확장된다.
스레드/프로세스 기반 모델
전통적인 Apache prefork, Java Servlet(Tomcat 기본) 등이 이 진영에 속한다.
- 연결 하나당 스레드(또는 프로세스) 하나를 할당
- 각 스레드는 스택 메모리(보통 1~8MB)를 점유
- 커널이 스레드를 번갈아 실행시키는 컨텍스트 스위칭 비용이 지속 발생
- 연결 1만 개 = 스레드 1만 개 → 메모리·스케줄링 비용 폭발
이벤트 기반 모델
Nginx, Node.js, Redis 등이 채택한 방식이다.
- 스레드 하나(또는 CPU 코어당 하나)가 수많은 연결을 관리
- 대부분의 연결은 실제로는 "대기 중"(클라이언트 입력, DB 응답)임을 활용
- 노는 시간 동안 스레드를 점유하지 않고, "데이터 준비됨" 같은 이벤트가 도착했을 때만 깨어나 처리
트레이드오프
이벤트 루프가 스레드 모델보다 무조건 우월한 게 아니다.
| 워크로드 | 유리한 모델 | 이유 |
|---|---|---|
| I/O 대기 다수 (웹 서버, API 게이트웨이) | 이벤트 루프 | 대부분 시간이 대기 |
| CPU 집약 작업 다수 | 스레드/프로세스 | OS가 코어 간 분산해줌 |
| 혼합 워크로드 | 하이브리드 (이벤트 루프 + 워커 스레드) | Node의 worker_threads 모델 |
이벤트 루프의 본질
구조
이벤트 루프 자체는 단순한 무한 루프다.
while (alive) {
events = wait_for_events(); // OS에 "준비된 이벤트 있냐" 질의
for (e in events) {
dispatch(e.callback); // 등록된 콜백 실행
}
}
핵심은 두 단계로 환원된다.
- 이벤트 수령 — OS가 알려준 I/O 이벤트를 받는다 (
epoll_wait등) - 콜백 디스패치 — 각 이벤트에 묶인 콜백을 순차 실행한다
이름의 의미: "이벤트"가 핵심, "루프"는 부차적
모든 서버는 무한 루프다. 이벤트 루프를 다른 모델과 구분 짓는 것은 **"이벤트를 기다렸다가 콜백을 디스패치한다"**는 동작 그 자체다.
본질을 한 문장으로 정의하면 이렇다.
"OS가 알려준 I/O 이벤트를 기다렸다가, 그 이벤트에 등록된 콜백을 디스패치하는 루프"
"이벤트 루프"인 것과 아닌 것
| 모델 | 루프? | 이벤트 기반? | 통상 명칭 |
|---|---|---|---|
| Apache prefork | ❌ (요청당 프로세스) | ❌ | 프로세스 모델 |
| Java Servlet (Tomcat 기본) | ❌ (요청당 스레드) | ❌ | 스레드-퍼-리퀘스트 |
| Nginx / Node / Redis | ✅ | ✅ | 이벤트 루프 |
| Go (goroutine) | 내부적으로 ✅ | 내부적으로 ✅ | 고루틴 / M:N 스케줄러 |
| Java Virtual Threads | 내부적으로 ✅ | 내부적으로 ✅ | 가상 스레드 |
Go의 고루틴과 자바 VT는 속은 이벤트 루프 메커니즘을 쓰면서 사용자에게는 동기 코드 스타일을 노출한다. 그래서 "이벤트 루프"라는 이름을 받지 않는다. 이름은 사용자에게 노출되는 추상화를 따른다.
기반 기술: fd와 epoll
이벤트 루프가 작동하기 위한 두 가지 OS 수준 기반 기술을 정리한다.
fd (File Descriptor)
정의
리눅스를 쓰다 보면 "모든 것은 파일이다(Everything is a file)"라는 말을 귀에 딱지가 앉도록 듣는다. 이 철학의 실체가 바로 fd(File Descriptor)다. 거창한 게 아니라 그냥 0, 1, 2, 3 같은 음이 아닌 정수일 뿐이다. C의 포인터가 메모리 주소를 가리킨다면, fd라는 숫자는 커널이 쥐고 있는 자원의 "방 번호"를 가리킨다. 소켓이든 디스크 파일이든 심지어 타이머든, 리눅스에서는 전부 이 fd라는 방 번호 하나로 묶어서 epoll로 감시할 수 있다. 이 단순함이 괴물 같은 처리량의 시작이다.
int fd = open("/tmp/log.txt", O_RDONLY); // fd = 3
read(fd, buf, 100);
close(fd);"Everything is a file" 철학
리눅스에서 fd로 다루는 자원은 종류가 다양하다.
| 자원 | fd 지원 |
|---|---|
| 일반 파일, 디렉토리 | ✅ |
| TCP/UDP 소켓 | ✅ |
| 파이프 | ✅ |
| 터미널 (stdin/stdout) | ✅ |
시그널 (signalfd) | ✅ |
타이머 (timerfd) | ✅ |
이벤트 (eventfd) | ✅ |
| epoll 인스턴스 자체 | ✅ |
자원 종류와 무관하게 read(), write(), close(), epoll이 동일한 API로 동작한다. 이것이 epoll이 소켓·파이프·타이머를 모두 단일 메커니즘으로 감시할 수 있는 이유다.
커널 내부 구조 (3계층)
프로세스 (User Space)
│ fd = 3 (정수)
▼
① fd 테이블 (프로세스마다 1개)
│ 0 → stdin, 1 → stdout, 2 → stderr, 3 → ...
▼
② 열린 파일 테이블 (시스템 전체)
│ 현재 오프셋, 모드(RD/WR), 참조 카운트
▼
③ inode 테이블 (실제 자원)
디스크 블록, 소켓이면 TCP 상태, 권한 등
3계층 구조는 dup(), fork() 후 fd 공유 같은 시나리오를 지원하기 위해 필요하다.
운영 관점
- 커널은 가장 작은 미사용 정수를 fd로 할당한다. 셸의 리다이렉트(
>)가 이를 활용한다 - 기본 fd: 0(stdin), 1(stdout), 2(stderr), 3부터 사용자 자원
- 서버에서 동시 연결 1만 개 = fd 1만 개
ulimit -n으로 프로세스당 fd 상한 설정 (기본 1024, 보통 65535로 상향)close()누락 시 fd 누수 → "Too many open files" 오류로 신규 연결 거부- 진단:
ls -la /proc/<PID>/fd/,lsof -p <PID>
윈도우와의 차이
| 항목 | 리눅스 fd | 윈도우 |
|---|---|---|
| 타입 | int | HANDLE (포인터) |
| 통일성 | 모두 fd | 파일은 HANDLE, 소켓은 SOCKET 분리 |
| API | 공통 | ReadFile vs recv 분리 |
| 이벤트 알림 | epoll | IOCP (모델 자체가 다름) |
epoll
정의
epoll은 리눅스 커널이 제공하는 I/O 이벤트 알림 시스템 콜이다. "수많은 fd 중 준비된 것만 효율적으로 알려달라"는 요구를 충족한다.
select/poll의 한계
select(10000, &readfds, ...);
// 매 호출마다 1만 개 fd 목록을 커널에 복사 — O(n)
// 커널이 1만 개를 모두 훑음 — O(n)
// 반환 후에도 어느 것이 준비됐는지 1만 개를 다시 훑어야 함 — O(n)연결 수가 늘면 비용이 선형 증가한다. C10K 한계의 직접적 원인이었다.
epoll API: 세 개의 시스템 콜
epoll_create1() — 감시 인스턴스 생성
int epfd = epoll_create1(0);커널 내부에 감시할 fd 목록을 저장할 자료구조(Red-Black Tree) 생성.
epoll_ctl() — 감시 목록에 등록/수정/제거
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);한 번 등록하면 끝. 매번 다시 보낼 필요 없다.
epoll_wait() — 준비된 것만 수신
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);
// 1만 개 등록했어도 준비된 게 3개면 3개만 반환 — O(준비된 개수)작동 원리: 커널 콜백 등록 모델
select가 "물어볼 때마다 다 확인"하는 풀(pull) 모델이라면, epoll은 푸시(push) 모델이다.
epoll_ctl로 fd 등록 시 → 커널이 그 fd의 wait queue에 콜백을 심음- 네트워크 카드가 데이터 수신 → 소켓 버퍼에 채움 → wait queue의 콜백 실행
- 콜백이 그 fd를 "준비됨 리스트(ready list)"에 옮김
epoll_wait은 ready list만 읽어 반환 → O(1)에 가까움
핵심: 커널이 능동적으로 준비된 것만 모아두므로 사용자가 매번 훑지 않아도 된다.
다른 OS의 동등물
| OS | API |
|---|---|
| Linux | epoll |
| BSD/macOS | kqueue (epoll보다 일반적, 파일/시그널/타이머도 감시) |
| Windows | IOCP (완료 기반: "끝나면 알려달라") |
| 크로스플랫폼 | libuv, libevent 같은 추상화 라이브러리 |
Node.js 아키텍처
전체 구조: V8 + libuv
흔한 오해 중 하나는 "libuv가 V8 엔진 내부에 있다"는 것이다. 실제로는 V8과 libuv는 형제 관계이며, Node.js는 둘을 묶어 사용하는 런타임이다.
4계층 구조
┌─────────────────────────────────────────────┐
│ [사용자 JS 코드] │
│ const fs = require('fs'); │
│ fs.readFile('a.txt', cb); │
├─────────────────────────────────────────────┤
│ [Node.js 코어 모듈 — JS] │
│ lib/fs.js, lib/http.js, lib/net.js ... │
├─────────────────────────────────────────────┤
│ [Node.js C++ 바인딩 — 접착제] │
│ src/node_file.cc, src/node_http_parser.cc │
├──────────────────────┬──────────────────────┤
│ [V8 엔진] │ [libuv 라이브러리] │
│ (C++, Google) │ (C) │
│ - JS 파싱/실행 │ - 이벤트 루프 │
│ - 가비지 컬렉터 │ - 스레드 풀 │
│ - 호출 스택 │ - epoll/kqueue/IOCP │
│ - 메모리 (힙) │ - 파일/소켓/DNS 추상화│
├──────────────────────┴──────────────────────┤
│ [OS 커널 — syscall] │
│ read(), write(), epoll_wait(), ... │
└─────────────────────────────────────────────┘
각 컴포넌트의 역할
- V8: JS 코드를 기계어로 컴파일·실행. I/O는 V8의 관심사가 아니다 — V8 단독으로는 파일을 읽거나 소켓을 열 수 없다
- libuv: 비동기 I/O와 이벤트 루프를 크로스 플랫폼으로 제공하는 C 라이브러리. JS를 모른다
- Node.js: 두 컴포넌트를 C++ 바인딩으로 연결하는 런타임
다른 런타임과의 비교: 컴포넌트 독립성
| 런타임 | JS 엔진 | 이벤트 루프 / I/O |
|---|---|---|
| Node.js | V8 | libuv |
| Deno | V8 | Tokio (Rust) |
| Bun | JavaScriptCore | 자체 (uSockets) |
| Cloudflare Workers | V8 (isolate) | 자체 Workers Runtime |
| (구) node-chakracore | Chakra | libuv |
같은 V8을 써도 이벤트 루프는 다르게 선택할 수 있고, libuv도 다른 엔진과 조합 가능하다. 두 컴포넌트가 독립적이라는 증거다.
소스 코드 위치
node/
├── deps/
│ ├── v8/ ← V8 엔진 (구글 별도 프로젝트)
│ └── uv/ ← libuv (별도 프로젝트)
├── src/ ← 바인딩 C++ 코드
│ ├── node_file.cc
│ ├── node_http_parser.cc
│ └── ...
└── lib/ ← 코어 모듈 JS
├── fs.js
└── ...
deps/ 폴더에 V8과 libuv가 나란히 존재한다.
libuv 내부: 이벤트 루프와 스레드 풀
libuv 내부에는 두 개의 별개 메커니즘이 공존한다. 둘을 혼동하지 않는 것이 중요하다.
두 메커니즘의 구분
┌──────────────────────────────────────────────────────────┐
│ libuv │
│ │
│ ┌────────────────────────┐ ┌──────────────────────┐ │
│ │ ① 이벤트 루프 │ │ ② 스레드 풀 │ │
│ │ (메인 스레드 1개) │ │ (워커 4개, 기본) │ │
│ │ 싱글 스레드 │ │ 멀티 스레드 │ │
│ │ │ │ │ │
│ │ while(true) { │ │ T1: idle/blocking │ │
│ │ epoll_wait() │ │ T2: idle/blocking │ │
│ │ dispatch_cb() │ │ T3: idle/blocking │ │
│ │ } │ │ T4: idle/blocking │ │
│ └─────────┬──────────────┘ └──────────┬───────────┘ │
│ │ │ │
│ └───── 작업 위임 / 결과 회수 ───┘ │
└──────────────────────────────────────────────────────────┘
- 이벤트 루프: 메인 스레드 단 하나가 무한 루프를 돌리며 콜백을 디스패치
- 스레드 풀: epoll로 처리 불가능한 블로킹 작업을 백그라운드에서 대신 수행
협력 시퀀스: fs.readFile 예시
[메인 스레드 — 이벤트 루프] [스레드 풀 — 워커]
fs.readFile('a.txt', cb)
│ 1. 작업 패킷을 스레드풀 큐에 푸시
├─────────────위임────────────────► T1: 동기 read() 호출
│ 2. 메인 스레드는 즉시 다음 줄로 (디스크 대기 중 블로킹)
│ (다른 콜백·epoll 처리) T2~T4: 다른 작업 가능
│
│ T1: read() 완료
│ ◄───── 결과를 poll 큐에 푸시
│ 3. 다음 poll 페이즈에서 + eventfd로 메인 스레드 알림
│ cb(err, data) 실행 T1: idle 복귀
▼
스레드 풀의 워커는 블로킹 시스템 콜만 대신 호출한다. JS 콜백을 직접 실행하지 않는다.
워커가 JS를 실행하지 못하는 이유
- JS 실행은 V8이 담당
- V8의 한 isolate(인스턴스)는 한 스레드만 진입 가능
- libuv 워커가 V8에 진입하려면 락 획득 + 메인 스레드 정지 → 비동기 의미 상실
따라서 이렇게 정리된다.
- 워커는 C 레벨에서 데이터만 처리
- 완료 시 결과 버퍼를 메인 스레드 poll 큐에 전달
- 메인 스레드가 poll 페이즈에서 JS 콜백 실행
이것이 "JS는 싱글 스레드, I/O는 멀티 스레드"의 정확한 의미다.
libuv 스레드 풀 vs worker_threads
| 항목 | libuv 스레드 풀 | worker_threads |
|---|---|---|
| 목적 | 블로킹 syscall 대행 | JS 코드 병렬 실행 |
| V8 isolate | 없음 | 워커마다 별도 |
| JS 실행 | 불가 | 가능 |
| 사용자 노출 | 내부 전용 | new Worker(...) API |
| 통신 | 내부적 | postMessage / SharedArrayBuffer |
| 개수 제어 | UV_THREADPOOL_SIZE (기본 4) | 사용자가 직접 생성 |
CPU 집약 작업은 worker_threads로, I/O는 libuv 스레드 풀(자동)로 분리하는 것이 권장 패턴이다.
이벤트 루프의 6페이즈와 마이크로태스크
페이즈 다이어그램
┌───────────────────────────┐
┌─>│ timers │ setTimeout, setInterval 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 일부 시스템 콜백 (TCP 에러 등)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ libuv 내부용
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │ ← I/O 이벤트 대기/처리
│ └─────────────┬─────────────┘ │ data, etc. │ (epoll 호출 지점)
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ setImmediate 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ socket.on('close') 등
└───────────────────────────┘
마이크로태스크 우선순위
각 페이즈 사이마다 마이크로태스크 큐가 비워진다. 마이크로태스크에는 다음이 포함된다.
Promise.then,queueMicrotaskprocess.nextTick(마이크로태스크 중에서도 최우선)
실행 순서는 이렇다.
process.nextTick콜백Promise.then,queueMicrotask콜백- 다음 페이즈 진입
결과적으로 이렇게 된다.
Promise.then은 현재 페이즈가 끝나는 즉시 실행setTimeout(fn, 0)은 다음 timers 페이즈까지 대기- 따라서 거의 항상 Promise가 setTimeout보다 먼저 실행됨
nextTick starvation
process.nextTick을 재귀적으로 큐에 넣으면 이벤트 루프가 I/O 페이즈로 진입하지 못해 다른 콜백이 굶주리는 현상이 발생한다. 따라서 nextTick은 동기적 정리 작업에만 사용하고, 반복적 작업에는 setImmediate를 사용하는 것이 안전하다.
I/O 처리 분기: 네트워크 vs 파일
"Node가 I/O 처리 시 멀티스레드를 쓰는가"라는 질문의 답은 I/O 종류에 따라 다르다.
네트워크 I/O: 스레드 풀 미사용
- TCP/UDP 소켓, HTTP, 파이프
- libuv가 epoll/kqueue/IOCP에 fd를 등록만 하고 즉시 반환
- 데이터 도착 시 커널이 메인 스레드를 깨워 콜백 실행
- 논블로킹
[메인 스레드] ── epoll_wait() ── [커널] ←── NIC
(인터럽트로 깨움)
파일 I/O, DNS, 일부 crypto/zlib: 스레드 풀 사용
fs.readFile,fs.writeFile,fs.stat등 모든 파일 작업dns.lookup(getaddrinfo) —dns.resolve는 UDP 소켓이므로 epoll 사용crypto.pbkdf2,crypto.scrypt,crypto.randomBytes(async)zlib압축/해제
분기 이유
리눅스 epoll은 디스크 fd를 효율적으로 다루지 못한다.
- 소켓 fd: "데이터 없으면 EAGAIN 반환" 가능 → epoll로 감시 가능
- 파일 fd: 항상 "준비됨"으로 표시됨 → 실제 read() 시 블로킹
- POSIX에 진짜 논블로킹 디스크 I/O API가 부재 (
O_NONBLOCK이 디스크에는 실효 없음)
따라서 libuv는 디스크 작업을 스레드 풀로 위임하고, 워커가 동기 read()를 호출하는 방식으로 처리한다.
리눅스 5.1+의
io_uring은 디스크 I/O도 진정한 비동기로 처리 가능하다. libuv가 점진적으로 도입 중이나, 기본 경로는 여전히 스레드 풀이다.
작업별 분기 정리
| 작업 | 스레드 풀? | 메커니즘 |
|---|---|---|
net.connect, HTTP 서버 | ❌ | epoll |
fs.readFile | ✅ | 워커가 동기 read() |
dns.lookup | ✅ | 워커가 동기 getaddrinfo() |
dns.resolve | ❌ | UDP 소켓 + epoll |
crypto.pbkdf2 (async) | ✅ | 워커가 계산 |
crypto.createHash (sync) | ❌ | 메인 스레드 블로킹 |
zlib.gzip (async) | ✅ | 워커 |
setTimeout, setImmediate | ❌ | 타이머 힙 + 이벤트 루프 |
운영 고려사항
UV_THREADPOOL_SIZE 환경변수로 풀 크기 조절 가능 (기본 4, 최대 1024).
UV_THREADPOOL_SIZE=8 node app.jsDB 클라이언트, 다수 파일 I/O를 처리하는 서버는 이 값을 늘려야 풀 포화로 인한 대기를 방지할 수 있다.
블로킹 문제
이벤트 루프 모델의 구조적 약점이다.
문제 정의
이벤트 루프는 콜백을 순차적으로 단일 스레드에서 실행한다. 한 콜백이 CPU를 오래 점유하면(대규모 배열 정렬, 동기 암호화, 대용량 JSON.parse 등) 이벤트 루프 전체가 정지하며, 모든 다른 연결의 응답이 지연된다.
스레드 모델에서는 OS 스케줄러가 다른 스레드에 CPU를 할당하므로 한 요청의 지연이 다른 요청으로 전파되지 않는다. 이벤트 루프는 이러한 보호 장치가 없다.
대응 전략
worker_threads로 분리: CPU 집약 작업을 별도 V8 isolate에서 실행- 작업 분할: 큰 작업을 잘게 쪼개
setImmediate로 이벤트 루프에 양보 - 외부 프로세스 위임: 무거운 연산은 별도 서비스로 이전
Nginx가 이 문제에서 비교적 자유로운 이유는 사용자 코드가 개입할 여지가 없기 때문이다(뒤의 Nginx 절 참조). Node.js는 사용자가 임의의 JS를 실행하므로 이 위험을 구조적으로 안고 간다.
다른 시스템과의 비교
Nginx: 멀티 워커 프로세스 모델
- 마스터 프로세스 1개 + CPU 코어 수만큼의 워커 프로세스
- 각 워커가 독립된 이벤트 루프를 운영
- 워커 간 메모리 공유 없음 → 락 불필요, 워커 장애 격리
- 처리 워크로드(요청 수신, 정적 파일 서빙, 프록시)가 본질적으로 I/O 중심 → 이벤트 루프 모델과 정합
- 사용자 코드 진입점이 없음 → CPU 블로킹 위험 부재
Redis: 싱글 스레드 모델
특징
- 명령 실행이 단일 스레드:
GET,SET,LPUSH등 모든 커맨드가 메인 스레드에서 순차 실행 - 락 불필요: 모든 자료구조 연산이 atomic —
INCR이 동시성 문제 없이 동작 - I/O 멀티플렉싱은
ae라이브러리: epoll(Linux) / kqueue(BSD) / evport(Solaris) / select(폴백)를 추상화. libuv보다 경량 - 6.0부터 I/O 스레드 도입: 네트워크 read/write 파싱만 멀티스레드. 명령 실행은 여전히 싱글
- 4.0부터 백그라운드 스레드:
UNLINK,FLUSHDB ASYNC같은 대용량 객체 삭제용
Node와의 비교
| 항목 | Redis | Node.js |
|---|---|---|
| 사용자 코드 실행 | ❌ 없음 (커맨드만) | ✅ 임의 JS |
| 진짜 싱글 스레드? | ✅ 명령 실행 단일 스레드 | ⚠️ JS만 싱글, I/O는 스레드 풀 |
| 블로킹 위험 | 낮음 (단, KEYS * 등 O(N) 커맨드 주의) | 높음 (사용자 코드 의존) |
| 멀티코어 활용 | 클러스터로 샤딩 | worker_threads / cluster |
| I/O 추상화 | ae | libuv |
| 페이즈 개념 | 단순 (beforeSleep → epoll → 콜백) | 6페이즈 + 마이크로태스크 |
블로킹 위험이 낮은 이유
Redis는 고정된 커맨드 집합만 실행하므로 각 커맨드의 시간 복잡도가 문서화되어 있다(O(1), O(log N), O(N)). 운영자가 KEYS *, SMEMBERS(대용량 셋), LRANGE 0 -1(대용량 리스트) 같은 O(N) 커맨드를 회피하면 블로킹은 거의 발생하지 않는다.
Node는 사용자가 임의 코드를 작성하므로 "느린 커맨드 목록"이라는 개념 자체가 성립하지 않는다.
Redis도 이벤트 루프인가
그렇다. Redis ae.c의 메인 루프 구조는 다음과 같다.
while (!eventLoop->stop) {
if (eventLoop->beforesleep) beforesleep(eventLoop); // expiration, AOF flush 등
aeProcessEvents(eventLoop, AE_ALL_EVENTS); // epoll_wait + 콜백 디스패치
}Node의 uv_run과 구조가 동일하다. 차이는 콜백의 정체뿐이다.
- Node: 사용자 등록 JS 콜백 (
server.on('request', ...)) - Redis: Redis 내장 커맨드 핸들러 (
getCommand,setCommand등)
Virtual Threads / Goroutine: 다른 추상화
"I/O에 유리하다면 Java Project Loom의 가상 스레드나 Go의 고루틴이 더 좋지 않은가"라는 질문이 자주 제기된다.
시기적 차이
| 기술 | 등장 시기 |
|---|---|
| Node.js | 2009 |
| Go goroutine | 2009 (Node와 동시기) |
| JVM Virtual Threads | 2023 (JDK 21에서 정식) |
Node 설계 당시 "수만 개 연결을 다루는 검증된 방법"은 epoll + 이벤트 루프뿐이었다. VT는 옵션 자체가 아니었다.
언어 특성의 제약
JavaScript는 단일 스레드 실행 모델로 설계되었다(브라우저 출신). 공유 메모리 동시성 개념이 부재하며, V8은 한 isolate 내에서 멀티스레드 JS 실행을 지원하지 않는다.
VT는 "여러 경량 스레드가 공유 힙에서 코드를 실행"하는 모델을 전제한다. JS 엔진 구조상 수용 불가능하다. Node가 멀티코어를 활용하려면 별도 V8 isolate(worker_threads) 또는 별도 프로세스(cluster)가 필요한 이유다.
VT 내부도 결국 epoll
자바 VT가 socket.read()를 호출하면 JVM이 해당 VT를 carrier thread에서 unmount하고, 내부적으로 epoll에 등록한 뒤, 데이터 도착 시 재mount한다. 시스템 콜 레벨에서는 epoll을 동일하게 사용한다. 차이는 사용자에게 동기 코드 스타일로 보이게 한다는 점뿐이다.
Node의 async/await도 같은 추상화를 다른 방향에서 시도한 결과다(콜백 지옥 → Promise → async/await로 진화).
정리
| 축 | 의미 |
|---|---|
| 시기 | 2009년에는 VT 옵션이 없었음 |
| 언어 | JS의 싱글 스레드 모델, V8의 멀티스레드 미지원 |
| 메커니즘 | VT 내부도 결국 epoll → "다른 길이 아니라 다른 포장지" |
| 단순성 | 락 없는 모델의 가치 |
결론: "VT가 I/O에 더 우월하다"가 아니라 **"VT는 동기 코드 스타일을 유지하면서 이벤트 루프의 이점을 얻는 트릭"**이다.
결론
본 글은 이벤트 루프를 OS 기반 기술(fd, epoll)부터 Node.js 구체 구현(V8 + libuv)까지 일관된 관점으로 정리했다. 핵심 결론은 다음과 같다.
본질의 위치
"이벤트 루프"라는 단어가 가리키는 핵심은 무한 루프 자체가 아니라, OS 이벤트 알림 시스템 콜(epoll/kqueue/IOCP)과 그 위의 콜백 디스패치 패턴이다. Nginx, Node, Redis가 같은 이름을 공유하는 이유는 이 패턴을 공유하기 때문이다.
밑바닥 OS부터 내 코드까지의 레이어
| 계층 | 역할 | 책임 |
|---|---|---|
| OS | epoll/kqueue/IOCP | 준비된 fd만 효율적으로 통지 |
| fd | 자원 통합 추상화 | 소켓·파일·타이머·시그널을 동일 API로 |
| libuv (또는 ae/Tokio) | 크로스플랫폼 이벤트 루프 | 플랫폼 차이 흡수 + 스레드 풀 |
| 런타임 (Node 등) | 사용자 노출 모델 | 콜백 / Promise / async-await |
워크로드 의존성
이벤트 루프는 만능 해법이 아니다. I/O 대기가 지배적인 워크로드에 적합하며, CPU 집약 작업에는 스레드/프로세스 모델 또는 워커 분리가 필요하다. Node의 worker_threads, Nginx의 워커 프로세스, Redis의 클러스터링이 모두 이 한계를 보완하는 메커니즘이다.
정리
결국 이벤트 루프는 무한 루프가 아니라 하나의 아키텍처 패턴이다. 빠른 건 epoll 덕이고, 그 위를 fd라는 추상화가 덮고 있고, 잘 맞느냐 아니냐는 워크로드에 달려 있다.
마치며
이 글의 출발점은 단순한 질문이었다. "Node가 싱글 스레드라는데 어떻게 1만 명을 처리하지?" 답을 찾으려고 들어가 보니, 결국 닿게 되는 곳은 항상 두 군데였다. fd라는 통합 추상화와 epoll이라는 푸시 모델 알림 시스템 콜.
이벤트 루프라는 단어는 자주 등장하지만, 그 단어가 가리키는 게 무엇인지 — Node, Nginx, Redis가 왜 같은 이름을 공유하는지, Go와 자바 VT는 왜 같은 메커니즘을 쓰면서도 다른 이름을 받는지 — 한 번 정리해두면 다른 시스템을 읽을 때 시야가 훨씬 넓어진다.
시각화
이벤트 루프 흐름을 직접 따라가 보려고 HTML로 만들어봤다.
- epoll 흐름 시각화 — 사용자 프로세스가 epoll에 fd를 등록한 뒤, 네트워크 카드 인터럽트가 어떻게 ready list까지 도달하는지 8단계 애니메이션
- Node 이벤트 루프 시각화 — JS 코드부터 V8 호출 스택, 이벤트 루프 6페이즈, libuv 스레드 풀까지 전체 흐름을 단계별로 추적