자바스크립트 이벤트 루프: 싱글 스레드가 비동기 작업을 처리하는 방식
빠른 답
- 싱글 스레드는 자바스크립트 코드가 실행되는 콜 스택이 하나라는 뜻이지, 런타임 전체가 한 작업만 한다는 뜻은 아닙니다.
- 타이머, 네트워크, 파일 I/O 같은 대기 작업은 브라우저 Web APIs나 Node.js 런타임이 맡고, 완료된 콜백만 다시 실행 큐로 들어옵니다.
Promise.then,queueMicrotask같은 마이크로태스크는 일반 태스크보다 먼저 처리되므로setTimeout보다 앞서 실행될 수 있습니다.- 브라우저와 Node.js는 이벤트 루프 구조가 다르고, Node.js 20 이후에는 타이머와
setImmediate의 상대 순서를 설명할 때 오래된 자료와 차이가 있습니다.
목차
시간 흐름으로 이해하기
흐름으로 보기
이 흐름에서 자바스크립트 엔진이 직접 모든 일을 붙잡고 있는 것은 아닙니다. 엔진은 현재 실행 중인 함수 본문을 처리하고, 오래 걸리는 대기 작업은 브라우저나 Node.js 런타임에 넘깁니다. 작업이 끝나면 콜백이 큐에 들어가고, 이벤트 루프가 콜 스택이 비는 시점에 맞춰 다시 자바스크립트 실행 흐름으로 가져옵니다.
싱글 스레드라는 말의 정확한 범위
자바스크립트를 싱글 스레드 언어라고 할 때의 초점은 자바스크립트 실행 컨텍스트를 처리하는 콜 스택이 하나라는 점입니다. 변수 값을 읽고, 함수를 호출하고, 예외를 던지고, return으로 빠져나오는 실행 자체는 한 줄기 흐름으로 진행됩니다. 그래서 같은 순간에 두 개의 자바스크립트 함수 본문이 동일한 콜 스택에서 동시에 실행되지는 않습니다.
다만 런타임 전체가 한 가지 일만 한다는 뜻은 아닙니다. 브라우저는 네트워크 요청을 기다리는 동안 사용자 입력을 받을 수 있고, Node.js는 파일 읽기 요청을 넘겨둔 뒤 다음 코드를 계속 실행할 수 있습니다. 동시에 진행되는 것은 자바스크립트 함수 본문이라기보다 네트워크 대기, 타이머 대기, 파일 I/O 같은 런타임 작업입니다.
이 차이를 이해할 때 값, 상태, 오류를 구분하면 실행 순서가 더 잘 보입니다. 동기 함수의 반환값은 지금 계산된 값입니다. 반면 Promise는 아직 완료되지 않았을 수 있는 작업의 상태를 나타내는 객체입니다. fetch의 실패도 동기 throw처럼 즉시 바깥 try...catch로 잡히는 오류가 아니라, 거부된 Promise 상태로 관찰됩니다.
try {
const result = fetch("https://example.com/api/posts");
console.log("result:", result.constructor.name);
} catch (error) {
console.log("caught:", error.message);
}
console.log("after fetch");
대체로 다음처럼 출력됩니다.
result: Promise
after fetch
fetch 호출은 네트워크 응답 값을 즉시 반환하지 않습니다. 반환되는 것은 나중에 성공하거나 실패할 수 있는 작업의 상태, 즉 Promise입니다. 네트워크 오류나 응답 처리는 await, then, catch 같은 Promise 흐름에서 다루어야 합니다.
이벤트 루프가 실행 순서를 정하는 방식
이벤트 루프는 콜 스택과 큐 사이를 조율하는 런타임의 실행 모델입니다. 현재 실행 중인 동기 코드가 끝나 콜 스택이 비면, 이벤트 루프는 다음에 실행할 작업을 고릅니다. 이때 큐에 먼저 들어간 작업이 항상 먼저 실행된다고만 이해하면 Promise.then과 setTimeout의 순서를 자주 잘못 예측하게 됩니다.
브라우저 기준으로는 HTML 표준의 이벤트 루프 모델이 기본 설명입니다. WHATWG HTML Standard는 태스크 큐와 마이크로태스크 큐를 구분하고, 마이크로태스크 체크포인트를 수행한다고 설명합니다. 마이크로태스크는 일반 태스크와 같은 큐에서 경쟁하는 작업이 아니라, 현재 작업이 끝난 뒤 다음 태스크로 넘어가기 전에 비워지는 별도 흐름입니다. 기준 문서는 WHATWG HTML Standard의 Event loops입니다.
짧은 코드로 확인해보면 차이가 분명합니다.
console.log("A");
setTimeout(() => {
console.log("B: timeout");
}, 0);
Promise.resolve().then(() => {
console.log("C: promise");
});
queueMicrotask(() => {
console.log("D: microtask");
});
console.log("E");
예상 출력은 다음 순서입니다.
A
E
C: promise
D: microtask
B: timeout
A와 E는 현재 콜 스택에서 실행되는 동기 코드입니다. setTimeout의 콜백은 타이머 태스크로 예약됩니다. Promise.then과 queueMicrotask는 마이크로태스크 큐에 들어갑니다. 현재 동기 코드가 끝나면 이벤트 루프는 타이머 콜백으로 바로 넘어가지 않고 먼저 마이크로태스크 큐를 비웁니다. 그래서 C, D가 B보다 먼저 출력됩니다.
태스크와 마이크로태스크를 구분해야 하는 이유
태스크는 이벤트 루프가 처리하는 일반 작업 단위입니다. 타이머 콜백, 사용자 이벤트, 메시지 이벤트, 일부 I/O 콜백이 여기에 해당합니다. 마이크로태스크는 현재 실행 흐름이 끝난 직후, 다음 태스크로 넘어가기 전에 처리되는 후속 작업입니다. Promise.then, Promise.catch, Promise.finally, queueMicrotask, 브라우저의 MutationObserver 콜백이 대표적입니다.
오래된 학습 자료에서는 일반 태스크를 “매크로태스크”라고 부르는 경우가 많습니다. 아직 교육용 표현으로 널리 쓰이지만, HTML 표준 문서를 읽을 때는 task와 microtask라는 이름을 기준으로 맞추는 편이 혼동이 적습니다. 즉 “매크로태스크 큐”라는 표현을 보더라도 보통 타이머, 이벤트, 메시지 같은 일반 태스크 흐름을 가리킨다고 이해하면 됩니다.
마이크로태스크의 의미는 “더 빠른 비동기”라기보다 “현재 작업 직후에 상태를 마무리하는 예약 작업”에 가깝습니다. 마이크로태스크 안에서 다시 마이크로태스크를 예약하면 다음 태스크나 렌더링 기회가 계속 밀릴 수 있습니다.
let count = 0;
function loop() {
count += 1;
if (count <= 3) {
console.log("microtask", count);
queueMicrotask(loop);
}
}
setTimeout(() => {
console.log("timeout");
}, 0);
queueMicrotask(loop);
console.log("sync end");
출력은 다음처럼 나타납니다.
sync end
microtask 1
microtask 2
microtask 3
timeout
이 예시는 세 번만 반복하므로 문제가 작아 보입니다. 하지만 종료 조건 없이 마이크로태스크를 계속 예약하면 타이머 콜백과 브라우저 렌더링이 뒤로 밀립니다. Promise 콜백도 자바스크립트 코드이기 때문에 계산량이 크면 메인 스레드를 오래 점유할 수 있습니다.
브라우저와 Node.js의 차이
브라우저와 Node.js는 같은 자바스크립트 문법을 실행하지만 런타임 구성은 다릅니다. 브라우저에는 DOM, 타이머, 네트워크, 사용자 입력, 렌더링 파이프라인이 있고, Node.js에는 파일 시스템, 소켓, 프로세스, libuv 기반 이벤트 루프가 있습니다. 그래서 이벤트 루프를 설명할 때도 실행 환경을 함께 보아야 합니다.
브라우저에서는 UI 이벤트, 타이머, 네트워크 응답, 렌더링 기회가 중요한 축입니다. 마이크로태스크는 브라우저가 다음 태스크를 처리하거나 화면을 갱신하기 전에 실행될 수 있습니다. 마이크로태스크가 길게 이어지면 클릭 반응이나 화면 갱신이 늦어지는 현상이 생길 수 있습니다.
Node.js에서는 이벤트 루프가 여러 단계로 나뉩니다. 공식 문서 기준으로 timers, pending callbacks, poll, check, close callbacks 같은 단계가 있고, setImmediate는 check 단계에서 실행됩니다. 자세한 흐름은 Node.js 공식 Event Loop 문서에서 확인할 수 있습니다.
버전 차이도 실행 순서 설명에 영향을 줍니다. Node.js 20부터 포함된 libuv 1.45.0 이후에는 타이머 실행 위치가 바뀌었습니다. 오래된 자료에서는 타이머가 poll 단계 전후에 실행된다고 설명하는 경우가 있지만, 현재 Node.js 20 이상 설명에서는 타이머가 poll 단계 이후에만 실행된다고 보아야 합니다. 이 변화는 setTimeout과 setImmediate의 상대 순서를 설명할 때 중요합니다.
2026년 4월 기준으로 Node.js 24는 Active LTS, Node.js 22와 20은 Maintenance LTS 범위에 있습니다. Node.js 20은 2026년 4월 30일 EOL 예정이므로 새 프로젝트나 장기 운영 환경에서는 Node.js 22 또는 24 계열로 옮기는 계획을 함께 두는 편이 좋습니다. 지원 상태는 Node.js Releases에서 확인할 수 있습니다.
실행 환경을 남기는 설정 예시
이벤트 루프 문제는 “어디서 실행했는가”에 따라 관찰 결과가 달라질 수 있습니다. 브라우저인지 Node.js인지, Node.js라면 어떤 메이저 버전과 libuv 버전인지 로그로 남기면 재현이 쉬워집니다.
Node.js 프로젝트에서는 다음처럼 런타임 범위를 명시할 수 있습니다.
{
"name": "event-loop-demo",
"type": "module",
"engines": {
"node": ">=22 <25"
},
"scripts": {
"check:runtime": "node -p \"process.version + ' / uv ' + process.versions.uv\"",
"demo": "node event-loop-demo.js"
}
}
process.versions.uv를 함께 출력하면 Node.js 내부에서 사용하는 libuv 버전도 확인할 수 있습니다.
npm run check:runtime
예상 출력 형태는 다음과 같습니다.
v24.13.1 / uv 1.51.0
정확한 숫자는 설치된 Node.js에 따라 달라집니다. 중요한 점은 실행 순서를 설명할 때 런타임 버전을 함께 남기는 것입니다. 이벤트 루프는 추상 모델이지만, 실제 디버깅에서는 버전과 실행 환경이 관찰 결과를 바꿀 수 있습니다.
Node.js에서 관찰하는 실행 순서
다음 코드는 동기 코드, Promise 마이크로태스크, 타이머 태스크, Node.js 전용 process.nextTick을 한 번에 비교합니다. process.nextTick은 브라우저에는 없습니다.
console.log("1 sync");
setTimeout(() => {
console.log("5 timeout");
}, 0);
Promise.resolve().then(() => {
console.log("4 promise");
});
process.nextTick(() => {
console.log("3 nextTick");
});
console.log("2 sync");
Node.js에서의 출력은 보통 다음과 같습니다.
1 sync
2 sync
3 nextTick
4 promise
5 timeout
동기 코드가 먼저 끝나는 것은 브라우저와 같습니다. 그 다음 Node.js는 process.nextTick 큐를 매우 이른 시점에 처리합니다. 그 뒤 Promise 마이크로태스크가 실행되고, 타이머 콜백은 다음 이벤트 루프 단계에서 실행됩니다.
process.nextTick이라는 이름 때문에 다음 이벤트 루프 반복에서 실행된다고 생각하기 쉽지만, 실제로는 현재 작업이 끝난 직후 이벤트 루프가 다음 단계로 진행되기 전에 실행됩니다. 재귀적으로 많이 사용하면 I/O가 처리될 기회를 줄일 수 있습니다. 브라우저와 Node.js를 함께 설명할 때는 process.nextTick을 일반적인 Promise 마이크로태스크와 같은 것으로 섞어 말하지 않는 편이 이해가 쉽습니다.
오류와 로그로 디버깅하기
실행 순서가 예상과 다를 때는 로그에 큐의 성격을 함께 남겨보면 원인을 좁히기 쉽습니다. 숫자만 찍으면 다시 코드를 따라가야 하므로, 동기 코드인지 마이크로태스크인지 태스크인지 표시하는 방식이 도움이 됩니다.
console.log("[sync] start");
setTimeout(() => {
console.log("[task] setTimeout");
}, 0);
Promise.resolve()
.then(() => {
console.log("[microtask] promise then");
throw new Error("promise failed");
})
.catch((error) => {
console.log("[microtask] promise catch:", error.message);
});
console.log("[sync] end");
예상 출력은 다음과 같습니다.
[sync] start
[sync] end
[microtask] promise then
[microtask] promise catch: promise failed
[task] setTimeout
이 예시에서 오류는 동기 try...catch로 잡히는 오류가 아니라 Promise 체인의 거부 상태로 이동합니다. 그래서 비동기 오류를 볼 때는 지금 손에 있는 것이 실제 값인지, 아직 완료되지 않은 Promise인지, 이미 거부된 Promise인지 먼저 구분해야 합니다.
setTimeout(fn, 0)도 자주 오해됩니다. 0은 정확히 0밀리초 뒤 실행된다는 뜻이 아닙니다. 최소 지연 시간이 지난 뒤 태스크로 실행될 수 있다는 뜻에 가깝습니다. 앞에 긴 동기 작업이 있거나 마이크로태스크가 계속 쌓이면 타이머 콜백은 그만큼 늦어질 수 있습니다.
흔한 오해
“자바스크립트는 싱글 스레드라서 비동기 작업도 실제로는 동시에 일어나지 않는다”는 말은 실행 주체를 구분하지 않으면 오해를 부릅니다. 자바스크립트 코드 실행은 한 번에 하나지만, 네트워크 대기나 파일 I/O 같은 작업은 런타임과 운영체제 쪽에서 동시에 진행될 수 있습니다.
“Promise는 setTimeout보다 빠르다”는 표현도 정확하지 않습니다. Promise 자체가 빠른 것이 아니라 Promise 반응 콜백이 마이크로태스크로 처리되기 때문에 다음 태스크보다 먼저 실행되는 것입니다. Promise 콜백 안에서 무거운 계산을 하면 여전히 메인 스레드가 막힙니다.
“fetch가 끝나면 바로 then이 실행된다”는 설명도 한 단계가 빠져 있습니다. 네트워크 작업이 완료되면 Promise 상태가 바뀌고, 그 반응 콜백이 마이크로태스크로 예약됩니다. 현재 실행 중인 작업이 끝나고 마이크로태스크 체크포인트에 도달해야 관찰 가능한 로그가 찍힙니다.
“브라우저 이벤트 루프와 Node.js 이벤트 루프는 같다”는 생각도 피해야 합니다. 둘 다 이벤트 루프라는 큰 틀을 쓰지만, 브라우저는 렌더링과 사용자 입력이 중요하고 Node.js는 libuv 단계, I/O, setImmediate, process.nextTick이 중요합니다. 같은 예제라도 어디서 실행했는지에 따라 설명의 중심이 달라집니다.
원문 참고
https://www.maeil-mail.kr/question/57
댓글
댓글 쓰기