이전 글
이전에 비동기 포스팅에서 알아본 마이크로 태스크 큐에 대해 좀 더 탐색하는 포스팅이다.
이전 글을 요약하면
"비동기 작업에도 우선순위가 있다. 비동기 작업은 마이크로 태스크 큐 혹은 매크로 태스크 큐(=일반적으로 말하는 태스크 큐)에 담기는데, 마이크로 태스크 큐에 있는 태스크가 태스크 큐에 있는 태스크보다 우선순위를 가진다. 그래서 이벤트 루프가 마이크로 태스크 큐에 담긴 태스크를 먼저 콜스택에 옮겨서 자바스크립트 엔진이 실행할 수 있도록 한다."
이 지식만을 가지고 있다가, 다음과 같은 의문이 있었다.
1. 마이크로 태스크 큐에 태스크가 엄청나게 많으면, 태스크 큐의 태스크는 무한정 대기하는가?
2. 태스크 큐의 태스크를 처리하다가, 마이크로 태스크 큐에 또 다른 태스크가 들어온다면??
이벤트 루프에서 비동기 작업을 다룰 때 조금 더 디테일한 과정이 있더라.
이제 이 의문들을 하나씩 풀어보자!.
이벤트 루프 알고리즘
우선, 런타임(브라우저 / 노드)에서 이벤트 루프가 비동기 작업을 돕는다는 것은 알 것이다. 혹시나 처음 들어보는 말이라면, 싱글스레드로 동작하는 자바스크립트 엔진이 왜 여러 작업들이 함께 발생하는 것 처럼 느껴지게 하는지, 자바스크립트 엔진과 이벤트 루프가 어떻게 동작하는지부터 알아보길 추천한다.
이벤트 루프는, 콜스택과 태스크 큐의 상태를 끊임없이 감시하고 있다.
자바스크립트 엔진이 콜스택에 들어오는 태스크들을 처리하는데, 콜스택이 비어있고 태스크 큐에 대기중인 태스크가 있다면 이벤트 루프가 태스크를 엔진으로 밀어넣어 엔진이 실행할 수 있도록 한다. 그래서 엔진은 "콜스택에 들어온 태스크를 처리"하며 루프는 "큐에 대기중인 태스크를 콜스택에 밀어줌으로서 처리"하는 것이다. 둘 다 끊임없이 돌아갈 것이고 브라우저에 의해 최적화되어 태스크를 기다리는 동안에는 CPU 자원 소비가 최소화 되도록 설계되어있을 것이다.
이벤트 루프가 마이크로 태스크 큐, 태스크 큐, 렌더링 작업을 다루는 순서는 다음과 같다.
우선 콜스택이 비어있다는 전제하에,
1. 마이크로 태스크 큐에 있는 마이크로 태스크를 FIFO로 순차 실행한다.
2. 마이크로 태스크 큐가 비면, 렌더링 작업을 수행한다.
3. 렌더링 작업 후에는 매크로 태스크 큐(=태스크 큐)에 있는 태스크를 실행한다.
4. 매크로 태스크 큐의 작업이 1개 실행되고, 다시 1번으로 돌아간다.
요약하면
매크로 태스크 1개 => 마이크로 태스크 전부 => 렌더링작업 수행 => 매크로 태스크 1개 => ...
의 반복이다.
주목할 점은 마이크로 태스크가 모두 처리되지 않으면, 렌더링이 되지 않는다는 것이다.
대표적으로 마이크로 태스크 큐에 담길 태스크는 Promise 콜백과 then, catch, finally 콜백, async 함수 등이 있다.
렌더링 시점 예측하기
마이크로 태스크와 매크로 태스크를 기반으로 렌더링 시점을 예측할 수 있다.
아래의 코드를
- 동기적 작업
- 매크로 태스크의 비동기 작업
- 마이크로 태스크의 비동기 작업
으로 나누고, 단순한 counter를 통해 코드 실행과 렌더링을 살펴보자.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div>
<div>동기코드 : <span id="synchronous">0</span></div>
<div>매크로 : <span id="macro">0</span></div>
<div>마이크로 : <span id="micro">0</span></div>
<button id="run">실행</button>
<button id="reset">초기화</button>
</div>
<script>
const synchronous = document.querySelector('#synchronous');
const macro = document.querySelector('#macro');
const micro = document.querySelector('#micro');
document.querySelector('#reset').addEventListener('click', reset);
document.querySelector('#run').addEventListener('click', run);
function heavyTask(elem, iter) {
for (let i = 1; i <= iter; i++) {
elem.textContent = i;
}
}
function run() {
// asynchronous macro task
setTimeout(() => {
heavyTask(macro, 300000);
console.log('macro task end');
});
// synchronous task
heavyTask(synchronous, 100000);
console.log('synchronous task end');
// asynchronous micro task
queueMicrotask(() => {
heavyTask(micro, 200000);
console.log('micro task end');
});
}
function reset() {
synchronous.textContent = 0;
macro.textContent = 0;
micro.textContent = 0;
}
</script>
</body>
</html>
예시코드이며 실행하면 아래와 같이 생겼다.
먼저 각 함수들과 작업들을 소개하겠다.
heaveTask 함수
function heavyTask(elem, iter) {
for (let i = 1; i <= iter; i++) {
elem.textContent = i;
}
}
엘리먼트(돔 노드)와 반복수를 받아, for문을 통해 화면 엘리먼트에 현재 반복수를 보여준다.
아마도, 개발자는 화면의 숫자가 1->2->3->4->...->iter 까지 올라가는 것을 화면에 보여주고싶은 것 같다.
reset 함수
function reset() {
synchronous.textContent = 0;
macro.textContent = 0;
micro.textContent = 0;
}
모든 엘리먼트들의 텍스트를 0으로 초기화한다.
run 함수
function run() {
// asynchronous macro task
setTimeout(() => {
heavyTask(macro, 300000);
console.log('macro task end');
});
// synchronous task
heavyTask(synchronous, 100000);
console.log('synchronous task end');
// asynchronous micro task
queueMicrotask(() => {
heavyTask(micro, 200000);
console.log('micro task end');
});
}
run 함수는
- setTimeout의 매크로 태스크
- queueMicrotask의 마이크로 태스크
- 그 외 동기 실행 코드
로 이루어진다.
run 함수에 각 작업들을 실행하기 전에, 작업 순서를 예측하고 실행한 결과를 함께 살펴볼 예정이다.
1. 동기 + 매크로 태스크만 실행하기.
콜스택에서 매크로 태스크 setTimeout을 실행하고 webAPI에 타이머를 위임한다. 동기 코드는 즉시 실행된다. iterator가 10만이기 때문에, 화면의 숫자가 1~10만까지 바뀔 것이고, 실행이 완료되면 'synchromous task end'를 출력하고 콜스택이 비워진다. 이 때 매크로 태스크 큐에 있던 setTimeout 콜백이 실행되어 화면 매크로의 숫자가 30만까지 바뀌는 것이 예상 흐름이다.
결과는 개발자의 의도와 상당히 차이가 있다.
우선 for 루프를 통해 계속 카운터 렌더링을 수행하려는 의도는 말을 듣질 않는다. 자바스크립트 엔진이 코드를 실행하는 동안에는 별도의 렌더링을 수행할 수 없다. 이것은 브라우저가 렌더링 엔진이 아닌 자바스크립트 엔진에 제어권을 넘겨준 것이기 때문이기도 하고, 앞서 마이크로태스크 -> 렌더링 -> 매크로태스크의 절차를 거친다고 배운것을 바탕으로, 현재 엔진에서 코드를 실행중이라면 엔진에 실행컨텍스트가 비워졌을 때 마이크로 태스크 큐를 살펴보고, 렌더링 작업이 수행되겠구나.를 예측할 수 있다.
그래서 결국 elem.textContent를 변경시키는 작업은, 모든 코드가 실행된 뒤 i를 반영하여 한번에 렌더링이 되는 것이다.
이 점을 바탕으로 현재 코드의 동작을 살펴보면,
콜스택에서 매크로 태스크 setTimeout을 실행하고 webAPI에 타이머를 위임한다. 동기 코드는 즉시 실행된다. iterator가 10만이기 때문에, i가 1~10만까지 바뀔 것이고, 실행이 완료되면 'synchromous task end'를 출력하고 콜스택이 비워진다. 이 때 마이크로 태스크 큐에 작업은 존재하지 않으므로, 렌더링이 수행된다.(당연히 수행될 렌더링 작업이 없다면 별도로 수행되진 않는다.) 수행해야 할 렌더링은 화면의 동기코드를 100,000으로 보여주는 것. 그러므로 먼저 "동기코드: 100000"이 렌더링된다. 그리고 매크로 태스크 큐에 있던 setTimeout 콜백이 콜스택에서 실행되어 i=30만까지 증가한다. 콜스택이 비워지면 마이크로 태스크 큐에 작업이 존재하지 않으므로, "매크로 : 300000"가 렌더링된다.
처음 작업이라 자세한 설명과 함께 살펴봤다.
다음부터는 큰 흐름만 이해하면서 예측해보자.
2. 동기 + 마이크로 태스크 실행
우선 queueMicrotask는 마이크로 태스크 큐에 콜백을 밀어 넣어주는 함수다.
동기와 마이크로 태스크의 조합을 예측해보면
동기코드가 실행되어 i=10만의 렌더링을 발생시킨 뒤, 마이크로 태스크가 실행되어 i=20만의 렌더링을 발생시킨다.
렌더링은 마이크로 태스크 처리 이후 수행된다. 그러므로 아직 렌더링이 수행되지 않은 시점이다.
결국 렌더링을 수행하는 시점에는, 동기+마이크로 2가지의 변경사항을 한꺼번에 반영하게 된다.
3. 매크로 태스크 + 마이크로 태스크 실행
이제 이정도야 거뜬하다!.
코드를 쭉 실행시켜서, setTimeout, queueMicrotask 함수를 순차적으로 실행한다. 이제 콜스택은 비어있다. 마이크로 태스크를 먼저 실행해서 20만 렌더링을 발생시키고, 렌더링 과정에 의해 즉시 "마이크로 : 200000"가 렌더링된다. 그리고 매크로 태스크를 실행하여 30만 렌더링을 발생시킨다. 마이크로 태스크 큐가 비었으므로, 바로 "매크로 : 300000" 렌더링을 수행하게 된다.
4. 모두 실행
렌더링 시 동기 + 마이크로가 먼저 반영되고, 매크로 렌더링이 두번째로 반영된다.
원래 개발자의 의도였던 1~10만까지 화면이 변하게 설계하고 싶다. 어떻게 해야할까?
3가지정도 예시를 통해 생각해보자.
1번 예시 : 동기적으로 1~10만까지 직접 타이핑해서 수행한다.
function renderCounter(elem, num) {
elem.textContent = num;
}
function run() {
renderCounter(synchronous, 1);
renderCounter(synchronous, 2);
renderCounter(synchronous, 3);
renderCounter(synchronous, 4);
...
renderCounter(synchronous, 100000);
}
2번 예시 : 마이크로 태스크를 1~10만까지 발생시킨다.
function run() {
for (let i = 1; i <= 100000; i++) {
queueMicrotask(() => heavyTask(micro, i));
}
}
3번 예시 : 매크로 태스크를 1~10만까지 발생시킨다.
function run() {
for (let i = 1; i <= 100000; i++) {
setTimeout(() => heavyTask(macro, i), 0);
}
}
어떤것이 의도대로 동작할까?
1번은 run 함수의 실행컨텍스트에서 i=1~10만까지 갱신하므로, 렌더링은 run함수가 끝나야 수행된다. 결국 한번에 10만으로 바뀐다.
2번은 run 함수의 실행컨텍스트를 통해 마이크로 태스크 큐에 10만개의 작업이 쌓이게 될 것인데, 마이크로 태스크를 모두 실행해야 렌더링으로 넘어간다. 결국 2번도 한번에 10만으로 바뀐다.
3번은 run 함수에 의해 매크로 태스크 큐에 10만개의 작업이 쌓인다. 매크로 태스크를 하나씩 처리할 때마다 마이크로 태스크를 살펴보고 / 렌더링 작업을 수행한 뒤 다시 매크로 태스크를 하나 처리하러 온다. 결국 3번이 의도한 대로 동작하게 된다.
그래서 작업 수행과 렌더링을 병행해야 한다면,(무거운 작업을 수행하면서, 사용자에게 progress bar와 같이 진척속도를 알려줘야 한다거나) 큰 작업을 작은 작업으로 나누는 것이 도움이 된다. 그렇지만, 예시처럼 모든 숫자 변화를 보여주는 렌더링을 설계하면, 과부하로 인해 브라우저가 응답을 멈출 수 있으니 주의할 필요가 있다.
tmi를 던져보자면....
setTimeout 타이머로 매크로 태스크를 생성할 때, 두번째 인자를 0으로 설정해도 크롬 환경에서는 최소 4ms을 보장한다. 무거운 작업을 수행할 때, 원형 loading indicator를 애니메이션과 함께 사용하면 작업 도중에도 애니메이션은 렌더링과정과는 달리 브라우저에서 그려주므로 도움이 될 수 있겠다. 그러나, 작업이 꽤나 무거워서 진척 상황을 progress bar로 렌더링해야 한다면(3초 이상 로딩 스피너만 돌아가게 된다면, 꽤나 곤란할 수 있다.) 작업을 나누고 현재 진척 상황을 기준으로 progress bar를 다시 렌더링해주는 방식이 더 괜찮을 수 있다. 이 때는 앞서 다룬 매크로 태스크를 이용할 수 있겠다.
참고
https://ko.javascript.info/event-loop
https://velog.io/@dami/JS-Microtask-Queue
'JavaScript > theory' 카테고리의 다른 글
이벤트 위임, 버블링, 캡쳐링 (Event Delegation, bubbling, capturing) (0) | 2022.07.11 |
---|---|
[함수형] 커링 Currying을 배워보자. (0) | 2022.06.01 |
브라우저/노드 환경에서 모듈, AMD, CommonJS, UMD 알아보기 (1) | 2022.02.15 |
자바스크립트의 this는 어떻게 결정되는가? (0) | 2022.01.25 |
자바스크립트의 undefined, null에 대해 알아보자. (0) | 2022.01.19 |