이벤트 루프의 마이크로 태스크 큐와 태스크 큐의 차이점

두 태스크 큐의 차이점은? 🧐


들어가기 전에

자바스크립트는 싱글 스레드 언어로 한 번에 하나의 동작만 실행할 수 있는데, 이런 환경에서 동시성을 지원하기 위해 등장한 매커니즘이 이벤트 루프다.

이벤트 루프는 콜스택, 브라우저 렌더링, 마이크로 태스크 큐, 태스크 큐(= 매크로 태스크 큐라고도 한다)를 모니터링 하며 각 영역에서 순차적으로 작업을 처리하고 이 과정을 반복하여 일종의 순환 구조를 형성한다.

이 중 마이크로 태스크 큐와 태스크 큐의 차이점을 알아보고자 한다.

태스크 큐 vs 마이크로 태스크 큐

둘 다 큐(Queue) 자료 구조를 사용하지만, 태스크 큐는 Web APIs 중 setTimeout, setInterval, 이벤트 등의 콜백 함수가 대기하는 공간이고, 반면에 마이크로 태스크 큐는 프로미스와 Mutation Observer의 콜백 함수가 대기하는 공간이다.

MDN에는 두 태스크 큐는 비슷하지만, 처리방법이 다르다고 적혀있다.

우선, 매번 태스크가 종료될 때마다 이벤트 루프는 이 태스크가 다른 JavaScript 코드로 통제권을 넘기는지 확인합니다. 그렇지 않은 경우 마이크로태스크 큐의 모든 마이크로태스크를 처리합니다. 곧 마이크로태스크 큐는, 이벤트 처리와 기타 콜백 실행 이후마다, 즉 이벤트 루프의 한 주기에 여러 번 처리됩니다. 두 번째로, 마이크로태스크가 queueMicrotask()를 통해 더 많은 마이크로태스크를 큐에 추가하는 경우, 이렇게 새롭게 추가된 마이크로태스크 또한 다음 태스크 실행 전에 모두 실행됩니다. 이벤트 루프는 대기열이 텅 빌 때까지 마이크로태스크를 계속 호출하기 때문입니다.

경고: 마이크로태스크 스스로 더 많은 마이크로태스크를 큐에 넣을 수 있으며 큐가 빌 때까지 마이크로태스크 처리는 멈추지 않기 때문에, 이벤트 루프가 끝없는 마이크로태스크 처리 루프에 빠질 현실적인 위험이 있습니다. 재귀적으로 마이크로태스크를 추가할 때 주의하세요.

위 내용과 같이 적혀있는데 직접 코드를 작성해서 무슨 차이가 있는지 살펴보자.

태스크 큐

태스크 큐에 대표적으로 포함되는 콜백 함수로는 setTimeout API가 있는데, setTimeout을 무한정으로 호출하는 재귀 함수를 만들어서 태스크 큐에 콜백 함수를 쌓아보자.

const macroLoop = () => {
  console.log('Macro task queue with setTimeout');
  setTimeout(() => {
    console.log('setTimeout');
    macroLoop();
  }, 0);
};

위와 같이 setTimeout이 무한정 호출되는 재귀 함수를 만들었다. macroLoop가 실행되면 해당 함수가 콜스택에 추가되고, setTimeout 콜백 함수는 태스크 큐에 추가된다. 이후에 macroLoop가 실행을 마치고 콜스택에서 제거되면 setTimeout의 콜백 함수인 console.log('setTimeout'); macroLoop();가 콜스택으로 옮겨져 실행되고 다시 macroLoop 함수가 호출되면서 위 작업을 반복하게 된다.

hover css가 적용된 버튼을 추가를 하고, 위 함수가 실행된 후 버튼에 마우스를 올려놓으면 어떻게 될까?

결과

태스크 큐 무한루프

버튼의 hover 효과가 정상적으로 작동하는데 이것은 태스크 큐에 콜백 함수가 계속 쌓이는 와중에 이벤트 루프가 브라우저를 꾸준히 렌더링 하기 때문이다.

마이크로 태스크 큐

이제는 마이크로 태스크 큐를 살펴보자.

위와 비슷한 재귀 함수를 실행시키되 setTimeout이 아닌 프로미스를 무한정으로 호출하여 마이크로 태스크 큐에 콜백 함수가 쌓이도록 한다.

const microLoop = () => {
  console.log('Micro task queue with Promise');
  Promise.resolve().then(() => {
    console.log('Promise');
    microLoop();
  });
};

위 코드에서 microLoop가 실행되면 프로미스의 then에 등록된 콜백 함수는 마이크로 태스크 큐에 추가된다. microLoop가 콜스택에서 제거되면 마이크로 태스크 큐에 있던 콜백 함수가 콜스택으로 이동되어 실행된다. 이후에 microLoop 함수가 다시 실행되면서 마이크로 태스크 큐에 다시 콜백 함수가 추가된다.

위 재귀 함수가 실행되는 동안, 아까와 같은 버튼에 마우스를 다시 올려보면 어떻게 될까?

결과

마이크로 태스크 큐 무한루프

태스크 큐와는 다르게 버튼의 hover css가 적용되지 않는다.

즉, 브라우저가 렌더링을 하지 않는다는 것인데 왜 마이크로 태스크 큐는 태스크 큐와 다른 동작을 할까?

차이점

위 MDN에서 마이크로 태스크 큐는 이벤트 루프의 한 주기에 여러 번 처리됩니다., 이벤트 루프는 대기열이 텅 빌 때까지 마이크로 태스크를 계속 호출하기 때문입니다.라고 한다.

그럼, 태스크 큐와 마이크로 태스크 큐는 정확하게 무엇이 다른걸까?

이벤트 루프는 각 영역을 순회하면서 업무를 처리하고 있는데 마이크로 태스크 큐에 콜백 함수가 있을 경우에 이벤트 루프는 루프를 돌지 않고 마이크로 태스크 큐에 머물면서 콜백 함수를 순차적으로 콜스택으로 옮긴다. 반면에, 태스크 큐에 콜백 함수가 있을 경우 이벤트 루프는 루프를 돌면서 태스크 큐의 콜백 함수를 하나씩 콜스택으로 옮긴다.

즉, 마이크로 태스크 큐에 콜백 함수가 남아 있으면 이벤트 루프는 루프를 순회하지 않는다. 이벤트 루프는 마이크로 태스크 큐에만 머물러 있기 때문에 이 과정에서 브라우저가 렌더링되지 않고 버튼의 hover css 효과가 적용 되지 않는다.

MDN 문서에 언급된 마이크로 태스크 처리 루프의 위험성은 이와 같은 동작 방식으로 인한 것으로써 끊임없이 마이크로 태스크를 처리하며 루프를 순회하지 않기 때문이다.

둘 다 있을 경우엔? 🧐

만약 마이크로 태스크 큐와 태스크 큐 둘 다 콜백 함수가 있다면 이벤트 루프는 어느 콜백 함수를 먼저 처리할까? 특정 버튼을 클릭했을 때 마이크로 태스크 큐와 태스크 큐의 콜백 함수가 쌓이게 하되, 순서를 마구 섞어놓아보자.

아래와 같이 setTimeout과 프로미스를 각각 3개씩 만들어 놓고 handleClick이 실행되면 어떻게 될까?

const handleClick = () => {
  console.log('handle click button!');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
  Promise.resolve().then(() => {
    console.log('Promise1');
  });
  setTimeout(() => {
    console.log('setTimeout2');
  }, 0);
  Promise.resolve().then(() => {
    console.log('Promise2');
  });
  Promise.resolve().then(() => {
    console.log('Promise3');
  });
  setTimeout(() => {
    console.log('setTimeout3');
  }, 0);
};

결과

동시에

마이크로 태스크 큐의 콜백 함수가 먼저 실행되고 태스크 큐의 콜백 함수가 나중에 실행된다. 😋

참고

https://developer.mozilla.org/ko/docs/Web/API/HTML_DOM_API/Microtask_guide