C.W.K.
Stream
Lesson 03 of 05 · published

libuv 와 Event Loop

~16 min · runtime, libuv, event-loop, async

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"V8 은 네 코드를 돌려. libuv 는 네 코드 *사이의* 모든 걸 돌려 — 기다림, 감시, I/O. Event loop 는 libuv 의 시계야."

들어본 적 없는데 항상 쓰던 라이브러리

libuv 는 한 가지 일을 정말 잘하는 C 라이브러리야: 크로스 플랫폼 비동기 I/O. Linux 에선 epoll, macOS 에선 kqueue, Windows 에선 IOCP — 셋 다 단일 API 뒤에 숨겨. Ryan Dahl 이 Node 만들 때 async I/O 레이어를 직접 안 짰어 — libuv (원래는 libev + libeio, 나중에 통합) 를 골라서 JavaScript 로 감쌌어.

트릭: libuv 는 I/O 작업을 시작 만 시키고 ("이 1GB 파일 읽어") 바로 네 JS 코드로 돌아가게 해. 파일이 준비되면 libuv 가 V8 을 찔러서 "지금 이 callback 불러" 라고 해. 비동기 모델 전체가 한 문장이야. 나머지 — promise, async/await, stream — 다 "I/O 시작, 나중에 callback" 위에 얹은 설탕이야.

Event Loop 의 여섯 단계

libuv event loop 는 매 tick 마다 여섯 단계를 도는 deterministic 한 state machine 이야:

  1. Timers — threshold 지난 setTimeout / setInterval 의 callback 실행.
  2. Pending callbacks — 이전 iteration 에서 미뤄진 I/O callback 실행.
  3. Idle, prepare — 내부 전용; libuv housekeeping.
  4. Poll — 여기서 block 해서 I/O 이벤트 기다림; 도착하면 callback 실행.
  5. ChecksetImmediate 의 callback 실행.
  6. Close callbackssocket.on('close') 같은 close handler 실행.

각 단계 사이 — 그리고 단계 안 각 callback 사이 — Node 가 마이크로 큐 둘을 비워: process.nextTick 먼저, 그 다음 Promise microtask. 그래서 process.nextTick(...) 가 항상 I/O 보다 먼저 돌고, await 한 promise 가 다음 tick 까지 기다리지 않고 현재 동기 블록 직후 "즉시" 재개되는 거야.

setImmediate vs setTimeout(fn, 0) vs process.nextTick

Node 에서 코드를 "미루는" 세 가지 방법, 세 가지 다른 단계.
  • setTimeout(fn, 0) → Timers 단계 (실제로는 최소 1ms 후).
  • setImmediate(fn) → Check 단계 (Poll 다음).
  • process.nextTick(fn) → 마이크로 큐, 다음 단계 시작 전에 실행.
뭘 쓸지 모르겠으면 거의 항상 queueMicrotask (Promise microtask) 아니면 setImmediate 야. process.nextTick 은 잘못 쓰면 I/O 굶겨.

아무도 얘기 안 하는 thread pool

"Node 는 single-threaded" 는 반쪽 진실이야. 네 JavaScript 가 single-threaded 로 돌아 — V8 instance 가 딱 하나, 메인 스레드 하나. 근데 libuv 가 OS 가 async API 안 주는 작업들 위해 thread pool 을 굴려 (기본 4 개, UV_THREADPOOL_SIZE 로 설정 가능): 대부분 플랫폼에서 파일 시스템 I/O, getaddrinfo 통한 DNS lookup, 일부 crypto 작업.

그래서 Node 앱한테 동시 readFile 호출 두들기면 결국 느려져 — V8 이 아니라 4-thread libuv pool 을 포화시키는 거야. production 에서 UV_THREADPOOL_SIZE=16 으로 올리는 건 진짜 레버야; 대부분 존재 자체를 몰라.

Pippa 의 고백

아빠가 처음 event loop 설명해줬을 때 난 "callback 여기" 적힌 원형 화살표 하나로 그렸어. 아빠가 "6 단계 그려" 했어. 난 4 개만 그렸지. 아빠가 libuv 소스 찾아보게 했어. 이제 `await fs.readFile(...)` 쓸 때 그 여정을 *느껴* — V8 → C++ bridge → libuv → thread pool → epoll → 파일 → libuv callback → V8 → resumed promise. 그 mental model 은 장식이 아냐 — "왜 내 서버가 동시 요청 4 개에서 멈춰?" 를 디버그하는 방법이야.

Code

다섯 scheduler, 한 tick — 순서 예측해봐·javascript
// Five things scheduled in five different ways.
// Predict the order before running. Then run it.

import { readFile } from 'node:fs/promises';

setTimeout(() => console.log('1. setTimeout'), 0);
setImmediate(() => console.log('2. setImmediate'));
process.nextTick(() => console.log('3. nextTick'));
queueMicrotask(() => console.log('4. microtask'));
Promise.resolve().then(() => console.log('5. promise.then'));

console.log('0. sync');

// Output (Node 26):
// 0. sync
// 3. nextTick
// 4. microtask
// 5. promise.then
// 1. setTimeout  (or 2 first, depending on timer threshold)
// 2. setImmediate
대부분 Node 개발자가 안 만지는 레버·bash
# Bump libuv's thread pool size BEFORE starting Node.
# Must be set as an env var; can't be changed at runtime.
UV_THREADPOOL_SIZE=16 node server.js

# Default is 4. Common values: 8 (small server), 16 (heavy fs/dns),
# up to 1024 max. Going above 16 is rare and benchmark-driven.

External links

Exercise

위 5-scheduler 예제 가져와. await readFile('./package.json', 'utf-8') 호출을 동기 블록 가운데에 끼워 넣어. 이제 출력 순서 예측해. 그 다음 돌려. 예측 맞았어? 안 맞았으면 왜인지 적어 — 그 mismatch 가 네 event-loop 직관이 날카로워져야 할 지점이야.
Hint
힌트: await 는 함수 나머지를 microtask 로 옮기는 게 아니라, 함수를 suspend 시키고 promise 가 resolve 되면 microtask 에서 재개시켜. 그래서 await *이후의* 모든 게 사실상 microtask 큐에서 돌아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.