JavaScript 이벤트 루프 구조
JavaScript 이벤트 루프 구조
JavaScript는 싱글 스레드
JavaScript는 하나의 호출 스택(Call Stack) 만 가진 싱글 스레드 언어다. 한 번에 하나의 작업만 실행할 수 있다. 그럼에도 비동기 처리가 가능한 이유는 이벤트 루프(Event Loop) 가 있기 때문이다.
전체 구조
┌─────────────────────────────────────────────────────┐
│ JavaScript 엔진 │
│ ┌──────────────┐ ┌───────────────────────────┐ │
│ │ Call Stack │ │ Memory Heap │ │
│ │ │ │ (객체, 변수 저장) │ │
│ │ 함수 실행 │ │ │ │
│ │ 컨텍스트 │ │ │ │
│ └──────────────┘ └───────────────────────────┘ │
└─────────────────────────────────────────────────────┘
↑ 실행할 작업을 꺼냄
│
┌───────────────────────────────────────────┐
│ 이벤트 루프 (Event Loop) │
│ │
│ 1. Call Stack이 비었는가? │
│ 2. Microtask 큐에 작업이 있는가? → 전부 실행 │
│ 3. 렌더링이 필요한가? → 렌더링 │
│ 4. Macrotask 큐에서 1개 꺼내 실행 │
│ 5. 1번으로 돌아감 │
└───────────────────────────────────────────┘
↑ 작업을 가져옴
│
┌──────────────────┐ ┌──────────────────┐
│ Microtask Queue │ │ Macrotask Queue │
│ (우선순위 높음) │ │ (우선순위 낮음) │
│ │ │ │
│ Promise.then │ │ setTimeout │
│ queueMicrotask │ │ setInterval │
│ MutationObserver │ │ MessageChannel │
│ │ │ I/O 콜백 │
│ │ │ UI 이벤트 (click) │
└──────────────────┘ └──────────────────┘
이벤트 루프 실행 순서
[macrotask 1개 실행] → [microtask 큐 전부 소진] → [브라우저 렌더링 (필요 시)] → [macrotask 1개 실행] → ...
핵심 규칙:
- macrotask는 한 번에 1개만 실행한다
- microtask는 큐가 빌 때까지 전부 실행한다 (microtask 안에서 microtask를 추가해도 계속 실행)
- 브라우저 렌더링은 macrotask와 macrotask 사이에 필요할 때 수행된다
Call Stack (호출 스택)
현재 실행 중인 함수들이 쌓이는 곳이다. LIFO(후입선출) 구조로, 가장 마지막에 들어온 함수가 먼저 실행 완료된다.
function a() {
b();
console.log('a');
}
function b() {
console.log('b');
}
a();
// Call Stack 변화:
// [a] → [a, b] → [a] → [] (빈 스택)
// 출력: b → a
Call Stack이 비어야 이벤트 루프가 다음 작업(microtask 또는 macrotask)을 가져온다.
Macrotask (Task)
브라우저 또는 Node.js 환경이 이벤트 루프에 등록하는 작업 단위다.
| API | 설명 |
|---|---|
setTimeout / setInterval
|
타이머 기반 지연 실행 |
MessageChannel |
메시지 포트 간 통신 |
requestAnimationFrame |
다음 렌더링 프레임 전에 실행 |
| I/O 콜백 | 네트워크 요청, 파일 읽기 등 |
| UI 이벤트 |
click, scroll, keydown 등 |
특징: 한 사이클에 1개만 실행되고, 이후 microtask 큐를 처리한다.
Microtask
현재 실행 중인 작업이 끝난 직후, 다음 macrotask로 넘어가기 전에 실행되는 작업이다.
| API | 설명 |
|---|---|
Promise.then / catch / finally
|
Promise 후속 처리 |
queueMicrotask |
명시적 microtask 등록 |
MutationObserver |
DOM 변경 감지 콜백 |
async/await |
await 이후 코드 (Promise 기반) |
특징: 큐에 있는 전부를 소진할 때까지 다음 단계로 넘어가지 않는다.
실행 순서 예시
기본 예시
console.log('1: 동기');
setTimeout(() => {
console.log('5: macrotask');
}, 0);
Promise.resolve()
.then(() => console.log('3: microtask 1'))
.then(() => console.log('4: microtask 2'));
console.log('2: 동기');
// 출력: 1 → 2 → 3 → 4 → 5
실행 흐름:
- 동기 코드 실행:
1,2출력 - Call Stack이 비면 microtask 큐 소진:
3,4출력 - macrotask 큐에서 1개 실행:
5출력
microtask 안에서 microtask 추가
setTimeout(() => console.log('3: macrotask'), 0);
Promise.resolve().then(() => {
console.log('1: microtask');
queueMicrotask(() => console.log('2: 중첩 microtask'));
});
// 출력: 1 → 2 → 3
microtask 내에서 추가된 microtask도 같은 사이클에서 전부 실행된 후 macrotask로 넘어간다.
복합 예시
console.log('1');
setTimeout(() => {
console.log('6');
Promise.resolve().then(() => console.log('7'));
}, 0);
Promise.resolve().then(() => {
console.log('3');
setTimeout(() => console.log('8'), 0);
});
queueMicrotask(() => console.log('4'));
console.log('2');
setTimeout(() => console.log('5'), 0);
// 출력: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
실행 흐름:
-
동기:
1,2 -
microtask 큐 소진:
3(Promise),4(queueMicrotask) -
macrotask 1개:
5(첫 번째 setTimeout… 아닌 순서대로)
실제로는 setTimeout 등록 순서에 따라
5가 먼저일 수도 있고6이 먼저일 수도 있다. 핵심은 macrotask 1개 실행 후 microtask 큐를 소진하는 패턴이다.
requestAnimationFrame의 위치
requestAnimationFrame(rAF)은 macrotask도 microtask도 아닌 별도의 콜백 큐에 속한다. 브라우저 렌더링 직전에 실행된다.
[macrotask] → [microtask 전부] → [rAF 콜백] → [브라우저 렌더링] → [macrotask] → ...
setTimeout(() => console.log('3: macrotask'), 0);
requestAnimationFrame(() => console.log('2: rAF'));
Promise.resolve().then(() => console.log('1: microtask'));
// 일반적 출력: 1 → 2 → 3
// (단, rAF와 macrotask 순서는 브라우저 구현에 따라 다를 수 있음)
async/await와 이벤트 루프
async/await는 Promise의 문법적 설탕(syntactic sugar)이다. await 이후 코드는 microtask로 실행된다.
async function foo() {
console.log('1: 동기');
await Promise.resolve();
console.log('3: microtask (await 이후)');
}
foo();
console.log('2: 동기');
// 출력: 1 → 2 → 3
await 이전까지는 동기 실행이고, await 이후 코드는 Promise.then과 동일하게 microtask 큐에 등록된다.
정리
| 구분 | macrotask | microtask |
|---|---|---|
| 실행 시점 | 이벤트 루프 사이클 시작 | macrotask 실행 직후 |
| 실행 개수 | 한 사이클에 1개 | 큐가 빌 때까지 전부 |
| 우선순위 | 낮음 | 높음 |
| 대표 API | setTimeout, MessageChannel | Promise.then, queueMicrotask |
| 렌더링 | macrotask 사이에 발생 | 렌더링 전에 전부 처리 |