본문으로 건너뛰기
김신건의 로그

[Javascript] 이벤트 루프

· 수정 · 📖 약 3분 · 1,404자/단어 #javascript #async #runtime #browser #nodejs
setTimeout Macrotask, settimeout-macrotask, 이벤트 루프, event loop, event-loop, 이벤트루프, JavaScript 이벤트 루프, JS 이벤트 루프, JavaScript event loop

정의

이벤트 루프 (Event Loop) 는 싱글 스레드 JavaScript 엔진이 비동기 작업을 처리하기 위해 돌리는 무한 루프다. 콜 스택이 비어있을 때마다 마이크로태스크 큐와 매크로태스크 큐를 정해진 규칙으로 비워가며 실행한다.

ECMAScript 명세 (§9.5 Jobs) 는 “Job 큐” 로, WHATWG HTML 명세 (§8.1 Event loop) 는 “task queue” 와 “microtask queue” 로 정의한다.

자세한 흐름은 비동기와 타이밍, 콜백부터 async/await까지의 발전사 글 참조.

핵심 특성

측면동작
실행 모델싱글 스레드, 한 번에 한 줄
블로킹 처리호스트 (브라우저 / Node.js) 가 백그라운드 스레드로
결과 전달콜백을 두 큐 중 하나에 넣음
우선순위마이크로태스크 > 렌더링 > 매크로태스크
실행 단위”한 틱” 동안 매크로 1개 + 마이크로 전부 + 렌더링

네 가지 구성요소

1. Call Stack (콜 스택)

지금 실행 중인 함수들의 LIFO 스택. 함수 호출 시 push, 반환 시 pop. 가장 늦게 들어온 것이 가장 먼저 나간다.

function main() { a(); }
function a()    { b(); }
function b()    { console.log('hi'); }
main();
// 콜 스택 변화:
// [main] → [main, a] → [main, a, b] → [main, a, b, log]
//        → [main, a, b] → [main, a] → [main] → []

핵심 규칙: 콜 스택이 비어있어야만 이벤트 루프가 다음 작업을 큐에서 꺼낼 수 있다. 무거운 동기 작업이 콜 스택을 점유하면 비동기 콜백도, 사용자 이벤트도 모두 막힌다.

각 스택 프레임은 함수의 Execution Context 를 담는다, 지역 변수, this, 외부 Lexical Environment 참조 등.

2. Web API / Node.js API

호스트 환경이 제공하는 백그라운드 처리. JS 엔진과 별도 스레드 다. 엔진은 싱글 스레드지만 호스트는 멀티 스레드.

호스트제공 API
브라우저setTimeout/setInterval, fetch/XHR, DOM 이벤트, IndexedDB, Geolocation, WebSocket …
Node.jsfs, net, crypto, setImmediate, process.nextTick

JS 코드가 setTimeout(cb, 1000) 을 호출하면:

  1. 엔진은 콜백을 호스트에 위임 한 뒤 즉시 다음 줄로 진행
  2. 호스트의 별도 스레드가 1초를 카운트다운
  3. 만료되면 콜백을 태스크 큐 에 등록 (엔진과의 소통 창구)
  4. 엔진은 콜 스택이 비면 큐에서 콜백을 꺼낸다

이게 싱글 스레드 JS 가 “동시에 여러 일” 을 처리할 수 있는 이유다, 호스트가 멀티 스레드로 백그라운드 처리 를 해주기 때문.

3. Task Queue (매크로태스크 큐)

WHATWG HTML 명세상 “task queue”. FIFO, 한 틱에 한 개 만 꺼낸다.

들어오는 콜백 (출처별):

출처예시
타이머setTimeout, setInterval 콜백
DOM 이벤트click, input, scroll, keydown 핸들러
네트워크fetch 응답 도착 시점의 콜백
Node.js 전용setImmediate
메시지MessageChannel, postMessage

핵심 규칙: 한 틱에 정확히 한 개 만 꺼낸다. 꺼낸 작업은 콜 스택에서 끝까지 실행되고, 끝난 후 마이크로태스크 큐가 전부 비워진 뒤에야 다음 매크로태스크가 시작된다.

그래서 setTimeout 콜백들끼리는 항상 한 박자씩 떨어져 있고, 그 사이 사이에 Promise.then 들이 끼어든다.

4. Microtask Queue (마이크로태스크 큐)

ECMAScript “PromiseJobs” + WHATWG “microtask queue”. FIFO, 한 틱에 전부 비울 때까지 처리한다.

들어오는 콜백 (출처별):

출처예시
Promise.then, .catch, .finally 의 콜백
async/awaitawait 이후의 함수 재개
직접 등록queueMicrotask(fn)
DOM 변경 관찰MutationObserver 콜백
Node.js 전용(그보다 앞 우선순위: process.nextTick)

핵심 규칙: 큐가 비어있을 때까지 모두 실행. 처리 중에 새로운 마이크로태스크가 등록되어도 같은 틱 안에서 처리된다.

그래서 Promise.then 으로 자기 자신을 무한 등록하면 (function loop() { Promise.resolve().then(loop) }) 큐가 영원히 빌 일이 없어 페이지가 freeze 된다. setTimeout 으로 같은 일을 하면 매 틱마다 한 개씩만 처리되어 렌더링과 이벤트가 정상 동작한다.

IMPORTANT

“마이크로 > 매크로” 의 본질은 큐 처리량의 차이. 매크로는 한 개씩, 마이크로는 전부. 그래서 같은 시점에 등록해도 마이크로가 항상 먼저 끝난다.

한 틱의 알고리즘

while (true) {
  // 1. 매크로태스크 큐에서 한 개 꺼냄
  task = taskQueue.shift();
  if (task) execute(task);

  // 2. 마이크로태스크 큐를 *완전히* 비울 때까지
  while (microtaskQueue.length > 0) {
    microtask = microtaskQueue.shift();
    execute(microtask);
    // 이 microtask 안에서 새 microtask 가 등록되어도 이 루프 안에서 처리됨
  }

  // 3. 필요하면 렌더링 (브라우저)
  if (shouldRender()) {
    runAnimationFrameCallbacks();
    style → layout → paint;
  }
}

우선순위 실험

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
queueMicrotask(() => console.log('D'));
console.log('E');

// 출력: A, E, C, D, B

이유:

  • A, E: 동기, 콜 스택에서 즉시
  • C, D: 마이크로태스크, 동기 끝나자마자
  • B: 매크로태스크, 다음 틱

함정과 한계

마이크로태스크 무한 등록

마이크로태스크 큐는 “완전히 비울 때까지” 처리하므로, 안에서 또 마이크로태스크를 등록하면 렌더링과 매크로태스크가 영원히 못 돈다.

function loop() { Promise.resolve().then(loop); }
loop(); // 페이지 freeze

setTimeout 의 최소 4ms

5회 이상 중첩된 setTimeout 은 0ms 를 요청해도 최소 4ms 지연. 모든 주요 브라우저가 명세 준수.

Node.js 의 특수 단계

Node.js 의 libuv 기반 이벤트 루프는 더 세분화되어 있다.

단계처리 대상
timerssetTimeout, setInterval 만료된 콜백
pending callbacks일부 시스템 작업의 콜백
idle, prepare내부용
pollI/O 콜백, 새 이벤트 대기
checksetImmediate 콜백
close callbackssocket.on('close', ...)

각 단계 사이마다 마이크로태스크 큐가 비워지고, process.nextTick 은 더 앞 우선순위.

실무 예시

무거운 작업 잘게 쪼개기

function processInChunks(items, processor) {
  let i = 0;
  function next() {
    const start = Date.now();
    while (i < items.length && Date.now() - start < 50) {
      processor(items[i++]);
    }
    if (i < items.length) setTimeout(next, 0); // UI 에 양보
  }
  next();
}

다음 마이크로태스크에 미루기

function notify(observer, data) {
  queueMicrotask(() => observer.update(data));
  // 호출자의 동기 코드가 완전히 끝난 직후, 다음 매크로 전에
}

참고

이 글의 용어 (4개)
[Javascript] async/awaitjavascript
정의 / 는 기반 비동기 코드를 마치 동기 코드처럼 쓸 수 있게 해주는 ES2017 의 문법 설탕. 본질은 Promise 그 자체, 문법만 다르다. 전체 동작 메커니즘은 글 참조…
[Javascript] Lexical Environmentjavascript
정의 Lexical Environment (어휘적 환경) 는 ECMAScript 명세 (§9.1) 가 정의한 내부 객체. 함수가 호출될 때마다 새로 만들어지며, 이 호출의 변수들…
[Javascript] Microtask Queuejavascript
정의 Microtask Queue (마이크로태스크 큐) 는 JavaScript 가 매 틱마다 완전히 비울 때까지 처리하는 우선순위 큐다. 매크로태스크 큐보다 항상 먼저 비워진다.…
[Javascript] Promisejavascript
정의 Promise 는 미래의 어떤 시점에 결정될 값을 나타내는 객체다. ECMAScript 2015 (ES6) 에 도입되어 기반 비동기의 가독성과 에러 처리 문제를 해결했다. …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기