JavaScript는 싱글 스레드로 한 번에 하나의 코드만 실행합니다. 그런데 타이머와 네트워크 요청과 클릭 이벤트는 마치 동시에 처리되는 것처럼 동작합니다. 이 모든 순서를 뒤에서 조율하는 것이 이벤트 루프입니다.
이벤트 루프를 이해하려면 먼저 주변 구성 요소를 파악해야 합니다.
함수 호출이 쌓이는 스택입니다. 함수가 호출되면 프레임이 push되고, 반환되면 pop됩니다. JavaScript 엔진은 이 스택을 위에서부터 하나씩 처리합니다. 스택이 비어 있어야 이벤트 루프가 다음 작업을 가져올 수 있습니다.
push: 스택 맨 위에 항목을 추가하는 동작.
pop: 스택 맨 위에서 항목을 꺼내는 동작.
객체가 메모리에 할당되는 공간입니다. 스택과 달리 구조화되어 있지 않으며, 더 이상 참조되지 않는 객체는 가비지 컬렉션이 자동으로 회수합니다.
가비지 컬렉션(Garbage Collection): 더 이상 참조되지 않는 메모리를 엔진이 자동으로 탐지해 회수하는 과정.
브라우저가 제공하는 비동기 기능들입니다. setTimeout, fetch, DOM 이벤트 리스너 등이 여기에 해당합니다. JavaScript 엔진 자체에는 없고, 브라우저가 스레드에서 처리합니다. Node.js는 대신 libuv로 이 역할을 처리하며, 자세한 차이는 아래 "Node.js와 브라우저의 차이"에서 다룹니다.
스레드(Thread): 프로그램이 실행되는 흐름의 단위. JavaScript 엔진은 스레드가 하나뿐이지만, 브라우저는 여러 스레드를 두고 타이머·네트워크 요청 같은 작업을 병렬로 처리합니다.
Promise.then, Promise.catch, queueMicrotask로 등록한 콜백이 들어오는 큐입니다. 세 함수 모두 각자의 콜백을 만들어 같은 이 큐에 밀어 넣습니다. 높은 우선순위를 가지며, Call Stack이 비워질 때마다 이 큐를 완전히 소진합니다.
setTimeout, setInterval, I/O 콜백이 들어오는 큐입니다. Microtask Queue가 모두 비워진 이후에야 하나씩 꺼내집니다.
┌─────────────────────────────────────────┐
│ JavaScript Engine │
│ │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ Call Stack │ │ Heap │ │
│ │ │ │ (memory) │ │
│ │ fn3() │ │ │ │
│ │ fn2() │ │ { obj: ... } │ │
│ │ fn1() │ │ │ │
│ └──────┬──────┘ └───────────────┘ │
└──────────┼──────────────────────────────┘
│ when empty
│
┌──────────▼──────────────────────┐
│ Event Loop │
│ │
│ 1. Call Stack empty? │
│ 2. drain Microtask Queue │
│ 3. pop one from Macrotask Queue │
│ 4. (browser) render │
│ 5. repeat │
└───────┬──────────────────┬───────┘
│ │
┌──────────────┐ ┌─────────────────┐
│ Microtask Q │ │ Macrotask Queue │
│ │ │ │
│ Promise.then │ │ setTimeout │
│ queueMicro.. │ │ setInterval │
│ │ │ I/O callbacks │
└──────────────┘ └─────────────────┘
▲
┌───────────────────┐
│ Web APIs │
│ timer, fetch, DOM │
└───────────────────┘
이벤트 루프는 아래 순서를 반복합니다.
| 종류 | 예시 | 실행 시점 |
|---|---|---|
| Microtask | Promise.then, Promise.catch, queueMicrotask | Call Stack이 빌 때마다, 매번 전부 |
| Macrotask | setTimeout, setInterval, setImmediate, I/O | Microtask 소진 후, 한 번에 하나 |
핵심 차이는 "얼마나 빨리, 몇 개나"입니다. 마이크로태스크는 현재 사이클에서 전부 처리되고, 매크로태스크는 다음 사이클에서 하나씩 처리됩니다.
아래 코드의 출력 순서를 예측해 보십시오.
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
}).then(() => {
console.log('4');
});
console.log('5');
실행 순서: 1 → 5 → 3 → 4 → 2
1, 5: 동기 코드이므로 Call Stack에서 즉시 실행됩니다.setTimeout 콜백: Web API가 0ms 뒤 Macrotask Queue에 넣습니다.Promise.then 콜백: Microtask Queue에 들어갑니다.3 출력, 이어서 .then 체인으로 4 출력.2 출력.브라우저의 렌더링(레이아웃 계산, 페인트)은 매크로태스크 사이에 끼어듭니다. 즉, 이벤트 루프 한 사이클이 끝날 때마다 렌더링 기회가 생깁니다.
// 이 코드는 1초 동안 화면을 완전히 멈춥니다
const start = Date.now();
while (Date.now() - start < 1000) {
// Call Stack을 1초간 점유
}
Call Stack이 점유되는 동안 이벤트 루프는 아무것도 처리할 수 없습니다. 렌더링도, 클릭 이벤트도 모두 대기합니다. 긴 계산이 필요하다면 setTimeout이나 requestAnimationFrame으로 작업을 나누어 실행해야 합니다.
setTimeout(fn, 0)은 "지금 당장"이 아니라 "Macrotask Queue에 넣어라"는 의미입니다.
console.log('before');
setTimeout(() => console.log('timeout'), 0);
console.log('after');
// 출력: before → after → timeout
0ms라도 Web API를 거쳐 Macrotask Queue에 들어가야 합니다. 현재 Call Stack의 동기 코드와 그 뒤에 쌓인 모든 마이크로태스크가 끝나야 비로소 실행됩니다. "즉시 실행"처럼 보이지만 실제로는 현재 사이클이 완전히 끝난 다음입니다.
브라우저와 Node.js 모두 이벤트 루프를 사용하지만 구현이 다릅니다.
브라우저는 HTML 명세를 따르며, 렌더링 파이프라인이 이벤트 루프에 통합되어 있습니다. Node.js는 libuv라는 C 라이브러리로 이벤트 루프를 구현하며, 파일 시스템·네트워크 I/O를 처리합니다.
libuv: Node.js의 비동기 I/O를 담당하는 C 라이브러리. 이벤트 루프, 스레드 풀, 운영체제별 비동기 API를 추상화합니다.
Node.js에는 process.nextTick이라는 추가 큐가 있습니다. 이것은 Microtask Queue보다도 먼저 처리되어, 현재 작업이 끝나는 즉시 실행됩니다.
// Node.js에서의 우선순위
process.nextTick(() => console.log('nextTick')); // 1순위
Promise.resolve().then(() => console.log('then')); // 2순위
setTimeout(() => console.log('timeout'), 0); // 3순위
| 개념 | 핵심 |
|---|---|
| 이벤트 루프 | Call Stack이 빌 때마다 큐에서 작업을 꺼내 실행하는 반복 메커니즘 |
| Call Stack | 함수 호출이 쌓이고 제거되는 구조, 비어야 다음 작업을 가져올 수 있음 |
| Web APIs | 브라우저가 스레드에서 처리하는 비동기 기능 (setTimeout, fetch 등). Node.js는 libuv가 대신 처리 |
| Microtask Queue | Promise.then 등이 들어가는 큐, Call Stack이 빌 때마다 전부 소진 |
| Macrotask Queue | setTimeout 등이 들어가는 큐, Microtask 소진 후 하나씩 처리 |
| process.nextTick | Node.js 전용 큐, Microtask보다 먼저 처리됨 |
JavaScript의 동시성은 실제로 동시에 실행되는 것이 아니라, 이벤트 루프가 작업의 순서를 정교하게 조율하는 것입니다. Call Stack이 빌 때마다 마이크로태스크를 전부 처리하고, 그다음 매크로태스크 하나를 실행하는 이 단순한 규칙이 타이머·Promise·이벤트의 실행 순서를 결정합니다.