이벤트 루프 아키텍처 완벽 정리: Node.js를 중심으로
C10K 문제부터 fd·epoll, libuv 스레드 풀, Node.js의 6페이즈, Redis·Nginx·가상 스레드 비교까지 — '이벤트 루프'라는 단어가 가리키는 게 뭔지 정리했다.
이벤트 루프 아키텍처: Node.js를 중심으로
"이벤트 루프"는 무한 루프가 아니라 아키텍처 패턴이다. 마법의 핵심은 epoll, 추상화의 핵심은 fd, 트레이드오프의 핵심은 워크로드 의존성이다.
들어가며: 인터랙티브 시각화 두 개
이 글은 길어서 중간에 막힐 수 있다. 3장의 epoll 부분이나 4장의 Node 페이즈 부분을 읽다가 "패킷이 어떻게 NIC에서 메인 스레드까지 도달하지?", "JS 한 줄이 V8과 libuv를 어떻게 왕복하지?" 하고 헷갈리면 같은 디렉터리에 올려둔 두 시각화를 보면 빠르게 잡힌다.
- epoll 흐름 시각화 (User Process ↔ Kernel ↔ NIC) — 사용자 프로세스가 epoll에 fd를 등록한 뒤, 네트워크 카드 인터럽트가 어떻게 ready list까지 도달하는지 8단계 애니메이션
- Node 이벤트 루프 시각화 (V8 + libuv 6페이즈) — JS 코드부터 V8 호출 스택, 이벤트 루프 6페이즈, libuv 스레드 풀까지 전체 흐름을 단계별로 추적
본문 3.2.4와 4.3에서 이 두 시각화로 직접 돌아온다.
목차
- 1장. 배경: C10K 문제와 동시성 모델
- 2장. 이벤트 루프의 본질
- 3장. 기반 기술: fd와 epoll
- 4장. Node.js 아키텍처
- 5장. 다른 시스템과의 비교
- 6장. 결론
- 부록 A. 자주 묻는 질문
1장. 배경: C10K 문제와 동시성 모델
1.1 문제 정의
서버는 본질적으로 "동시에 들어오는 수천 개의 연결을 어떻게 처리하느냐"는 문제를 푼다. 1999년 Dan Kegel이 제기한 C10K 문제(Concurrent 10,000 connections)는 "동시 연결 1만 개를 한 대의 서버가 감당할 수 있는가"를 묻는다.
지금은 1만 개가 작아 보이지만, 당시 일반적인 서버는 수백 연결만 처리해도 비명을 질렀다. C10K는 이후 C10M(천만 연결)으로까지 확장된다.
1.2 스레드/프로세스 기반 모델
전통적인 Apache prefork, Java Servlet(Tomcat 기본) 등이 이 진영에 속한다.
- 연결 하나당 스레드(또는 프로세스) 하나를 할당
- 각 스레드는 스택 메모리(보통 1~8MB)를 점유
- 커널이 스레드를 번갈아 실행시키는 컨텍스트 스위칭 비용이 지속 발생
- 연결 1만 개 = 스레드 1만 개 → 메모리·스케줄링 비용 폭발
1.3 이벤트 기반 모델
Nginx, Node.js, Redis 등이 채택한 방식이다.
- 스레드 하나(또는 CPU 코어당 하나)가 수많은 연결을 관리
- 대부분의 연결은 실제로는 "대기 중"(클라이언트 입력, DB 응답)임을 활용
- 노는 시간 동안 스레드를 점유하지 않고, "데이터 준비됨" 같은 이벤트가 도착했을 때만 깨어나 처리
1.4 트레이드오프
이벤트 루프가 스레드 모델보다 무조건 우월한 게 아니다.
| 워크로드 | 유리한 모델 | 이유 |
|---|---|---|
| I/O 대기 다수 (웹 서버, API 게이트웨이) | 이벤트 루프 | 대부분 시간이 대기 |
| CPU 집약 작업 다수 | 스레드/프로세스 | OS가 코어 간 분산해줌 |
| 혼합 워크로드 | 하이브리드 (이벤트 루프 + 워커 스레드) | Node의 worker_threads 모델 |
2장. 이벤트 루프의 본질
2.1 구조
이벤트 루프 자체는 단순한 무한 루프다.
while (alive) {
events = wait_for_events(); // OS에 "준비된 이벤트 있냐" 질의
for (e in events) {
dispatch(e.callback); // 등록된 콜백 실행
}
}
핵심은 두 단계로 환원된다.
- 이벤트 수령 — OS가 알려준 I/O 이벤트를 받는다 (
epoll_wait등) - 콜백 디스패치 — 각 이벤트에 묶인 콜백을 순차 실행한다
2.2 이름의 의미: "이벤트"가 핵심, "루프"는 부차적
모든 서버는 무한 루프다. 이벤트 루프를 다른 모델과 구분 짓는 것은 **"이벤트를 기다렸다가 콜백을 디스패치한다"**는 동작 그 자체다.
본질을 한 문장으로 정의하면 이렇다.
"OS가 알려준 I/O 이벤트를 기다렸다가, 그 이벤트에 등록된 콜백을 디스패치하는 루프"
2.3 "이벤트 루프"인 것과 아닌 것
| 모델 | 루프? | 이벤트 기반? | 통상 명칭 |
|---|---|---|---|
| Apache prefork | ❌ (요청당 프로세스) | ❌ | 프로세스 모델 |
| Java Servlet (Tomcat 기본) | ❌ (요청당 스레드) | ❌ | 스레드-퍼-리퀘스트 |
| Nginx / Node / Redis | ✅ | ✅ | 이벤트 루프 |
| Go (goroutine) | 내부적으로 ✅ | 내부적으로 ✅ | 고루틴 / M:N 스케줄러 |
| Java Virtual Threads | 내부적으로 ✅ | 내부적으로 ✅ | 가상 스레드 |
Go의 고루틴과 자바 VT는 속은 이벤트 루프 메커니즘을 쓰면서 사용자에게는 동기 코드 스타일을 노출한다. 그래서 "이벤트 루프"라는 이름을 받지 않는다. 이름은 사용자에게 노출되는 추상화를 따른다.
2.4 공유 아이덴티티의 세 층위
"이벤트 루프"라는 명명이 적용되려면 세 층위가 모두 일치해야 한다.
- 메커니즘 층: OS의 이벤트 알림 시스템 콜(
epoll/kqueue/IOCP) 사용 - 구조 층:
while(true) { wait_events(); dispatch_callbacks(); }패턴 - 사용자 모델 층: "콜백 등록 → 이벤트 발생 시 콜백 호출" 모델을 사용자에게 노출
Nginx, Node.js, Redis는 세 층이 모두 일치한다. Go/VT는 1, 2층은 같지만 3층을 동기 스타일로 숨기므로 다른 이름을 받는다.
3장. 기반 기술: fd와 epoll
이벤트 루프가 작동하기 위한 두 가지 OS 수준 기반 기술을 정리한다.
3.1 fd (File Descriptor)
3.1.1 정의
fd는 프로세스가 커널이 관리하는 I/O 리소스(파일, 소켓, 파이프 등)를 가리키기 위해 사용하는 음이 아닌 작은 정수다. C 포인터가 메모리를 가리키듯, fd는 정수로 커널 자원을 가리킨다.
int fd = open("/tmp/log.txt", O_RDONLY); // fd = 3
read(fd, buf, 100);
close(fd);3.1.2 "Everything is a file" 철학
리눅스에서 fd로 다루는 자원은 종류가 다양하다.
| 자원 | fd 지원 |
|---|---|
| 일반 파일, 디렉토리 | ✅ |
| TCP/UDP 소켓 | ✅ |
| 파이프 | ✅ |
| 터미널 (stdin/stdout) | ✅ |
시그널 (signalfd) | ✅ |
타이머 (timerfd) | ✅ |
이벤트 (eventfd) | ✅ |
| epoll 인스턴스 자체 | ✅ |
자원 종류와 무관하게 read(), write(), close(), epoll이 동일한 API로 동작한다. 이것이 epoll이 소켓·파이프·타이머를 모두 단일 메커니즘으로 감시할 수 있는 이유다.
3.1.3 커널 내부 구조 (3계층)
프로세스 (User Space)
│ fd = 3 (정수)
▼
① fd 테이블 (프로세스마다 1개)
│ 0 → stdin, 1 → stdout, 2 → stderr, 3 → ...
▼
② 열린 파일 테이블 (시스템 전체)
│ 현재 오프셋, 모드(RD/WR), 참조 카운트
▼
③ inode 테이블 (실제 자원)
디스크 블록, 소켓이면 TCP 상태, 권한 등
3계층 구조는 dup(), fork() 후 fd 공유 같은 시나리오를 지원하기 위해 필요하다.
3.1.4 운영 관점
- 커널은 가장 작은 미사용 정수를 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>
3.1.5 윈도우와의 차이
| 항목 | 리눅스 fd | 윈도우 |
|---|---|---|
| 타입 | int | HANDLE (포인터) |
| 통일성 | 모두 fd | 파일은 HANDLE, 소켓은 SOCKET 분리 |
| API | 공통 | ReadFile vs recv 분리 |
| 이벤트 알림 | epoll | IOCP (모델 자체가 다름) |
3.2 epoll
3.2.1 정의
epoll은 리눅스 커널이 제공하는 I/O 이벤트 알림 시스템 콜이다. "수많은 fd 중 준비된 것만 효율적으로 알려달라"는 요구를 충족한다.
3.2.2 select/poll의 한계
select(10000, &readfds, ...);
// 매 호출마다 1만 개 fd 목록을 커널에 복사 — O(n)
// 커널이 1만 개를 모두 훑음 — O(n)
// 반환 후에도 어느 것이 준비됐는지 1만 개를 다시 훑어야 함 — O(n)연결 수가 늘면 비용이 선형 증가한다. C10K 한계의 직접적 원인이었다.
3.2.3 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(준비된 개수)3.2.4 작동 원리: 커널 콜백 등록 모델
select가 "물어볼 때마다 다 확인"하는 풀(pull) 모델이라면, epoll은 푸시(push) 모델이다.
epoll_ctl로 fd 등록 시 → 커널이 그 fd의 wait queue에 콜백을 심음- 네트워크 카드가 데이터 수신 → 소켓 버퍼에 채움 → wait queue의 콜백 실행
- 콜백이 그 fd를 "준비됨 리스트(ready list)"에 옮김
epoll_wait은 ready list만 읽어 반환 → O(1)에 가까움
핵심: 커널이 능동적으로 준비된 것만 모아두므로 사용자가 매번 훑지 않아도 된다.
이 흐름이 한 번에 잘 안 잡힌다면 epoll 흐름 시각화에서 8단계 애니메이션으로 직접 보면 훨씬 빠르게 잡힌다. NIC 인터럽트가 어떻게 wait queue → ready list까지 도달하는지 단계별로 추적한다.
3.2.5 트리거 모드
- Level-Triggered (LT, 기본): 준비되어 있는 동안 계속 알림.
select와 동일 동작 - Edge-Triggered (ET): 상태 전이 순간만 한 번 알림.
EAGAIN이 반환될 때까지 모두 읽어야 함. Nginx가 채택
3.2.6 다른 OS의 동등물
| OS | API |
|---|---|
| Linux | epoll |
| BSD/macOS | kqueue (epoll보다 일반적, 파일/시그널/타이머도 감시) |
| Windows | IOCP (완료 기반: "끝나면 알려달라") |
| 크로스플랫폼 | libuv, libevent 같은 추상화 라이브러리 |
3.2.7 한계
- CPU 작업과 무관: "I/O 준비됨"만 알려줄 뿐, 콜백 실행은 사용자 스레드의 책임
- 리눅스 전용 → 크로스플랫폼이 필요하면 추상화 라이브러리 필요
- 디스크 I/O에 약함: 디스크 fd는 항상 "준비됨"으로 처리됨 → Node가 파일 I/O를 별도 스레드 풀로 분리하는 이유 (4.4절 참조)
4장. Node.js 아키텍처
4.1 전체 구조: V8 + libuv
흔한 오해 중 하나는 "libuv가 V8 엔진 내부에 있다"는 것이다. 실제로는 V8과 libuv는 형제 관계이며, Node.js는 둘을 묶어 사용하는 런타임이다.
4.1.1 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(), ... │
└─────────────────────────────────────────────┘
4.1.2 각 컴포넌트의 역할
- V8: JS 코드를 기계어로 컴파일·실행. I/O는 V8의 관심사가 아니다 — V8 단독으로는 파일을 읽거나 소켓을 열 수 없다
- libuv: 비동기 I/O와 이벤트 루프를 크로스 플랫폼으로 제공하는 C 라이브러리. JS를 모른다
- Node.js: 두 컴포넌트를 C++ 바인딩으로 연결하는 런타임
4.1.3 다른 런타임과의 비교: 컴포넌트 독립성
| 런타임 | 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도 다른 엔진과 조합 가능하다. 두 컴포넌트가 독립적이라는 증거다.
4.1.4 소스 코드 위치
node/
├── deps/
│ ├── v8/ ← V8 엔진 (구글 별도 프로젝트)
│ └── uv/ ← libuv (별도 프로젝트)
├── src/ ← 바인딩 C++ 코드
│ ├── node_file.cc
│ ├── node_http_parser.cc
│ └── ...
└── lib/ ← 코어 모듈 JS
├── fs.js
└── ...
deps/ 폴더에 V8과 libuv가 나란히 존재한다.
4.2 libuv 내부: 이벤트 루프와 스레드 풀
libuv 내부에는 두 개의 별개 메커니즘이 공존한다. 둘을 혼동하지 않는 것이 중요하다.
4.2.1 두 메커니즘의 구분
┌──────────────────────────────────────────────────────────┐
│ libuv │
│ │
│ ┌────────────────────────┐ ┌──────────────────────┐ │
│ │ ① 이벤트 루프 │ │ ② 스레드 풀 │ │
│ │ (메인 스레드 1개) │ │ (워커 4개, 기본) │ │
│ │ 싱글 스레드 │ │ 멀티 스레드 │ │
│ │ │ │ │ │
│ │ while(true) { │ │ T1: idle/blocking │ │
│ │ epoll_wait() │ │ T2: idle/blocking │ │
│ │ dispatch_cb() │ │ T3: idle/blocking │ │
│ │ } │ │ T4: idle/blocking │ │
│ └─────────┬──────────────┘ └──────────┬───────────┘ │
│ │ │ │
│ └───── 작업 위임 / 결과 회수 ───┘ │
└──────────────────────────────────────────────────────────┘
- 이벤트 루프: 메인 스레드 단 하나가 무한 루프를 돌리며 콜백을 디스패치
- 스레드 풀: epoll로 처리 불가능한 블로킹 작업을 백그라운드에서 대신 수행
4.2.2 협력 시퀀스: 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 콜백을 직접 실행하지 않는다.
4.2.3 워커가 JS를 실행하지 못하는 이유
- JS 실행은 V8이 담당
- V8의 한 isolate(인스턴스)는 한 스레드만 진입 가능
- libuv 워커가 V8에 진입하려면 락 획득 + 메인 스레드 정지 → 비동기 의미 상실
따라서 이렇게 정리된다.
- 워커는 C 레벨에서 데이터만 처리
- 완료 시 결과 버퍼를 메인 스레드 poll 큐에 전달
- 메인 스레드가 poll 페이즈에서 JS 콜백 실행
이것이 "JS는 싱글 스레드, I/O는 멀티 스레드"의 정확한 의미다.
4.2.4 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 스레드 풀(자동)로 분리하는 것이 권장 패턴이다.
4.3 이벤트 루프의 6페이즈와 마이크로태스크
4.3.1 페이즈 다이어그램
┌───────────────────────────┐
┌─>│ timers │ setTimeout, setInterval 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 일부 시스템 콜백 (TCP 에러 등)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ libuv 내부용
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │ ← I/O 이벤트 대기/처리
│ └─────────────┬─────────────┘ │ data, etc. │ (epoll 호출 지점)
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ setImmediate 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ socket.on('close') 등
└───────────────────────────┘
이 6페이즈가 JS 한 줄과 어떻게 연결되는지 헷갈리면 Node 이벤트 루프 시각화에서 직접 페이즈를 돌려보면 잡힌다. setTimeout, Promise, fs.readFile이 각각 어느 페이즈로 향하는지 단계별로 추적한다.
4.3.2 마이크로태스크 우선순위
각 페이즈 사이마다 마이크로태스크 큐가 비워진다. 마이크로태스크에는 다음이 포함된다.
Promise.then,queueMicrotaskprocess.nextTick(마이크로태스크 중에서도 최우선)
실행 순서는 이렇다.
process.nextTick콜백Promise.then,queueMicrotask콜백- 다음 페이즈 진입
결과적으로 이렇게 된다.
Promise.then은 현재 페이즈가 끝나는 즉시 실행setTimeout(fn, 0)은 다음 timers 페이즈까지 대기- 따라서 거의 항상 Promise가 setTimeout보다 먼저 실행됨
4.3.3 nextTick starvation
process.nextTick을 재귀적으로 큐에 넣으면 이벤트 루프가 I/O 페이즈로 진입하지 못해 다른 콜백이 굶주리는 현상이 발생한다. 따라서 nextTick은 동기적 정리 작업에만 사용하고, 반복적 작업에는 setImmediate를 사용하는 것이 안전하다.
4.4 I/O 처리 분기: 네트워크 vs 파일
"Node가 I/O 처리 시 멀티스레드를 쓰는가"라는 질문의 답은 I/O 종류에 따라 다르다.
4.4.1 네트워크 I/O: 스레드 풀 미사용
- TCP/UDP 소켓, HTTP, 파이프
- libuv가 epoll/kqueue/IOCP에 fd를 등록만 하고 즉시 반환
- 데이터 도착 시 커널이 메인 스레드를 깨워 콜백 실행
- 논블로킹
[메인 스레드] ── epoll_wait() ── [커널] ←── NIC
(인터럽트로 깨움)
4.4.2 파일 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압축/해제
4.4.3 분기 이유
리눅스 epoll은 디스크 fd를 효율적으로 다루지 못한다.
- 소켓 fd: "데이터 없으면 EAGAIN 반환" 가능 → epoll로 감시 가능
- 파일 fd: 항상 "준비됨"으로 표시됨 → 실제 read() 시 블로킹
- POSIX에 진짜 논블로킹 디스크 I/O API가 부재 (
O_NONBLOCK이 디스크에는 실효 없음)
따라서 libuv는 디스크 작업을 스레드 풀로 위임하고, 워커가 동기 read()를 호출하는 방식으로 처리한다.
리눅스 5.1+의
io_uring은 디스크 I/O도 진정한 비동기로 처리 가능하다. libuv가 점진적으로 도입 중이나, 기본 경로는 여전히 스레드 풀이다.
4.4.4 작업별 분기 정리
| 작업 | 스레드 풀? | 메커니즘 |
|---|---|---|
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 | ❌ | 타이머 힙 + 이벤트 루프 |
4.4.5 운영 고려사항
UV_THREADPOOL_SIZE 환경변수로 풀 크기 조절 가능 (기본 4, 최대 1024).
UV_THREADPOOL_SIZE=8 node app.jsDB 클라이언트, 다수 파일 I/O를 처리하는 서버는 이 값을 늘려야 풀 포화로 인한 대기를 방지할 수 있다.
4.5 블로킹 문제
이벤트 루프 모델의 구조적 약점이다.
4.5.1 문제 정의
이벤트 루프는 콜백을 순차적으로 단일 스레드에서 실행한다. 한 콜백이 CPU를 오래 점유하면(대규모 배열 정렬, 동기 암호화, 대용량 JSON.parse 등) 이벤트 루프 전체가 정지하며, 모든 다른 연결의 응답이 지연된다.
스레드 모델에서는 OS 스케줄러가 다른 스레드에 CPU를 할당하므로 한 요청의 지연이 다른 요청으로 전파되지 않는다. 이벤트 루프는 이러한 보호 장치가 없다.
4.5.2 대응 전략
worker_threads로 분리: CPU 집약 작업을 별도 V8 isolate에서 실행- 작업 분할: 큰 작업을 잘게 쪼개
setImmediate로 이벤트 루프에 양보 - 외부 프로세스 위임: 무거운 연산은 별도 서비스로 이전
Nginx가 이 문제에서 비교적 자유로운 이유는 사용자 코드가 개입할 여지가 없기 때문이다(5.1절). Node.js는 사용자가 임의의 JS를 실행하므로 이 위험을 구조적으로 안고 간다.
5장. 다른 시스템과의 비교
5.1 Nginx: 멀티 워커 프로세스 모델
- 마스터 프로세스 1개 + CPU 코어 수만큼의 워커 프로세스
- 각 워커가 독립된 이벤트 루프를 운영
- 워커 간 메모리 공유 없음 → 락 불필요, 워커 장애 격리
- 처리 워크로드(요청 수신, 정적 파일 서빙, 프록시)가 본질적으로 I/O 중심 → 이벤트 루프 모델과 정합
- 사용자 코드 진입점이 없음 → CPU 블로킹 위험 부재
5.2 Redis: 싱글 스레드 모델
5.2.1 특징
- 명령 실행이 단일 스레드:
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같은 대용량 객체 삭제용
5.2.2 Node와의 비교
| 항목 | Redis | Node.js |
|---|---|---|
| 사용자 코드 실행 | ❌ 없음 (커맨드만) | ✅ 임의 JS |
| 진짜 싱글 스레드? | ✅ 명령 실행 단일 스레드 | ⚠️ JS만 싱글, I/O는 스레드 풀 |
| 블로킹 위험 | 낮음 (단, KEYS * 등 O(N) 커맨드 주의) | 높음 (사용자 코드 의존) |
| 멀티코어 활용 | 클러스터로 샤딩 | worker_threads / cluster |
| I/O 추상화 | ae | libuv |
| 페이즈 개념 | 단순 (beforeSleep → epoll → 콜백) | 6페이즈 + 마이크로태스크 |
5.2.3 블로킹 위험이 낮은 이유
Redis는 고정된 커맨드 집합만 실행하므로 각 커맨드의 시간 복잡도가 문서화되어 있다(O(1), O(log N), O(N)). 운영자가 KEYS *, SMEMBERS(대용량 셋), LRANGE 0 -1(대용량 리스트) 같은 O(N) 커맨드를 회피하면 블로킹은 거의 발생하지 않는다.
Node는 사용자가 임의 코드를 작성하므로 "느린 커맨드 목록"이라는 개념 자체가 성립하지 않는다.
5.2.4 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등)
5.3 Virtual Threads / Goroutine: 다른 추상화
"I/O에 유리하다면 Java Project Loom의 가상 스레드나 Go의 고루틴이 더 좋지 않은가"라는 질문이 자주 제기된다.
5.3.1 시기적 차이
| 기술 | 등장 시기 |
|---|---|
| Node.js | 2009 |
| Go goroutine | 2009 (Node와 동시기) |
| JVM Virtual Threads | 2023 (JDK 21에서 정식) |
Node 설계 당시 "수만 개 연결을 다루는 검증된 방법"은 epoll + 이벤트 루프뿐이었다. VT는 옵션 자체가 아니었다.
5.3.2 언어 특성의 제약
JavaScript는 단일 스레드 실행 모델로 설계되었다(브라우저 출신). 공유 메모리 동시성 개념이 부재하며, V8은 한 isolate 내에서 멀티스레드 JS 실행을 지원하지 않는다.
VT는 "여러 경량 스레드가 공유 힙에서 코드를 실행"하는 모델을 전제한다. JS 엔진 구조상 수용 불가능하다. Node가 멀티코어를 활용하려면 별도 V8 isolate(worker_threads) 또는 별도 프로세스(cluster)가 필요한 이유다.
5.3.3 VT 내부도 결국 epoll
자바 VT가 socket.read()를 호출하면 JVM이 해당 VT를 carrier thread에서 unmount하고, 내부적으로 epoll에 등록한 뒤, 데이터 도착 시 재mount한다. 시스템 콜 레벨에서는 epoll을 동일하게 사용한다. 차이는 사용자에게 동기 코드 스타일로 보이게 한다는 점뿐이다.
Node의 async/await도 같은 추상화를 다른 방향에서 시도한 결과다(콜백 지옥 → Promise → async/await로 진화).
5.3.4 정리
| 축 | 의미 |
|---|---|
| 시기 | 2009년에는 VT 옵션이 없었음 |
| 언어 | JS의 싱글 스레드 모델, V8의 멀티스레드 미지원 |
| 메커니즘 | VT 내부도 결국 epoll → "다른 길이 아니라 다른 포장지" |
| 단순성 | 락 없는 모델의 가치 |
결론: "VT가 I/O에 더 우월하다"가 아니라 **"VT는 동기 코드 스타일을 유지하면서 이벤트 루프의 이점을 얻는 트릭"**이다.
6장. 결론
본 글은 이벤트 루프를 OS 기반 기술(fd, epoll)부터 Node.js 구체 구현(V8 + libuv)까지 일관된 관점으로 정리했다. 핵심 결론은 다음과 같다.
6.1 본질의 위치
"이벤트 루프"라는 단어가 가리키는 핵심은 무한 루프 자체가 아니라, OS 이벤트 알림 시스템 콜(epoll/kqueue/IOCP)과 그 위의 콜백 디스패치 패턴이다. Nginx, Node, Redis가 같은 이름을 공유하는 이유는 이 패턴을 공유하기 때문이다.
6.2 추상화의 위계
| 계층 | 역할 | 책임 |
|---|---|---|
| OS | epoll/kqueue/IOCP | 준비된 fd만 효율적으로 통지 |
| fd | 자원 통합 추상화 | 소켓·파일·타이머·시그널을 동일 API로 |
| libuv (또는 ae/Tokio) | 크로스플랫폼 이벤트 루프 | 플랫폼 차이 흡수 + 스레드 풀 |
| 런타임 (Node 등) | 사용자 노출 모델 | 콜백 / Promise / async-await |
6.3 워크로드 의존성
이벤트 루프는 만능 해법이 아니다. I/O 대기가 지배적인 워크로드에 적합하며, CPU 집약 작업에는 스레드/프로세스 모델 또는 워커 분리가 필요하다. Node의 worker_threads, Nginx의 워커 프로세스, Redis의 클러스터링이 모두 이 한계를 보완하는 메커니즘이다.
6.4 정리
"이벤트 루프"는 무한 루프가 아니라 아키텍처 패턴이다. 마법의 핵심은 epoll, 추상화의 핵심은 fd, 트레이드오프의 핵심은 워크로드 의존성이다.
부록 A. 자주 묻는 질문
기술 면접·토론에서 이해도를 점검하기 좋은 질문과 핵심 답이다.
Q1. Node가 싱글 스레드라면 어떻게 파일 10개를 동시에 읽나요? A. libuv 스레드 풀(기본 4개)이 디스크 I/O를 백그라운드에서 병렬 처리한다. JS는 싱글 스레드이지만 I/O는 멀티 스레드다.
Q2. setTimeout(fn, 0)은 정말 0ms 뒤에 실행되나요?
A. 아니다. 다음 timers 페이즈까지 대기하며, 최소 지연(Node 내부적으로 1ms)이 존재한다.
Q3. 이벤트 루프가 epoll을 사용하는데 왜 CPU 작업에 약한가요? A. epoll은 I/O 준비 상태만 통지한다. 콜백 실행은 여전히 단일 스레드의 책임이므로, 콜백이 CPU를 점유하면 루프가 정지한다.
Q4. Nginx는 워커가 다수인데 왜 Node는 기본이 하나인가요?
A. 설계 철학의 차이다. Node도 cluster 모듈이나 PM2로 멀티 프로세스 운영이 가능하며, 프로덕션에서는 실제로 그렇게 사용된다.
Q5. 이벤트 루프가 스레드 모델보다 우월한가요? A. 아니다. 워크로드에 따라 다르다. I/O 대기 중심이면 이벤트 루프, CPU 중심이면 스레드 모델 또는 하이브리드가 유리하다.
Q6. libuv는 V8 안에 있나요? A. 아니다. V8과 libuv는 형제 관계이며, Node.js가 둘을 C++ 바인딩으로 연결한다.
Q7. libuv가 멀티 스레드로 이벤트 루프를 돌리나요? A. 아니다. 이벤트 루프는 메인 스레드 단 하나가 운영한다. libuv 안에 별도의 스레드 풀이 있지만, 이는 블로킹 syscall 대행용이며 이벤트 루프 자체는 싱글 스레드다.
마치며
이 글의 출발점은 단순한 질문이었다. "Node가 싱글 스레드라는데 어떻게 1만 명을 처리하지?" 답을 찾으려고 들어가 보니, 결국 닿게 되는 곳은 항상 두 군데였다. fd라는 통합 추상화와 epoll이라는 푸시 모델 알림 시스템 콜.
이벤트 루프라는 단어는 자주 등장하지만, 그 단어가 가리키는 게 무엇인지 — Node, Nginx, Redis가 왜 같은 이름을 공유하는지, Go와 자바 VT는 왜 같은 메커니즘을 쓰면서도 다른 이름을 받는지 — 한 번 정리해두면 다른 시스템을 읽을 때 시야가 훨씬 넓어진다.
복습용으로 두 시각화를 다시 한번 링크해둔다.
- epoll 흐름 시각화 — fd 등록 → 인터럽트 → ready list → epoll_wait
- Node 이벤트 루프 시각화 — JS 코드 → V8 → libuv → 6페이즈