자바스크립트 이벤트 루프, 정확히 이해하기

pingulee조회 10 · 댓글 0

면접에서 "자바스크립트는 싱글 스레드입니다"라고 답하면 대개 통과한다. 그런데 그 다음 질문에서 대부분 막힌다. setTimeout(fn, 0)은 왜 Promise.resolve().then(fn)보다 늦게 실행되는가. await가 걸린 함수는 정확히 어느 시점에 재개되는가. 무한 루프도 아닌 코드가 왜 화면을 얼려버리는가.

싱글 스레드라는 말은 틀리지 않았지만 아무것도 설명하지 못한다. 실제로 설명력을 갖는 것은 콜 스택 하나, 큐 두 종류, 그리고 그 사이에 끼어드는 렌더링이라는 구조다. 이 글은 그 구조를 명세 수준에서 짚고, 실제 코드가 어떤 순서로 도는지 한 줄씩 따라간다. 마지막에는 브라우저와 Node.js가 왜 다르게 동작하는지까지 본다.

먼저 오해부터 걷어낸다

"자바스크립트 엔진에 이벤트 루프가 있다"는 설명을 자주 보는데, 이것은 사실이 아니다. ECMAScript 명세(ECMA-262)에는 setTimeout도, fetch도, DOM도, 이벤트 루프도 없다. 명세가 정의하는 것은 실행 컨텍스트, 콜 스택, 그리고 잡 큐(Job Queue)라는 아주 얇은 개념뿐이다.

setTimeoutHTML 명세의 Timers 절이 정의한다. 이벤트 루프 역시 HTML 명세의 Event loops 절에 있다. 즉 이벤트 루프는 언어가 아니라 호스트 환경의 물건이다. 그래서 브라우저와 Node.js에서 동작이 다를 수 있고, 실제로 다르다.

호출 스택과 Web APIs, 마이크로태스크 큐, 태스크 큐를 오가는 이벤트 루프
초록색 마이크로태스크 큐는 태스크 큐보다 먼저 비워진다

이 구분이 중요한 이유는 실용적이다. 브라우저에서 검증한 비동기 순서 가정을 Node.js 서버 코드에 그대로 가져가면 깨진다. 반대도 마찬가지다. "자바스크립트의 이벤트 루프"라는 단일한 무언가는 존재하지 않는다.

콜 스택: 한 줄뿐인 작업대

콜 스택은 지금 실행 중인 함수들의 스택이다. 함수를 부르면 프레임이 쌓이고, 반환하면 걷힌다. 스택이 하나뿐이라는 것이 싱글 스레드의 실체다.

function third() {
  throw new Error('여기');
}
function second() {
  third();
}
function first() {
  second();
}
first();

에러를 던지면 나오는 스택 트레이스가 바로 이 스택의 스냅샷이다.

Error: 여기
    at third (index.js:2:9)
    at second (index.js:5:3)
    at first (index.js:8:3)
    at index.js:10:1

핵심은 이것이다. 스택에 무언가가 남아 있는 동안 브라우저는 아무것도 할 수 없다. 클릭 처리도, 스크롤도, 화면 갱신도 전부 같은 스레드가 한다. 그래서 다음 코드는 3초 동안 탭을 완전히 죽인다.

const until = Date.now() + 3000;
while (Date.now() < until) {
  // 아무것도 안 하지만 스택은 비지 않는다
}

이 3초 동안 사용자가 누른 클릭은 사라지지 않는다. 큐에 쌓여 있다가 스택이 비는 순간 한꺼번에 처리된다. 사용자 입장에서는 "먹통이었다가 갑자기 여러 번 눌린" 것처럼 보인다.

두 개의 큐, 그리고 결정적인 비대칭

비동기 작업이 끝나면 콜백이 큐에 들어간다. 큐는 두 종류이고, 이벤트 루프가 두 큐를 다루는 방식이 다르다. 여기가 순서 문제의 전부다.

마이크로태스크 매크로태스크
대표 예 Promise.then/catch/finally, await 재개, queueMicrotask, MutationObserver setTimeout, setInterval, setImmediate(Node), I/O, UI 이벤트, postMessage
한 바퀴에 처리하는 양 큐가 빌 때까지 전부 딱 하나
실행 중 추가된 작업 같은 바퀴에서 이어서 처리 다음 바퀴로
렌더링 기회 전부 비운 뒤에야 생김 하나 처리할 때마다 생김

비대칭을 한 문장으로 줄이면 이렇다. 마이크로태스크는 큐를 비우고, 매크로태스크는 하나만 꺼낸다. 이 한 줄에서 나머지가 전부 따라 나온다.

왜 하필 이렇게 나눴나

Promise는 "값이 준비되면 즉시 이어서"라는 의미론을 가진다. 만약 .then 콜백이 매크로태스크였다면, p.then(a).then(b).then(c) 체인 한 번에 렌더링이 세 번 끼어들 수 있다. 중간 상태가 화면에 보인다는 뜻이다. 마이크로태스크로 두면 체인 전체가 원자적으로 끝난 뒤에야 화면이 갱신된다.

대신 대가가 있다. 마이크로태스크는 큐를 완전히 비울 때까지 렌더링을 막는다. 그래서 이런 코드는 무한 루프와 동일하게 브라우저를 죽인다.

function starve() {
  Promise.resolve().then(starve);
}
starve();

while (true) {}와 달리 이 코드는 스택이 계속 비워진다. 그런데도 화면은 멈춘다. 마이크로태스크 체크포인트가 끝나지 않기 때문이다. 스택이 비는 것과 이벤트 루프가 다음으로 넘어가는 것은 다른 얘기다.

한 바퀴를 한 줄씩 따라가기

이제 실제 코드로 확인한다.

console.log('start');
 
setTimeout(() => console.log('timeout'), 0);
 
Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));
 
console.log('end');

출력은 이렇게 나온다.

start
end
promise 1
promise 2
timeout

setTimeout0으로 줬는데도 가장 마지막이다. 이유는 아래 표의 4~7행에 있다.

이벤트 루프 한 바퀴가 도는 순서를 단계별로 정리한 표

주목할 지점은 5행이다. promise 1 콜백이 반환하면서 다음 .then이 마이크로 큐에 새로 들어간다. 그런데도 promise 2는 같은 체크포인트 안에서 실행된다. 마이크로태스크 처리 중에 추가된 마이크로태스크는 다음 바퀴로 넘어가지 않는다. 이것이 앞서 본 starve()가 브라우저를 죽이는 이유이자, Promise 체인이 원자적으로 보이는 이유다.

await는 무엇으로 바뀌는가

async/await는 문법 설탕이라고들 하는데, 무엇으로 풀리는지 정확히 알아야 순서를 예측할 수 있다.

async function run() {
  console.log('A');
  await null;
  console.log('B');
}
run();
console.log('C');

출력은 A, C, B다. await를 만나면 함수는 거기서 즉시 반환하고, 나머지 부분(console.log('B'))은 마이크로태스크로 예약된다. await null처럼 Promise가 아닌 값을 기다려도 마찬가지다. 값이 이미 준비되어 있어도 한 틱은 반드시 양보한다.

대략 이렇게 변환된다고 보면 된다.

function run() {
  console.log('A');
  return Promise.resolve(null).then(() => {
    console.log('B');
  });
}

여기서 실무적으로 자주 걸리는 함정이 있다.

// 순차 실행: 총 3초
const a = await fetchA(); // 1초
const b = await fetchB(); // 1초
const c = await fetchC(); // 1초
 
// 병렬 실행: 총 1초
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

위쪽은 fetchA가 끝나야 fetchB시작된다. 세 요청이 서로를 기다릴 이유가 없다면 명백한 낭비다. await가 붙은 줄은 "여기서 멈춘다"는 뜻이지 "여기서 시작한다"가 아니라는 점을 기억하면 헷갈리지 않는다.

setTimeout(fn, 0)은 0ms가 아니다

setTimeout의 두 번째 인자는 최소 대기 시간이지 실행 시각이 아니다. 타이머가 만료되면 콜백이 매크로 큐에 들어갈 뿐이고, 실제 실행은 스택이 비고 마이크로태스크가 다 끝난 뒤다.

여기에 HTML 명세가 정한 추가 제약이 있다. 중첩 깊이가 5를 넘으면 최소 대기가 4ms로 고정된다.

let count = 0;
let last = performance.now();
 
function tick() {
  const now = performance.now();
  console.log(count, (now - last).toFixed(1) + 'ms');
  last = now;
  if (++count < 8) setTimeout(tick, 0);
}
setTimeout(tick, 0);

크롬에서 돌리면 대략 이렇게 나온다.

0  1.2ms
1  0.9ms
2  1.1ms
3  0.8ms
4  4.3ms   ← 여기서부터 4ms 하한
5  4.1ms
6  4.2ms
7  4.0ms

setTimeout으로 루프를 돌리면서 "0ms니까 즉시"라고 가정하면 안 되는 이유다. 진짜로 다음 틱에 양보만 하고 싶다면 queueMicrotask(마이크로태스크) 또는 MessageChannel(4ms 하한이 없는 매크로태스크)을 쓴다.

// 4ms 하한 없이 매크로태스크로 양보
const channel = new MessageChannel();
const pending = [];
channel.port1.onmessage = () => pending.shift()?.();
 
function nextTask(fn) {
  pending.push(fn);
  channel.port2.postMessage(null);
}

React의 스케줄러가 오랫동안 이 기법을 썼다. 지금은 뒤에서 볼 scheduler.postTask가 표준으로 자리잡는 중이다.

긴 작업이 화면을 삼키는 이유

렌더링은 이벤트 루프의 한 단계다. 스택이 비고 마이크로태스크가 끝난 다음에만 기회가 생긴다. 60fps를 유지하려면 프레임 하나에 16.7ms인데, JS 작업 하나가 200ms를 잡아먹으면 그동안 프레임은 하나도 그려지지 않는다.

긴 작업을 한 번에 처리할 때와 잘라서 처리할 때의 프레임 비교

총 소요 시간은 잘랐을 때가 오히려 길다. 그런데도 잘라야 하는 이유는 사용자가 체감하는 것은 총 시간이 아니라 반응 없는 구간의 길이이기 때문이다. Google의 Core Web Vitals에서 INP(Interaction to Next Paint)가 응답성 지표로 채택된 것도 같은 맥락이다. INP는 상호작용부터 다음 페인트까지의 시간을 재는데, 200ms 이하가 "좋음" 기준이다.

자르는 방법

가장 정석은 scheduler.yield()다. Chrome 129부터 정식 지원한다.

async function processAll(items) {
  for (const [index, item] of items.entries()) {
    heavyWork(item);
 
    // 5ms마다 브라우저에 양보
    if (index % 100 === 0 && 'scheduler' in globalThis) {
      await scheduler.yield();
    }
  }
}

scheduler.yield()setTimeout(0)보다 나은 점은 양보한 작업이 큐 뒤로 밀리지 않는다는 것이다. setTimeout으로 양보하면 그 사이 들어온 다른 태스크들 뒤에 줄을 서게 되어, 잘게 자를수록 전체가 느려진다. scheduler.yield()는 우선순위를 유지한 채 앞자리로 돌아온다.

아직 지원하지 않는 브라우저를 함께 다뤄야 한다면 이렇게 감싼다.

function yieldToMain() {
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

정말 무거운 계산이라면 애초에 메인 스레드에서 빼는 것이 맞다. Web Worker는 별도 스레드에서 돌고 자체 이벤트 루프를 가진다. DOM에 접근할 수 없다는 제약이 있지만, 파싱·압축·암호화·이미지 처리처럼 순수 계산은 전부 넘길 수 있다.

Node.js는 다른 루프를 돈다

여기서부터가 실무에서 진짜로 사람을 잡는 부분이다. Node.js의 이벤트 루프는 libuv가 구현하고, 단계(phase)로 나뉜다.

브라우저의 이벤트 루프와 Node.js libuv 단계 비교

브라우저에는 없고 Node에만 있는 것이 둘 있다.

process.nextTick은 마이크로태스크보다도 먼저 처리되는 별도 큐다. 이름과 달리 "다음 틱"이 아니라 "지금 하던 것 직후"에 가깝다.

setImmediatecheck 단계 전용이다. 이름은 즉시처럼 보이지만 poll 단계를 지난 뒤에 돈다.

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

출력은 이렇다.

nextTick
promise
timeout
immediate

nextTick이 Promise보다 앞선다는 것, 그리고 setImmediatesetTimeout(0)보다 뒤라는 것이 브라우저 감각과 다르다.

순서가 보장되지 않는 경우

위 예제에서 timeoutimmediate의 순서는 사실 메인 모듈에서는 보장되지 않는다. 프로세스 시작 시점과 첫 루프 진입 사이의 시간이 1ms를 넘느냐에 따라 갈리기 때문이다. 같은 코드를 반복 실행하면 순서가 뒤바뀌는 것을 볼 수 있다.

반면 I/O 콜백 안에서는 항상 setImmediate가 먼저다. 이미 poll 단계에 있으므로 바로 다음이 check 단계이기 때문이다.

import { readFile } from 'node:fs';
 
readFile(import.meta.filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 항상: immediate → timeout

이런 순서에 의존하는 코드를 쓰면 안 된다는 것이 교훈이다. 순서가 중요하다면 명시적으로 체이닝하라.

실무에서 이 지식이 쓰이는 자리

긴 리스트 렌더링. 1만 개 항목을 한 번에 그리면 몇 백 ms가 날아간다. 가상 스크롤을 쓰거나, 최소한 청크로 나눠 scheduler.yield()로 양보한다.

이벤트 핸들러 안의 무거운 계산. 클릭 핸들러에서 바로 계산하면 INP가 그대로 나빠진다. 화면 갱신(로딩 표시)을 먼저 커밋하고, await yieldToMain() 뒤에 계산을 시작하면 사용자는 즉시 반응을 본다.

button.addEventListener('click', async () => {
  spinner.hidden = false;
  await yieldToMain(); // 여기서 로딩 표시가 실제로 그려진다
  const result = heavyCalculation();
  render(result);
  spinner.hidden = true;
});

이 한 줄이 없으면 spinner.hidden = false는 DOM에만 반영되고 화면에는 그려지지 않는다. 스택이 안 비었으니 렌더 기회가 없기 때문이다. 계산이 끝난 뒤에야 그려지는데, 그때는 이미 숨길 시점이라 스피너가 아예 보이지 않는다. "로딩 표시를 넣었는데 안 보인다"는 버그의 정체가 대개 이것이다.

서버의 요청 처리. Node에서 CPU 바운드 작업을 동기로 처리하면 그 요청뿐 아니라 모든 요청이 막힌다. 이벤트 루프가 하나이기 때문이다. worker_threads로 넘기거나, 별도 프로세스로 분리한다. perf_hooksmonitorEventLoopDelay로 루프 지연을 실제로 측정할 수 있다.

import { monitorEventLoopDelay } from 'node:perf_hooks';
 
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
 
setInterval(() => {
  console.log('p99 지연', (histogram.percentile(99) / 1e6).toFixed(1), 'ms');
  histogram.reset();
}, 10_000);

p99가 수십 ms를 넘어가면 무언가가 루프를 붙잡고 있다는 신호다.

정리

  • 이벤트 루프는 언어가 아니라 호스트 환경의 것이다. 브라우저(HTML 명세)와 Node(libuv)는 다르다.
  • 마이크로태스크는 큐를 비우고, 매크로태스크는 하나만 꺼낸다. 실행 중 추가된 마이크로태스크도 같은 바퀴에서 처리된다.
  • await는 값이 준비돼 있어도 한 틱을 양보한다. 그래서 순차 await는 병렬 기회를 날린다.
  • setTimeout(fn, 0)은 0ms가 아니다. 중첩 5회부터 4ms 하한이 걸린다.
  • 렌더링은 스택이 비고 마이크로태스크가 끝난 뒤에만 기회가 생긴다. 긴 작업은 잘라서 양보해야 화면이 산다.
  • Node에서 process.nextTick은 Promise보다 먼저, setImmediatesetTimeout(0)보다 (I/O 안에서는) 먼저다.

이 구조를 알고 나면 "왜 이 순서로 찍히지?"라는 질문이 대부분 스스로 풀린다. 남은 것은 실제로 측정해 보는 일뿐이다. 크롬 개발자 도구의 Performance 패널에서 긴 작업은 빨간 삼각형으로 표시된다. 한 번 열어서 자기 앱의 메인 스레드가 어떻게 생겼는지 보는 것을 권한다.

참고 자료

태그

댓글 0

첫 댓글을 남겨보세요.