자바스크립트 이벤트 루프 - 콜 스택, 마이크로태스크, setTimeout 0이 즉시가 아닌 이유
이 단원에서 배우는 것
중급 과정에서 Promise, async/await, fetch까지 다뤘다. 문법은 익혔지만 "왜 이 로그가 저 로그보다 먼저 찍히는가", "왜 setTimeout(fn, 0)이 즉시가 아닌가"는 아직 설명되지 않았다. 이 단원은 그 아래층, 즉 자바스크립트 엔진과 호스트 환경이 코드를 언제 실행하기로 결정하는지를 다룬다. 고급 과정 전체가 여기서 시작한다. 18단원의 메모리, 21단원의 성능은 모두 이벤트 루프 위에서 벌어지는 일이다.
고급 과정(17~24단원)의 예제는 초급에서 만들던 스터디 관리 대시보드를 다시 꺼내 쓴다. 참가자(member)에 이름, 출석 횟수(sessions), 회비 납부 여부(paid)가 있는 그 구조 그대로이고, 달라진 것은 규모다. 참가자가 3만 명이 되고 서버 API가 붙으면서 초급에서는 보이지 않던 문제들이 드러난다.
- 콜 스택, 태스크 큐, 마이크로태스크 큐가 각각 무엇을 담고 언제 비워지는지 설명한다.
- 동기 코드,
Promise.then,await,setTimeout이 섞인 코드의 출력 순서를 종이 위에서 예측한다. - UI가 멈추는 원인을 "긴 태스크"라는 단위로 측정하고, 작업을 쪼개 렌더링에 기회를 준다.
기준은 ES2022 이후의 브라우저와 Node.js 20 이상이다. 큐를 돌리는 주체는 언어 명세가 아니라 호스트(브라우저 또는 Node.js)라서 둘의 동작이 미묘하게 다르다. 그 차이도 마지막에 짚는다.
왜 필요한가
출석 체크 버튼을 눌렀을 때 서버에 저장하고 화면을 갱신하는 코드를 썼다고 하자.
saveButton.addEventListener("click", () => {
saveAttendance(memberId); // async 함수, await 안 함
render(); // 저장된 결과로 다시 그리고 싶다
showToast("저장했습니다");
});
테스트하면 대체로 잘 동작한다. 그런데 가끔 화면이 저장 전 값으로 그려진다. 사람에 따라, 네트워크 상태에 따라 결과가 달라진다. 이 코드를 고치는 방법은 await를 붙이는 것이고 그건 이미 안다. 문제는 왜 가끔만 틀리는지를 설명할 수 없다는 데 있다. 설명하지 못하면 다음번에 비슷하지만 조금 다른 버그를 만났을 때 또 감으로 고치게 된다.
더 곤란한 쪽은 이런 코드다.
listBox.textContent = "불러오는 중...";
const rows = buildRows(members); // 참가자 3만 명, 800ms 걸리는 동기 작업
listBox.replaceChildren(...rows);
"불러오는 중..."은 한 번도 화면에 나타나지 않는다. 코드 순서상 먼저 대입했는데도 그렇다. 브라우저가 화면을 다시 그리는 시점이 언제인지 모르면 이 현상은 버그처럼 보인다. 이벤트 루프는 이런 "순서가 코드 순서와 다른" 현상 전부를 하나의 모델로 설명한다.
문법과 예제
구성 요소 네 가지
실행 모델을 이루는 부품은 네 개다.
| 부품 | 담는 것 | 비우는 규칙 |
|---|---|---|
| 콜 스택 | 지금 실행 중인 함수 호출 | 비어야 다음 단계로 넘어간다 |
| 마이크로태스크 큐 | then/catch/finally 콜백, await 이후 코드, queueMicrotask, MutationObserver | 스택이 빌 때마다 큐가 완전히 빌 때까지 전부 실행 |
| 태스크 큐(매크로태스크) | setTimeout/setInterval 콜백, DOM 이벤트, 네트워크 응답 도착 처리, MessageChannel | 한 번에 하나만 꺼내 실행 |
| 렌더링 | 스타일 계산, 레이아웃, 페인트 | 태스크 하나가 끝난 뒤, 대략 화면 주사율에 맞춰 |
한 바퀴는 이렇게 돈다. 태스크 하나 실행 → 마이크로태스크 큐 전부 비우기 → (필요하면) 렌더링 → 다음 태스크. 이 문장 하나가 이 단원의 전부라고 해도 된다.
출력 순서 예측하기
console.log("1 동기");
setTimeout(() => console.log("5 타이머"), 0);
Promise.resolve().then(() => console.log("3 마이크로태스크"));
queueMicrotask(() => console.log("4 마이크로태스크"));
console.log("2 동기");
출력은 1, 2, 3, 4, 5다. 동기 코드는 지금 실행 중인 태스크의 일부라 먼저 다 끝난다. 그 태스크가 끝나면 마이크로태스크 큐를 비우므로 3과 4가 나온다. 타이머 콜백은 별개의 태스크라 그 다음 바퀴에서 실행된다. setTimeout(fn, 0)의 0은 "지금"이 아니라 "0밀리초 뒤에 태스크 큐에 넣어도 된다"는 뜻이고, 큐에 들어간 뒤 실제 실행은 앞선 태스크와 모든 마이크로태스크가 끝난 뒤다.
await 는 then 등록이다
async function saveAttendance() {
console.log("A");
await null; // 값이 뭐든 여기서 함수가 잘린다
console.log("C");
}
console.log("start");
saveAttendance();
Promise.resolve().then(() => console.log("D"));
console.log("B");
출력은 start, A, B, C, D다. await를 만나면 async 함수는 그 자리에서 반환하고, 뒷부분은 마이크로태스크로 예약된다. 그래서 A는 동기로 찍히고 C는 B보다 늦다. C가 D보다 앞서는 이유는 saveAttendance() 호출이 Promise.resolve().then(...)보다 먼저 마이크로태스크 큐에 들어갔기 때문이다. 앞의 문제 코드에서 render()가 저장 전 값을 그린 이유가 정확히 이것이다. saveAttendance(memberId)는 첫 await까지만 실행하고 곧장 돌아오고, render()는 그 뒤 같은 태스크 안에서 실행된다. 서버 응답은 몇 밀리초 뒤 별개의 태스크로 도착한다.
화면이 멈추는 이유
앞에서 "불러오는 중..."이 안 보인 이유는 이제 설명된다. textContent 대입은 DOM을 바꾸지만 화면을 그리지는 않는다. 그리기는 지금 태스크가 끝난 뒤에 일어나는데, 같은 태스크 안에서 800ms짜리 동기 작업이 이어지므로 그동안 화면은 한 프레임도 갱신되지 않는다. 작업이 끝나 태스크가 종료될 때는 이미 최종 상태라서 중간 문구가 보일 틈이 없다.
해결은 작업을 여러 태스크로 쪼개는 것이다. 스터디 참가자 3만 명의 출석률을 계산하는 예로 보자.
// 브라우저용. 한 덩어리씩 처리하고 렌더링에 자리를 내준다.
function nextTask() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function summarize(members, onProgress) {
const CHUNK = 2000;
let totalSessions = 0;
for (let i = 0; i < members.length; i += CHUNK) {
for (const m of members.slice(i, i + CHUNK)) {
totalSessions += m.sessions;
}
onProgress(Math.min(i + CHUNK, members.length));
await nextTask(); // 여기서 브라우저가 화면을 그릴 수 있다
}
return totalSessions;
}
await nextTask() 자리에서 현재 태스크가 끝나므로 브라우저는 onProgress가 바꾼 DOM을 그릴 기회를 얻는다. 주의할 점은 await Promise.resolve()로는 안 된다는 것이다. 그건 마이크로태스크라서 같은 바퀴 안에서 처리되고, 렌더링은 여전히 밀린다. 렌더링을 끼워 넣으려면 태스크 경계를 넘어야 한다.
중단 없이 계속 도는 대신 브라우저가 한가할 때만 일하게 하려면 requestIdleCallback을, 다음 프레임 직전에 DOM을 만지려면 requestAnimationFrame을 쓴다. 셋의 성격은 다르다.
| API | 실행 시점 | 쓰는 곳 |
|---|---|---|
queueMicrotask | 현재 태스크 직후, 렌더링 전 | 상태 변경을 모아 한 번만 반영 |
setTimeout(fn, 0) | 다음 태스크 | 렌더링 기회를 주고 이어서 계산 |
requestAnimationFrame | 다음 페인트 직전 | 애니메이션, DOM 측정 후 변경 |
얼마나 멈췄는지 측정하기
감으로 "느리다"고 말하지 말고 숫자를 본다. 50ms를 넘는 태스크는 브라우저가 긴 태스크(long task)로 분류하고, 그 시간만큼 클릭과 입력이 밀린다.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`긴 태스크 ${entry.duration.toFixed(1)}ms`, entry.name);
}
});
observer.observe({ type: "longtask", buffered: true });
이 값이 사용자 체감으로 이어지는 지표가 INP(Interaction to Next Paint)이고, 21단원에서 다시 다룬다.
Node.js 와의 차이
Node.js에도 같은 모델이 있지만 큐가 더 잘게 나뉜다. 실무에서 부딪히는 지점은 둘이다.
// Node.js 전용
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
// nextTick → promise → (timeout/immediate 는 상황에 따라 순서가 갈림)
process.nextTick은 마이크로태스크보다도 먼저 처리되는 Node 고유 큐다. 그리고 위 setTimeout과 setImmediate의 순서는 메인 모듈에서 실행할 경우 실행할 때마다 달라질 수 있다. 이벤트 루프 시작 시점과 타이머 준비 시점이 프로세스 기동 부하에 따라 엇갈리기 때문이다. 이 두 API의 순서에 로직을 의존시키면 안 된다.
실무에서 자주 틀리는 것
1. setTimeout(fn, 0) 을 "다음 줄보다 살짝 뒤"로 이해한다
// DOM 을 넣고 높이를 재려는 코드
listBox.append(newRow);
setTimeout(() => {
console.log(newRow.offsetHeight); // 대체로 맞지만 보장이 아니다
}, 0);
이 코드가 자주 맞는 이유는 우연히 그 사이에 레이아웃이 끝나기 때문이지, 규칙 때문이 아니다. 앞선 태스크가 길면 0ms 타이머는 수백 ms 뒤에 실행되고, 중첩 타이머가 5단계를 넘으면 브라우저는 최소 지연을 4ms로 올린다. 배터리 절약 모드나 백그라운드 탭에서는 1초 이상으로 늘어난다. 레이아웃 값이 필요하면 offsetHeight를 그냥 읽으면 된다. 읽는 순간 브라우저가 강제로 레이아웃을 계산해 준다. 다음 페인트를 기다려야 한다면 requestAnimationFrame을 쓴다. 타이밍을 시간으로 맞추려는 코드는 언젠가 반드시 깨진다.
2. 마이크로태스크로 무한 루프를 만든다
function poll() {
Promise.resolve().then(poll); // 화면이 영원히 멈춘다
}
poll();
마이크로태스크 큐는 빌 때까지 비운다는 규칙이 있다. 마이크로태스크가 계속 자기 자신을 다시 등록하면 큐가 절대 비지 않고, 그 사이 태스크와 렌더링은 한 번도 실행되지 않는다. 브라우저 탭이 완전히 얼어붙고 개발자 도구도 반응하지 않는다. 반면 setTimeout(poll, 0)은 태스크라서 CPU는 먹지만 화면은 살아 있다. 재귀 폴링이 필요하면 태스크를 쓴다.
3. 반복문 안에서 await 를 걸어 놓고 병렬이라 생각한다
// 참가자 100명, 요청 1건당 50ms → 5초 걸린다
for (const m of members) {
await saveAttendance(m.id);
}
// 동시에 보낸다 → 대략 50ms + 서버 처리
await Promise.all(members.map((m) => saveAttendance(m.id)));
순차 실행이 필요한 경우도 있으니 위 코드가 항상 틀린 것은 아니다. 문제는 의도 없이 순차가 되는 경우다. 반대로 Promise.all도 만능이 아니어서, 100건을 한꺼번에 던지면 서버가 429를 돌려주거나 브라우저의 도메인당 동시 연결 한도에 걸려 오히려 느려진다. 실무에서는 동시 실행 개수를 5~10개로 묶는다. 하나라도 실패했을 때 나머지 결과가 필요하면 Promise.all 대신 Promise.allSettled를 쓴다.
4. 이벤트 루프가 막힌 것을 네트워크 지연으로 오해한다
"API가 느리다"는 제보를 받고 서버 로그를 보면 응답이 20ms에 끝나 있는 경우가 흔하다. 실제로는 응답이 도착했지만 그 처리 태스크가 앞의 긴 동기 작업 뒤에 줄 서 있던 것이다. 개발자 도구 Network 탭의 응답 시간과 Performance 탭의 태스크 길이를 함께 봐야 구분된다. fetch 전후로 performance.now()를 찍는 것만으로도 1차 판별은 된다. 요청 시작부터 await 반환까지의 시간이 서버 응답 시간보다 훨씬 크면 범인은 네트워크가 아니라 메인 스레드다.
5. try/catch 로 감싸면 비동기 에러도 잡힌다고 믿는다
try {
setTimeout(() => { throw new Error("저장 실패"); }, 0);
} catch (err) {
console.log("잡힘"); // 절대 실행되지 않는다
}
타이머 콜백은 다른 태스크에서 실행되고, 그때 try 블록의 콜 스택은 이미 사라졌다. 이 에러는 전역으로 새어 나가 브라우저에서는 window의 error 이벤트, Node.js에서는 uncaughtException이 된다. 콜백 안에서 잡아야 한다. 같은 이유로 await를 빠뜨린 async 호출에서 던져진 에러는 바깥 try/catch에 걸리지 않고 처리되지 않은 거부(unhandled rejection)가 된다.
큐 처리 규칙의 원문은 HTML 명세의 Event loops 절에 있다.
스스로 확인하기
- 다음 코드의 출력 순서를 쓰라.
console.log("A"); setTimeout(() => console.log("B"), 0); (async () => { console.log("C"); await Promise.resolve(); console.log("D"); })(); Promise.resolve().then(() => console.log("E")); console.log("F"); - 진행률 표시가 화면에 나타나게 하려면
await Promise.resolve()가 아니라await new Promise(r => setTimeout(r, 0))이어야 하는 이유를 한 문단으로 설명하라. - 버튼 클릭 후 화면 갱신이 400ms씩 밀린다는 제보를 받았다. 원인이 서버인지 메인 스레드인지 구분하기 위해 무엇을 측정하겠는가? 최소 두 가지를 쓰라.
정답
- A, C, F, D, E, B. A는 동기다. 즉시 실행 async 함수의
console.log("C")는await앞이라 동기로 실행된다. F까지가 현재 태스크다. 그다음 마이크로태스크 큐를 순서대로 비우는데, D를 예약한await가 E를 예약한then보다 먼저 등록됐으므로 D, E 순이다. 타이머 콜백 B는 별개의 태스크라 마지막이다. Promise.resolve()의await는 마이크로태스크로 재개된다. 마이크로태스크 큐는 현재 태스크가 끝난 뒤 완전히 빌 때까지 처리되며 그 사이에는 렌더링이 끼어들지 못하므로, 반복문이 도는 동안 화면은 한 번도 갱신되지 않는다.setTimeout은 새 태스크를 만들어 현재 태스크를 실제로 종료시키고, 태스크와 태스크 사이에 브라우저가 스타일 계산과 페인트를 수행할 수 있게 한다.- 첫째, 개발자 도구 Network 탭에서 해당 요청의 서버 응답 시간(TTFB)을 본다. 둘째, Performance 탭을 녹화해 클릭 이후 50ms를 넘는 긴 태스크가 있는지 확인하거나
PerformanceObserver로longtask를 수집한다. 셋째,fetch직전과await반환 직후에performance.now()를 기록해 그 차이를 서버 응답 시간과 비교한다. 차이가 크면 응답은 도착했지만 처리 태스크가 큐에서 대기한 것이므로 원인은 메인 스레드다.