C
TypeScript/비동기/Lesson 02

이벤트 루프 — Microtask vs Macrotask, 타입으로 큐를 표현하기

30분·theory
이 챕터
2/7
TypeScript

이벤트 루프 — Microtask vs Macrotask, 타입으로 큐를 표현하기

💡 왜 배워야 할까요? — 큐가 보이면 디버깅이 보인다

🎯 Promise 가 setTimeout 보다 먼저 실행되는 이유 = microtask queue 가 macrotask queue 보다 우선. 면접 단골 질문.
💼 TS 는 큐를 바꾸지 않지만, 큐에 들어가는 함수의 시그니처를 강제할 수 있습니다. 잘못된 콜백을 큐에 넣는 실수를 컴파일 타임에 차단합니다.
`queueMicrotask`·`setImmediate`·`requestAnimationFrame` 의 콜백 시그니처를 TS 가 정확히 알고 있어, 잘못된 호출 패턴을 막아줍니다.
🔗 비동기 코드의 순서 버그(레이스 컨디션)는 타입 시스템이 잡아주지 못합니다 — 하지만 큐 모델을 이해해야 그런 버그를 디버깅할 수 있습니다.
🏢 실무에서는
React 의 `useEffect`, Next.js 의 `useTransition`, Vue 의 `nextTick` — 모두 microtask·macrotask 큐 위에 올라가 있습니다. `setState` 직후 DOM 을 읽으면 옛 값이 나오는 이유, `await` 한 줄 사이에 props 가 바뀔 수 있는 이유 — 전부 이벤트 루프 모델로 설명됩니다.

Call Stack → Web API → Queue → Loop

1. 이벤트 루프의 4 구성 요소

구성역할
Call Stack지금 실행 중인 함수의 스택
Web API / Node APIsetTimeout·fetch·fs.readFile 같은 호스트가 제공하는 비동기 작업소
Macrotask QueuesetTimeout·setInterval·setImmediate·IO 콜백이 대기하는 줄
Microtask QueuePromise.then·queueMicrotask·MutationObserver 가 대기하는 줄

2. Loop 의 한 사이클

1. Call Stack 이 비면 Microtask Queue 를 전부 비웁니다.
2. 그 다음 Macrotask Queue 에서 1개만 꺼냅니다.
3. 다시 Microtask Queue 를 비웁니다.
4. 반복.

ts
console.log('A');                                  // 1
setTimeout(() => console.log('B'), 0);             // macrotask
Promise.resolve().then(() => console.log('C'));    // microtask
console.log('D');                                  // 2
// 출력: A → D → C → B  (동기 → microtask → macrotask)

3. TS 가 보호해주는 부분

ts
// queueMicrotask 의 콜백 시그니처는 () => void
queueMicrotask(() => console.log('hi'));   // ✅
// queueMicrotask((x: number) => console.log(x)); // ❌ TS 가 막음 — 인자 없는 콜백만

// setTimeout 의 콜백은 가변 인자도 받을 수 있음 (TS 가 알고 있음)
setTimeout((name: string) => console.log(name), 1000, '홍길동');
//                                                     ^^^^^^^ 콜백의 첫 인자로 전달

4. async/await 가 큐와 만나는 지점

ts
async function f() {
  console.log('1');
  await Promise.resolve(); // 여기서 함수가 일시정지
  console.log('2');        // 이 줄은 microtask 큐로 들어감
}
f();
console.log('3');
// 출력: 1 → 3 → 2
// await 이후 코드는 항상 microtask 로 후속 처리됨
💻 🅰️ JS 방식 — 큐 모델 시뮬레이션 (타입 없음)
// ❌ JS — 큐를 표현해도 타입이 빈약

const microtasks = []; // 무엇이 들어가는지 모름
const macrotasks = [];

function queueMicro(fn) { microtasks.push(fn); } // fn 의 시그니처 ?
function queueMacro(fn) { macrotasks.push(fn); }

function runLoop() {
  // microtask 전부 비우기
  while (microtasks.length) microtasks.shift()();
  // macrotask 1개
  if (macrotasks.length) macrotasks.shift()();
}

queueMacro(() => console.log('B: macrotask'));
queueMicro(() => console.log('A: microtask'));
console.log('start');
runLoop();
runLoop();
// 출력: start → A → B
💻 🅱️ TS 방식 — 큐의 타입을 명시한다
// ✅ TS — 큐를 타입으로 표현

type Task = () => void;

const microtasks: Task[] = [];
const macrotasks: Task[] = [];

function queueMicro(fn: Task): void { microtasks.push(fn); }
function queueMacro(fn: Task): void { macrotasks.push(fn); }

function runLoop(): void {
  while (microtasks.length) {
    const task = microtasks.shift();
    task?.(); // shift 결과는 Task | undefined — TS 가 강제로 좁히게 함
  }
  const next = macrotasks.shift();
  next?.();
}

queueMacro(() => console.log('B: macrotask'));
queueMicro(() => console.log('A: microtask'));
// queueMicro('not a function'); // ❌ TS 가 즉시 거부 — Task 가 아님
// queueMicro((x: number) => x); // ❌ Task 시그니처 () => void 와 다름

console.log('start');
runLoop();
runLoop();

// 실제 브라우저 API 도 같은 원리
queueMicrotask(() => console.log('진짜 microtask'));
setTimeout((name: string) => console.log('macrotask:', name), 0, '홍길동');
//          ^^^^^^^^^^^^^ TS 가 setTimeout 의 가변 인자 시그니처를 알고 있음

💡 💡 이벤트 루프 면접 단골 + TS 가 잡아주는 실수

1. Promise 가 setTimeout(0) 보다 먼저 실행되는 이유
microtask 큐 우선. 한 사이클 안에서 microtask 가 전부 비워진 다음에야 macrotask 1개를 처리.

2. await 이후의 코드는 항상 microtask 로

ts
async function f() {
  console.log('A');
  await Promise.resolve(); // 여기서 함수 일시정지
  console.log('B');        // microtask 로 후속
}

3. queueMicrotask 콜백은 인자가 없다 — TS 가 강제

ts
queueMicrotask(() => console.log('ok'));        // ✅
// queueMicrotask((x: number) => console.log(x)); // ❌

4. setTimeout 의 가변 인자는 TS 가 알고 있다

ts
setTimeout((a: number, b: number) => console.log(a + b), 0, 1, 2);
// 콜백 인자(a, b) ↔ setTimeout 의 3·4번째 인자(1, 2) 매핑을 TS 가 검증

5. 무한 microtask 는 메인 스레드를 멈춘다

ts
function infinite() { Promise.resolve().then(infinite); } // 🚨 브라우저 멈춤

TS 가 막아주지 못함 — 코드 리뷰로 차단.

⚡ 직접 실행해보기 — 이벤트 루프 순서

동기 → microtask → macrotask 순서를 직접 확인합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

다음 코드의 출력 순서는? ```ts console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D'); ```
💡 동기 코드(A, D) 가 먼저 → Call Stack 이 비면 microtask 큐(C) 비우기 → 그 다음 macrotask(B) 1개. 그래서 **A → D → C → B**. microtask 가 macrotask 보다 먼저인 게 핵심.