본문으로 건너뛰기
yutils

event loop 은 어떻게 동작할까?

call stack + task queue + microtask queue, setTimeout(fn, 0) 가 0 ms 아닌 이유, microtask starvation, Node 의 libuv thread pool, 브라우저와 Node event loop 의 차이, 'async' 가 queue 의 sugar 인 이유.

약 9분 읽기

JavaScript 는 single-threaded — 그런데 어떻게 비동기 fetch / setTimeout / Promise 가 동시 작동? 답은 event loop. call stack, task queue, microtask queue 가 협업. 이 가이드는 setTimeout(fn, 0) 이 0ms 아닌 이유, microtask starvation, Node 와 브라우저 의 차이를 정리한다.

Event Loop 의 핵심 — 3 component

┌─────────────────────────────┐
│        Call Stack           │
│  (현재 실행 중 함수의 frame들)│
└──────────┬──────────────────┘
           │ pop / push
           ↓
┌─────────────────────────────┐
│   Microtask Queue           │   ← Promise.then, queueMicrotask
│   (Promise callbacks)       │
└──────────┬──────────────────┘
           │ 매 task 끝날 때마다 drain (전부)
           ↓
┌─────────────────────────────┐
│   Task Queue (macrotask)    │   ← setTimeout, setInterval,
│   (timer / I/O callback)    │     I/O, postMessage
└─────────────────────────────┘

규칙:
1. call stack 이 비면 → microtask queue 의 모든 task 처리 (drain)
2. microtask 다 비우면 → task queue 의 1 개 처리
3. 그 task 가 microtask 만들면 → 그 microtask 도 처리하고 task 끝
4. 반복

왜 setTimeout(fn, 0) 이 0ms 아닌가

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");

출력: A, C, B

이유:
1. console.log("A") 실행 (call stack)
2. setTimeout 호출 → callback 을 task queue 에 넣음 + delay 0
3. console.log("C") 실행
4. call stack 비움
5. microtask 없음
6. task queue 에서 setTimeout callback 꺼냄 → "B"

→ "0ms delay" 는 "다음 turn 에 실행" 의미.
   현재 task 가 끝나야 다음 task 시작.
   최소 ~4ms (브라우저 clamp) — HTML5 spec.

활용:
  setTimeout(fn, 0) = "큰 작업을 잠깐 끊고 UI 그릴 시간 주기"

Microtask vs Macrotask

setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");

출력: sync, promise, timer

이유:
1. console.log("sync") — call stack
2. Promise.then 의 callback → microtask queue
3. setTimeout callback → task queue
4. call stack 비움
5. microtask drain (모두) → "promise"
6. task queue 의 1 개 처리 → "timer"

규칙:
- microtask: Promise.then, queueMicrotask, MutationObserver
- macrotask: setTimeout, setInterval, setImmediate (Node), I/O,
            postMessage, requestAnimationFrame (browser)

→ Promise.then chain 이 setTimeout 보다 항상 먼저.

Microtask Starvation

function recursive() {
  Promise.resolve().then(recursive);
}
recursive();
// → microtask 가 자기 자신을 무한 큐잉
//   microtask drain 안 끝남 → macrotask 와 UI 그리기 못함 → freeze

vs setTimeout:
function recursive() {
  setTimeout(recursive, 0);
}
recursive();
// → 매 turn 의 macrotask, microtask drain 가능 → UI 그릴 수 있음

→ Promise chain 이 너무 깊으면 browser 또는 Node 가 멈춤.
   chunking 필요 (setTimeout 으로 잠깐 yield).

Node.js 의 Event Loop (libuv)

Node 의 event loop = libuv 제공. 6 phase:

┌───────────────────────────┐
│   timers (setTimeout)     │  ← 만료된 timer callbacks
├───────────────────────────┤
│   pending callbacks       │  ← OS 의 I/O completion
├───────────────────────────┤
│   idle, prepare           │  ← internal
├───────────────────────────┤
│   poll (I/O)              │  ← 새 I/O event 받기, callback 실행
├───────────────────────────┤
│   check (setImmediate)    │  ← setImmediate callbacks
├───────────────────────────┤
│   close callbacks         │  ← close event (socket.on('close'))
└───────────────────────────┘
     ↓ 한 phase 끝나면 microtask drain 후 다음 phase

setImmediate vs setTimeout(fn, 0):
- setTimeout: timers phase
- setImmediate: check phase
- 거의 같은 시점이지만 phase 따라 순서 다름

Node 의 추가 — process.nextTick:
- microtask 보다도 우선 (event loop phase 사이에 즉시)
- 자칫 starvation 위험 (recursive nextTick)

Async / Await 의 실제

async function foo() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}

foo();
console.log("C");

출력: A, C, B

이유:
- await 가 Promise.then(...) 의 sugar
- await 의 다음 줄은 microtask 로 schedule
- 즉 foo() 의 후반 ("B") 은 동기 코드 ("C") 후의 microtask

→ async/await 는 단순 syntax — underlying 은 Promise + microtask.
   이 모델 이해해야 race condition / ordering 문제 디버깅 가능.

Worker — single thread 의 해제

Web Worker (browser):
  const worker = new Worker("worker.js");
  worker.postMessage({task: "compute"});
  worker.onmessage = (e) => console.log(e.data);

  // worker.js
  self.onmessage = (e) => {
    const result = heavyComputation(e.data);
    self.postMessage(result);
  };

→ 별도 OS thread (또는 process). main thread 안 막음.
→ CPU-bound 작업의 정답.

Node worker_threads — 같은 패턴. 또한 libuv 의 thread pool (4 thread,
   UV_THREADPOOL_SIZE) 이 fs / crypto / dns 같은 일부 sync API 처리.

흔한 함정

  • blocking sync 함수 — JSON.parse(huge), bcrypt (sync) 등이 event loop 정지. async 또는 worker.
  • setTimeout 의 timing 신뢰 — clamp / 시스템 load 영향. 정확한 timing 은 performance.now() 또는 audio API.
  • Promise chain 의 깊이 — microtask starvation. 큰 batch 처리 시 yield (setTimeout) 끼우기.
  • error 가 silent — uncaught Promise rejection 이 silent 일 수 있음. process.on("unhandledRejection") 또는 window.addEventListener("unhandledrejection").
  • worker 과다 사용 — worker 마다 ~MB 메모리, spawn 비용. pool 형태로 재사용.

마무리

Event loop 은 magic 아닌 명확한 알고리즘 — call stack + microtask + macrotask 의 strict ordering. 이 model 이해하면 "왜 이 callback 이 저것보다 늦게?" 같은 mystery 사라짐.

실용 — sync 무거운 작업은 worker / chunked async. Promise.then chain 깊으면 yield 끼우기. Node 의 nextTick 신중. CPU-bound 은 worker, I/O-bound 는 await 로 충분.

가이드 목록으로