home

task queue, microtask queue event loop, execution context, engine, requestAnimationFrame

글 분류
main
키워드
javascript
생성일
2024/08/01 09:12
최근 수정일
2024/11/25 01:16
작성중

TL;DR

JS Runtime은 브라우저, node / JS Engine은 V8, JavascriptCore
microtask > animation frame > task순으로 우선순위가 존재한다.
microtask, task, animation frame 모두 제 시간에 실행된다는 완벽한 보장은 없다.

0. 들어가기 전에

JavaScript를 일정 기간 사용해 온 개발자임에도 불구하고, JS Runtime의 작동 방식에 대해 충분히 이해하지 못한 것 같아, 이를 글로 정리하고 각종 task의 우선순위를 코드로 직접 테스트해보려는 글이다.
제목만 보고 “이미 다 아는 내용인데?“라고 넘기지 말고, 한 번쯤 읽어보는 것도 좋지 않을까 하는 건방진 제안을 해본다.
“You don’t know how the JS runtime works.”
Node 기반 JS Runtime이 아닌 Chromium 기반의 Edge 브라우저에서 실행되는 JS 런타임을 기준으로 설명한다. Node 기반과 큰 차이는 없으나, requestAnimationFrame과 같은 API나 마우스 클릭과 같은 사용자 상호작용이 없기 때문에, 더 간소화된 형태라고 이해하면 된다.

이 글에서 다루는 것

JS 엔진, JS 런타임
task, microtask, request animation frame
event loop
Execution Context, Global Execution Context, Execution Context Stack
CSS Animation

이 글에서 다루지 않는 것

스레드, 프로세스의 같은 기본적인 개념
hz, 화면 refresh rate

1. JS engine과 JS runtime의 차이점

JS 엔진 → JavaScript 코드를 컴파일하고 실행하는 엔진으로, 코드의 구문을 분석하고 실행하는 역할을 담당 JS 런타임 → JavaScript 코드의 실행 환경으로, JS 엔진 외에도 API와 같은 기능들을 포함하여 브라우저나 Node.js에서 코드가 실행되도록 돕는 전체 환경
이 모든걸 시작하기전에 JS 엔진과, JS 런타임에 대한 정확한 구분이 필요하다.

1.1 Engine

ECMA-262 명세에 정의된 ECMAScript를 구현한 것이므로 “ECMAScript Engines”이라고 부르는 것이 더 정확한 표현이다.
실제 자바스크립트를 실행하는 주체.
ECMA-262 이외의 추가 기능이 적용되지 않으며, 일반적으로 잘 알려진 엔진들인 V8, SpiderMonkey, JavaScriptCore 등이 이에 해당된다.

1.2 Runtime

JS 엔진을 내장한 프로그램으로, 주로 ECMAScript host라고 불린다.
예: Chrome, Firefox, Edge, Safari, Node.js, Deno, Bun 등
기본적으로 JS 엔진을 내장하고 그 외에 추가적인 기능이 포함된다.
예: 브라우저의 경우 DOM, 서버의 경우 파일 접근 시스템이 추가되는 것과 유사한 예시
추가되는 기능들에 대한 규칙은 존재하지 않는다.
예: Node.js에는 fetch가 없고 Deno에는 fetch가 있는 것처럼
파일 시스템에 접근하는 방법 또한 다르다.
이벤트 루프는 JS 엔진의 구성요소가 아니다. → ECMA-262 명세에도 존재하지 않는다.
런타임마다 서로 다르게 구현되지만, 동작 자체는 크게 다르지 않다.
예: Task Queue, Web APIs, Node APIs 등

1.3 Web APIs

웹앱이 브라우저의 다양한 기능을 활용할 수 있도록 브라우저가 제공하는 인터페이스.
예: DOM API, Timeout API, Fetch API, Geolocation API

2. Single thread

JavaScript 엔진은 단일 스레드로 동작하여 한 번에 하나의 작업만 처리하지만, Event loop를 통해 비동기 작업을 효율적으로 관리한다.
“JS의 Engine은 기본적으로 싱글 스레드이다.” → 이 한줄의 문장이 뜻하는 바는 무엇일까?
JS 엔진은 싱글 스레드로, 메인 스레드에서 모든 작업을 처리한다. 예를 들어, setTimeout을 활용한 비동기 콜백 함수의 실행, 렌더링(JS뿐만 아니라 CSS도 최종적으로 메인 스레드에서 처리된다.), DOM 조작, 다양한 JS 계산, 마우스 클릭과 같은 이벤트 처리 및 그에 따른 콜백 함수 실행 등이 모두 이 메인 스레드에서 이루어진다.
이로인해, 하나의 작업이 오랜 시간 동안 스레드를 점유하면, 다른 작업들 또한 늦게 처리되므로, 빠른 응답이 필요한 사용자 상호작용의 경우 사용자가 “느리다”라고 느낄 수 있다.
장점으로는 단일 스레드만 존재하기 때문에 레이스 컨디션과 같은 문제를 신경 쓸 필요가 없다는 점이 있다.
코드를 실행하는 주체인 JS 엔진은 싱글 스레드이지 JS 런타임(e.g. 브라우저)은 싱글 스레드가 아니다. 즉, 이벤트 루프, Web APIs는 JS 엔진의 스레드에서 실행되지 않고, 각자의 스레드를 가지고 있다.
JS 실행 환경은 싱글 스레드임에도 불구하고, 이벤트 루프 덕분에 개발자들에게는 여러 작업을 동시에 수행할 수 있는 것처럼 보인다.
이벤트 루프에 대해 알고 있는 독자도 있고, 모르는 독자도 있겠지만, 추후에 다룰 내용이므로 일단 여기서는 깊이 설명하지 않겠다.
정확히 이야기하자면 web APIs는 JS 엔진과는 별개의 스레드에서 작동한다. 해당 작업에 대한 callback 함수가 메인 스레드에서 실행되는 것 이다.
결과적으로, JS 엔진은 싱글 스레드라고 할 수 있다. 그렇다면, JS 런타임에 해당하는 브라우저(예: Chrome, Safari, Firefox)는 싱글 스레드일까? 아니다. JS 런타임은 멀티 프로세스, 멀티 스레드로 작동한다.

2.1 브라우저는 single thread single process인가?

서로 다른 process를 점유한 browser tab
앞서 확인했듯이, JS 엔진은 싱글 스레드로 동작한다. 그렇다면 JS 런타임인 브라우저는 어떨까? 결과부터 말하자면, 브라우저는 멀티 프로세스, 멀티 스레드로 동작한다.
하지만 브라우저가 멀티 프로세스, 멀티 스레드로 동작하더라도, 프론트엔드 개발자가 작성하는 코드는 싱글 스레드 기반으로 실행된다. 다른 프로세스들은 개발자가 직접적으로 관여하거나 조작하는 것이 아니라, 브라우저가 관리하는 리소스이기 때문이다.
Chromium 기반 브라우저의 경우, 메뉴에서 “More Tools” → “Task Manager”를 통해 사용 중인 브라우저가 생성한 프로세스를 확인할 수 있다.
운영체제에서 실제로 할당받은 PID를 확인하려면, 아래 명령어를 터미널에서 실행하여 확인할 수 있다
# 맥에서 사용 가능한 커맨드 pgrep -x "Microsoft Edge" | while read pid; do echo "Parent Process: $pid $(ps -p $pid -o comm=)"; pgrep -P $pid | while read child_pid; do echo " Child Process: $child_pid $(ps -p $child_pid -o comm=)"; done; done
Bash
복사
표준 브라우저 명세에서는 각종 프로세스를 어떻게 관리해야 하는지에 대한 명확한 규정이 없기 때문에, 브라우저마다 프로세스와 스레드 관리 방식이 서로 다르다는 점을 알아두자.

2.2 single thread인걸 어떻게 알 수 있을까?

while (true) {}
JavaScript
복사
브라우저의 개발자 도구에 위의 코드를 붙여넣어 실행해보면, 해당 탭이 작동을 멈추는 현상을 확인할 수 있다.
클릭이나 텍스트 선택(이용자가 복사-붙여넣기 시 드래그하는 동작) 같은 기본적인 상호작용이 불가능해지고, 스크롤 반응이 느려지며(거의 작동하지 않음), 스크롤이 되더라도 페이지 내용이 완전히 렌더링되지 않는다.
무한 반복하는 GIF가 있을 경우, GIF의 애니메이션도 멈춘다.
각 탭은 서로 다른 프로세스를 점유하고 있기 때문에, 다른 탭들은 정상적으로 작동한다.

2.3 Multithread 가능하게 만드는 방법

JS Runtime은 multithread가 가능하게 만드는 각종 API를 제공한다. 이전에는 싱글스레드가 맞는 표현이였지만 이제는 “기본으로는 single thread지만 multi thread가 가능하게 만들 수 있다”로 해석하는게 옳다.
NodeJS의 Workers threads - Workers API
WebAPI의 Web Workers API

3. 실행 컨텍스트(Execution Context)

JavaScript 실행 컨텍스트는 코드가 실행되는 환경으로, 변수, 함수, 객체의 범위와 실행 순서를 관리하는 메커니즘

3.1 들어가기 전에

편의성을 위해, 이하 Execution Context는 EC, Global Execution Context는 GEC, Execution Context Stack은 ECS로 표기한다.
GEC와 EC가 존재하는 ECS의 예시 이미지

3.2 Global Execution Context (GEC)

Global Execution Context는 JavaScript 코드가 처음 실행될 때 생성되는 최상위 컨텍스트로, 전역 스코프와 관련된 변수와 함수를 관리한다.
자바스크립트 엔진이 실행될 때마다 GEC가 생성된다. 아래 예시들은 GEC에 해당하는 환경들이다.
브라우저의 window
node의 global

3.3 Execution Context (EC)

EC는 코드 실행 시 필요한 정보를 담고 있는 환경으로, 호출된 함수의 변수, 객체, this 값 등을 관리한다.
자바스크립트에서 함수를 호출할 때마다 JS 엔진은 EC를 생성한다..
코드의 한 부분이 시작 될 때 새로운 EC가 생성되고 끝날때 EC가 사라진다고 생각하면 편하다.

3.4 Execution Context를 생성하는 3가지 상황

Global Context → 코드의 main body, 함수 외부에 존재하는 코드
각 함수들 → 각 함수들은 각자의 EC에서 실행된다.
eval 함수

3.5 Execution Context Stack (ECS)

Execution Context Stack은 자바스크립트에서 실행 중인 코드의 컨텍스트를 관리하기 위해 사용되는 스택 자료구조로, 함수 호출 시 생성된 컨텍스트를 순차적으로 쌓아 관리한다.
새로운 EC가 생성되면, ECS에 추가되며 해당 EC가 종료되면 ECS에서도 제거된다.
GEC는 EC와는 다르게 작동하며, 프로그램이 실행됨과 동시에 생성된다. ECS에서 EC가 모두 비워지고 GEC만 남아 있는 상태에서 각 task queue의 모든 태스크가 완료되면, GEC는 ECS에서 제거되고 프로그램이 종료된다.

3.6 Call Stack vs Execution Context Stack

한 번쯤 봤을 법한 JavaScript Visualizer 9000에서 “Call Stack”은 실제로 “Execution Context Stack”이라는 표현이 더 정확하다.

3.7 ECS 이해를 위한 예시코드

이 섹션의 예시 코드는 MDN 존재하는 코드다.
const outputElem = document.getElementById("output"); const userLanguages = { Mike: "en", Teresa: "es", }; function greetUser(user) { function localGreeting(user) { let greeting; const language = userLanguages[user]; switch (language) { case "es": greeting = `¡Hola, ${user}!`; break; case "en": default: greeting = `Hello, ${user}!`; break; } return greeting; } outputElem.innerText += `${localGreeting(user)}\n`; } greetUser("Mike"); greetUser("Teresa"); greetUser("Veronica");
JavaScript
복사
1.
글로벌 실행 컨텍스트 (Global Execution Context) 생성 및 실행
a.
자바스크립트 엔진은 프로그램을 실행할 때 GEC를 먼저 생성한다.
b.
outputElem, userLanguages 객체 및 greetUser 함수가 전역 컨텍스트에 정의된다.
2.
greetUser(”Mike”)가 실행될 때 greetUser()에 해당하는 EC가 생성된다.
a.
이때 greetUser가 localGreeting을 호출 할 때, 해당 함수의 EC를 ECS에 생성한다.
b.
localGreeting이 값을 반환할 때 ECS에서 해당 EC를 제거한다.
c.
이때 ECS에는 greetUser이 남아있으므로 localGreeting을 호출 후 남아있던 상태로 존재하던 greetUser를 마저 실행한다.
d.
greetUser가 결과값을 반환하고 해당 함수의 EC는 ECS에서 제거된다. 이때 ECS에는 Global EC만 존재하는 상태다.
3.
위 와 같은 프로세스를 다른 매개변수로 2번 더 실행한다. (Teresa, Veronica)
4.
마지막 greetUser(”Veronica”)까지 같은 프로세스로 실행한 후 ECS에서 해당 EC를 제거하고 나면 이제 Global EC만 존재하며 그마저도 ECS에서 제거되고 나면 ECS에는 남는 로직이 없고 프로그램은 종료된다.

3.8 재귀함수의 경우?

재귀 함수는 스스로를 호출하는 함수로, 여러 깊이 또는 재귀 레벨에 걸쳐 호출될 수 있다. 각 재귀 호출은 새로운 실행 컨텍스트를 생성하며, 이는 JavaScript 런타임이 재귀 레벨과 결과 반환을 추적할 수 있게 한다. 하지만, 함수가 재귀될 때마다 새로운 컨텍스트를 생성하기 위해 더 많은 메모리가 필요하다는 점도 유의해야 한다.

4. Event Loop

싱글 스레드 환경에서 비동기적인 작업들을 관리하는 메커니즘.
자바스크립트는 싱글 스레드 언어로, 기본적으로 코드를 동기 블로킹 방식으로 실행한다. 이는 특정 코드 실행이 완료될 때까지 다음 코드의 실행을 차단한다는 의미인데, 자바스크립트는 이런 동기적 실행의 한계를 극복하고, 효율적인 비동기 프로그래밍을 지원하기 위해 이벤트 루프라는 방식을 사용한다.
Event loop는 주로 아래와 같은 코드로 구현되기 때문에 “loop”라는 이름이 붙었다. → 이 코드는 매우 간단한 버전이며, 실제로 각 우선순위를 다루는 Event loop의 코드는 해당 섹션에서 추후에 다룰 예정이다.
while (queue.waitForMessage()) { queue.processNextMessage(); }
TypeScript
복사
실제로 queue는 좀 더 자세히 구분하면 task queue, microtask queue, animation frames(requestAnimationFrame으로 추가되는 작업들을 모아두는 그것, 정확한 명칭은 따로있으니 후에 기술한다.)으로 나눌 수 있다.
이벤트 루프에서 각 task(micro task, animtation frames 포함)는 완료될 때까지 다른 task를 실행하지 않는다.
이로 인해 main thread를 오래 점유하는 태스크가 존재할경우 setTimeout과 같은 함수의 실행은 지연될 수밖에 없다. MDN 공식 문서에서도 setTimeout과 같은 시간 후 실행 함수의 파라미터는 최소 지연 시간(minimum delay)이라고 명시하고 있다.

4.1 Event Loop가 하는 일

간단히 말하자면, Event loop는 지속적으로 ECS와 다양한 큐를 확인하며, ECS가 비어 있을 경우 큐에 있는 작업들을 ECS로 옮겨 실행되도록 하는 역할을 한다.(실행은 위에서도 여러번 말했듯이 main thread가 한다)
ECS가 비어야만 큐에 있는 작업들을 ECS로 옮겨져서 실행될 수 있다는 규칙은 Event loop에서 가장 중요한 규칙 중 하나다. 이로 인해, 시간에 민감한 작업들이 “제시간에” 실행되기 어려운 경우가 발생할 수 있다.
JS 엔진의 특성상, 이미 실행 중인 작업을 멈추고 다른 작업을 실행한 후 다시 이전 작업을 실행하는 방식은 불가능하다.
이러한 태생적 한계 때문에 React는 16버전부터 자체적으로 새로운 렌더링 엔진인 Fiber를 도입했다. 이는 JS 엔진 위에 자체 렌더링 엔진을 구축하여, 렌더링(브라우저의 렌더링이 아닌 React 컴포넌트의 렌더링) 작업의 중단 및 재개와 같은 동작을 가능하게 한다.

4.2 Event Loop Cycle

Event loop가 한번 돌때 해야할 일을 모두 처리하는 과정
while (true) { // 1. microtask queue 처리 while (microtaskQueue.hasTasks()) { microtaskQueue.pop().execute(); } // 2. 렌더링 필요한지 검사 if (shouldRepaint()) { // 애니메이션 프레임 큐에서 작업 실행 while (animationQueue.hasTasks()) { animationQueue.pop().execute(); } repaint(); // 화면 갱신 } // 3. task queue에서 작업 실행 const task = taskQueue.pop(); if (task) { task.execute(); } else { // task가 없으면 루프를 잠시 멈춤 (필요시) // 필요할 경우 } }
JavaScript
복사
Event loop cycle이란 event loop가 한 번 돌 때 처리되는 일들을 뜻하며, 이는 task queue에서 대기 중인 task를 하나씩 꺼내어 실행하고, 비동기 작업의 완료 여부를 확인하며, 새로운 이벤트가 발생했는지 체크하는 과정을 포함한다.
Event loop를 좀더 자세히 구현하자면 위의 코드와 같다. 우선순위는 추후에 다룰내용이므로 무시해도 좋다.

5. Tasks, Task Queue

Task - 특정 비동기 작업의 결과로 생성된 콜백 함수가 실행 대기 중인 task queue에서 호출되어 메인 스레드에서 처리되는 작업 단위 Task Queue - 실행되어야 할 task들이 대기하고 있는 대기열. 해당 대기열에 존재하는 taks들은 이벤트 루프에 의해 순차적으로 ECS로 넘어가 JS engine에 의해 실행된다.
간혹가다 macro task라고 부르는 사용자들도 있지만 ECMAScript 명세에도, MDN 문서에도 존재하지 않는 단어이다. 정확한 표현은 task가 맞다.
스크립트 실행, 비동기로 dispatch된 이벤트, interval, timeout과 같은 작업들 → 해당 섹션에서는 task만 예시로 설명을한다. 본격적인 실행 우선순위 서로 다른 종류의 task에 대한 설명은 Microtask에서 설명한다.

5.1 task queue에 추가되는 case

// DOM 이벤트 document.querySelector('button').addEventListener('click', () => { console.log('Button clicked'); }); // setTimeout, setInterval setTimeout(() => { console.log('Executed after 1000ms'); }, 1000); // I/O 작업 fs.readFile('example.txt', 'utf8', (err, data) => { console.log('File read'); });
TypeScript
복사

6. Microtasks, Microtask Queue

Microask - 특정 비동기 작업의 결과로 생성된 콜백 함수가 실행 대기 중인 microtask queue에서 호출되어 메인 스레드에서 처리되는 작업 단위, task 보다 높은 우선순위를 가지고 있다. Microtask Queue - 실행되어야 할 microtask들이 대기하고 있는 대기열. 해당 대기열에 존재하는 taks들은 이벤트 루프에 의해 순차적으로 ECS로 넘어가 JS engine에 의해 실행된다.
ECS가 비어있을때 microtask queue, task queue 모두 각 task가 존재할때, microtask queue에 존재하는 microtask가 먼저 실행된다.
microtasks는 task에 비해 우선순위가 높다. → ECS가 비어 각 task들이 실행되어야할 때 microtask queue에 있는 micro task들이 먼저 실행된다.
event loop는 microtask queue를 확인하여 작업이 있는지 확인하고 있을 경우 microtask를 먼저 queue가 비워질때 까지 실행하고 다음 task를 실행한다.
메인 스레드에서 microtask를 실행하는 도중에 queueMicrotask()와 같은 함수로 microtask queue에 추가될 경우, 다음 task가 실행되기 전에 queue에 남아있는 모든 microtask가 실행되고, 이후 다음 우선순위의 작업(task, animation frames, 또는 다음 loop cycle)을 수행한다.
microtask는 task와는 다르게 task는 하나의 작업이 끝날때마다 렌더가 필요할 경우 렌더를 수행하고 다음 task를 수행할 필요가 있는가? 를 확인하나 microtask는 모든 microtask queue가 비워질 떄까지 렌더링도 하지않고 수행한다.
microtask 내부에 존재하는 task는 microtask의 일부분으로 처리되므로 별도의 task로 수행되지 않는다.
MDN 공식문서에는 microtask를 직접 조작하는 경우는 다른 해결방법이 없을때에만 사용한다.

6.1 microtask queue에 추가되는 case

// queueMicrotask queueMicrotask(() => { console.log('Microtask'); }); // Promises - async/await도 포함됨 Promise.resolve().then(() => { console.log('Promise resolved'); }); // MutationObserver const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { console.log(mutation); }); }); observer.observe(document.body, { attributes: true });
TypeScript
복사

6.2 microtask 우선순위 예시코드

console.log('Start'); setTimeout(() => { console.log('Timeout'); }, 0); Promise.resolve().then(() => { console.log('Promise1'); }); Promise.resolve().then(() => { console.log('Promise2'); }); console.log('End'); // Start // End // Promise 1 // Promise 2 // Timeout
TypeScript
복사
GEC에 존재하는 console.log(”Start”)와 console.log(”End”)는 즉시 실행된다.
ECS에 console.log(”Start”), console.log(”end”)가 사라진 후
Promise의 then은 microtask queue에 존재하므로 console.log(”End”)가 실행된 후 바로 실행된다.
setTimeout의 콜백함수는 task queue에 존재하므로 microtask queue가 비워진 후 실행된다.

6.3 queueMicrotask

queueMicrotask(() => { /* microtask에 추가할 내용들 */ });
TypeScript
복사
각종 task queue를 직접 조작하여 코드를 작성하겠다라는 결심을 하기 전에 “대부분의 개발자들은 microtask를 직접 명시적으로 사용할 일이 없을 것 이다.”라는 문장을 기억하자.
이와같이 유저의 단말기에서 다른 코드들이 실행되기 전에 먼저 실행되는 메타 프로그래밍, 저수준 프로그래밍 적인 기술은 조심히 사용해야한다.
queueMicrotask와 같은 공식 함수가 도입되기 전에는, Promise.resolve().then과 같은 방법으로 microtask queue에 태스크를 직접 추가하는 방식이 많이 사용되었다.

6.4 microtask 사용 예시 1. - 조건 부 promise의 실행순서 보장

// 실행순서에 문제가 존재하는 코드 customElement.prototype.getData = (url) => { if (this.cache[url]) { this.data = this.cache[url]; this.dispatchEvent(new Event("load")); } else { fetch(url) .then((result) => result.arrayBuffer()) .then((data) => { this.cache[url] = data; this.data = data; this.dispatchEvent(new Event("load")); }); } }; element.addEventListener("load", () => console.log("Loaded data")); console.log("Fetching data…"); element.getData(); console.log("Data fetched"); // 캐시되지 않았을 경우 // Fetching data // Data fetched // Loaded data // 캐시됐을 경우 // Fetching data // Loaded data // Data fetched // 개선된 버전 customElement.prototype.getData = (url) => { if (this.cache[url]) { queueMicrotask(() => { this.data = this.cache[url]; this.dispatchEvent(new Event("load")); }); } else { fetch(url) .then((result) => result.arrayBuffer()) .then((data) => { this.cache[url] = data; this.data = data; this.dispatchEvent(new Event("load")); }); } };
TypeScript
복사

6.5 microtask 사용 예시 2. - 작업 일괄 처리

const messageQueue = []; let sendMessage = (message) => { messageQueue.push(message); if (messageQueue.length === 1) { queueMicrotask(() => { const json = JSON.stringify(messageQueue); messageQueue.length = 0; console.log('Sending to server:', json); // For demonstration, replace with fetch call // Example fetch call: // fetch("url-of-receiver", { // method: 'POST', // headers: { // 'Content-Type': 'application/json' // }, // body: json // }); }); } }; console.log("Start") // Simulate multiple sendMessage calls sendMessage('Message 1'); sendMessage('Message 2'); sendMessage('Message 3'); // Example to show messages being sent in the same event loop iteration setTimeout(() => { sendMessage('Message 4'); sendMessage('Message 5'); }, 0); console.log("End") // Start // End // Sending to server:"[\"Message 1\",\"Message 2\",\"Message 3\"]" // Sending to server:"[\"Message 4\",\"Message 5\"]"
TypeScript
복사
Global EC에 존재하는 “Start”, “End”가 먼저 출력된 후 큐에 담겨져 있던 Message 1, 2, 3이 실행된다.
그리고 taskQueue에 존재하는 4,5가 microtask queue가 비워진 후 다시 실행한다.
이와같이 작업 일괄처리를 실제 수행할 수 있다.
queueMicrotask(() => {console.log("microtask")} console.log('Script start'); // (1) console.log('Script end'); // (2)
TypeScript
복사

7. requestAnimationFrame -The map of animation frame callbacks

animation을 위해 존재하는 queue 형태의 animation 작업들의 저장소

7.1 들어가기 전에

이해도를 높이기 위해 단말기 모니터 주사율은 60hz, 브라우저는 chrome으로 가정한다.
60hz의 refresh rate를 가진 단말기 화면에서는 대략 16.67ms에 한번씩 화면이 refresh 되야 이용자들이 부드러움을 느낄 수 있다.
120Hz(ProMotion이 적용된 iPad, iPhone, MacBook) 지원 기기의 경우 약 8.3ms마다 화면이 리프레시되어야 기기가 제공하는 성능을 최대로 활용할 수 있다.
JavaScript로 처리되어야 하는 애니메이션과 CSS로 처리되어야 하는 애니메이션은 각각 다르다. 이 글에서는 JavaScript에서 처리해야 하는 애니메이션에 대해서만 다룬다.
처리되어야할 애니메이션들은(화면에 보여야할) ordered map 형태로 animation callback들이 모여있는 the map of animation frame callbacks에 저장된다. → 위 섹션들에서 몇번 언급된 animation frames의 실제 공식 명칭은 이와 같다. 하지만 너무 긴 이름이므로 이후부턴 animation frames라고 호칭하겠다.

7.2 requestAnimationFrame

브라우저의 다음 리페인트 시점에 맞춰 주어진 콜백 함수를 호출하여 애니메이션을 부드럽게 실행할 수 있게 해주는 함수
requestAnimationFrame의 이상적인 실행도
requestAnimationFrame은 브라우저에게 다음 리페인트(repaint) 전에 지정된 함수를 실행하도록 요청하는 함수이다. 여기서 “요청”이라는 단어를 강조하는 이유는, 정확한 타이밍에 실행되지 않을 수 있다는 점을 설명하기 위해서이다.
“요청”이라는 단어를 강조하는 이유는, 정확한 타이밍에 실행되지 않을 수 있다는 점은 6. 정확한 타이밍에 실행 될 수 없는 requestAnimationFrame 섹션에서 설명한다.
requestAnimationFrame은 animation frame에 실행되어야 하는 콜백 함수들을 담는다. Event Loop는 실행될 타이밍에 도달하면, 현재 존재하는 애니메이션 프레임에 담긴 모든 애니메이션을 수행하고, 이후 페인트paint 작업을 수행한다. 이때, 애니메이션 프레임에 존재하는 “모든 콜백들을 실행하는 과정에서 새로 생성된 애니메이션들은 현재 렌더링 단계가 아닌 다음 렌더링 단계에서 실행된다.”
이용자가 창을 최소화 하거나 탭을 이동했을 경우 requestAnimationFrame과 리렌더는 발생하지 않는다.

7.3 animation frames와 각종 queue의 우선순위 확인

정확한 테스트를 위해 개발자 도구를 사용하지 않고 실제 HTML의 버튼에 onClick handler를 추가했다.
busyWait 함수는 강제로 해당 task(microtask, animation frames, task)의 실행 속도를 늦추기 위해 추가했다.
이 섹션에서 예시 코드는 TestPage의 보일러 플레이트 코드를 모두 제거하고 testFunc 내부의 코드만 수정한다고 가정한다.
const TestPage = () => { const onClickHandler = () => { //우선순위 확인을 위한 테스트 함수 }; return ( <div> <h1>TestPage</h1> <button onClick={() => { testFunc(); }} > Click </button> </div> ); }; export default TestPage;
TypeScript
복사
console.log("GEC start"); // microtask - (Promise) Promise.resolve().then(() => { console.log("microtask "); }); // task - (setTimeout) setTimeout(() => { console.log("task"); }, 0); // animation frame requestAnimationFrame(() => { console.log("animation frame"); }); console.log("GEC end") // 실제 순서 // GEC start // GEC end // microtask // animation frame // task
TypeScript
복사
우선순위는 microtask, animation frame, task 순서이다.

8. 정확한 타이밍에 실행 될 수 없는 requestAnimationFrame

위에서 언급했듯이, requestAnimationFrame도 정확히 매 16.67ms마다 실행을 보장하지 않는다. 이는 setTimeout과 같은 함수들이 지정된 시간에 정확히 실행되지 못하는 것과 같은 원리이다.
requestAnimationFrame이라는 이름에서도 알 수 있다시피 “request”, 즉. “요청”이라는 단어를 사용한 이유는 두가지가 있다.
1.
브라우저가 해당 함수를 즉시 실행하는 것이 아니라, 최적의 타이밍에 실행하도록 예약하기 때문
2.
이 또한 브라우저의 JS Engeine의 main tread(single thread)에서 실행되므로 정확히 “다음 리페인트 전에 실행시킨다!”라는 보장은 불가능 하기 때문이다.
JS Engine이나 Event loop는 내가 지금 처리하고있는 작업들이 언제 끝나는지 알 수 없다는 사실을 다시한번 상기해보자. 이말인 즉슨 지금 작업하고있는 작업들 때문에 화면 refresh가 늦어질 수 있다는 뜻이다.

8.1 테스트 코드

import { useRef } from "react"; import * as style from "./style.css"; const TestPage = () => { const circleRef = useRef<HTMLDivElement>(null); const keepCpuBusy = (ms: number) => { const start = performance.now(); while (performance.now() - start < ms) {} }; const moveDivRight = () => { if (circleRef.current) { let left = 0; const move = () => { if (circleRef.current) { left += 1; circleRef.current.style.transform = `translateX(${left}px)`; if (left < window.innerWidth - circleRef.current.clientWidth) { requestAnimationFrame(() => { move(); }); } } }; move(); } }; const onClickHandler = () => { moveDivRight(); }; return ( <main> <h2>Test Page</h2> <button onClick={onClickHandler}>Test button</button> {/* Circle that moves slowly to the right end of browser */} <div ref={circleRef} className={style.circle} /> </main> ); }; export default TestPage;
TypeScript
복사
테스트코드 실행 시 예상도
NextJS 기반의 테스트 페이지에서 Button의 onClick 이벤트를 사용하여 테스트를 수행한다.
keepCpuBusy 함수는 전달받은 파라미터에 해당하는 밀리초(ms) 동안 대기하는 함수로, 실행 시간이 오래 걸리는 함수를 모방한다.
moveDivRight 함수는 자바스크립트를 사용하여 화면의 끝까지 원을 천천히 이동시키는 함수로, 내부적으로 requestAnimationFrame을 사용한다.
이후 예시 코드들은 moveDivRight 함수만을 다룬다.

8.2 밀리는 frame - requestAnimationFrame 자체적 처리시간

const moveDivRight = () => { if (circleRef.current) { let left = 0; const move = () => { if (circleRef.current) { left += 1; circleRef.current.style.transform = `translateX(${left}px)`; if (left < window.innerWidth - circleRef.current.clientWidth) { requestAnimationFrame(() => { // Added line if (left === 100) { console.log("after 100px"); keepCpuBusy(1000); } move(); }); } } }; move(); } };
TypeScript
복사
RAF로 요청된 함수의 오래걸리는 작업으로 인해 밀린 frame 예시 이미지
100px 좌측으로 이동한 후 1000ms 이상의 작업을 추가하면, 기존 코드와 달리 이 작업 때문에 원이 부드럽게 이동하지 않고 1000ms 정도 멈춘 후 다시 이동한다.
requestAnimationFrame으로 요청한 함수의 실행 시간이 16.67ms를 초과하면 초과한 시간만큼 프레임이 밀리게 되므로 이용자는 부드럽지 못한 애니메이션을 경험하게 된다.

8.3 밀리는 frame - 오래걸리는 microtask, task

const moveDivRight = () => { if (circleRef.current) { let left = 0; const move = () => { if (circleRef.current) { left += 1; circleRef.current.style.transform = `translateX(${left}px)`; if (left === 100) { queueMicrotask(() => { console.log("microtask"); keepCpuBusy(1000); }); setTimeout(() => { console.log("setTimeout"); keepCpuBusy(1000); }, 0); } if (left < window.innerWidth - circleRef.current.clientWidth) { requestAnimationFrame(() => { move(); }); } } }; move(); } };
TypeScript
복사
오래걸리는 microtask, task로 인해 밀린 frame
위의 사례와 유사하게, requestAnimationFrame으로 전달된 함수 자체의 실행 시간은 짧지만, 이벤트 루프에 의해 추가된 microtask나 task가 오래 걸리는 경우, requestAnimationFrame으로 요청된 함수는 그만큼 프레임이 지연되어 실행된다. 이로 인해 부드러운 애니메이션이 보장되지 않을 수 있다.

8.4 nested requestAnimationFrame

const TestPage = () => { const clickHandler = () => { console.log("GEC start"); function nestedAnimationFrame() { console.log("Outer requestAnimationFrame called"); requestAnimationFrame(() => { console.log("Inner requestAnimationFrame called"); }); } requestAnimationFrame(() => { console.log("First requestAnimationFrame called"); nestedAnimationFrame(); }); queueMicrotask(() => { console.log("microtask"); }); setTimeout(() => { console.log("task"); }, 0); console.log("GEC end"); }; return ( <div> <h2>Test Page</h2> <button type="button" onClick={clickHandler}> button </button> </div> ); }; export default TestPage; // button 클릭 시 // GEC start // GEC end // First requestAnimationFrame called // task // Inner requestAnimationFrame called
TypeScript
복사
중첩된 requestAnimationFrame 콜백 함수 내부에서 다른 requestAnimationFrame이 중첩 형태로 다시 호출될 경우, 해당 호출은 다음 paint 사이클에서 실행된다.

9. 결론

taks, microtask, requestAnimationFrame를 사용할 때 우선순위를 잘 파악하고 쓰자.
requestAnimationFrame, microtask, task 모두 제 시간에 실행된다는 완벽한 보장은 없다.
requestAnimationFrame을 사용할 때는 더욱 신중해야 한다. 최신 기기들은 144Hz, 120Hz의 화면 재생률을 가지고 있는데, 60Hz 기기의 경우 16.67ms마다 화면을 repaint하면 되지만, 120Hz 기기는 8.3ms, 144Hz 기기는 6.94ms마다 화면을 repaint해야 기기의 성능을 활용할 수 있다.
고주사율을 가진 기기 사용자들은 “부드럽지 않음”을 더욱 민감하게 느낄 수 있으므로, JS로 애니메이션이나 이펙트를 구현할 때 이를 유념해야 한다.

참조