자바스크립트 런타임은 콜 스택(Call Stack), 태스크 큐(Task Queue), 마이크로태스크 큐(Microtask Queue), 이벤트 루프(Event Loop)로 구성되어 있습니다. 이 이벤트 루프가 어떻게 동작하는지 잘 이해하고 있어야 최적화나 올바른 아키텍처 설계가 가능해집니다.

 

다음의 코드 실행 결과가 어떻게 될지 아시나요? 

console.log('Start');

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

Promise.resolve().then(() => {
  console.log('promise resolved');
});

console.log('End');

정답은 다음과 같습니다.

Start
End
promise resolved
setTimeout done

이 코드의 정확한 실행 순서를 유추하기 위해서는 자바스크립트의 자바스크립트 런타임의 구성을 살펴 볼 필요가 있습니다. 이 자바스크립트 런타임은 자바스크립트 코드를 실행하기 위한 전체 환경을 지칭하며, 자바스크립트가 비동기 작업을 어떻게 처리하는 지 설명할 수 있습니다. 자바스크립트는 싱글 스레드 언어로 한 번에 한 가지 작업만을 처리할 수 있습니다. 그러나 이벤트 루프를 통해 비동기적으로 작업을 처리하며 여러 작업을 동시에 처리하는 것처럼 보이게 동작할 수 있습니다.

콜 스택

자바스크립트를 포함한 많은 언어들은 함수 호출을 스택으로 관리합니다. 실행할 함수가 있을 때 마다 콜 스텍에 함수를 추가하고 실행이 완료되면 스택에서 제거됩니다. 스택으로 관리하기 때문에 먼저 실행된 함수가 끝난 다음에 해당 함수를 호출한 코드의 다음 줄이 실행됩니다. 동기적으로 실행되는 코드는 코드의 작성 순서대로 콜 스텍에 추가되고 제거되는 과정을 반복하면서 전체 코드가 실행됩니다. 다시 말해 콜 스택의 코드는 순서대로 실행 되며, 하나의 함수가 실행이 끝나야 다음 함수가 실행될 수 있습니다.

이벤트 루프

자바스크립트의 콜 스택에 함수가 비워지면 자바스크립트의 유일한 스레드는 이벤트 루프가 제어권을 가지게 됩니다. 이벤트 루프는 태스크 큐라는 곳을 살펴 더 실행해야 할 함수를 찾아 하나씩 가져옵니다. 만약 이벤트 루프가 함수를 찾았다면 콜 스택으로 해당 함수를 옮기고 제어권을 다시 콜 스택으로 넘깁니다. 만약 콜 스택에 올릴 함수가 더 이상 존재하지 않으면 제어권은 이벤트 루프가 계속 가지면서 존재할 수도 있는 Web API의 작업 완료를 기다립니다.

Web API

만약, 비동기 작업을 처리하여야 한다면 어떻게 할까요? 자바스크립트는 타이머 함수(setTimeout), DOM 이벤트, HTTP 요청 등 코드의 작성 순서와 관계없이 동작할 수 밖에 없는 비동기 작업들이 있습니다. 이런 비동기 작업을 처리하기 위해 브라우저의 Web API(node 환경에서는 자체 모듈 및 libuv 라이브러리)가 필요합니다. 타이머와 같은 비동기 작업들은 자바스크립트 엔진이 아닌 web API에 의해 관리됩니다. 콜 스택에 올라온 비동기 함수는 즉시 실행이 완료된 것으로 처리되며 WebAPI로 보내집니다. web API는 자바스크립트 대신 해당 작업을 처리하고 완료가 되면 다시 자바스크립트의 런타임으로 결과를 돌려줍니다.

Web API
자바스크립트 엔진이 아닌 외부환경(브라우저나 Node)에서 제공되는 API
setTimeout, fetch, DOM 조작 API 등은 자바스크립트 코드에서 사용할 수 있지만 실제로는 자바스크립트 엔진 밖에서 작동합니다. Web API에서는 비동기 작업들을 수행하고 완료되면 콜백을 태스크 큐에 추가합니다.

태스크 큐

web API에 의해 처리된 비동기 작업은 자바스크립트의 태스크 큐에 전달됩니다. 태스크 큐는 매크로 태스크 큐(Macro Task Queue)마이크로 태스크 큐(Micro Task Queue)로 나뉘어져 있습니다. 태크스 큐 매크로 태스크 큐이며 엄밀히 따지면 마이크로 태스크 큐는 태스크 큐에 포함되지 않는다고 합니다. 마이크로 태스크 큐는 이벤트 루프가 가진 태스크 큐 하지만 여기서는 명확한 구분을 위해 태스크 큐를 매크로 태스크 큐라고 칭하겠습니다. 이글에서 태스크 큐는 둘 모두를 포함하는 단어입니다.

 

태스크 큐는 모두 Web API에 의해 완료된 작업들이 들어가게 됩니다. 태스크 큐에 들어가는 내용은 비동기 함수의 반환값이 되거나 비동기 함수의 콜백 함수들입니다. 자바스크립트가 실행 중에 만나는 모든 비동기 코드는 Web API에서 처리되고 태스크 큐로 들어오는 것입니다. 매크로 태스크 큐에는 setTimeout, setInterval, I/O 작업들의 콜백들이 보관됩니다. 마이크로 태스크 큐에는 Promise.then, MutationObserver(DOM 트리의 변경을 감지)와 같은 작업들이 완료된 후 실행될 콜백들이 대기합니다.

 

태스크 큐에서 대기하는 작업들은 이벤트 루프에 의해 대기하고 있던 작업을 하나씩 콜 스택으로 보내집니다. 그러면 콜 스택에서 콜백 함수들이 실행됩니다. 이벤트 루프가 태스크 큐의 작업을 가져갈 때는 마이크로 태스크 큐의 작업을 우선적으로 처리합니다. 즉 콜 스택이 비어 있으면 이벤트 루프는 항상 마이크로 태스크 큐의 작업을 먼저 콜 스택으로 전달하고 마이크로 태크스 큐까지 비었다면 매크로 태스크 큐의 작업을 콜스택으로 가져갑니다.

태스크 큐를 두 가지 채널로 분리한 이유

작업을 마이크로태스크와 매크로태스크로 구분한 이유는 자바스크립트가 단일 스레드에서 동작하는 특성 때문에, 작업의 우선순위를 관리하고 사용자 경험을 개선하기 위해서입니다. 마이크로 태스크 큐에 배치되는 작업들은 대부분 현재 실행 중인 코드와 직접적으로 연관되며 이어져야 하는 후속 작업입니다. 예를 들어, Promise의 처리와 같은 작업은 바로 이어서 처리되어야만 코드의 실행 흐름이 끊기지 않고 자연스럽게 이어질 수 있습니다. 반대로 매크로 태스크 큐에 배치되는 작업들은 일정 시간이 걸릴 수 있는 비동기 작업들로 당장 처리하고 있는 코드(혹은 직전에 처리한 코드)와 직접적인 연관이 있지는 않습니다. 이런 작업들은 당장 처리하고 있는 코드와 즉각적이로 이어져서 실행될 필요가 없습니다. 그래서 이전의 작업과 이어져야하는 상대적으로 더 긴급한 작업인 마이크로 태스크 큐의 작업들이 완료된 후 매크로 태스크 큐의 작업이 실행되도록 한 것입니다.

마이크로 태스크 큐와 매크로 태스크 큐를 이용하여 사용자 경험 개선하기

엄청나게 오래 걸리는 작업을 잘게 쪼개어, 중간중간에 사용자의 입력 혹은 UI 렌더링을 실행할 수 있도록 할 때 태스크 큐를 이용한 개선을 사용할 수 있습니다. 작업이 잘게 쪼개어 진다면 쪼개진 다음 작업을 실행하기 전에 사용자의 입력을 받을 수 있습니다.

const start = Date.now();

function count() {
  let num = 0;
  // CPU 소모가 많은 무거운 작업을 수행
  for (let i = 0; i < 1e9; i++) {
    num++;
  }

  alert(`이벤트를 받지 못하는 시간: ${Date.now() - start}ms`);
}

count();

아래의 버튼을 눌러 실험해 볼 수 있습니다.

See the Pen 마이크로 태스크 큐와 매크로 태스크 큐 이용하여 개선하기 - 개선 전 코드 by 최도훈 (@Dohun-choi-the-selector) on CodePen.

 

 

위의 코드는 무거운 작업을 실행하는 count 함수입니다. count 함수가 실행되는 동안 다른 작업을 시작할 수 없습니다. 이 코드를 지금 보시는 브라우저의 콘솔창에서 실행시킨다면 alert가 실행되기 전까지 어떠한 상호작용도 할 수 없습니다. 예를 들어 코드 실행 후 우클릭을 한다면 경고창을 종료한 뒤에야 우클릭의 결과가 나타납니다.

 

하지만 위의 코드를 다음과 같이 쪼개면 작업 중간중간 다른 작업을 할 수 있습니다.

const start = Date.now();

function improvedCount(num = 0) {
  if (num < 1e9 - 1e6) {
    setTimeout(improvedCount.bind(null, num + 1e6));
  }

  do {
    num++;
  } while (num % 1e6 != 0);

  console.log(`이벤트 받는 시간입니다.\n전체 코드 진행 ${num}`);

  if (num === 1e9) {
    alert(`전체 실행 시간: ${Date.now() - start}ms`);
  }
}

improvedCount();

See the Pen 마이크로 태스크 큐와 매크로 태스크 큐 이용하여 개선하기 - 개선된 코드 by 최도훈 (@Dohun-choi-the-selector) on CodePen.

 

 

count함수가 한번에 실행되지 않고 100만 단위로 분리되어 실행됩니다. 그리고 다음 회차의 실행은 setTimeout을 통해 매크로 태스크 큐에 들어갑니다. 그러면 이벤트 루프는 다음 improvedCount 재귀가 시작 되기 전 마이크로 태스크 큐를 확인하며 다른 작업을 실행할 수 있습니다. 여기에 더해 setTimeout의 최소 시간은 4ms이기 때문에 콜백 호출을 실제 작업보다 먼저 실행하여 빠르게 다음 작업을 실행할 수 있도록 하는 것이 전체 작업 실행 시간을 줄이는 것에 도움이 됩니다. 위의 코드를 실행시킨다면 코드가 실행중이더라도 다른 상호작용을 자바스크립트가 받아 다음 작업 실행 이전에 빠르게 처리할 수 있습니다. 하지만 쪼개진 작업들의 전체 실행 시간은 (잘게 쪼갤 수록 더 많이)증가하기 때문에 주의가 필요합니다.

요약

excalidraw로 직접 그린 그림

  1. 자바스크립트 엔진은 함수의 실행을 스택의 형태로 관리하며 이를 콜 스택이라고 부른다.
    1-1. 자바스크립트는 싱글 스레드이기 때문에 함수 실행 중 다른 작업을 할 수 없다.
  2. 콜 스택에 올라온 함수는 즉시 실행되며 완료되면 스택에서 제거한다.
  3. 함수의 실행 과정에서 다른 함수를 호출하면 호출된 함수는 콜 스택의 가장 위에 쌓인다.
  4. 콜스택에 올라온 비동기 함수는 즉시 실행 완료된 것으로 처리되며 Web API로 보내진다.
    4-1. Web API는 자바스크립트 대신 비동기 작업을 실행하며 완료된 작업은 태스크 큐로 보낸다.
    4-2. setTimeout, setInterval, I/O 작업의 콜백은 매크로 태스크 큐로 Promise.then, MutationObserver는 마이크로 태스크 큐로 보낸다.
  5. 콜 스택이 전부 비게 되면 제어권이 이벤트 루프로 넘어가 태스크 큐에서 작업을 찾는다.
    5-1. 이벤트 루프는 마이크로 태스크 큐를 먼저 살핀다.
  6. 이벤트 루프가 작업을 하나 찾으면 그 즉시 콜 스택에 작업을 올리고 제어권을 콜 스택으로 넘겨준다.
  7. 모든 콜스택과 태스크 큐가 비게 되면 제어권을 이벤트 루프가 가지고 있는다.

참고 자료

모던 자바스크립트 튜토리얼, 이벤트 루프와 매크로태스크, 마이크로태스크

MDN, JavaScript의 queueMicrotask()와 함께 마이크로태스크 사용하기

 

수정 내역

2024.09.26

마이크로 태스크 큐와 매크로 태스크 큐 이용하여 개선하기 추가, CodePen 추가, 제목 수정(자바스크립트 코드의 실행 순서를 알아보자→태스크큐를 중점으로 자바스크립트 코드의 실행 순서를 알아보자)

+ Recent posts