Menu

Node.js 이벤트 루프: 단계, nextTick, setImmediate

Node.js 이벤트 루프는 콜백을 단계(timers, poll, check, close)별로 실행하고, 콜백 사이마다 process.nextTick 큐를 비운 다음 Promise 큐를 비웁니다. 예제를 실행해서 정확한 순서를 확인해 보세요.

이 페이지에는 실행 가능한 에디터가 있습니다. 편집하고 실행하면 결과를 바로 볼 수 있습니다.

Node.js 이벤트 루프는 동기 코드가 끝난 뒤 다음에 어떤 콜백을 실행할지 정하는 루프입니다. 정해진 순서로 단계를 돕니다. timers(setTimeout, setInterval), pending callbacks, poll(파일 읽기, 네트워크 데이터 같은 I/O), check(setImmediate), close callbacks입니다. Node.js는 콜백 하나를 실행할 때마다 먼저 process.nextTick 큐를 비우고 그다음 Promise 큐를 비우므로, 이 둘은 루프가 다음으로 넘어가기 전에 항상 실행됩니다.

sync, nextTick, promise, setTimeout 순서로, 작성한 순서와 반대로 출력됩니다. 동기 코드가 항상 먼저 끝나고, 그다음 Node.js가 nextTick 큐를, 이어서 Promise 큐를 비우며, 그제서야 루프가 시작되어 timers 단계에 도달합니다.

이벤트 루프의 단계

Node.js의 루프는 Node.js가 I/O에 쓰는 C 라이브러리 libuv에서 옵니다. 루프는 첫 바퀴 전에 이미 시간이 된 타이머를 실행하고, 그다음 한 바퀴마다 다음 단계를 차례로 실행합니다.

  1. Pending callbacks: 일부 네트워크 에러처럼 이전 바퀴에서 미뤄진 몇몇 시스템 콜백.
  2. Poll: 새 I/O 이벤트와 그 콜백(파일 읽기가 끝남, 소켓에 데이터가 도착함). 예정된 다른 일이 없으면 루프는 여기서 기다립니다.
  3. Check: setImmediate 콜백.
  4. Close callbacks: 파괴된 소켓 같은 'close' 이벤트.
  5. Timers: 시간이 된 setTimeout과 setInterval의 콜백.

많은 가이드는 매 바퀴 timers를 맨 앞에 두는데, 이는 버전 20(libuv 1.45) 이전의 Node.js가 동작하던 방식입니다. 어느 쪽이든 콜백이 실행되는 순서는 같습니다. 한 바퀴의 timers 다음에는 다음 바퀴의 poll이 옵니다.

단계 밖에는 큐 두 개가 있습니다. nextTick 큐와 마이크로태스크 큐(Promise 콜백과 queueMicrotask)입니다. Node.js는 어느 단계에서든 콜백 하나를 실행할 때마다 nextTick부터 이 둘을 비웁니다. 기다릴 것(타이머, 열린 파일이나 소켓, 서버)이 있는 동안 루프는 계속 돌고, 아무것도 남지 않으면 프로세스가 종료됩니다.

이것은 Node.js 고유의 루프입니다. 브라우저도 태스크와 마이크로태스크라는 개념은 같지만 단계도, setImmediate도, process.nextTick도 없습니다. 언어 수준의 큰 그림은 자바스크립트 이벤트 루프 페이지에 있습니다.

process.nextTick vs Promise

둘 다 루프가 다음으로 넘어가기 전에 실행되지만 섞이지는 않습니다. Node.js는 실행 중에 추가된 tick까지 포함해 nextTick 큐 전체를 실행하고, 그다음에야 Promise 큐로 넘어갑니다. 이 결과를 예측해 보세요.

순서는 tick 1, tick 2, tick from tick, promise 1, promise 2입니다. tick 안에서 예약한 tick도 같은 큐에 들어가서, Promise가 먼저 만들어졌어도 모든 Promise보다 먼저 실행됩니다.

ES 모듈(import를 쓰는 파일)의 최상위에서는 이 둘이 바뀝니다. 모듈 코드 자체가 Promise 작업 안에서 실행되기 때문에, Node.js가 nextTick 큐에 손대기 전에 그 Promise 콜백이 끝납니다. 콜백 안에서는 두 모듈 방식 모두 nextTick이 먼저라는 규칙이 지켜집니다.

setImmediate vs setTimeout(fn, 0)

setTimeout(fn, 0)의 실제 의미는 "최소 1ms 뒤, timers 단계에서"입니다. setImmediate(fn)은 "다음 check 단계에서"라는 뜻입니다. 어느 쪽이 먼저 실행될지는 어디서 호출하느냐에 달려 있습니다.

I/O 콜백 안에서는 순서가 정해져 있습니다. 콜백은 poll 단계에서 실행되고, 바로 뒤에 check 단계가 오며, timers 단계는 그다음에야 옵니다.

매번 nextTick, setImmediate, setTimeout이 출력됩니다. 그래서 "지금 하던 I/O 작업 다음에 이걸 실행"하려면 setImmediate가 맞습니다.

메인 모듈에서, 어떤 콜백 안도 아닌 곳에서는 둘의 순서가 정해져 있지 않습니다.

// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

루프가 시작될 때 1ms 이상 지났다면 타이머 시간이 되어 먼저 출력되고, 아니라면 setImmediate가 이깁니다. 어느 결과에 기대는 코드도 쓰지 마세요.

실행 시점용도
process.nextTick(fn)현재 작업 직후, Promise보다 먼저호출한 쪽이 리스너를 붙인 뒤에 이벤트나 에러 내보내기
Promise.then, queueMicrotasknextTick 큐가 빈 뒤일반적인 비동기 코드
setImmediate(fn)check 단계, I/O 콜백 뒤작업 조각 사이에 루프에 차례 넘기기
setTimeout(fn, 0)timers 단계, 최소 1ms 뒤정확한 시점이 중요하지 않은 지연

이벤트 루프 블로킹

자바스크립트 스레드가 하나뿐이라서, 오래 실행되는 콜백은 다른 모든 것을 멈춥니다. 타이머, I/O 콜백, 그리고 서버라면 다른 모든 요청까지요. 타이머의 지연 시간은 최솟값이지 보장이 아닙니다.

loop finished가 먼저 출력되고 그다음 true가 나옵니다. 타이머는 10ms 뒤에 시간이 되었지만, 바쁜 루프가 스레드를 돌려줄 때까지 실행되지 못했습니다. 서버의 라우트 핸들러 안에 같은 루프가 있으면 다른 모든 요청이 0.5초 동안 멈춥니다.

긴 작업은 나누고, 조각 사이에 setImmediate로 루프에 차례를 주는 것이 해결책입니다.

타이머가 part 1 뒤에 끼어듭니다. setImmediate(() => work(part + 1))을 work(part + 1)로 바꿔 다시 실행하면 세 part가 한 번에 실행되고 timer는 마지막에 출력됩니다.

이미지 처리처럼 처음부터 끝까지 무거운 작업은 나누는 것만으로는 부족합니다. worker_threads로 메인 스레드 밖으로 옮기세요.

흔한 실수

process.nextTick을 재귀적으로 호출하는 것. nextTick 콜백이 계속 다른 nextTick을 예약하면 루프가 앞으로 나아가지 못해 타이머와 I/O가 굶게 됩니다. 반복 작업에는 setImmediate를 쓰세요.

서버에서 동기 API를 쓰는 것. fs.readFileSync, crypto.pbkdf2Sync, 큰 JSON.parse 호출은 모든 사용자에 대해 루프를 막습니다. 요청 핸들러 안에서는 비동기 버전을 쓰세요.

setTimeout(fn, 0)이 바로 실행될 거라고 기대하는 것. 모든 동기 코드, 모든 nextTick, 모든 Promise 콜백이 끝나고, 루프가 timers 단계에 도달해야 실행됩니다.

자주 묻는 질문

Node.js는 싱글 스레드인가요?

여러분의 자바스크립트는 스레드 하나에서 실행되므로 두 콜백이 동시에 실행되는 일은 없습니다. Node.js 자체는 더 많은 스레드를 씁니다. libuv는 파일 시스템 작업, DNS 조회, 일부 암호화 작업을 위해 스레드 풀(기본 4개)을 두고, 결과를 루프에 돌려줍니다.

Node.js 프로그램은 언제 종료되나요?

이벤트 루프가 더 기다릴 것이 없을 때입니다. 타이머도, 진행 중인 I/O도, 열린 서버나 소켓도 없는 상태입니다. 실행 중인 setInterval이나 요청을 기다리는 서버는 지우거나 닫을 때까지 프로세스를 살려 둡니다.

Node.js 이벤트 루프는 브라우저의 것과 같나요?

스레드 하나가 대기열의 콜백을 실행한다는 개념은 같지만 세부는 다릅니다. 브라우저에는 setImmediate도 process.nextTick도 없고 작업 사이에 렌더링을 끼워 넣는 반면, Node.js는 libuv의 단계를 돕니다. 언어 수준의 공통 부분은 자바스크립트 이벤트 루프 페이지에서 다룹니다.

이벤트 루프가 막혔는지 어떻게 알 수 있나요?

루프가 얼마나 늦게 도는지 측정하세요. perf_hooks의 monitorEventLoopDelay()가 지연을 기록하는데, 평균이 수십 밀리초라면 어떤 콜백이 스레드를 너무 오래 잡고 있는 것입니다. 어느 함수인지는 --cpu-prof로 Node.js를 실행해서 확인합니다.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기