자바스크립트 고급 #4 이벤트 루프와 태스크
중급 #2 에서 Promise와 async/await의 사용법을 봤습니다. 이번 글은 그 비동기가 실제로 어떻게 동작하는지 — 이벤트 루프와 태스크 큐를 들여다봅니다.
자바스크립트는 싱글 스레드 #
먼저 가장 중요한 사실 — 자바스크립트는 한 번에 한 줄만 실행합니다. 한 함수가 동작하는 동안 다른 함수는 절대 끼어들 수 없습니다.
function a() {
b();
console.log('a 끝');
}
function b() {
console.log('b 끝');
}
a();
// b 끝
// a 끝
이걸 가능하게 하는 자료구조가 콜 스택(call stack) 입니다. 함수가 호출되면 스택에 쌓이고, 끝나면 빠져나갑니다.
콜 스택이 비어 있어야 다음 일을 함 #
자바스크립트가 비동기를 다루는 핵심 규칙:
콜 스택이 비어 있을 때만 다음 비동기 작업이 실행된다.
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 1, 3, 2 (이 순서)
setTimeout(..., 0) 인데도 2가 마지막. 이유는 — 콜 스택에 동기 코드가 모두 끝나야 타이머 콜백이 실행되기 때문입니다. 0ms 라도요.
이벤트 루프 #
이걸 관리하는 게 이벤트 루프(event loop) 입니다. 단순화하면 다음 동작을 끝없이 반복합니다.
while (true) {
if (콜 스택이 비어 있음) {
if (마이크로태스크 큐에 대기 중) {
// 큐가 빌 때까지 모두 실행
마이크로태스크 큐의 모든 작업 실행;
} else if (매크로태스크 큐에 대기 중) {
매크로태스크 하나 꺼내서 실행;
}
}
}핵심 포인트가 두 가지 있습니다.
- 콜 스택이 비어야 큐를 본다 — 동기 코드가 우선
- 마이크로태스크가 매크로태스크보다 우선
마이크로태스크 vs 매크로태스크 #
자바스크립트의 비동기는 두 가지 큐로 나뉩니다.
| 종류 | 어디서 들어오는가 |
|---|---|
| 매크로태스크 (Task) | setTimeout, setInterval, I/O, UI 이벤트 |
| 마이크로태스크 (Microtask) | Promise.then/.catch/.finally, queueMicrotask, await 다음 부분 |
마이크로태스크가 매크로태스크보다 먼저 실행됩니다. 그것도 큐가 비어 모든 마이크로태스크가 실행될 때까지.
핵심 예제 #
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 출력 순서: 1, 4, 3, 2
설명:
console.log('1')— 동기 실행setTimeout— 매크로태스크 큐에 등록Promise.resolve().then(...)— 마이크로태스크 큐에 등록console.log('4')— 동기 실행- 콜 스택 비었음 → 마이크로태스크 큐 확인 →
3출력 - 마이크로태스크 큐 비어있음 → 매크로태스크 큐 확인 →
2출력
setTimeout이 0ms 인데도 Promise.then보다 늦은 이유가 이것입니다.
await 다음 부분도 마이크로태스크
#
async function a() {
console.log('1');
await null;
console.log('3');
}
console.log('start');
a();
console.log('2');
// start, 1, 2, 3
a() 안에서 await null을 만나면 — 그 시점에 함수가 잠깐 멈추고, 다음 줄(console.log('3'))이 마이크로태스크로 등록 됩니다. 컨트롤이 호출자로 돌아가서 console.log('2')가 실행되고, 그 다음 마이크로태스크가 실행됩니다.
이게 async/await의 실제 동작 원리입니다.
queueMicrotask — 직접 마이크로태스크 만들기
#
console.log('1');
queueMicrotask(() => console.log('2'));
console.log('3');
// 1, 3, 2
브라우저와 Node 모두 이 함수를 가집니다. Promise.resolve().then(...)보다 더 짧고 명확하게 마이크로태스크를 만들 수 있습니다. 라이브러리 내부 구현에서 가끔 등장합니다.
같은 큐 안에서는 FIFO #
같은 큐 안의 작업들은 들어온 순서대로 처리됩니다.
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);
// A, B, C
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
Promise.resolve().then(() => console.log('C'));
// A, B, C
한 매크로태스크 vs 모든 마이크로태스크 #
다시 한 번 강조 — 매크로태스크 하나 처리할 때마다 마이크로태스크 큐가 비어질 때까지 모든 마이크로태스크를 끝내고 다음 매크로태스크로 갑니다.
setTimeout(() => {
console.log('macro 1');
Promise.resolve().then(() => console.log('micro 1'));
}, 0);
setTimeout(() => console.log('macro 2'), 0);
// macro 1, micro 1, macro 2
macro 1이 실행되며 micro 1이 마이크로태스크 큐에 들어갑니다. macro 1이 끝나고 콜 스택이 비면 — 매크로태스크 큐로 가기 전에 마이크로태스크 큐를 먼저 비워요. 그래서 micro 1이 macro 2보다 먼저 출력됩니다.
실전에서 의식해야 할 부분 #
1) 무한 마이크로태스크 → 매크로태스크 굶기 #
마이크로태스크 안에서 또 마이크로태스크를 끝없이 등록하면, 매크로태스크와 UI 업데이트가 차단됩니다.
function loop() {
Promise.resolve().then(loop);
}
loop();
// 브라우저 멈춤 — 다른 이벤트, 렌더링 모두 못 함
setTimeout으로 같은 짓을 하면 매크로태스크라 UI가 갱신될 짬이 있습니다. 마이크로태스크는 더 우선이라 더 위험합니다.
2) DOM 업데이트와 마이크로태스크 #
Promise.resolve().then(...) 안의 DOM 변경은 렌더링되기 전에 적용됩니다. 같은 프레임 안에서 DOM을 여러 번 바꿔도 한 번만 그려져요.
button.addEventListener('click', () => {
el.textContent = '1';
Promise.resolve().then(() => {
el.textContent = '2'; // 사용자는 '2'만 봄
});
});이게 React 같은 라이브러리가 마이크로태스크를 활용해 batch 업데이트를 할 수 있는 이유 중 하나입니다.
3) await 사이의 동기 코드
#
async function process() {
console.log('1');
await fetch('/api');
console.log('2'); // fetch 가 끝난 후에야 실행
}
process();
console.log('3'); // process 가 await 에서 멈춘 동안 실행
process()가 await를 만나면 잠시 멈춥니다. 그동안 호출자(console.log('3'))가 진행할 기회를 가져요. fetch가 끝나면 process의 다음 줄이 마이크로태스크로 등록됩니다.
requestAnimationFrame과 requestIdleCallback
#
브라우저 환경에는 매크로태스크 외에 렌더링과 동기화 된 콜백이 두 가지 있습니다.
requestAnimationFrame — 다음 프레임에 실행
#
requestAnimationFrame(() => {
// 다음 프레임 그려지기 직전 실행 (보통 60fps = 16.7ms 간격)
el.style.transform = `translateX(${x}px)`;
});애니메이션, DOM 변경의 그룹화에 적합합니다. setTimeout보다 더 정확하게 프레임에 맞춰 실행됩니다.
requestIdleCallback — 한가할 때 실행
#
requestIdleCallback(() => {
// 메인 스레드가 한가할 때 실행 — 우선순위 낮은 작업
saveAnalytics();
});급하지 않은 작업(분석 데이터 저장, 백그라운드 미리계산)에 적합합니다. 사용자 인터랙션을 막지 않게.
Node의 이벤트 루프 — 살짝 다름 #
Node도 이벤트 루프가 있지만, 단계가 더 세분화됐습니다.
| 단계 | 처리 |
|---|---|
| timers | setTimeout / setInterval 콜백 |
| I/O callbacks | 일부 시스템 콜백 |
| poll | 새 I/O 이벤트 받기 |
| check | setImmediate 콜백 |
| close | socket.on('close', ...) |
setImmediate와 process.nextTick 같은 Node 전용 도구도 있는데 — process.nextTick은 마이크로태스크보다도 먼저 실행됩니다. 일반 코드에서 자주 쓸 일은 없습니다.
마무리 #
이번 글에서 정리한 내용:
- 자바스크립트는 싱글 스레드, 콜 스택이 비어야 비동기 처리
- 이벤트 루프 — 콜 스택 빌 때까지 동기, 그 다음 마이크로 → 매크로
- 마이크로태스크 — Promise.then, queueMicrotask, await 다음
- 매크로태스크 — setTimeout, I/O, UI 이벤트
- 한 매크로 처리 후 마이크로 큐가 빌 때까지 비움
- 무한 마이크로태스크는 UI 차단
requestAnimationFrame/requestIdleCallback은 렌더링과 동기화- Node의 이벤트 루프는 단계 더 세분화
다음 글(#5 메모리 모델과 GC)에서는 자바스크립트가 메모리를 어떻게 관리하는지 — 가비지 컬렉터의 동작과 누수가 일어나는 패턴들을 다룹니다.