이벤트 루프 완전 이해
JavaScript는 싱글 스레드 언어입니다. 그런데 어떻게 비동기 작업을 처리할까요? 그 비밀은 이벤트 루프(Event Loop) 에 있습니다.
이벤트 루프란?
JavaScript 런타임은 단일 스레드에서 동작하지만, 브라우저나 Node.js 같은 런타임 환경이 추가적인 API와 스레드를 제공하여 비동기 처리를 가능하게 합니다.
이벤트 루프는 이 모든 것을 조율하는 메커니즘입니다.
런타임 구조
┌──────────────────────────────────────────────────────────┐
│ JavaScript 런타임 │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────┐ │
│ │ Call Stack │ │ Web APIs │ │
│ │ │ │ (setTimeout, fetch, DOM Events) │ │
│ │ main() │ └──────────────┬───────────────────┘ │
│ │ foo() │ │ │
│ │ bar() │ ┌──────────────▼───────────────────┐ │
│ └──────┬──────┘ │ Macrotask Queue │ │
│ │ │ [setTimeout cb, setInterval cb] │ │
│ │ └──────────────┬───────────────────┘ │
│ │ │ │
│ │ ┌──────────────▼───────────────────┐ │
│ │ │ Microtask Queue │ │
│ │ │ [Promise.then, queueMicrotask] │ │
│ │ └──────────────┬───────────────────┘ │
│ │ │ │
│ └──────────── Event Loop ──┘ │
└──────────────────────────────────────────────────────────┘
핵심 구성 요소
1. Call Stack (콜 스택)
현재 실행 중인 함수들의 스택입니다. LIFO(Last In, First Out) 방식으로 동작합니다.
function greet(name) {
return `Hello, ${name}!`;
}
function main() {
const message = greet('World');
console.log(message);
}
main();
// 콜 스택 변화:
// 1. main() 추가
// 2. greet('World') 추가
// 3. greet 반환 → 제거
// 4. console.log 추가 → 실행 → 제거
// 5. main 반환 → 제거
2. Web API (브라우저 제공 API)
setTimeout, fetch, DOM Events 등 브라우저가 제공하는 비동기 API입니다. 별도 스레드에서 처리됩니다.
// Web API가 처리하는 것들
setTimeout(() => console.log('타임아웃'), 1000); // 타이머 API
fetch('https://api.example.com/data'); // 네트워크 API
document.addEventListener('click', handler); // DOM Events API
3. Macrotask Queue (매크로태스크 큐)
Web API 작업이 완료된 후 실행할 콜백이 쌓이는 큐입니다.
매크로태스크에 해당하는 것들:
setTimeoutsetIntervalsetImmediate(Node.js)- I/O 콜백
- UI 렌더링
4. Microtask Queue (마이크로태스크 큐)
Promise의 .then(), .catch(), .finally() 콜백과 queueMicrotask() 콜백이 쌓이는 큐입니다.
마이크로태스크에 해당하는 것들:
Promise.then/catch/finallyqueueMicrotask()MutationObserverasync/await(내부적으로 Promise 사용)
이벤트 루프 동작 원리
이벤트 루프는 다음 순서로 반복합니다:
1. Call Stack이 비어있는지 확인
2. Microtask Queue 처리 (전부 비울 때까지)
3. Macrotask Queue에서 하나 꺼내어 실행
4. 다시 Microtask Queue 처리
5. 1로 돌아가서 반복
// 이벤트 루프 동작 예제
console.log('1: 동기 코드'); // 즉시 실행
setTimeout(() => console.log('2: setTimeout'), 0); // Macrotask Queue
Promise.resolve().then(() => console.log('3: Promise.then')); // Microtask Queue
console.log('4: 동기 코드'); // 즉시 실행
// 출력 순서:
// 1: 동기 코드
// 4: 동기 코드
// 3: Promise.then ← Microtask가 먼저!
// 2: setTimeout ← Macrotask는 나중
Microtask vs Macrotask 우선순위
마이크로태스크가 매크로태스크보다 항상 먼저 실행됩니다.
console.log('시작');
// Macrotask
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => console.log('setTimeout 내부 Promise'));
}, 0);
setTimeout(() => console.log('setTimeout 2'), 0);
// Microtask
Promise.resolve()
.then(() => {
console.log('Promise 1');
return 'Promise 1 반환값';
})
.then(() => console.log('Promise 2'));
queueMicrotask(() => console.log('queueMicrotask'));
console.log('끝');
// 출력 순서:
// 시작
// 끝
// Promise 1 ← Microtask
// queueMicrotask ← Microtask
// Promise 2 ← Microtask (이전 .then에서 생성됨)
// setTimeout 1 ← Macrotask
// setTimeout 내부 Promise ← Microtask (setTimeout 실행 후 Microtask 처리)
// setTimeout 2 ← Macrotask
실행 순서 심화 예제
// 복잡한 혼합 예제
async function asyncFunc() {
console.log('async 함수 시작'); // 2
await Promise.resolve();
console.log('await 이후'); // 5
}
console.log('스크립트 시작'); // 1
setTimeout(() => console.log('setTimeout'), 0); // 7
asyncFunc();
Promise.resolve().then(() => console.log('Promise.then')); // 4
console.log('스크립트 끝'); // 3
// 출력:
// 스크립트 시작 (1)
// async 함수 시작 (2) - async 함수는 await 전까지 동기적으로 실행
// 스크립트 끝 (3)
// Promise.then (4) - Microtask
// await 이후 (5) - await는 내부적으로 Promise.then
// setTimeout (6) - Macrotask
requestAnimationFrame 위치
requestAnimationFrame은 매크로태스크와 마이크로태스크 사이 특별한 위치에 있습니다.
// requestAnimationFrame은 렌더링 전에 실행됩니다
console.log('시작');
setTimeout(() => console.log('setTimeout'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('Promise'));
// 일반적인 출력 순서 (브라우저 환경):
// 시작
// Promise ← Microtask
// rAF ← 렌더링 전 (브라우저마다 다를 수 있음)
// setTimeout ← Macrotask
// rAF는 일반적으로 60fps 기준 ~16ms마다 실행됨
// 실제 애니메이션용으로 설계된 API
function animate() {
// 프레임마다 실행할 로직
updatePosition();
requestAnimationFrame(animate); // 다음 프레임 예약
}
requestAnimationFrame(animate);
Node.js 이벤트 루프
Node.js의 이벤트 루프는 브라우저보다 더 세분화된 단계를 가집니다.
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ ← 이전 루프의 I/O 에러 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ ← 내부용
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ poll │ ← I/O 이벤트 대기/처리
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ ← setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──┤ close callbacks │ ← socket.close 등
└───────────────────────────┘
// Node.js에서 setImmediate vs setTimeout
const { performance } = require('node:perf_hooks');
// I/O 콜백 내에서는 setImmediate가 항상 setTimeout보다 먼저
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'));
setImmediate(() => console.log('setImmediate'));
// 항상: setImmediate → setTimeout
});
// I/O 외부에서는 순서가 보장되지 않음
setTimeout(() => console.log('timeout'));
setImmediate(() => console.log('immediate'));
// 순서 불확정 (시스템 타이밍에 따라)
// process.nextTick은 모든 것보다 먼저 (Microtask보다도!)
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// 출력: nextTick → Promise
스택 오버플로우와 해결책
// 스택 오버플로우 유발 코드 (하지 말 것)
function infiniteRecursion() {
return infiniteRecursion(); // Maximum call stack size exceeded
}
// 해결책: 비동기로 스택 분산
function safeRecursion(n) {
if (n <= 0) return;
// setTimeout으로 콜 스택 비우기
setTimeout(() => safeRecursion(n - 1), 0);
}
// 더 나은 방법: queueMicrotask 또는 Promise
async function asyncRecursion(n) {
if (n <= 0) return;
await Promise.resolve(); // 스택 양보
return asyncRecursion(n - 1);
}
실전 예제: 이벤트 루프 모니터링
// 이벤트 루프 지연 측정 (Node.js)
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const delay = now - lastCheck - 1000;
if (delay > 50) {
console.warn(`이벤트 루프 지연 감지: ${delay}ms`);
}
lastCheck = now;
}, 1000);
// 이벤트 루프를 블로킹하는 나쁜 예
function blockingOperation() {
const start = Date.now();
while (Date.now() - start < 500) {
// 500ms 동안 블로킹! 다른 비동기 작업 실행 불가
}
}
// 올바른 방법: 작업을 청크로 분할
async function nonBlockingOperation(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk);
// 이벤트 루프에 제어권 양보
await new Promise(resolve => setTimeout(resolve, 0));
}
}
고수 팁
1. 마이크로태스크 무한 루프 주의
// 위험: 마이크로태스크 큐가 비워지지 않으면 UI가 멈춤
function dangerousMicrotaskLoop() {
Promise.resolve().then(dangerousMicrotaskLoop);
// 브라우저가 렌더링을 못 함!
}
// 안전: setTimeout으로 매크로태스크 활용
function safeMacrotaskLoop() {
setTimeout(safeMacrotaskLoop, 0);
// 각 iteration 사이에 렌더링 기회 제공
}
2. CPU 집약적 작업은 Web Worker로
// 메인 스레드에서 무거운 작업 → 이벤트 루프 블로킹
// 해결책: Web Worker 사용
const worker = new Worker('heavy-computation.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => {
console.log('결과:', e.data.result);
};
3. 프로미스 체이닝의 마이크로태스크 누적
// 각 .then()은 새로운 마이크로태스크를 생성
Promise.resolve()
.then(() => 1) // Microtask 1
.then(() => 2) // Microtask 2 (1 완료 후 생성)
.then(() => 3) // Microtask 3 (2 완료 후 생성)
.then(console.log);
// 같은 .then 호출은 동시에 큐에 등록됨
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
// A → B (등록 순서대로)