JavaScript는 싱글 스레드다. 콜 스택도 하나다. 그런데 setTimeout은 돌아가고, fetch는 기다리지 않는다. 도대체 누가 이걸 가능하게 하는 걸까?
JavaScript 엔진(V8 등)은 콜 스택(Call Stack)이 하나다. 한 번에 하나의 작업만 처리할 수 있다는 뜻이다. 함수가 호출되면 스택에 쌓이고, 실행이 끝나면 빠진다. 위에서부터 하나씩, 순서대로.
function first() { console.log("1"); }
function second() { console.log("2"); }
function third() { console.log("3"); }
first();
second();
third();
// 출력: 1, 2, 3 (무조건 이 순서)

단순하고 예측 가능하다. 하지만 이 구조에는 치명적인 문제가 있다. 하나가 오래 걸리면 전부 멈춘다.
// 만약 이 함수가 5초 걸린다면?
const data = fetchSync("/api/heavy-data"); // ← 5초 동안 화면 멈춤
console.log(data); // ← 5초 후에야 실행
button.addEventListener("click", handler); // ← 그동안 클릭도 안 됨
네트워크 요청이 끝날 때까지 화면이 얼어붙는다. 버튼도 안 눌리고, 스크롤도 안 된다. 이래서는 웹 앱을 만들 수 없다.
여기서 핵심 반전이 하나 있다.
setTimeout, fetch, addEventListener — 이런 비동기 함수들은 JavaScript 엔진이 처리하는 게 아니다. 브라우저가 제공하는 Web API가 처리한다.

Web API는 각각 별도의 스레드에서 동작한다. setTimeout을 호출하면 Timer API 스레드에서 시간을 세고, fetch를 호출하면 Ajax API 스레드에서 네트워크 통신을 한다. 싱글 스레드인 건 JavaScript 엔진뿐이지, 브라우저 자체는 멀티 스레드다.
이벤트 루프는 직접 작업을 실행하지 않는다. 하는 일은 딱 하나다.
콜 스택이 비어 있으면, 태스크 큐에서 작업을 꺼내 콜 스택에 올려준다.
택배 기사가 물건을 만들지도, 사용하지도 않는 것처럼 — 이벤트 루프도 작업을 만들거나 실행하지 않고, 옮기기만 한다. 실제 작업의 주체는 Web API(비동기 처리)와 JS 엔진(콜백 실행)이다.
console.log("시작");
setTimeout(() => {
console.log("타이머 콜백");
}, 1000);
console.log("끝");

setTimeout(cb, 0)이라고 해도 결과는 같다. 0ms 후에 태스크 큐에 들어가지만, 콜 스택이 비어야 실행되기 때문이다.
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// 출력: 1 → 3 → 2 (절대 1 → 2 → 3이 아님!)
태스크 큐는 사실 하나가 아니다. 두 종류의 큐가 있고, 실행 우선순위가 다르다.
| 구분 | 마이크로태스크 큐 (Microtask) | 매크로태스크 큐 (Macrotask) |
|---|---|---|
| 우선순위 | 높음 (먼저 실행) | 낮음 (나중 실행) |
| 대표 API | Promise.then/catch/finally | setTimeout |
queueMicrotask() | setInterval | |
MutationObserver | DOM 이벤트 핸들러 | |
async/await (의 후속 처리) | requestAnimationFrame* | |
| 실행 방식 | 큐가 완전히 빌 때까지 전부 실행 | 한 번에 하나만 실행 |
*
requestAnimationFrame은 엄밀히 매크로태스크는 아니고 렌더링 단계에서 실행되지만, 매크로태스크 이후에 처리된다는 점에서 여기에 분류했다.
console.log("1: 동기");
setTimeout(() => {
console.log("2: 매크로태스크 (setTimeout)");
}, 0);
Promise.resolve().then(() => {
console.log("3: 마이크로태스크 (Promise)");
});
console.log("4: 동기");

Promise가 setTimeout보다 항상 먼저 실행된다. 둘 다 0ms 후에 큐에 들어가더라도, 마이크로태스크 큐가 먼저 비워지기 때문이다.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve()
.then(() => {
console.log("3");
// 마이크로태스크 안에서 또 마이크로태스크를 추가!
Promise.resolve().then(() => console.log("4"));
})
.then(() => console.log("5"));
console.log("6");
실행 흐름:
[동기] "1" 출력, setTimeout 등록, Promise 체인 등록, "6" 출력
[마이크로태스크 큐 비우기]
"3" 출력 → 내부에서 새 Promise.then("4") 등록
"5" 출력 (체이닝된 .then)
"4" 출력 (중간에 추가된 마이크로태스크도 전부 처리!)
[마이크로태스크 큐 완전히 비었음 → 매크로태스크]
"2" 출력
출력 순서: 1 → 6 → 3 → 5 → 4 → 2
setTimeout("2")이 가장 마지막이다. 마이크로태스크 안에서 추가된 마이크로태스크("4")조차 매크로태스크보다 먼저 실행된다.
이벤트 루프는 아래 4단계를 무한 반복한다.

핵심은 2단계다. 마이크로태스크 큐는 한 번 시작하면 큐가 완전히 빌 때까지 전부 실행한다. 중간에 새로운 마이크로태스크가 추가되더라도 전부 처리한 후에야 다음 단계로 넘어간다.
이벤트 루프에서 빠지기 쉬운 부분이 렌더링이다. 브라우저는 보통 60fps(약 16.6ms마다 한 번)를 목표로 화면을 그리는데, 렌더링은 마이크로태스크가 모두 끝난 후에 일어난다.


마이크로태스크가 너무 많으면 렌더링이 밀린다.
// ⚠️ 위험한 코드: 마이크로태스크 무한 루프
function danger() {
Promise.resolve().then(() => {
danger(); // 큐가 영원히 비지 않음 → 렌더링 불가 → 화면 멈춤!
});
}
danger(); // 브라우저 프리징 🥶
// ✅ 안전한 대안: 매크로태스크로 끊어주기
function safe() {
setTimeout(() => {
safe(); // 매크로태스크 사이에 렌더링이 일어날 수 있음
}, 0);
}
safe(); // 화면 정상 동작
requestAnimationFrame은 렌더링 직전에 실행되므로, DOM 조작이나 애니메이션 관련 작업은 여기에 넣는 게 가장 적절하다.
// ✅ 부드러운 애니메이션을 위한 패턴
function animate() {
element.style.transform = `translateX(${position}px)`;
position += 2;
if (position < 300) {
requestAnimationFrame(animate); // 다음 렌더링 직전에 다시 호출
}
}
requestAnimationFrame(animate);
배운 걸 종합해서, 아래 코드의 출력 순서를 예측해보자.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => console.log("C"))
.then(() => console.log("D"));
requestAnimationFrame(() => console.log("E"));
queueMicrotask(() => console.log("F"));
console.log("G");
정답 보기
A → G → C → F → D → E → B
[동기] A, G
[마이크로] C → F → D (Promise.then과 queueMicrotask 모두 마이크로태스크)
[렌더링] E (requestAnimationFrame은 렌더링 단계)
[매크로] B (setTimeout)
※ 실행 환경에 따라 E와 B의 순서는 달라질 수 있다. 핵심은 동기 → 마이크로 → 매크로 순서가 보장된다는 점이다.
정리하면 이렇다.
JavaScript는 싱글 스레드가 맞다. 하지만 브라우저는 아니다. Web API라는 별도의 스레드 풀이 비동기 작업을 대신 처리해주고, 이벤트 루프가 그 결과물을 다시 JavaScript 엔진으로 옮겨주는 구조 덕분에 — 싱글 스레드임에도 비동기 처리가 가능한 것이다.
이벤트 루프 자체는 작업을 처리하지 않는다. "콜 스택 비었니? 큐에 뭐 있니?" 이 두 가지를 끊임없이 확인하며 작업을 옮겨주는 것이 전부다. 소박하지만, 이 단순한 루프 위에 웹의 모든 비동기가 돌아가고 있다.