Task Queue vs Microtask Queue - 비동기 실행 순서의 비밀

이런 코드의 실행 순서를 맞출 수 있으신가요?

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

“1, 2, 3, 4? 아니면 1, 4, 2, 3?”

정답은 1, 4, 3, 2입니다. setTimeoutPromise.then 둘 다 비동기인데, 왜 Promise가 먼저 실행될까요?

처음에는 “둘 다 비동기니까 등록된 순서대로 실행되겠지”라고 생각했습니다. 하지만 JavaScript에는 두 개의 다른 큐가 있고, 이 큐들 사이에는 명확한 우선순위가 있습니다.

이 문서에서는 Task Queue와 Microtask Queue의 차이점을 철저히 파헤쳐서, 어떤 비동기 코드든 실행 순서를 정확히 예측할 수 있게 해드리겠습니다.

목차


왜 두 개의 큐가 필요할까요?

1. 작업의 우선순위가 다릅니다

모든 비동기 작업이 동등하지 않습니다:

// 사용자가 버튼을 클릭했을 때
button.addEventListener('click', () => {
  // 1. 즉시 UI 업데이트가 필요한 작업 (높은 우선순위)
  updateButtonState();

  // 2. 나중에 해도 되는 작업 (낮은 우선순위)
  sendAnalytics();
});

Promise의 .then()은 “지금 하던 일이 끝나면 바로” 실행되어야 하고, setTimeout은 “시간이 되면 언젠가” 실행되면 됩니다.

2. Promise 체이닝의 일관성

Promise.resolve()
  .then(() => console.log('A'))
  .then(() => console.log('B'))
  .then(() => console.log('C'));

console.log('D');

만약 Promise가 Task Queue를 사용했다면, 다른 Task들이 중간에 끼어들 수 있어서 A, B, C의 순서가 보장되지 않았을 겁니다. Microtask Queue 덕분에 Promise 체인은 원자적(atomic)으로 실행됩니다.

3. 렌더링 타이밍 제어

┌─────────────────────────────────────────────────────────┐
│                    이벤트 루프 한 사이클                    │
├─────────────────────────────────────────────────────────┤
│  1. Task 하나 실행                                        │
│          ↓                                               │
│  2. Microtask 전부 실행 (큐가 빌 때까지)                    │
│          ↓                                               │
│  3. 렌더링 (필요한 경우)                                   │
│          ↓                                               │
│  4. 다음 Task 실행...                                     │
└─────────────────────────────────────────────────────────┘

Microtask는 렌더링 전에 모두 처리되고, Task는 렌더링 사이에 하나씩 처리됩니다.


Task Queue (Macrotask Queue)란?

Task Queue는 일반적인 비동기 작업들이 대기하는 큐입니다. “Macrotask Queue”라고도 불립니다.

Task Queue에 들어가는 것들

// 1. setTimeout / setInterval
setTimeout(() => console.log('Task!'), 0);
setInterval(() => console.log('Repeating Task!'), 1000);

// 2. setImmediate (Node.js 전용)
setImmediate(() => console.log('Immediate Task!'));

// 3. I/O 작업 (Node.js)
fs.readFile('file.txt', (err, data) => {
  console.log('File read Task!');
});

// 4. UI 렌더링 작업 (브라우저)
// - 클릭, 스크롤 등의 이벤트 핸들러

// 5. MessageChannel
const channel = new MessageChannel();
channel.port1.onmessage = () => console.log('Message Task!');
channel.port2.postMessage('');

// 6. requestAnimationFrame (특수한 Task)
requestAnimationFrame(() => console.log('Animation Task!'));

Task의 특징

Task Queue 동작 방식:

  Task Queue                        Call Stack
  ┌─────────────┐                  ┌─────────────┐
  │ setTimeout  │ ──── 하나씩 ────▶ │             │
  │ click event │     이동          │  실행 중     │
  │ setInterval │                  │             │
  └─────────────┘                  └─────────────┘
        ▲
        │ 새 Task 추가
        │
   Web APIs / Node.js

핵심 규칙: 이벤트 루프 한 사이클당 Task는 하나만 실행됩니다.

// 두 개의 setTimeout이 있어도
setTimeout(() => console.log('Task 1'), 0);
setTimeout(() => console.log('Task 2'), 0);

// 이벤트 루프 사이클 1: Task 1 실행 → Microtask 처리 → (렌더링)
// 이벤트 루프 사이클 2: Task 2 실행 → Microtask 처리 → (렌더링)

Microtask Queue란?

Microtask Queue는 높은 우선순위의 비동기 작업들이 대기하는 큐입니다.

Microtask Queue에 들어가는 것들

// 1. Promise.then / catch / finally
Promise.resolve().then(() => console.log('Microtask!'));

// 2. queueMicrotask (명시적 Microtask 등록)
queueMicrotask(() => console.log('Explicit Microtask!'));

// 3. MutationObserver
const observer = new MutationObserver(() => {
  console.log('DOM Mutation Microtask!');
});
observer.observe(document.body, { childList: true });

// 4. async/await (await 이후의 코드)
async function example() {
  console.log('Before await');
  await Promise.resolve();
  console.log('After await - Microtask!');
}

Microtask의 특징

Microtask Queue 동작 방식:

  Microtask Queue                   Call Stack
  ┌─────────────┐                  ┌─────────────┐
  │ Promise.then│ ──── 전부 ──────▶ │             │
  │ await 이후   │     비울 때까지    │  실행 중     │
  │ queueMicro  │     이동          │             │
  └─────────────┘                  └─────────────┘

핵심 규칙: Microtask Queue가 빌 때까지 모든 Microtask를 실행합니다.

Promise.resolve()
  .then(() => {
    console.log('Microtask 1');
    // Microtask 안에서 새 Microtask 추가
    Promise.resolve().then(() => console.log('Microtask 3'));
  })
  .then(() => console.log('Microtask 2'));

setTimeout(() => console.log('Task'), 0);

// 출력: Microtask 1, Microtask 2, Microtask 3, Task
// Microtask 3이 Task보다 먼저 실행됩니다!

핵심 차이점 비교

한눈에 보는 비교표

특성 Task Queue Microtask Queue
우선순위 낮음 높음
실행 개수 사이클당 1개 큐가 빌 때까지 전부
렌더링 각 Task 사이에 가능 렌더링 전에 모두 처리
대표 API setTimeout, setInterval Promise.then, queueMicrotask
용도 무거운 작업 분할 빠른 후속 처리

시각적 비교

시간 흐름 →

Task Queue 사용 시:
┌──────┐   ┌───────┐   ┌──────┐   ┌───────┐   ┌──────┐
│Task 1│ → │Render │ → │Task 2│ → │Render │ → │Task 3│
└──────┘   └───────┘   └──────┘   └───────┘   └──────┘

Microtask Queue 사용 시:
┌──────────────────────────────────────┐   ┌───────┐
│ Microtask 1 → Microtask 2 → Microtask 3 │ → │Render │
└──────────────────────────────────────┘   └───────┘

코드로 보는 차이

// Task 방식: 렌더링이 중간에 일어날 수 있음
function updateWithTasks() {
  setTimeout(() => { element.style.left = '100px'; }, 0);
  setTimeout(() => { element.style.left = '200px'; }, 0);
  setTimeout(() => { element.style.left = '300px'; }, 0);
  // 사용자가 100px, 200px 위치를 볼 수도 있음
}

// Microtask 방식: 렌더링 전에 모두 처리
function updateWithMicrotasks() {
  Promise.resolve().then(() => { element.style.left = '100px'; });
  Promise.resolve().then(() => { element.style.left = '200px'; });
  Promise.resolve().then(() => { element.style.left = '300px'; });
  // 사용자는 최종 위치인 300px만 봄
}

실행 순서 규칙

황금 규칙

┌─────────────────────────────────────────────────────────┐
│                     실행 순서 우선순위                      │
├─────────────────────────────────────────────────────────┤
│  1순위: 현재 실행 중인 동기 코드 (Call Stack)                │
│  2순위: Microtask Queue (모두 비울 때까지)                  │
│  3순위: Task Queue (하나만)                               │
│  4순위: 렌더링 (필요한 경우)                                │
│  5순위: 다음 사이클 반복...                                 │
└─────────────────────────────────────────────────────────┘

실행 순서 시각화

console.log('1: 동기');

setTimeout(() => console.log('2: Task'), 0);

Promise.resolve().then(() => console.log('3: Microtask'));

console.log('4: 동기');
실행 흐름:

Call Stack          Microtask Queue      Task Queue
┌─────────────┐    ┌─────────────┐     ┌─────────────┐
│ console('1')│    │             │     │             │
└─────────────┘    │             │     │             │
      ↓            │             │     │             │
┌─────────────┐    │             │     ┌─────────────┐
│ setTimeout  │────│─────────────│────▶│ console('2')│
└─────────────┘    │             │     └─────────────┘
      ↓            ┌─────────────┐
┌─────────────┐    │ console('3')│◀─── Promise.then 등록
│ Promise     │────│             │
└─────────────┘    └─────────────┘
      ↓
┌─────────────┐
│ console('4')│
└─────────────┘
      ↓
[Call Stack 비어있음]
      ↓
┌─────────────┐    ┌─────────────┐
│ console('3')│◀───│ console('3')│ Microtask 먼저!
└─────────────┘    └─────────────┘
      ↓
┌─────────────┐                  ┌─────────────┐
│ console('2')│◀─────────────────│ console('2')│ Task는 나중에
└─────────────┘                  └─────────────┘

출력: 1, 4, 3, 2

실전 예제로 마스터하기

예제 1: 기본 순서

console.log('A');

setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);

Promise.resolve()
  .then(() => console.log('D'))
  .then(() => console.log('E'));

Promise.resolve()
  .then(() => console.log('F'));

console.log('G');

// 출력: A, G, D, F, E, B, C

분석:

  1. 동기: A, G
  2. Microtask: D, F (두 Promise.then이 동시에 큐에), E (D 실행 후 등록됨)
  3. Task: B, C

예제 2: 중첩된 비동기

setTimeout(() => {
  console.log('Task 1');
  Promise.resolve().then(() => console.log('Microtask in Task 1'));
}, 0);

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

Promise.resolve().then(() => {
  console.log('Microtask 1');
  setTimeout(() => console.log('Task in Microtask'), 0);
});

// 출력: Microtask 1, Task 1, Microtask in Task 1, Task 2, Task in Microtask

분석:

사이클 1:
- Call Stack: (비어있음)
- Microtask: "Microtask 1" 출력, Task 등록
- Task: 없음 (아직 Call Stack 실행 중이었음)

사이클 2:
- Task: "Task 1" 출력
- Microtask: "Microtask in Task 1" 출력

사이클 3:
- Task: "Task 2" 출력
- Microtask: 없음

사이클 4:
- Task: "Task in Microtask" 출력

예제 3: async/await

async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');
}

async function bar() {
  console.log('bar');
}

console.log('script start');

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

foo();

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

console.log('script end');

// 출력:
// script start
// foo start
// bar
// promise executor
// script end
// foo end
// promise then
// setTimeout

핵심 포인트:

  • await 이후의 코드(foo end)는 Microtask로 처리됩니다
  • Promise executor는 동기적으로 실행됩니다
  • Promise .then()은 Microtask입니다

예제 4: queueMicrotask vs Promise

console.log('1');

queueMicrotask(() => console.log('2: queueMicrotask'));

Promise.resolve().then(() => console.log('3: Promise.then'));

queueMicrotask(() => console.log('4: queueMicrotask'));

console.log('5');

// 출력: 1, 5, 2, 3, 4
// queueMicrotask와 Promise.then은 같은 큐를 사용합니다 (등록 순서대로)

예제 5: 무한 Microtask

// ⚠️ 위험한 코드 - 절대 실행하지 마세요!
function infiniteMicrotask() {
  Promise.resolve().then(() => {
    console.log('Microtask');
    infiniteMicrotask(); // 무한히 Microtask 추가
  });
}

infiniteMicrotask();
setTimeout(() => console.log('이건 절대 실행 안 됨'), 0);

// Microtask가 무한히 추가되어 Task Queue는 영원히 실행되지 않습니다
// 브라우저가 멈춥니다!

함정과 주의사항

함정 1: Microtask 폭탄

// ❌ 위험: Microtask에서 Microtask를 계속 생성
function processBatch(items, index = 0) {
  if (index >= items.length) return;

  Promise.resolve().then(() => {
    processItem(items[index]);
    processBatch(items, index + 1); // 재귀적으로 Microtask 추가
  });
}

// 10만 개의 아이템 처리
processBatch(hugeArray); // 💥 브라우저 멈춤, 렌더링 안 됨
// ✅ 해결: Task로 분할하여 렌더링 기회 제공
function processBatch(items, index = 0, batchSize = 100) {
  const end = Math.min(index + batchSize, items.length);

  for (let i = index; i < end; i++) {
    processItem(items[i]);
  }

  if (end < items.length) {
    setTimeout(() => processBatch(items, end, batchSize), 0);
  }
}

함정 2: setTimeout(0)은 “즉시”가 아닙니다

// ❌ 잘못된 기대: 0ms니까 바로 실행되겠지
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
console.log('End');

// 출력: Start, End, Timeout
// "0ms"여도 다음 이벤트 루프 사이클까지 기다려야 합니다
// ✅ 즉시 실행이 필요하면 Microtask 사용
console.log('Start');
queueMicrotask(() => console.log('Microtask'));
console.log('End');

// 출력: Start, End, Microtask
// 현재 동기 코드가 끝나면 바로 실행

함정 3: 렌더링 차단

// ❌ Microtask가 렌더링을 차단함
button.addEventListener('click', () => {
  element.style.backgroundColor = 'red';

  // 이 Microtask들이 모두 끝나야 렌더링됨
  for (let i = 0; i < 1000; i++) {
    Promise.resolve().then(() => heavyCalculation());
  }

  element.style.backgroundColor = 'blue';
  // 사용자는 red를 볼 수 없음, 바로 blue만 보임
});
// ✅ 중간 렌더링이 필요하면 Task 사용
button.addEventListener('click', () => {
  element.style.backgroundColor = 'red';

  setTimeout(() => {
    heavyCalculation();
    element.style.backgroundColor = 'blue';
  }, 0);

  // 사용자가 red를 잠시 볼 수 있음
});

함정 4: Promise executor는 동기

// ❌ 잘못된 이해
console.log('A');

new Promise((resolve) => {
  console.log('B'); // 동기 실행!
  resolve();
});

console.log('C');

// 출력: A, B, C (B는 비동기가 아님!)
// ✅ 올바른 이해
console.log('A');

new Promise((resolve) => {
  console.log('B'); // 동기 - executor는 즉시 실행
  resolve();
}).then(() => {
  console.log('C'); // 비동기 - Microtask
});

console.log('D');

// 출력: A, B, D, C

함정 5: Node.js의 process.nextTick

// Node.js에서만 동작
console.log('A');

setTimeout(() => console.log('B: setTimeout'), 0);

Promise.resolve().then(() => console.log('C: Promise'));

process.nextTick(() => console.log('D: nextTick'));

console.log('E');

// 출력: A, E, D, C, B
// process.nextTick은 Microtask보다도 먼저 실행됩니다!
Node.js 우선순위:
1. 동기 코드
2. process.nextTick
3. Microtask (Promise)
4. Task (setTimeout)

성능 최적화 패턴

패턴 1: 배치 처리로 DOM 업데이트 최적화

// ❌ 비효율: 매번 리플로우 발생
items.forEach(item => {
  element.appendChild(createNode(item)); // 매번 DOM 업데이트
});

// ✅ 효율: Microtask로 배치 처리
const fragment = document.createDocumentFragment();

Promise.resolve().then(() => {
  items.forEach(item => {
    fragment.appendChild(createNode(item));
  });
  element.appendChild(fragment); // 한 번에 DOM 업데이트
});

패턴 2: 디바운스와 Microtask

// 여러 상태 변경을 하나로 모으기
class BatchedUpdater {
  constructor() {
    this.pending = false;
    this.updates = [];
  }

  schedule(update) {
    this.updates.push(update);

    if (!this.pending) {
      this.pending = true;
      queueMicrotask(() => this.flush());
    }
  }

  flush() {
    const updates = this.updates;
    this.updates = [];
    this.pending = false;

    // 모든 업데이트를 한 번에 처리
    updates.forEach(update => update());
  }
}

const updater = new BatchedUpdater();

// 여러 번 호출해도 한 번만 처리됨
updater.schedule(() => console.log('Update 1'));
updater.schedule(() => console.log('Update 2'));
updater.schedule(() => console.log('Update 3'));
// Microtask에서 한 번에: Update 1, Update 2, Update 3

패턴 3: 무거운 작업 분할

// ❌ 메인 스레드 블로킹
function processAllItems(items) {
  items.forEach(item => heavyProcess(item)); // UI 멈춤
}

// ✅ Task로 분할하여 렌더링 기회 제공
async function processAllItems(items) {
  const BATCH_SIZE = 50;

  for (let i = 0; i < items.length; i += BATCH_SIZE) {
    const batch = items.slice(i, i + BATCH_SIZE);

    // 각 배치 처리
    batch.forEach(item => heavyProcess(item));

    // 다음 배치 전에 렌더링 기회 제공
    if (i + BATCH_SIZE < items.length) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }

    // 진행률 업데이트
    updateProgress((i + BATCH_SIZE) / items.length * 100);
  }
}

패턴 4: 우선순위 기반 작업 스케줄링

class TaskScheduler {
  constructor() {
    this.highPriority = [];  // Microtask로 처리
    this.lowPriority = [];   // Task로 처리
  }

  scheduleHigh(task) {
    this.highPriority.push(task);
    queueMicrotask(() => this.processHigh());
  }

  scheduleLow(task) {
    this.lowPriority.push(task);
    setTimeout(() => this.processLow(), 0);
  }

  processHigh() {
    while (this.highPriority.length > 0) {
      const task = this.highPriority.shift();
      task();
    }
  }

  processLow() {
    if (this.lowPriority.length > 0) {
      const task = this.lowPriority.shift();
      task();

      if (this.lowPriority.length > 0) {
        setTimeout(() => this.processLow(), 0);
      }
    }
  }
}

const scheduler = new TaskScheduler();

// 높은 우선순위: UI 업데이트
scheduler.scheduleHigh(() => updateUI());

// 낮은 우선순위: 분석 데이터 전송
scheduler.scheduleLow(() => sendAnalytics());

실전 활용 사례

사례 1: React의 배치 업데이트

// React는 내부적으로 Microtask를 활용하여 상태 업데이트를 배치 처리합니다

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 이 세 번의 setState는 하나로 배치됩니다
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
    // 리렌더링은 한 번만 발생!
  };

  return <button onClick={handleClick}>{count}</button>;
}

사례 2: Vue의 nextTick

// Vue의 nextTick은 Microtask를 사용합니다
import { nextTick } from 'vue';

export default {
  methods: {
    async updateMessage() {
      this.message = 'Updated';

      // DOM 업데이트를 기다림 (Microtask 이후)
      await nextTick();

      // 이제 DOM이 업데이트되었음
      console.log(this.$el.textContent); // 'Updated'
    }
  }
}

사례 3: 애니메이션 프레임 관리

// requestAnimationFrame은 특수한 Task입니다
function animate() {
  // 1. Microtask: 상태 계산
  Promise.resolve().then(() => {
    calculateNextFrame();
  });

  // 2. 다음 프레임에서 렌더링
  requestAnimationFrame(() => {
    applyStyles();
    animate(); // 다음 프레임 예약
  });
}

// 시작
animate();

사례 4: API 응답 처리

// 여러 API 호출 결과를 효율적으로 처리
async function fetchAllData() {
  const results = await Promise.all([
    fetch('/api/users'),
    fetch('/api/posts'),
    fetch('/api/comments')
  ]);

  // 모든 데이터 파싱 (Microtask로 연속 처리)
  const [users, posts, comments] = await Promise.all(
    results.map(r => r.json())
  );

  // 한 번에 UI 업데이트
  queueMicrotask(() => {
    renderUsers(users);
    renderPosts(posts);
    renderComments(comments);
  });
}

디버깅 팁

팁 1: 실행 순서 추적하기

function trace(label, type) {
  const time = performance.now().toFixed(2);
  console.log(`[${time}ms] ${type}: ${label}`);
}

// 사용 예
trace('Script Start', 'SYNC');

setTimeout(() => trace('Timeout 1', 'TASK'), 0);
setTimeout(() => trace('Timeout 2', 'TASK'), 0);

Promise.resolve()
  .then(() => trace('Promise 1', 'MICROTASK'))
  .then(() => trace('Promise 2', 'MICROTASK'));

queueMicrotask(() => trace('queueMicrotask', 'MICROTASK'));

trace('Script End', 'SYNC');

/* 출력:
[0.00ms] SYNC: Script Start
[0.10ms] SYNC: Script End
[0.15ms] MICROTASK: Promise 1
[0.18ms] MICROTASK: queueMicrotask
[0.20ms] MICROTASK: Promise 2
[1.50ms] TASK: Timeout 1
[2.00ms] TASK: Timeout 2
*/

팁 2: Chrome DevTools 활용

// Performance 탭에서 Task와 Microtask 시각화
console.time('Operation');

setTimeout(() => {
  console.timeLog('Operation', 'Task executed');
}, 0);

Promise.resolve().then(() => {
  console.timeLog('Operation', 'Microtask executed');
});

console.timeEnd('Operation');

팁 3: 문제 패턴 식별

// 🔍 디버깅: Microtask가 Task를 블로킹하는지 확인
const taskStart = performance.now();

setTimeout(() => {
  const delay = performance.now() - taskStart;
  if (delay > 100) {
    console.warn(`Task delayed by ${delay.toFixed(2)}ms - Microtask blocking?`);
  }
}, 0);

// 의도적으로 긴 Microtask 체인
for (let i = 0; i < 10000; i++) {
  Promise.resolve().then(() => { /* heavy work */ });
}

결론

핵심 정리

┌─────────────────────────────────────────────────────────┐
│                    기억해야 할 것들                        │
├─────────────────────────────────────────────────────────┤
│ 1. Microtask가 Task보다 먼저 실행됨                       │
│ 2. Microtask는 큐가 빌 때까지 전부 실행                    │
│ 3. Task는 이벤트 루프 사이클당 하나만 실행                  │
│ 4. 렌더링은 Microtask 후, 다음 Task 전에 발생              │
│ 5. Promise.then, await, queueMicrotask → Microtask      │
│ 6. setTimeout, setInterval, I/O → Task                  │
└─────────────────────────────────────────────────────────┘

언제 뭘 써야 할까?

상황 사용할 것 이유
빠른 후속 처리 Promise.then, queueMicrotask 현재 작업 직후 실행
UI 중간 업데이트 필요 setTimeout 렌더링 기회 제공
무거운 작업 분할 setTimeout 메인 스레드 블로킹 방지
DOM 변경 후 읽기 queueMicrotask 렌더링 전 확정된 상태 접근
애니메이션 requestAnimationFrame 프레임과 동기화

마지막 한마디

“비동기 코드의 실행 순서를 예측할 수 없다면, 그건 마법이 아니라 버그입니다.”

Task Queue와 Microtask Queue의 차이를 이해하면:

  • 비동기 코드의 실행 순서를 정확히 예측할 수 있습니다
  • 성능 문제를 진단하고 해결할 수 있습니다
  • 프레임워크들이 왜 그렇게 동작하는지 이해할 수 있습니다

이제 어떤 복잡한 비동기 코드를 봐도, “이건 Task니까…”, “이건 Microtask니까…“라고 자신있게 분석할 수 있을 겁니다!


참고 자료

공식 문서

추천 아티클

관련 문서

댓글