같은 JavaScript인데 환경에 따라 실행 순서가 달라지는 이유
작성일:2026.10.14|수정일:2026.10.25|조회수:0

브라우저의 이벤트 루프를 다시 정리하다가 setImmediate()에 관한 설명을 읽게 되었다. setTimeout(callback, 0)과 비슷하지만, I/O 콜백 안에서는 setImmediate()가 먼저 실행된다는 내용이었다. 처음에는 기존 글에서 이 부분을 빠뜨렸다고 생각했다. 그런데 setImmediate()는 브라우저 API가 아니라 Node.js API다. 빠뜨린 것이 아니라 서로 다른 이벤트 루프를 하나로 생각하고 있었던 셈이다.
자바스크립트 엔진이 실행 컨텍스트와 콜 스택을 관리한다는 점은 브라우저와 Node.js에서 같다. Promise도 양쪽에서 사용할 수 있고 setTimeout()이라는 이름도 같다. 여기까지만 보면 이벤트 루프 역시 거의 같은 장치처럼 보인다. 하지만 이벤트 루프는 ECMAScript 언어가 모든 세부 동작을 정의하는 기능이 아니다. 브라우저와 Node.js라는 호스트 환경이 각자의 책임에 맞게 비동기 작업을 조직하는 실행 모델에 가깝다.
브라우저는 사용자 입력과 화면 렌더링을 다뤄야 한다. Node.js는 파일과 소켓을 비롯한 I/O를 다루고, 더 처리할 일이 없다면 프로세스를 종료해야 한다. 같은 자바스크립트를 실행하더라도 무엇을 기다리고 언제 다음 일을 선택해야 하는지가 다르다. 이 차이를 무시하면 setTimeout()과 Promise 정도까지는 그럴듯하게 설명할 수 있지만, requestAnimationFrame(), setImmediate(), process.nextTick()이 등장하는 순간 그림이 틀어지기 시작한다.
이벤트 루프는 자바스크립트 언어 그 자체가 아니다
ECMAScript 사양은 실행 컨텍스트와 Job, Promise 반응 같은 언어 차원의 동작을 정의한다. 반면 타이머, DOM 이벤트, 네트워크 요청, 파일 읽기는 실행 환경이 제공하는 기능이다. 브라우저의 이벤트 루프는 HTML 표준이 정의하고, Node.js는 libuv를 바탕으로 자신의 이벤트 루프를 구성한다.
물론 두 환경에는 공통점이 있다. 실행 중인 자바스크립트 콜백은 기본적으로 중간에 다른 콜백이 끼어들지 않고 끝까지 실행된다. 비동기 작업이 완료됐다고 해서 현재 콜백을 중단시키고 다른 콜백을 실행하지도 않는다. 나중에 선택된 콜백이 호출될 때 새로운 함수 실행 컨텍스트가 콜 스택에 올라간다.
Promise 반응과 queueMicrotask()도 두 환경 모두에서 microtask로 다뤄진다. 그러나 어떤 task나 I/O 콜백을 선택하고, microtask를 언제 비우고, 그 사이에 렌더링이나 프로세스 종료를 어떻게 판단할지는 호스트 환경의 몫이다.
브라우저는 task와 렌더링 기회를 다룬다
브라우저 이벤트 루프에서 자바스크립트 task와 microtask의 관계를 단순화하면 다음과 같다.
task 하나 실행→ microtask checkpoint→ 다음 task 선택이 그림에 렌더링을 고정된 세 번째 단계로 붙이면 현재 HTML 표준의 모델과 어긋난다. 사용자 에이전트는 렌더링 기회가 생긴 navigable에 대해 rendering task source에 렌더링 갱신 task를 큐에 넣는다. 이벤트 루프가 여러 task queue 중 이 task를 선택하면 그 안에서 requestAnimationFrame() 콜백과 스타일·레이아웃 갱신 등이 처리된다.
최초 스크립트 실행, 타이머 콜백, 사용자 입력에 따른 이벤트 전달 등은 task가 되는 대표적인 작업이다. 브라우저에는 task source에 따라 여러 task queue가 있을 수 있으며, 모든 콜백이 하나의 큐에 등록 순서대로 쌓이는 것은 아니다. 현재 task의 자바스크립트 실행이 끝나고 스택이 비면 브라우저는 microtask checkpoint를 수행한다. 이때 Promise 반응이나 queueMicrotask()로 등록한 작업을 microtask queue가 빌 때까지 처리한다.
console.log("script start")setTimeout(() => { console.log("timeout")}, 0)Promise.resolve().then(() => { console.log("promise")})console.log("script end")이 코드를 브라우저에서 실행하면 다음 순서를 관찰할 수 있다.
script startscript endpromisetimeout스크립트 task가 먼저 끝나고, 그 뒤의 microtask checkpoint에서 Promise 반응이 실행된다. 타이머의 지연 조건을 만족했다고 해도 타이머 콜백을 위한 task는 그 이후에야 실행 기회를 얻는다. 0은 즉시 실행한다는 뜻이 아니다.
브라우저에는 여기에 렌더링이라는 책임도 있다. 다만 매 task가 끝날 때마다 반드시 화면을 그리는 것은 아니다. 하드웨어 주사율과 사용자 에이전트의 스로틀링 등을 고려해 렌더링 기회를 판단하고, 필요할 때 렌더링 갱신 task를 예약한다. Node.js에는 이 자리가 필요하지 않다.
Node.js는 I/O를 단계별로 처리한다
Node.js는 기본적으로 하나의 자바스크립트 스레드를 사용하면서도 파일, 네트워크, 소켓 같은 비동기 I/O를 처리해야 한다. 이 아래에는 운영체제의 비동기 기능과 스레드 풀을 활용하는 libuv가 있다. 모든 I/O가 하나의 별도 스레드에서 처리되는 것도 아니고, 모든 작업이 스레드 풀로 전달되는 것도 아니다. 운영체제가 비동기 인터페이스를 제공하는 작업은 가능한 한 그 기능을 사용하고, 파일 시스템이나 일부 DNS·암호화 작업처럼 필요한 경우에는 libuv 스레드 풀을 사용할 수 있다.
Node.js 이벤트 루프는 처리할 콜백을 phase라는 단계로 나눠 관리한다. 현재 Node.js에서 중요한 단계만 남기면 다음과 같다.
(이벤트 루프 진입 전 timers를 처리할 수 있음)pending callbacks→ idle / prepare→ poll→ check→ close callbacks→ timers→ 다시 반복각 단계의 역할은 서로 다르다.
pending callbacks는 이전 반복에서 미뤄진 일부 시스템 I/O 콜백을 실행한다.idle / prepare는 Node.js 내부에서 사용한다.poll은 새 I/O 이벤트를 가져오고 대부분의 I/O 관련 콜백을 실행한다. 필요하면 여기서 I/O를 기다린다.check는setImmediate()콜백을 실행한다.close callbacks는 소켓처럼 닫힌 핸들의 일부 종료 콜백을 실행한다.timers는 실행 기준 시간이 지난setTimeout()과setInterval()콜백을 실행한다.
이 단계는 독립된 함수가 순서대로 한 번씩 호출되고 끝나는 목록이라기보다 계속 순환하는 구조다. 각 단계에 들어가면 해당 단계의 큐에 있는 콜백을 처리하고, 큐를 비웠거나 시스템별 한도에 도달하면 다음 단계로 이동한다.
여기서 Node.js 버전에 관한 단서가 하나 필요하다. Node.js v20.3.0에 포함된 libuv 1.45.0부터는 일반적인 이벤트 루프 반복에서 타이머를 poll 이후에 실행하도록 동작이 바뀌었다. 이전에는 타이머를 poll 전에 처리했다. 하위 호환성을 위해 이벤트 루프에 처음 진입하기 전에는 타이머를 한 번 처리할 수 있으므로, 단계 그림만 보고 최상위 코드의 세부 실행 순서를 단정해서는 안 된다.
setImmediate()는 무엇을 즉시 실행하는가
setImmediate()라는 이름은 꽤 대담하다. 하지만 현재 코드를 끊고 콜백을 즉시 실행한다는 뜻은 아니다. setImmediate()는 콜백을 Node.js 이벤트 루프의 check 단계에서 실행하도록 예약한다. 반면 setTimeout()은 지정한 시간이 지난 뒤 timers 단계에서 실행할 수 있도록 예약한다.
setTimeout(() => { console.log("timeout")}, 0)setImmediate(() => { console.log("immediate")})이 코드를 파일의 최상위에서 실행하면 둘의 순서를 일반적으로 보장할 수 없다. Node.js에서 setTimeout(fn, 0)의 지연 시간은 실제로 1밀리초로 보정되지만, 타이머의 실행 조건이 언제 충족됐는지와 이벤트 루프가 어떤 상태로 진입했는지에 따라 결과가 달라질 수 있다. 먼저 등록한 콜백이 언제나 먼저 실행되는 하나의 큐가 아니기 때문이다.
반면 I/O 콜백 안에서 둘을 예약하면 관계가 분명해진다.
const fs = require("node:fs")fs.readFile(__filename, () => { setTimeout(() => { console.log("timeout") }, 0) setImmediate(() => { console.log("immediate") })})파일 읽기가 끝난 뒤 실행되는 콜백은 poll과 관련된 시점에 처리된다. 그 안에서 예약한 setImmediate()는 바로 다음에 오는 check 단계에서 실행할 수 있다. 새로 등록한 타이머는 이후 타이머를 처리할 시점을 기다려야 한다.
poll └─ fs.readFile 콜백 ├─ setTimeout 예약 └─ setImmediate 예약check └─ immediate 실행close callbacks→ timers └─ timeout 실행따라서 이 예제에서는 immediate가 timeout보다 먼저 출력된다. 중요한 것은 두 함수를 어떤 순서로 호출했느냐가 아니라 이벤트 루프의 어느 위치에서 예약했느냐이다.
브라우저에서는 이 예제를 그대로 실행할 수도 없다. 브라우저의 표준 전역 API에는 setImmediate()가 없기 때문이다. 이름이 비슷한 타이머 API를 공유한다는 사실만으로 내부 실행 모델까지 같다고 볼 수 없는 이유다.
process.nextTick()은 이벤트 루프의 phase가 아니다
Node.js의 실행 순서를 더 헷갈리게 만드는 API가 process.nextTick()이다. 이름만 보면 다음 이벤트 루프 반복에서 실행될 것 같지만, 실제로는 현재 자바스크립트 콜백이나 작업이 끝난 뒤 이벤트 루프가 계속 진행하기 전에 next tick queue를 비운다. 이 처리는 phase마다 한 번만 일어나는 것이 아니라, Node.js가 각각의 자바스크립트 콜백 실행을 마친 뒤에도 일어날 수 있다.
console.log("start")process.nextTick(() => { console.log("nextTick")})Promise.resolve().then(() => { console.log("promise")})setImmediate(() => { console.log("immediate")})console.log("end")CommonJS 파일에서 실행하면 일반적으로 다음 순서가 나온다.
startendnextTickpromiseimmediateprocess.nextTick()은 libuv 이벤트 루프의 특정 phase가 아니다. 현재 작업이 끝난 뒤 next tick queue가 먼저 처리되고, Promise 반응을 비롯한 microtask를 처리한 다음 이벤트 루프가 계속 진행한다. process.nextTick()을 재귀적으로 계속 등록하면 이벤트 루프가 poll 같은 다음 단계로 진행하지 못해 I/O가 굶을 수도 있다.
다만 이 순서를 모든 문맥에 그대로 붙이면 또 틀릴 수 있다. ES 모듈의 최초 평가는 비동기 작업으로 감싸져 microtask 문맥에서 진행되므로, 최상위에서 process.nextTick()과 이미 이행된 Promise를 함께 예약한 예제는 CommonJS와 다른 순서가 나올 수 있다. nextTick이 Promise보다 빠르다는 문장은 어떤 코드가 어떤 문맥에서 실행되는지까지 포함해야 안전하다.
브라우저에는 process.nextTick()이 없다. 현재 Node.js 문서에서도 이 API는 Legacy로 분류되어 있으며, 대부분의 사용자 영역 코드에서는 여러 환경에서 같은 방식으로 사용할 수 있는 queueMicrotask()를 우선하도록 권한다.
같은 이름의 타이머도 생명주기가 다르다
브라우저에서는 페이지나 워커의 생명주기 안에서 이벤트 루프가 움직인다. Node.js 프로세스는 더 처리할 활성 핸들이나 요청이 없으면 종료된다. 그래서 Node.js의 타이머 객체에는 이벤트 루프를 계속 살려 둘지 조절하는 ref()와 unref()가 있다.
const timer = setTimeout(() => { console.log("timeout")}, 10_000)timer.unref()기본적으로 활성 타이머는 Node.js 프로세스를 계속 실행하게 한다. unref()를 호출하면 이 타이머 하나만 남았을 때는 콜백을 기다리지 않고 프로세스가 종료될 수 있다. unref()가 타이머를 취소하는 것은 아니므로 다른 활성 핸들이 프로세스를 유지한다면 콜백은 여전히 실행될 수 있다. 반대로 ref()는 해당 타이머가 다시 이벤트 루프를 유지하도록 만든다.
브라우저의 setTimeout()이 반환하는 값과 Node.js의 Timeout 객체가 표면적으로 같은 용도로 보이더라도, 프로세스의 생명주기를 제어한다는 책임은 Node.js에만 있다. 실행 환경의 차이는 콜백 순서뿐 아니라 API가 반환하는 값과 프로그램이 끝나는 조건에도 스며든다.
두 이벤트 루프를 하나의 그림으로 합치지 않는다
브라우저와 Node.js의 이벤트 루프를 비교할 때 가장 위험한 결론은 “단계 이름만 조금 다르고 사실상 같은 큐”라고 생각하는 것이다. 공통 기반과 호스트의 책임을 분리해서 봐야 한다.
공통 기반에는 다음이 있다.
- 자바스크립트 실행 컨텍스트와 콜 스택
- 현재 콜백의 run-to-completion
- Promise 반응과 microtask
- 지연 시간 이후에야 실행 가능해지는 타이머
브라우저가 추가로 책임지는 것은 다음과 같다.
- 여러 task source와 task queue
- DOM과 사용자 입력 이벤트
- microtask checkpoint
- 렌더링 기회와
requestAnimationFrame()
Node.js가 추가로 책임지는 것은 다음과 같다.
- libuv의 I/O 처리
poll,check,timers등의 phasesetImmediate()와process.nextTick()- 활성 핸들과 프로세스 종료
- 타이머와 Immediate의
ref()·unref()
결국 이벤트 루프는 자바스크립트가 가진 하나의 고정된 기계가 아니다. ECMAScript가 정의한 실행 규칙 위에 브라우저와 Node.js가 각자의 문제를 풀기 위해 세운 스케줄링 구조다. 브라우저 그림으로 Node.js의 setImmediate()를 설명할 수도 없고, libuv의 phase로 브라우저 렌더링을 설명할 수도 없다.
같은 setTimeout()을 사용하더라도 주변에 어떤 호스트가 있고, 현재 어느 처리 지점에서 콜백을 예약했는지를 먼저 봐야 한다. 실행 순서를 외우는 것보다 실행 환경의 책임을 구분하는 편이 오래 간다.
댓글
댓글을 불러오는 중...