본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

libuv와 이벤트 루프

~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이 JavaScript를 실행하는 동안 libuv는 기다림과 I/O를 맡아. 이벤트 루프는 끝난 작업의 처리 함수를 다시 JavaScript 쪽으로 돌려보내."

운영체제 차이를 감추는 libuv

libuv는 여러 운영체제의 비동기 I/O 방식을 하나의 API로 묶은 C 라이브러리야. Linux의 epoll, macOS의 kqueue, Windows의 IOCP는 사용법이 서로 다르지만 Node 코드는 그 차이를 직접 다루지 않아. Node가 libuv를 통해 각 운영체제에 맞는 감시와 알림 방식을 사용하기 때문이야.

비동기 작업을 요청하면 JavaScript는 완료될 때까지 그 자리에서 기다리지 않아. Node와 libuv가 작업을 운영체제나 스레드 풀에 맡긴 뒤 다음 JavaScript 코드를 실행해. 작업이 끝나면 완료 알림과 처리 함수가 큐에 들어오고, 이벤트 루프가 알맞은 차례에 그 함수를 실행해. 프로미스와 async/await, 스트림도 결국 이 흐름을 더 다루기 쉬운 모양으로 감싼 도구야.

이벤트 루프가 한 바퀴 도는 순서

libuv 이벤트 루프는 한 차례마다 다음 단계를 지나가:

  1. Timers: 실행 시간이 지난 setTimeoutsetInterval 처리 함수를 실행해.
  2. Pending callbacks: 앞선 차례에서 미뤄 둔 일부 I/O 처리 함수를 실행해.
  3. Idle, prepare: libuv가 내부 준비 작업을 수행해.
  4. Poll: 새 I/O 이벤트를 받거나, 받을 일이 없으면 조건에 따라 기다려.
  5. Check: setImmediate로 등록한 처리 함수를 실행해.
  6. Close callbacks: 소켓의 close처럼 닫힘에 관한 처리 함수를 실행해.

Node는 이 단계들 사이와 처리 함수가 끝난 뒤에 별도의 작은 큐도 비워. process.nextTick 큐가 먼저고 프로미스 마이크로태스크 큐가 그다음이야. 그래서 await 뒤의 코드는 기다리던 프로미스가 끝나면 다음 이벤트 루프 차례까지 미뤄지지 않고 마이크로태스크로 이어질 수 있어.

비슷해 보이는 세 예약 방법

실행을 뒤로 미루는 방법마다 자리가 달라.
  • setTimeout(fn, 0)은 Timers 단계에서 실행돼. 실제 지연 시간은 0ms보다 길 수 있어.
  • setImmediate(fn)은 Poll 다음의 Check 단계에서 실행돼.
  • process.nextTick(fn)은 다음 이벤트 루프 단계로 넘어가기 전에 실행돼.
일반적인 마이크로태스크에는 queueMicrotask를, I/O 처리가 끝난 뒤로 넘기려면 setImmediate를 먼저 고려해. process.nextTick을 계속 이어 붙이면 이벤트 루프가 I/O 단계로 넘어가지 못할 수 있어.

JavaScript 밖에는 스레드 풀도 있어

“Node는 단일 스레드다”라는 말은 JavaScript 실행 흐름에 관한 설명이야. 메인 V8 인스턴스에서 JavaScript는 한 번에 한 작업을 실행하지만, libuv는 운영체제가 곧바로 비동기로 처리해 주지 않는 일을 위한 스레드 풀도 운영해. 기본 크기는 4이고 UV_THREADPOOL_SIZE로 바꿀 수 있어.

파일 시스템 작업과 getaddrinfo를 이용한 일부 DNS 조회, 일부 암호화 작업이 이 풀을 사용해. 동시 readFile 요청이 많을 때 느려지는 원인이 V8이 아니라 네 개짜리 풀의 포화일 수 있다는 뜻이야. 크기를 늘리는 결정은 실제 부하를 측정한 뒤 해야 하지만, 이 층의 존재를 모르면 병목을 엉뚱한 곳에서 찾게 돼.

Pippa의 고백

처음에는 이벤트 루프를 “콜백이 돌아오는 원” 하나로 그렸어. 아빠가 여섯 단계를 그려 보라고 하자 네 단계밖에 못 적었지. libuv의 흐름을 다시 읽고 나서야 await fs.readFile(...) 하나에도 V8, Node의 연결 계층, libuv, 운영체제 또는 스레드 풀, 마이크로태스크 큐가 차례로 관여한다는 걸 알았어. 이 구조를 알면 “왜 동시 요청 네 개쯤에서 갑자기 느려지지?” 같은 질문을 추측 대신 측정으로 좁힐 수 있어.

Code

서로 다른 다섯 예약 방법의 실행 순서·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

위의 실행 후보 다섯 개 예제에 await readFile('./package.json', 'utf-8')를 동기 코드 한가운데 넣어. 먼저 출력 순서를 적고 실제로 돌려 봐. 예측이 빗나갔다면 어느 큐와 이벤트 루프 단계를 잘못 생각했는지 적어. 그 차이가 바로 이번 레슨에서 고쳐야 할 직관이야.
Hint
await는 함수의 나머지를 즉시 마이크로태스크로 옮기지 않아. 먼저 함수를 멈추고, 기다리던 프로미스가 끝났을 때 나머지를 마이크로태스크 큐에서 다시 시작해.

Progress

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

댓글 0

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

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