HTML DOM API/Using microtasks in JavaScript with queueMicrotask()

김동현·2026년 4월 7일

계속해서 공식 문서 번역을 도와드릴게요. 말씀하신 대로 생략이나 요약 없이, 실무에서 사용하는 자연스럽고 편안한 구어체로 꼼꼼하게 번역했습니다. 마크다운 구조와 코드 블록, 링크들도 모두 제대로 동작하도록 정리해 두었어요!


태스크와 마이크로태스크 (Tasks vs. microtasks)

마이크로태스크에 대해 제대로 이야기하려면, 먼저 자바스크립트의 태스크(task)가 무엇이고 마이크로태스크와 어떻게 다른지 아는 것이 좋아요. 간단하고 알기 쉽게 설명해 드릴 텐데요, 더 자세한 내용이 궁금하시다면 심층 분석: 마이크로태스크와 자바스크립트 런타임 환경(In depth: Microtasks and the JavaScript runtime environment) 문서를 읽어보시는 걸 추천해요.

태스크 (Tasks)

태스크(task)란 프로그램을 처음 실행하거나, 이벤트가 비동기적으로 발생하거나, 인터벌(interval) 또는 타임아웃(timeout)이 실행되는 등 표준 메커니즘에 의해 실행되도록 예약된 모든 작업을 말해요. 이것들은 모두 태스크 큐(task queue)에 예약(스케줄링)된답니다.

예를 들어, 다음과 같은 상황에서 태스크가 태스크 큐에 추가돼요:

여러분의 코드를 구동하는 이벤트 루프(event loop)는 이 태스크들을 큐에 들어온 순서대로 하나씩 차례대로 처리해요. 이벤트 루프가 한 번 도는(iteration) 동안 태스크 큐에서 가장 오래된, 실행 가능한 태스크 하나가 실행되죠. 그 후에는 마이크로태스크 큐가 텅 빌 때까지 모든 마이크로태스크들이 실행되고, 그 다음에 브라우저가 화면 렌더링을 업데이트할지 결정해요. 그리고 나서 이벤트 루프는 다음 반복으로 넘어가게 됩니다.

마이크로태스크 (Microtasks)

처음에는 마이크로태스크와 태스크의 차이가 별거 아닌 것처럼 보일 수 있어요. 실제로 비슷하기도 하고요. 둘 다 자바스크립트 코드로 이루어져 있고, 큐에 들어가서 적절한 시점에 실행되니까요. 하지만, 이벤트 루프가 반복을 시작할 때 태스크 큐에 있던 태스크만 하나씩 처리하는 것과 달리, 마이크로태스크 큐는 아주 다르게 처리한답니다.

크게 두 가지 핵심적인 차이가 있어요:
1. 하나의 태스크가 끝날 때마다 이벤트 루프는 해당 태스크가 다른 자바스크립트 코드로 제어권을 넘겨주는지 확인해요. 만약 그렇지 않다면, 마이크로태스크 큐에 있는 모든 마이크로태스크를 실행합니다. 즉, 마이크로태스크 큐는 이벤트와 다른 콜백들을 처리한 후를 포함하여, 이벤트 루프가 한 번 도는 동안 여러 번 처리될 수 있어요.
2. 만약 어떤 마이크로태스크가 queueMicrotask()를](https://developer.mozilla.org/en-US/docs/Web/API/Window/queueMicrotask)를) 호출해서 큐에 새로운 마이크로태스크를 추가한다면, 새로 추가된 마이크로태스크들은 다음 태스크가 실행되기 전에 모두 실행돼요. 왜냐하면 이벤트 루프는 마이크로태스크 큐에 남은 게 하나도 없을 때까지 계속해서 마이크로태스크를 호출하기 때문이죠. 중간에 계속 새롭게 추가되더라도 말이에요.

경고 (Warning):
마이크로태스크는 스스로 새로운 마이크로태스크를 큐에 계속 추가할 수 있고, 이벤트 루프는 큐가 완전히 빌 때까지 계속 처리하기 때문에, 자칫하면 이벤트 루프가 끝없이 마이크로태스크만 처리하게 될 위험이 있어요. 따라서 마이크로태스크를 재귀적으로 추가할 때는 아주 조심해야 한답니다.

마이크로태스크 사용하기 (Using microtasks)

이 내용을 더 깊이 파고들기 전에 꼭 짚고 넘어가야 할 점이 있어요. 대부분의 개발자들은 마이크로태스크를 쓸 일이 거의 없거나 아예 없을 거예요. 이건 최신 브라우저 환경의 자바스크립트 개발에서 사용되는 고도로 전문화된 기능으로, 사용자 컴퓨터에서 실행을 기다리는 수많은 작업들 사이로 특정 코드를 새치기(?)해서 실행되도록 스케줄링할 때 쓰거든요. 이 기능을 남용하면 심각한 성능 문제로 이어질 수 있습니다.

마이크로태스크 큐에 추가하기 (Enqueueing microtasks)

그렇기 때문에 다른 해결책이 전혀 없거나, 기능을 구현하기 위해 반드시 마이크로태스크가 필요한 프레임워크나 라이브러리를 만들 때만 사용하는 것이 좋습니다. 과거에는 (즉시 resolve되는 프로미스를 만드는 등) 편법을 써서 마이크로태스크를 큐에 넣곤 했지만, queueMicrotask() 메서드가 추가되면서 꼼수 없이 안전하게 마이크로태스크를 도입할 수 있는 표준 방법이 생겼어요.

queueMicrotask()를 도입함으로써, 프로미스를 몰래 이용해서 마이크로태스크를 만들 때 발생하던 이상한 버그들을 피할 수 있게 되었어요. 예를 들어 프로미스로 마이크로태스크를 만들면 콜백에서 발생한 에러가 일반적인 예외(exception)로 보고되지 않고 '거부된 프로미스(rejected promises)'로 처리되거든요. 게다가 프로미스를 생성하고 파괴하는 과정은 시간과 메모리 측면에서 모두 추가적인 오버헤드를 발생시키는데, 마이크로태스크를 제대로 추가해 주는 전용 함수를 쓰면 이런 낭비를 막을 수 있죠.

마이크로태스크를 처리하는 컨텍스트에서 호출할 자바스크립트 Function을](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function)을) queueMicrotask() 메서드에 인자로 넘겨주면 돼요. 이 메서드는 현재 실행 컨텍스트에 따라 Window나](https://developer.mozilla.org/en-US/docs/Web/API/Window)나) Worker 인터페이스에 의해 전역 컨텍스트에 노출되어 있답니다.

queueMicrotask(() => {
  /* 마이크로태스크 안에서 실행할 코드 */
});

마이크로태스크 함수 자체는 파라미터를 받지 않고, 값을 반환하지도 않아요.

마이크로태스크는 언제 사용해야 할까? (When to use microtasks)

이번 섹션에서는 마이크로태스크가 특히 유용하게 쓰이는 상황들을 살펴볼게요. 일반적으로는 자바스크립트 실행 컨텍스트의 메인 코드가 종료된 후, 하지만 이벤트 핸들러나 타임아웃, 인터벌, 기타 다른 콜백들이 처리되기 전에 결과를 확인/수집하거나 정리(cleanup) 작업을 해야 할 때 유용해요.

그게 언제 유용하냐고요?

마이크로태스크를 사용하는 주된 이유는, 결과나 데이터가 동기적으로 사용 가능할 때도 태스크의 실행 순서를 항상 일관되게 보장하는 동시에, 사용자가 느낄 만한 작업 지연 위험을 최소화하기 위해서예요.

조건부 프로미스 사용 시 실행 순서 보장하기 (Ensuring ordering on conditional use of promises)

마이크로태스크를 사용해서 실행 순서를 일관되게 맞출 수 있는 대표적인 상황은, if...else 문(또는 기타 조건문)의 한쪽 분기에서는 프로미스를 사용하고 다른 쪽 분기에서는 사용하지 않을 때예요. 아래 코드를 한번 볼까요?

customElement.prototype.getData = function (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"));
      });
  }
};

여기서 생기는 문제는 if...else 문의 한쪽 분기(이미지가 캐시에 있는 경우)에서는 일반 태스크를 사용하고, else 구문에서는 프로미스를 사용한다는 점이에요. 이로 인해 작업 순서가 뒤죽박죽될 수 있는 상황이 벌어집니다. 예를 들어 아래와 같은 코드를 실행한다고 해볼게요.

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

더 최악인 건, 어떨 때는 요소의 data 속성이 설정되기도 하고, 어떨 때는 이 코드가 다 끝나기도 전에 설정되지 않기도 한다는 거예요.

이럴 때 if 구문 안에 마이크로태스크를 사용해서 두 분기의 균형을 맞추면 작업 순서를 항상 일관되게 보장할 수 있어요:

customElement.prototype.getData = function (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"));
      });
  }
};

이렇게 하면 두 상황 모두 마이크로태스크 안에서 data를 설정하고 load 이벤트를 발생시키기 때문에 양쪽 분기의 타이밍 균형이 맞아요 (if 구문에서는 queueMicrotask()를 쓰고, else 구문에서는 fetch()가](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch)가) 생성한 프로미스를 쓰는 방식이죠).

작업 일괄 처리하기 (Batching operations)

여러 소스에서 들어오는 요청들을 하나의 배치(batch)로 모아서 처리할 때도 마이크로태스크를 쓸 수 있어요. 이렇게 하면 같은 종류의 작업을 여러 번 호출해서 생기는 불필요한 오버헤드를 피할 수 있죠.

아래 코드는 여러 메시지를 배열로 모아두었다가, 컨텍스트가 종료될 때 마이크로태스크를 이용해서 이 메시지들을 하나의 객체로 전송하는 함수를 만드는 예제예요.

const messageQueue = [];

let sendMessage = (message) => {
  messageQueue.push(message);

  if (messageQueue.length === 1) {
    queueMicrotask(() => {
      const json = JSON.stringify(messageQueue);
      messageQueue.length = 0;
      fetch("url-of-receiver", json);
    });
  }
};

sendMessage()가 호출되면, 전달된 메시지는 우선 메시지 큐 배열에 들어갑니다(push). 그 다음부터가 재미있어요.

만약 방금 배열에 추가한 메시지가 첫 번째 메시지라면, 일괄 전송을 담당할 마이크로태스크를 큐에 예약합니다. 이 마이크로태스크는 언제나 그렇듯 자바스크립트 실행 흐름이 최상위 레벨에 도달했을 때, 즉 콜백들이 실행되기 바로 직전에 실행돼요. 이게 무슨 뜻이냐면, 그 짧은 사이에 sendMessage()가 추가로 더 호출되더라도 메시지는 계속 배열에 쌓이기만 하고, 배열의 길이를 체크하는 조건문(length === 1) 덕분에 새로운 마이크로태스크가 큐에 또 추가되지는 않는다는 뜻이에요.

그렇게 마이크로태스크가 실행될 시점이 되면, 배열 안에는 전송을 기다리는 수많은 메시지들이 모여있게 됩니다. 마이크로태스크는 먼저 JSON.stringify() 메서드를 써서 이 메시지들을 JSON 문자열로 인코딩해요. 그러고 나면 배열의 내용물은 더 이상 필요 없으니 messageQueue 배열을 싹 비워줍니다. 마지막으로 fetch() 메서드를 사용해 이 JSON 문자열을 서버로 전송하죠.

이 방법을 쓰면 이벤트 루프가 한 번 도는 동안 발생한 모든 sendMessage() 호출들이, 잠재적으로 전송을 지연시킬 수 있는 타임아웃 같은 다른 태스크들의 방해 없이 하나의 fetch() 동작 안에 차곡차곡 모여서 처리될 수 있답니다.

서버는 이렇게 전송된 JSON 문자열을 받아서 디코딩한 뒤, 배열 안에서 찾은 메시지들을 처리하겠죠.

예제 (Examples)

간단한 마이크로태스크 예제 (Simple microtask example)

이 간단한 예제에서는 마이크로태스크를 큐에 넣었을 때, 해당 최상위 스크립트 본문의 실행이 모두 끝난 후에야 마이크로태스크의 콜백이 실행된다는 것을 확인할 수 있어요.

▶ MDN Playground에서 예제 실행해 보기 (새 탭에서 열기)

<pre id="log"></pre>

JavaScript

const logElem = document.getElementById("log");
const log = (s) => (logElem.innerText += `${s}\n`);

아래 코드에서는 마이크로태스크를 실행하도록 스케줄링하는 queueMicrotask() 호출을 볼 수 있어요. 화면에 텍스트를 출력해 주는 커스텀 함수인 log() 호출들 사이에 이 코드가 샌드위치처럼 끼어있죠.

log("Before enqueueing the microtask");
queueMicrotask(() => {
  log("The microtask has run.");
});
log("After enqueueing the microtask");

결과 (Result)

(대화형 예제 실행 결과 렌더링 영역)

Before enqueueing the microtask
After enqueueing the microtask
The microtask has run.

타임아웃과 마이크로태스크 예제 (Timeout and microtask example)

이번 예제에서는 0밀리초 뒤에(혹은 "최대한 빨리") 실행되도록 타임아웃을 예약했어요. 이를 통해 일반적인 새로운 태스크를 스케줄링할 때(예: setTimeout() 사용)와 마이크로태스크를 사용할 때, 여기서 말하는 "최대한 빨리"가 각각 어떻게 다르게 동작하는지 알 수 있습니다.

▶ MDN Playground에서 예제 실행해 보기 (새 탭에서 열기)

<pre id="log"></pre>

JavaScript

const logElem = document.getElementById("log");
const log = (s) => (logElem.innerText += `${s}\n`);

아래 코드는 0밀리초 뒤에 실행되도록 타임아웃을 설정한 다음, 마이크로태스크를 큐에 추가해요. 그리고 앞뒤로 log() 함수를 호출해서 추가적인 메시지들을 출력하고 있습니다.

const callback = () => log("Regular timeout callback has run");

const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");

log("Main program started");
setTimeout(callback, 0);
queueMicrotask(urgentCallback);
log("Main program exiting");

결과 (Result)

(대화형 예제 실행 결과 렌더링 영역)

Main program started
Main program exiting
*** Oh noes! An urgent callback has run!
Regular timeout callback has run

출력 결과를 잘 살펴보세요. 메인 프로그램 본문에서 출력한 로그가 가장 먼저 나오고, 그 다음에 마이크로태스크의 출력, 마지막으로 타임아웃 콜백의 출력이 나오는 것을 볼 수 있어요. 왜냐하면 메인 프로그램을 실행하던 태스크가 종료됐을 때, 타임아웃 콜백이 들어있는 일반 태스크 큐보다 마이크로태스크 큐가 먼저 처리되기 때문이에요. 헷갈리지 않으려면, 태스크와 마이크로태스크는 서로 별도의 큐에 저장되며 마이크로태스크가 항상 먼저 실행된다는 점을 꼭 기억해 두세요.

함수 안에서 마이크로태스크 실행하기 (Microtask from a function)

이 예제는 앞선 예제에 뭔가 계산 작업을 수행하는 함수 하나를 추가해서 살짝 확장해 보았어요. 이 함수는 queueMicrotask()를 써서 마이크로태스크를 예약하는데요. 여기서 얻어가야 할 핵심은, 마이크로태스크는 해당 함수가 끝날 때 처리되는 것이 아니라 메인 프로그램이 완전히 종료될 때 처리된다는 거예요.

▶ MDN Playground에서 예제 실행해 보기 (새 탭에서 열기)

<pre id="log"></pre>

JavaScript

const logElem = document.getElementById("log");
const log = (s) => (logElem.innerText += `${s}\n`);

다음은 메인 프로그램 코드입니다. 여기서 doWork() 함수가 내부적으로 queueMicrotask()를 호출하지만, 마이크로태스크는 메인 프로그램이 모두 끝나고 나서야 비로소 실행돼요. 메인 프로그램 실행이 다 끝나야 현재 태스크가 종료되고, 실행 스택(execution stack)에 남은 게 없어지기 때문이죠.

const callback = () => log("Regular timeout callback has run");

const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");

const doWork = () => {
  let result = 1;

  queueMicrotask(urgentCallback);

  for (let i = 2; i <= 10; i++) {
    result *= i;
  }
  return result;
};

log("Main program started");
setTimeout(callback, 0);
log(`10! equals ${doWork()}`);
log("Main program exiting");

결과 (Result)

(대화형 예제 실행 결과 렌더링 영역)

Main program started
10! equals 3628800
Main program exiting
*** Oh noes! An urgent callback has run!
Regular timeout callback has run

참고 자료 (See also)

profile
프론트에_가까운_풀스택_개발자

0개의 댓글