이벤트 루프(Event Loop)와 비동기 처리 흐름을 설명해주세요.
힌트
Call Stack, Web APIs, Callback Queue, Microtask Queue의 역할을 떠올려보세요.
정답 및 해설
이벤트 루프(Event Loop)와 비동기 처리 흐름을 설명해주세요.
JavaScript는 단일 스레드(Single Thread) 언어입니다. 즉, 한 번에 하나의 작업만 처리할 수 있습니다. 그럼에도 네트워크 요청, 타이머, 사용자 입력 등 비동기 작업을 처리할 수 있는 이유가 바로 이벤트 루프 덕분입니다. 이벤트 루프는 JavaScript 런타임이 어떻게 비동기 작업을 스케줄링하고 실행하는지 결정하는 핵심 메커니즘입니다.
JavaScript 런타임 구성 요소
이벤트 루프를 이해하려면 먼저 런타임을 구성하는 요소들을 알아야 합니다.
┌─────────────────────────────────────────┐
│ JavaScript Engine │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Call Stack │ │ Heap │ │
│ │ │ │ (객체 메모리) │ │
│ └─────────────┘ └─────────────────┘ │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Web APIs / Node APIs │
│ (setTimeout, fetch, DOM events, ...) │
└─────────────────────────────────────────┘
│
▼
┌──────────────────┐ ┌──────────────────┐
│ Microtask Queue │ │ Callback Queue │
│ (Promise, qMT) │ │ (setTimeout, ...) │
└──────────────────┘ └──────────────────┘
│ │
└──────────┬───────────┘
▼
Event Loop
Call Stack (콜 스택)
함수 호출이 쌓이는 LIFO(Last In, First Out) 자료구조입니다. 현재 실행 중인 함수들이 여기에 놓입니다.
function greet(name) {
console.log('Hello, ' + name);
}
function main() {
greet('Alice');
}
main();
// 콜 스택 변화:
// 1. main() 추가
// 2. greet('Alice') 추가
// 3. console.log 추가 → 실행 → 제거
// 4. greet() 제거
// 5. main() 제거
Web APIs
setTimeout, fetch, addEventListener 등은 JavaScript 엔진이 아닌 **브라우저(또는 Node.js)**가 제공하는 API입니다. 비동기 작업을 이쪽에 위임하여 콜 스택을 블로킹하지 않습니다.
큐(Queue)의 종류
Callback Queue (Macrotask Queue)
setTimeout, setInterval, I/O 콜백 등이 완료되면 여기에 쌓입니다.
Microtask Queue
Promise의 .then() / .catch() / .finally() 핸들러, queueMicrotask(), MutationObserver 콜백이 여기에 쌓입니다.
핵심: 이벤트 루프는 콜 스택이 비면, Microtask Queue를 먼저, 그 다음 Callback Queue를 처리합니다.
이벤트 루프 동작 원리
이벤트 루프의 한 사이클(Tick)은 다음과 같이 동작합니다.
- 콜 스택에서 현재 실행 중인 태스크를 완료합니다.
- Microtask Queue가 빌 때까지 모든 마이크로태스크를 처리합니다.
- 필요하면 화면을 렌더링합니다 (브라우저).
- Callback Queue에서 태스크 하나를 가져와 콜 스택에 넣습니다.
- 1번으로 돌아갑니다.
실행 순서 예제
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 출력 순서: 1 → 4 → 3 → 2
단계별 분석:
console.log('1')→ 콜 스택 실행 → 출력:1setTimeout(cb, 0)→ Web APIs로 위임, 0ms 후 Callback Queue에 콜백 추가Promise.resolve().then(cb)→ Microtask Queue에 콜백 추가console.log('4')→ 콜 스택 실행 → 출력:4- 콜 스택 비워짐 → Microtask Queue 처리 → 출력:
3 - Callback Queue 처리 → 출력:
2
더 복잡한 예제
Microtask가 Macrotask보다 먼저 실행되는 이유
console.log('start');
setTimeout(() => {
console.log('timeout 1');
}, 0);
Promise.resolve()
.then(() => {
console.log('promise 1');
return Promise.resolve();
})
.then(() => {
console.log('promise 2');
});
setTimeout(() => {
console.log('timeout 2');
}, 0);
console.log('end');
// 출력:
// start
// end
// promise 1
// promise 2
// timeout 1
// timeout 2
Promise 체인에서 .then() 하나가 실행될 때마다 다음 .then()이 Microtask Queue에 추가됩니다. Microtask Queue가 완전히 빌 때까지 Callback Queue는 처리되지 않습니다.
async/await와 이벤트 루프
async/await는 내부적으로 Promise를 사용하므로 같은 규칙이 적용됩니다.
async function fetchData() {
console.log('fetch start'); // 동기
const result = await Promise.resolve('data'); // 이 이후는 마이크로태스크
console.log('fetch end:', result);
}
console.log('before');
fetchData();
console.log('after');
// 출력:
// before
// fetch start
// after
// fetch end: data
await 이후의 코드는 Microtask Queue에 들어가므로, fetchData() 호출 이후의 동기 코드('after')가 먼저 실행됩니다.
스택 오버플로우와 블로킹
콜 스택 블로킹 예
// 나쁜 예 — 무거운 연산이 콜 스택을 점유
function heavyWork() {
let sum = 0;
for (let i = 0; i < 1_000_000_000; i++) {
sum += i;
}
return sum;
}
heavyWork(); // 이 동안 UI가 완전히 멈춤
해결책 — setTimeout으로 청크 분할
function heavyWorkAsync(callback) {
let sum = 0;
let i = 0;
function chunk() {
const end = Math.min(i + 1_000_000, 1_000_000_000);
for (; i < end; i++) {
sum += i;
}
if (i < 1_000_000_000) {
setTimeout(chunk, 0); // 이벤트 루프에 제어권 반환
} else {
callback(sum);
}
}
chunk();
}
정리
| 구성 요소 | 역할 |
|---|---|
| Call Stack | 현재 실행 중인 함수들의 스택 |
| Web APIs | 비동기 작업을 처리하는 브라우저/런타임 API |
| Microtask Queue | Promise 콜백, 높은 우선순위 |
| Callback Queue | setTimeout 등, 낮은 우선순위 |
| Event Loop | 콜 스택이 비면 큐에서 작업을 꺼내 실행 |
이벤트 루프를 이해하면 코드의 실행 순서를 예측하고, 불필요한 블로킹을 방지하며, 성능 최적화에 대한 근거 있는 결정을 내릴 수 있습니다.