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입니다. setTimeout과 Promise.then 둘 다 비동기인데, 왜 Promise가 먼저 실행될까요?
처음에는 “둘 다 비동기니까 등록된 순서대로 실행되겠지”라고 생각했습니다. 하지만 JavaScript에는 두 개의 다른 큐가 있고, 이 큐들 사이에는 명확한 우선순위가 있습니다.
이 문서에서는 Task Queue와 Microtask Queue의 차이점을 철저히 파헤쳐서, 어떤 비동기 코드든 실행 순서를 정확히 예측할 수 있게 해드리겠습니다.
목차
- 왜 두 개의 큐가 필요할까요?
- Task Queue (Macrotask 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
분석:
- 동기: A, G
- Microtask: D, F (두 Promise.then이 동시에 큐에), E (D 실행 후 등록됨)
- 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니까…“라고 자신있게 분석할 수 있을 겁니다!
참고 자료
공식 문서
추천 아티클
- Jake Archibald - Tasks, microtasks, queues and schedules
- JavaScript.info - Event loop: microtasks and macrotasks
댓글