개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

이벤트 루프 — 싱글 스레드가 동시성을 흉내내는 방법

2025년 9월 1일
JavaScript이벤트 루프콜스택마이크로태스크태스크큐

JavaScript는 싱글 스레드로 한 번에 하나의 코드만 실행합니다. 그런데 타이머와 네트워크 요청과 클릭 이벤트는 마치 동시에 처리되는 것처럼 동작합니다. 이 모든 순서를 뒤에서 조율하는 것이 이벤트 루프입니다.

구성 요소

이벤트 루프를 이해하려면 먼저 주변 구성 요소를 파악해야 합니다.

Call Stack

함수 호출이 쌓이는 스택입니다. 함수가 호출되면 프레임이 push되고, 반환되면 pop됩니다. JavaScript 엔진은 이 스택을 위에서부터 하나씩 처리합니다. 스택이 비어 있어야 이벤트 루프가 다음 작업을 가져올 수 있습니다.

push: 스택 맨 위에 항목을 추가하는 동작.

pop: 스택 맨 위에서 항목을 꺼내는 동작.

Heap

객체가 메모리에 할당되는 공간입니다. 스택과 달리 구조화되어 있지 않으며, 더 이상 참조되지 않는 객체는 가비지 컬렉션이 자동으로 회수합니다.

가비지 컬렉션(Garbage Collection): 더 이상 참조되지 않는 메모리를 엔진이 자동으로 탐지해 회수하는 과정.

Web APIs

브라우저가 제공하는 비동기 기능들입니다. setTimeout, fetch, DOM 이벤트 리스너 등이 여기에 해당합니다. JavaScript 엔진 자체에는 없고, 브라우저가 스레드에서 처리합니다. Node.js는 대신 libuv로 이 역할을 처리하며, 자세한 차이는 아래 "Node.js와 브라우저의 차이"에서 다룹니다.

스레드(Thread): 프로그램이 실행되는 흐름의 단위. JavaScript 엔진은 스레드가 하나뿐이지만, 브라우저는 여러 스레드를 두고 타이머·네트워크 요청 같은 작업을 병렬로 처리합니다.

Microtask Queue

Promise.then, Promise.catch, queueMicrotask로 등록한 콜백이 들어오는 큐입니다. 세 함수 모두 각자의 콜백을 만들어 같은 이 큐에 밀어 넣습니다. 높은 우선순위를 가지며, Call Stack이 비워질 때마다 이 큐를 완전히 소진합니다.

Macrotask Queue

setTimeout, setInterval, I/O 콜백이 들어오는 큐입니다. Microtask Queue가 모두 비워진 이후에야 하나씩 꺼내집니다.

전체 구조

┌─────────────────────────────────────────┐
│             JavaScript Engine            │
│                                         │
│   ┌─────────────┐   ┌───────────────┐   │
│   │  Call Stack │   │     Heap      │   │
│   │             │   │   (memory)    │   │
│   │  fn3()      │   │               │   │
│   │  fn2()      │   │  { obj: ... } │   │
│   │  fn1()      │   │               │   │
│   └──────┬──────┘   └───────────────┘   │
└──────────┼──────────────────────────────┘
           │ when empty
           │
┌──────────▼──────────────────────┐
│           Event Loop             │
│                                  │
│ 1. Call Stack empty?             │
│ 2. drain Microtask Queue         │
│ 3. pop one from Macrotask Queue  │
│ 4. (browser) render              │
│ 5. repeat                        │
└───────┬──────────────────┬───────┘
        │                  │
┌──────────────┐  ┌─────────────────┐
│ Microtask Q  │  │ Macrotask Queue │
│              │  │                 │
│ Promise.then │  │ setTimeout      │
│ queueMicro.. │  │ setInterval     │
│              │  │ I/O callbacks   │
└──────────────┘  └─────────────────┘
                           ▲
                 ┌───────────────────┐
                 │     Web APIs      │
                 │ timer, fetch, DOM │
                 └───────────────────┘

이벤트 루프 동작 순서

이벤트 루프는 아래 순서를 반복합니다.

  1. Call Stack 확인 — 스택이 비어 있는지 확인합니다.
  2. Microtask Queue 전부 소진 — 큐에 있는 모든 마이크로태스크를 실행합니다. 실행 도중 새 마이크로태스크가 추가되어도 모두 처리합니다.
  3. Macrotask Queue에서 하나 꺼내 실행 — 매크로태스크 하나만 꺼내서 Call Stack에 올립니다.
  4. 렌더링 (브라우저 한정) — 필요하다면 화면을 다시 그립니다.
  5. 1번으로 돌아가 반복

Microtask vs Macrotask

종류예시실행 시점
MicrotaskPromise.then, Promise.catch, queueMicrotaskCall Stack이 빌 때마다, 매번 전부
MacrotasksetTimeout, setInterval, setImmediate, I/OMicrotask 소진 후, 한 번에 하나

핵심 차이는 "얼마나 빨리, 몇 개나"입니다. 마이크로태스크는 현재 사이클에서 전부 처리되고, 매크로태스크는 다음 사이클에서 하나씩 처리됩니다.

실행 순서 예측

아래 코드의 출력 순서를 예측해 보십시오.

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
}).then(() => {
  console.log('4');
});

console.log('5');

실행 순서: 1 → 5 → 3 → 4 → 2

  • 1, 5: 동기 코드이므로 Call Stack에서 즉시 실행됩니다.
  • setTimeout 콜백: Web API가 0ms 뒤 Macrotask Queue에 넣습니다.
  • Promise.then 콜백: Microtask Queue에 들어갑니다.
  • 동기 코드가 끝나면 Call Stack이 빕니다.
  • Microtask Queue를 소진합니다 → 3 출력, 이어서 .then 체인으로 4 출력.
  • 모든 마이크로태스크가 끝난 뒤 Macrotask Queue에서 setTimeout 콜백을 꺼냅니다 → 2 출력.

렌더링과의 관계

브라우저의 렌더링(레이아웃 계산, 페인트)은 매크로태스크 사이에 끼어듭니다. 즉, 이벤트 루프 한 사이클이 끝날 때마다 렌더링 기회가 생깁니다.

// 이 코드는 1초 동안 화면을 완전히 멈춥니다
const start = Date.now();
while (Date.now() - start < 1000) {
  // Call Stack을 1초간 점유
}

Call Stack이 점유되는 동안 이벤트 루프는 아무것도 처리할 수 없습니다. 렌더링도, 클릭 이벤트도 모두 대기합니다. 긴 계산이 필요하다면 setTimeout이나 requestAnimationFrame으로 작업을 나누어 실행해야 합니다.

setTimeout(fn, 0)이 즉시 실행되지 않는 이유

setTimeout(fn, 0)은 "지금 당장"이 아니라 "Macrotask Queue에 넣어라"는 의미입니다.

console.log('before');
setTimeout(() => console.log('timeout'), 0);
console.log('after');

// 출력: before → after → timeout

0ms라도 Web API를 거쳐 Macrotask Queue에 들어가야 합니다. 현재 Call Stack의 동기 코드와 그 뒤에 쌓인 모든 마이크로태스크가 끝나야 비로소 실행됩니다. "즉시 실행"처럼 보이지만 실제로는 현재 사이클이 완전히 끝난 다음입니다.

Node.js와 브라우저의 차이

브라우저와 Node.js 모두 이벤트 루프를 사용하지만 구현이 다릅니다.

브라우저는 HTML 명세를 따르며, 렌더링 파이프라인이 이벤트 루프에 통합되어 있습니다. Node.js는 libuv라는 C 라이브러리로 이벤트 루프를 구현하며, 파일 시스템·네트워크 I/O를 처리합니다.

libuv: Node.js의 비동기 I/O를 담당하는 C 라이브러리. 이벤트 루프, 스레드 풀, 운영체제별 비동기 API를 추상화합니다.

Node.js에는 process.nextTick이라는 추가 큐가 있습니다. 이것은 Microtask Queue보다도 먼저 처리되어, 현재 작업이 끝나는 즉시 실행됩니다.

// Node.js에서의 우선순위
process.nextTick(() => console.log('nextTick'));   // 1순위
Promise.resolve().then(() => console.log('then')); // 2순위
setTimeout(() => console.log('timeout'), 0);       // 3순위

요약

개념핵심
이벤트 루프Call Stack이 빌 때마다 큐에서 작업을 꺼내 실행하는 반복 메커니즘
Call Stack함수 호출이 쌓이고 제거되는 구조, 비어야 다음 작업을 가져올 수 있음
Web APIs브라우저가 스레드에서 처리하는 비동기 기능 (setTimeout, fetch 등). Node.js는 libuv가 대신 처리
Microtask QueuePromise.then 등이 들어가는 큐, Call Stack이 빌 때마다 전부 소진
Macrotask QueuesetTimeout 등이 들어가는 큐, Microtask 소진 후 하나씩 처리
process.nextTickNode.js 전용 큐, Microtask보다 먼저 처리됨

JavaScript의 동시성은 실제로 동시에 실행되는 것이 아니라, 이벤트 루프가 작업의 순서를 정교하게 조율하는 것입니다. Call Stack이 빌 때마다 마이크로태스크를 전부 처리하고, 그다음 매크로태스크 하나를 실행하는 이 단순한 규칙이 타이머·Promise·이벤트의 실행 순서를 결정합니다.

← 이전 글모듈 — 코드를 나누고 연결하는 방법
다음 글 →Promise — 미래의 값을 다루는 방법