DEV
JS7 min

렌더링을 멈춰 세운 특권: 마이크로태스크와 이벤트 루프 실행 순서

자바스크립트에서 비동기(Asynchronous)를 이해한다는 건, 결국 '메인 스레드'라는 한정된 자원을 두고 벌어지는 치열한 스케줄링 싸움을 이해하는 것이다.

흔히 JS를 싱글 스레드라 부르지만, 진짜 문제는 그 하나뿐인 스레드를 자바스크립트 혼자 독점하는 게 아니라는 점이다. 우리가 짠 코드가 돌아가는 바로 그 순간에도 브라우저는 쉴 새 없이 사용자 입력을 받아내고, 스타일을 재계산(Style)하며, 요소의 위치를 다시 잡고(Layout), 화면에 픽셀을 찍어내야(Paint) 한다.

특히 60Hz를 넘어 120Hz, 144Hz 같은 고주사율 디스플레이가 대중화되면서, 브라우저가 한 프레임을 준비할 수 있는 시간은 비약적으로 짧아졌다.

JavaScript 실행뿐만 아니라 화면을 만들기 위한 여러 작업이 이 짧은 시간 안에서 치열하게 경쟁한다. 그렇다면 이런 가혹한 환경에서, 브라우저는 대체 어떻게 JavaScript와 렌더링 사이의 실행 순서를 결정할까? 그 중심에는 이벤트 루프(Event Loop)와 마이크로태스크(Microtask)가 있다.


1. 현상: Promise는 왜 setTimeout보다 먼저 실행될까?

먼저 우리에게 익숙한 코드부터 살펴보자.

javascript

console.log('1. Call Stack');

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

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

실행 결과는 다음과 같다.

text

1. Call Stack
3. Microtask
2. Task

처음 이 결과를 마주하면 직관과 꽤 어긋난다. setTimeout(..., 0)은 말 그대로 '0ms 뒤에 실행하라'고 명시했는데도 Promise의 then()이 먼저 실행되기 때문이다.

이 현상을 "Promise가 더 빠르다"라고 이해하면 곤란하다. 더 정확한 이유는 이벤트 루프의 실행 모델에 있다. setTimeout()은 지정된 시간이 지난 뒤 콜백을 Task로 예약한다. 반면, 위 코드의 Promise.resolve()처럼 이미 완료된(Resolved) Promise의 .then() 후속 작업은 Microtask로 예약된다.

현재 실행 중인 Task(콜 스택)가 끝나면, 이벤트 루프는 다음 Task를 꺼내기 전에 Microtask Checkpoint라는 특별한 단계를 거치며 대기 중인 Microtask를 먼저 처리한다. 이것이 Promise가 먼저 실행되는 진짜 이유다.

2. Event Loop에는 거대한 하나의 대기실만 있는 게 아니다

이벤트 루프를 설명할 때 흔히 Call Stack, Task Queue, Microtask Queue 세 가지 공간을 이야기한다. 이 중 Queue들의 특성을 조금 더 엄밀하게 짚고 넘어가자.

Microtask Queue

  • Promise.then(), catch(), finally()
  • queueMicrotask()
  • MutationObserver

현재 Task 실행이 끝난 뒤, 이벤트 루프는 Microtask Checkpoint를 수행한다. 이때 아주 치명적인 규칙이 하나 있다. Microtask를 처리하는 도중 새로운 Microtask가 추가되면, 그 작업 역시 같은 Checkpoint 안에서 처리된다는 것이다. 즉, Microtask Queue는 완전히 비워질 때까지 브라우저의 제어권을 놓아주지 않는다.

Task Queue (Macrotask)

setTimeout의 콜백이나 마우스 클릭 같은 이벤트 핸들러 등은 Task를 생성한다. 흔히 브라우저에 단 하나의 거대한 Task Queue가 있다고 상상하기 쉽지만, 실제 HTML Standard 스펙은 그렇지 않다.

이벤트 루프는 Task Source(타이머, 사용자 입력 이벤트 등)에 따라 하나 이상의 Task Queue를 가질 수 있다. 그리고 브라우저(User Agent)는 자신의 스케줄링 정책에 따라 어떤 큐에서 Task를 꺼내올지 적절히 선택한다.

3. Rendering Opportunity와 프레임의 진실

흔히 이벤트 루프의 흐름을

  1. Task
  2. Microtask
  3. Rendering
  4. 다음 Task

라고 단순화해서 배운다. 개념적 이해를 돕기엔 좋지만, 실제 스펙과는 거리가 있다.

정확히는 Microtask Checkpoint가 끝난 뒤, 브라우저가 현재 프레임을 갱신할 필요가 있는지(Rendering Opportunity)를 판단한다. 만약 렌더링이 필요하다면 아래의 과정을 거친다.

  1. requestAnimationFrame (rAF): 렌더링 파이프라인이 시작되기 직전에 실행되는 API. DOM 요소의 크기나 위치를 읽고 쓰기에 가장 적절한 타이밍이다.
  2. Style: 어떤 CSS 규칙이 적용될지 계산한다.
  3. Layout: 요소들이 화면 어디에, 얼마나 크게 위치할지 계산한다.
  4. Paint: 픽셀을 채워 넣는다.
  5. Composite: 나누어진 레이어들을 합성한다.

물론 매 프레임마다 이 모든 과정이 일어나는 것은 아니다. 레이아웃 변경이 없으면 Layout 단계가 생략되고, Raster/Composite 등 일부 작업은 메인 스레드가 아닌 GPU나 Compositor 스레드에서 병렬로 처리되기도 한다.

중요한 건, 이 모든 렌더링 파이프라인의 시작점 앞을 Microtask Checkpoint가 틀어막고 있다는 사실이다.

4. Frame Budget과 Long Task는 다르다

고주사율 시대가 열리면서 프레임 예산(Frame Budget)은 극도로 짧아졌다.

  • 60Hz → 약 16.7ms
  • 120Hz → 약 8.3ms

웹 성능 지표에서 Long Task는 공식적으로 '50ms 이상 메인 스레드를 점유하는 작업'을 뜻한다. 50ms가 넘어가면 사용자가 UI의 버벅거림을 체감하기 때문이다.

하지만 120Hz 디스플레이(8.3ms) 환경에서는 이야기가 조금 다르다. 10~20ms 정도의 작업은 공식적인 'Long Task' 기준에는 미치지 못하지만, 여전히 8.3ms라는 Frame Budget을 훌쩍 넘겨버린다. 결과적으로 프레임 드롭(Frame Drop)을 유발해 미세한 애니메이션 끊김을 만들어낸다.

즉, 긴 작업을 처리할 때는 공식적인 Long Task(50ms)를 피하는 것을 넘어, 120Hz 시대의 Frame Budget(8.3ms)까지 고려한 섬세한 스케줄링이 필요해졌다.

5. Chunking과 Yielding: 메인 스레드 양보하기

무거운 연산을 어떻게 처리해야 할까? 수만 개의 아이템을 순회하는 작업을 하나의 Task 안에서 다 돌리면 메인 스레드는 그대로 뻗어버린다. 정답은 작업을 여러 개의 Task로 잘게 쪼개는 것(Chunking)이다.

최근에는 브라우저에게 의도적으로 제어권을 양보하는 scheduler.yield() API가 등장했다.

javascript

async function yieldToMain() {
  // 2026년 기준, 미지원 브라우저를 위한 fallback
  if (globalThis.scheduler?.yield) {
    await scheduler.yield();
  } else {
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

async function processItems(items) {
  let start = performance.now();
  const deadline = 5; // 5ms마다 브라우저에게 렌더링/입력 기회 양보

  for (const item of items) {
    doHeavyWork(item);

    // 너무 잦은 양보는 오버헤드를 만드므로, 시간 기반으로 끊어준다.
    if (performance.now() - start > deadline) {
      await yieldToMain();
      start = performance.now(); // 타이머 초기화
    }
  }
}

여기서 흥미로운 점이 있다. scheduler.yield()는 Promise를 반환하는데 왜 Microtask처럼 렌더링을 막지 않을까?

만약 우리가 await Promise.resolve()를 써서 양보하려 했다면, 그 후속 작업은 Microtask로 예약되어 Checkpoint를 빠져나가지 못하고 메인 스레드를 계속 점유(Microtask Starvation)했을 것이다. 반면 scheduler.yield()는 명시적으로 Task 경계(Boundary)를 만들어 브라우저에게 제어권을 확실히 넘긴다. 게다가 단순한 setTimeout(0)과 달리, 이벤트 루프 안에서 '기존 작업의 연속성(Continuation)'을 높은 우선순위로 취급받도록 설계되어 있어 훨씬 효율적이다.

6. React Scheduler의 핵심은 MessageChannel이 아니다

이 흐름은 React의 Concurrent Rendering(동시성 렌더링)과도 맞닿아 있다.

React 18부터 도입된 Concurrent Rendering의 본질은 병렬 처리가 아니라 렌더링을 '중단 가능하게(Interruptible)' 만든 협력적 스케줄링(Cooperative Scheduling)이다.

React는 렌더링 작업을 잘게 쪼개고 일정 시간 수행한 뒤, 브라우저에게 제어권을 돌려줄지 끊임없이 확인한다. 이때 브라우저에 제어권을 넘기기 위해 내부적으로 MessageChannel 같은 Task 기반 메커니즘을 사용해 왔다.

핵심은 "React가 MessageChannel을 쓴다"라는 구현 세부 사항이 아니다. 앞서 살펴봤듯 Promise 같은 Microtask를 쓰면 브라우저가 끼어들 틈을 주지 못하기 때문에, 명확한 Task 경계를 만들어 사용자 입력이나 렌더링 기회를 확보하려 했다는 점이 중요하다.

마치며: 언제 멈출 것인가?

지금까지 살펴본 도구들의 역할을 한 줄로 요약하면 다음과 같다.

  • Microtask (Promise.then): "다음 차례로 넘어가기 전에, 이것만 빨리 마저 끝낼게." (논리적 후속 작업)
  • Task (setTimeout): "일단 제어권은 돌려줄 테니, 다음 차례에 틈 나면 실행해 줘."
  • requestAnimationFrame: "다음 화면을 그리기 직전에 이것부터 화면에 반영해 줘."
  • Web Worker: "메인 스레드에 두기엔 너무 무거우니, 아예 다른 스레드에서 혼자 처리할게."

120Hz 시대의 프론트엔드 환경에서 성능 최적화란 단순히 "어떻게 코드를 더 빨리 실행할까?"를 쥐어짜는 것이 아니다.

더 중요한 질문은 따로 있다.

"지금 실행 중인 코드가 끝난 뒤, 브라우저는 언제 다시 제어권을 되찾는가? (언제 멈출 것인가?)"

이 질문의 의미를 깨닫는 순간, 이벤트 루프, Frame Budget, 렌더링 파이프라인, scheduler.yield(), 그리고 React Concurrent Rendering에 이르기까지 흩어져 있던 파편들이 '메인 스레드의 제어권'이라는 하나의 줄기로 꿰어지는 것을 느낄 수 있을 것이다.


References