Node.js 이벤트 루프 — 큐 우선순위

기초·개념23분조회 –

Node.js 는 싱글 스레드인데 어떻게 동시에 많은 요청을 처리하느냐 — 이 질문에는 보통 이벤트 루프로 답합니다. 요청이 오면 I/O 를 걸어 두고 기다리지 않은 채 다음 요청을 받고, I/O 가 끝나면 그때 콜백을 실행한다는 설명입니다.

여기까지는 이해가 되지만 안 풀리는 게 남습니다. 동시에 처리한다면서 왜 요청 하나가 서버 전체를 멈추는 일이 생기느냐, 그리고 setTimeout(fn, 0) 을 걸었는데 왜 그게 제일 먼저 안 도느냐입니다. 둘 다 논블로킹이라는 한 단어로는 설명이 안 됩니다.

둘 다 콜백을 담는 큐가 하나가 아니라서 생기는 일입니다. 큐가 여러 개고 비우는 순서에 우선순위가 정해져 있어서, 어느 큐에 들어가느냐로 도는 시점이 갈립니다.

Call Stack 비움 — 다음 콜백 진입 조건

이벤트 루프는 Call Stack 이 완전히 비어야 큐에서 다음 콜백을 하나 올립니다.

어떤 콜백이 무거운 CPU 연산을 하면 그동안 Call Stack 이 안 비워지고 다른 콜백은 큐에서 계속 기다리게 되고, 이게 “한 요청이 서버 전체를 멈추는” 상황입니다. 논블로킹으로 도는 건 I/O 지 CPU 연산이 아니라서 그렇습니다.

큐를 관리하는 건 OS가 아니라 libuv

완료된 콜백이 대기하는 큐는 OS 큐가 아니라 libuv 가 Node 프로세스 안에서 직접 관리하는 자료구조입니다. 실제 파일·네트워크 I/O 는 libuv 가 OS 에 맡기고, 끝나면 그 결과 콜백을 이 큐에 넣습니다.

큐 우선순위

콜백 종류마다 큐가 다르고 이벤트 루프가 비우는 순서가 정해져 있습니다.

timerssetTimeout · setIntervalI/O 콜백checksetImmediatenextTick · 마이크로태스크Promise .then매 단계 전에이벤트 루프 한 바퀴
nextTick·Promise 는 단계마다 먼저 비워짐

process.nextTick 과 Promise 의 .then 은 매 단계 사이마다 먼저 비워져서, setTimeout(fn, 0) 을 걸어도 대기 중인 Promise 콜백이 있으면 그게 먼저 돕니다. “0ms 니까 즉시” 가 아니라 “timers 단계에서, 그것도 마이크로태스크를 다 비운 뒤” 입니다.

setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))
// 출력: promise → timeout

먼저 쓴 setTimeout 이 뒤에 나옵니다. 0ms 로 걸어도 마이크로태스크를 다 비우고 나서야 timers 단계로 넘어가기 때문입니다.

CPU 작업 분리 — 이벤트 루프 밖으로

무거운 CPU 연산이 이벤트 루프를 막으니 그건 worker_threads 로 빼서 별도 스레드에서 돌리고, 루프가 실제로 밀리는지는 루프 지연(event loop delay)을 재서 확인합니다. 원칙은 하나로, 이벤트 루프를 도는 스레드에서는 오래 걸리는 동기 작업을 하지 않습니다.

큐는 libuv 안에, 우선순위는 nextTick부터

Node 의 동시성은 여러 일을 한꺼번에 하는 게 아니라 짧은 콜백을 쉬지 않고 이어 돌리는 것이라, 그 줄에 오래 걸리는 일이 하나 끼면 뒤가 전부 밀립니다. setTimeout(0) 이 즉시가 아닌 것도, CPU 연산 하나가 전체를 멈추는 것도 결국 같은 자리에서 나옵니다.

응답이 전반적으로 밀리는데 I/O 대기는 안 늘었으면 루프 지연부터 재 봅니다. 그 값이 튀면 어느 콜백이 스택을 안 비우고 있는지 찾으면 됩니다. I/O 를 아무리 비동기로 짜도 그 사이에 낀 동기 연산 하나가 큐 전체를 잡고 있을 수 있습니다.

  1. 불러오는 중