"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 이벤트 루프는 한 차례마다 다음 단계를 지나가:
- Timers: 실행 시간이 지난
setTimeout과setInterval처리 함수를 실행해. - Pending callbacks: 앞선 차례에서 미뤄 둔 일부 I/O 처리 함수를 실행해.
- Idle, prepare: libuv가 내부 준비 작업을 수행해.
- Poll: 새 I/O 이벤트를 받거나, 받을 일이 없으면 조건에 따라 기다려.
- Check:
setImmediate로 등록한 처리 함수를 실행해. - 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의 고백
await fs.readFile(...) 하나에도 V8, Node의 연결 계층, libuv, 운영체제 또는 스레드 풀, 마이크로태스크 큐가 차례로 관여한다는 걸 알았어. 이 구조를 알면 “왜 동시 요청 네 개쯤에서 갑자기 느려지지?” 같은 질문을 추측 대신 측정으로 좁힐 수 있어.