Intro
브라우저의 main thread를 막으면 안 되는 이유에 대해서 다들 알고 있을 것이다. 브라우저 main thread를 막게 되면 브라우저는 freeze되어 user interaction을 처리 할수도, paint 작업을 할 수 없게 되어 사용자에게 높은 응답성을 제공할 수 없게 된다. 그러나 웹사이트를 만들다 보면 실행하는데 시간이 오래 걸리는 작업이 발생할 수밖에 없다. 이럴 때 어떻게 하면 사용자에게 높은 응답성을 제공할 수 있는지에 대해 말하고자 한다.
Preliminaries
먼저 브라우저에게 양보하는 방법에 대해 알기 위해서는 event loop에 대한 명확한 이해가 필요하다. event loop란 자바스크립트가 실행되는 동안 main thread에서 수행할 작업을 관리하고 순차적으로 실행하는 메커니즘이다. 간단히 말하면, event loop는 작업 큐에서 작업을 가져와 실행하고, UI 업데이트와 사용자 입력 처리 등 브라우저가 필요한 작업을 조율하는 역할을 한다.

이때 event loop는 task queue에서 먼저 task를 꺼내서 한 차례 실행하고, 그 다음 micro task queue에 쌓인 작업들을 모두 실행한다. 그 다음 브라우저의 repaint 시간, 즉 모니터에 주사율에 맞춰 시간이 되었다면 animation frame queue에서 callback들을 꺼내 모두 실행시킨다. 이때 task queue에 들어가는 task들은 브라우저의 Main thread에서 실행되는 작업을 의미한다. 이는 Script 태그로 작성된 JS 코드들, click, input같은 event handler, 그리고 macro task queue에 있는 callback들이다.
이때 한 가지 알아 놓아야 될 것이, 흔히들 micro task queue의 실행이 macro task queue보다 먼저 된다고 알려져 있다. 그런데 왜 macro task queue가 먼저 실행된다고 하는지 의아해하는 사람들이 있을 수 있다(사실 이름부터가 거대한(macro) vs 작은(Micro)이다). 이에 대해 알기 위해서는 event loop의 한 tick에 대해 이해해야 한다. event loop의 한 tick이란 macro task queue 실행 → micro task queue 실행 → animation frame queue 실행 → 그 후 브라우저의 main thread를 통해 paint하는 한 쌍의 작업들을 의미한다.
따라서 우리가 자바스크립트에서 setTimeout을 Promise의 resolve 함수보다 먼저 실행시켜도, 실제로 더 늦게 실행되는 것처럼 보이는 이유는, 이는 실제로 setTimeout의 callback 함수가 다음 tick에서 실행되기 때문이다.
그럼 이제 우리는 브라우저에 main thread를 양보(yield)하기 위한 방법을 알 수 있다. 바로 macro task queue를 활용하는 것이다.
micro task queue를 활용할 수 없는 이유는 그림1에서 볼 수 있듯이, micro task queue는 한 event loop 내에서 모두 실행되기 때문이다.
물론 time slice를 위해 requestAnimationFrame을 활용하는 방법에 대해 알고 있는 사람도 있을 것이다. 그러나 이는 엄밀히 yield는 아니다. 왜냐하면 animation callback은 한 event loop의 한 tick 내에서 실행되기 때문이다. 즉, setTimeout처럼 한 번에 paint 작업 이후 다음 tick에서 실행되는 것이 아니라, paint 전에 callback 함수들이 모두 실행되고 그 다음에 paint가 실행되는 시기다. 따라서 requestAnimationFrame으로 time slice를 하려면 requestAnimationFrame 안에 requestAnimationFrame을 넣어야 한다. 물론 반복적으로 실행되는 작업이 사용자에게 보여줘야 하는 작업이라면 requestAnimationFrame을 사용하는 것이 맞을 것이다. requestAnimationFrame callback의 실행 시간이 길 경우 INP가 낮아질 수 있긴 하지만, animation callback은 paint 되기 전에 실행되어 주사율에 맞는 부드러운 애니메이션을 보여줄 수 있고, UI 업데이트, 즉 유저에게 보여주어야 하는 task는 높은 우선순위를 가지기 때문이다. 그러나 ui를 업데이트 하지 않는 작업이라면 raf를 써서는 안될 것이다. 왜냐하면 animation callback은 paint cycle(60hz라면 16.6ms)에 맞춰 실행된다. 반면에 macro task queue는 다음 tick에 바로 실행된다. 따라서 macro task queue를 활용한 time slice보다 늦게 완료 될 것이다. (Raf는 paint cycle이 delay처럼 작동한다고 보면 이해에 도움이 될 것 같다)
정리하면, UI를 업데이트하고 time slice가 가능한 작업이라면 requestAnimationFrame을 사용하면 좋고, 아니라면 yield를 통해 다음 tick에서 실행되도록 하는 것이 좋을 것 같다.
Main
그럼 yield하는 방법들에 대해 알아보자.
Yield를 하려면 macro task queue를 활용해야 한다는 건 이제 알 것이다. Macro task queue에 callback을 push하는 방법은 널리 알려진 바로는 setTimeout이 있다. 그러나 setTimeout의 문제는, 5번 이상 반복 실행될 경우 지연 실행 시간을 0ms로 설정해도 브라우저의 스펙에 의해 디폴트로 delay가 4ms가 추가된다는 점이다. 이는 새로운 interval에도 해당한다. 따라서 이는 선호되는 방법이 아니다.
가장 좋은 방법은 브라우저 API인 scheduler.yield를 사용하는 것이다.
scheduler.yield는 실행되자마자 브라우저에게 main thread를 양보하고 Promise를 반환하기 때문에, scheduler.yield 이후의 코드는 micro task queue에 들어가서 실행되게 된다.
또한 scheduler.yield가 setTimeout보다 좋은 점은, continuous한 작업을 보장한다.
setTimeout으로 yield한다면 미리 예약된 다른 작업들이 있을 경우 yield한 작업들의 실행이 밀릴 수 있다 (task는 한 tick에 하나 실행되기 때문이다).
그러나 scheduler.yield를 사용한다면 즉시 브라우저에 main thread를 yield하고, 남은 작업들이 micro task queue를 통해 실행되기에 연속적인 작업들을 보장할 수 있다 (micro task는 한 tick에서 큐를 모두 비운다).

그러나 scheduler.yield는 현재 Safari에서 지원이 안 되기에 polyfill이 필요하다. 이때 나는 postMessage를 권한다. 원래 탭 간의 통신을 위해 있는 함수지만, macro task queue를 통해 실행되고 setTimeout처럼 4ms delay가 없기에 보다 선호된다. React에서도 React Scheduler에서 work를 실행할 때, DOM 환경의 경우 postMessage를 사용한다. 이를 코드로 보면 다음과 같다.
async function yieldToMain() {
if (globalThis.scheduler?.yield) {
return globalThis.scheduler.yield();
}
return new Promise((resolve) => {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = () => {
resolve();
};
port.postMessage(null);
});
}
참고자료
https://www.youtube.com/watch?v=u1kqx6AenYw&list=WL&index=9&t=843s
https://web.dev/articles/optimize-long-tasks?hl=ko
https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API
https://web.dev/articles/optimize-long-tasks#scheduler-yield
'JS' 카테고리의 다른 글
| 웹 프론트엔드 성능 지표: RAIL, Core Web Vitals (0) | 2025.12.17 |
|---|---|
| TypedArray에 대하여 (0) | 2025.11.24 |
| [JS] Javascript에서의 Closures (0) | 2025.09.19 |
| debounce와 throttle의 차이점 (0) | 2025.05.29 |
| ES2021에서 추가된 신기능 5가지 (1) | 2025.02.13 |