저는 면접 단골 질문인 "이 코드의 출력 순서를 맞혀보세요"를 처음 만났을 때, 정답표를 통째로 외워서 넘기려고 했습니다.
그런데 setTimeout이 두 개로 늘어나고 setTimeout 안에 또 Promise.then이 끼어드는 변형 문제가 나오자, 외운 순서는 곧바로 무너졌습니다.
결국 순서를 외우는 게 아니라, 자바스크립트가 콜백을 어떤 규칙으로 꺼내 실행하는지를 이해해야 한다는 걸 깨달았습니다.
이번 포스트에서는 그 규칙의 핵심인 이벤트 루프(Event Loop), 그리고 태스크 큐와 마이크로태스크 큐의 차이를 메커니즘으로 분해해 보겠습니다.
자바스크립트는 한 번에 한 줄만 실행한다
먼저 짚고 갈 전제가 있습니다. 자바스크립트 엔진은 싱글 스레드입니다. 즉, 동시에 두 개의 코드를 실행하지 못하고 한 번에 하나의 작업만 처리합니다.
이때 "지금 실행 중인 작업"을 쌓아 두는 자료구조가 **콜스택(Call Stack)**입니다. 함수가 호출되면 콜스택에 쌓이고(push), 함수가 끝나면 콜스택에서 빠집니다(pop). 콜스택에 무언가 남아 있는 동안 엔진은 다른 일을 할 수 없습니다.
function third() {
console.log('third'); // 3. third가 콜스택 맨 위에 쌓여 실행됨
}
function second() {
third(); // 2. second 안에서 third 호출 → third가 콜스택에 쌓임
}
function first() {
second(); // 1. first 안에서 second 호출 → second가 콜스택에 쌓임
}
first(); // 시작점: first가 콜스택에 쌓임여기까지는 동기 코드라서 직관적입니다. 위에서 아래로, 쌓인 순서의 역순으로 차곡차곡 비워집니다.
문제는 setTimeout이나 fetch처럼 "지금 당장이 아니라 나중에" 실행되어야 하는 콜백이 등장할 때입니다.
콜스택과 실행 컨텍스트의 관계가 더 궁금하다면 실행 컨텍스트와 스코프 체인 글을 함께 보면 좋습니다.
이벤트 루프는 '대기실의 번호표 호출' 같다
비동기 콜백을 다룰 때 저는 이벤트 루프를 관공서 민원 창구에 비유하면 이해가 쉬웠습니다.
- 콜스택은 지금 창구에서 응대 중인 단 한 명입니다. 창구는 하나뿐이라 동시에 두 명을 받지 못합니다.
setTimeout이나 이벤트 핸들러처럼 "나중에 처리할 일"은 곧장 창구로 가지 못하고 **대기실(큐)**에서 번호표를 뽑고 기다립니다.- 이벤트 루프는 창구를 지켜보는 안내 직원입니다. 창구가 비는 순간(콜스택이 빔)을 포착해, 대기실에서 다음 번호표를 불러 창구로 보냅니다.
핵심은 이벤트 루프가 창구가 완전히 빈 다음에야 다음 손님을 부른다는 점입니다. 누군가 응대받는 도중에는 절대 끼어들지 않습니다. 이 한 가지 규칙이 비동기 실행 순서의 거의 모든 것을 설명합니다.
그런데 대기실이 하나가 아니라는 게 함정입니다. 자바스크립트에는 우선순위가 다른 두 개의 대기실이 있습니다.
대기실은 두 개다: 태스크 큐와 마이크로태스크 큐
비동기 콜백은 종류에 따라 서로 다른 큐로 들어갑니다.
태스크 큐 (매크로태스크 큐)
- 들어가는 것:
setTimeout,setInterval의 콜백, DOM 이벤트 핸들러(클릭 등),MessageChannel등 - 흔히 **매크로태스크(macrotask)**라고 부릅니다.
마이크로태스크 큐
- 들어가는 것:
Promise.then/catch/finally의 콜백,queueMicrotask,await이후로 넘어가는 코드,MutationObserver - **마이크로태스크(microtask)**라고 부릅니다.
이름이 비슷해서 헷갈리지만, 둘의 처리 우선순위는 완전히 다릅니다. 이 차이가 이번 글의 핵심입니다.
이벤트 루프의 한 사이클: 단계별 분해
콜스택이 비었을 때 이벤트 루프가 정확히 무엇을 하는지 단계로 적어 보겠습니다.
- 콜스택이 비어 있는지 확인한다. (실행 중인 동기 코드가 없는지)
- 마이크로태스크 큐를 확인한다. 콜백이 있으면 하나 꺼내 실행한다.
- 그 콜백 실행이 끝나면 다시 마이크로태스크 큐를 확인한다. 큐가 텅 빌 때까지 2번을 반복한다.
- 마이크로태스크 큐가 완전히 비면, 태스크 큐(매크로태스크)에서 딱 하나를 꺼내 실행한다.
- 그 매크로태스크 하나가 끝나면, 다시 2번으로 돌아가 마이크로태스크 큐부터 전부 비운다.
여기서 비대칭이 보입니다.
- 마이크로태스크: 한 번 비우기 시작하면 큐가 빌 때까지 전부 실행한다.
- 매크로태스크: 한 사이클에 딱 하나만 실행하고, 그 사이에 마이크로태스크 큐를 통째로 비운다.
즉, 매크로태스크 하나가 끝날 때마다 그 직후에 쌓여 있는 마이크로태스크를 모조리 처리합니다. 이것이 "Promise.then이 setTimeout보다 먼저"라는 현상의 진짜 원인입니다. 우선순위가 높아서가 아니라, 큐를 비우는 규칙 자체가 다르기 때문입니다.
[ 이벤트 루프 한 사이클 ]
콜스택 비었나?
│
▼
마이크로태스크 큐 ──► 비어있지 않으면 하나 실행 ──┐
▲ │
└──────────── 큐가 빌 때까지 반복 ◄────────────┘
│
▼ (마이크로태스크 큐가 완전히 빔)
태스크 큐(매크로) ──► 딱 하나만 실행
│
└──► 다시 맨 위로 (마이크로태스크부터 또 전부 비움)왜 Promise.then이 setTimeout(0)보다 항상 먼저일까
이제 가장 유명한 예제로 확인해 보겠습니다.
console.log('1: 동기 코드 시작'); // 동기 → 즉시 실행
setTimeout(() => {
console.log('2: setTimeout 콜백'); // 매크로태스크 큐로 들어감
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise.then 콜백'); // 마이크로태스크 큐로 들어감
});
console.log('4: 동기 코드 끝'); // 동기 → 즉시 실행출력 결과는 이렇습니다.
1: 동기 코드 시작
4: 동기 코드 끝
3: Promise.then 콜백
2: setTimeout 콜백setTimeout을 코드상 위에 적었는데도 2가 가장 늦게 나옵니다. 단계별로 따라가 보겠습니다.
console.log('1')실행. 동기 코드라 콜스택에서 바로 처리됩니다. → 1 출력setTimeout(..., 0)을 만나면, 콜백은 매크로태스크 큐에 등록됩니다. (콜백은 아직 실행 안 됨)Promise.resolve().then(...)을 만나면, 콜백은 마이크로태스크 큐에 등록됩니다.console.log('4')실행. → 4 출력- 여기서 동기 코드가 모두 끝나 콜스택이 빕니다.
- 이벤트 루프가 마이크로태스크 큐부터 확인합니다.
3콜백이 있으니 실행. → 3 출력 - 마이크로태스크 큐가 비었으니, 이제 매크로태스크 큐에서 하나 꺼냅니다.
2콜백 실행. → 2 출력
setTimeout(0)의 0이 "0밀리초 후 즉시"라는 뜻이 아니라는 점이 보입니다.
콜백을 매크로태스크 큐에 넣겠다는 의미일 뿐이고, 그보다 우선순위가 높은 마이크로태스크 큐가 먼저 전부 비워져야 차례가 옵니다.
마이크로태스크가 "줄을 더 끼워 넣는" 경우
마이크로태스크 큐는 "빌 때까지 전부 실행한다"고 했습니다. 그렇다면 마이크로태스크 콜백 안에서 새 마이크로태스크를 또 만들면 어떻게 될까요? 그 새 콜백도 같은 사이클 안에서 처리됩니다. 매크로태스크로 넘어가기 전에 말이죠.
console.log('A'); // 동기
setTimeout(() => console.log('B'), 0); // 매크로태스크
Promise.resolve()
.then(() => {
console.log('C'); // 마이크로태스크 1
})
.then(() => {
console.log('D'); // 마이크로태스크 2 (C의 then이 반환한 프로미스에 연결)
});
console.log('E'); // 동기출력은 다음과 같습니다.
A
E
C
D
BC가 실행되면서 그 뒤의 .then(D)가 마이크로태스크 큐에 새로 등록됩니다.
이벤트 루프는 마이크로태스크 큐를 "빌 때까지" 비우는 규칙이므로, 새로 추가된 D까지 처리한 다음에야 매크로태스크인 B로 넘어갑니다.
그래서 매크로태스크 B가 가장 마지막입니다.
참고로 마이크로태스크 안에서 또 마이크로태스크를 무한히 만들면, 매크로태스크는 영원히 차례가 오지 않습니다. 렌더링이나 타이머가 멈춰 보이는 현상으로 이어질 수 있어 주의가 필요합니다.
좀 더 꼬아 보기: setTimeout과 Promise가 뒤섞인 문제
이제 단골 변형 문제를 풀어 보겠습니다. 매크로태스크와 마이크로태스크가 교차로 등장합니다.
console.log('start'); // 1. 동기
setTimeout(() => {
console.log('timeout 1'); // 매크로태스크 T1
Promise.resolve().then(() => {
console.log('promise in timeout 1'); // T1 실행 중에 생긴 마이크로태스크
});
}, 0);
setTimeout(() => {
console.log('timeout 2'); // 매크로태스크 T2
}, 0);
Promise.resolve().then(() => {
console.log('promise 1'); // 마이크로태스크 M1
});
console.log('end'); // 동기먼저 출력 결과부터 보겠습니다.
start
end
promise 1
timeout 1
promise in timeout 1
timeout 2순서를 단계별로 분해하면 다음과 같습니다.
| 단계 | 무슨 일이 일어나는가 | 출력 |
|---|---|---|
| 1 | console.log('start') 동기 실행 | start |
| 2 | 첫 번째 setTimeout → 매크로태스크 큐에 T1 등록 | (없음) |
| 3 | 두 번째 setTimeout → 매크로태스크 큐에 T2 등록 | (없음) |
| 4 | Promise.then → 마이크로태스크 큐에 M1 등록 | (없음) |
| 5 | console.log('end') 동기 실행 → 콜스택 빔 | end |
| 6 | 이벤트 루프: 마이크로태스크 큐 비우기 → M1 실행 | promise 1 |
| 7 | 마이크로태스크 큐 빔 → 매크로태스크 하나 꺼냄 → T1 실행 | timeout 1 |
| 8 | T1 실행 중 새 마이크로태스크가 생김(promise in timeout 1) | (큐에 등록) |
| 9 | T1이 끝남. 다음 매크로태스크로 넘어가기 전 마이크로태스크 큐부터 비움 | promise in timeout 1 |
| 10 | 마이크로태스크 큐 빔 → 다음 매크로태스크 T2 실행 | timeout 2 |
여기서 가장 헷갈리는 부분이 9단계입니다.
T2도 이미 큐에서 기다리고 있지만, T1이 끝나자마자 곧장 T2로 가지 않습니다.
규칙대로 "매크로태스크 하나(T1)가 끝나면 마이크로태스크 큐부터 전부 비운다"가 적용되어, T1이 만들어 낸 promise in timeout 1이 T2보다 먼저 실행됩니다.
이 문제를 외워서 푸는 건 거의 불가능에 가깝습니다. 하지만 "매크로 하나 → 마이크로 전부 → 매크로 하나 → 마이크로 전부" 라는 리듬만 기억하면 손으로 따라 그릴 수 있습니다.
setTimeout의 delay는 '최소 대기 시간'이다
마지막으로 또 하나의 오해를 풀어 보겠습니다. setTimeout(fn, 1000)은 "정확히 1초 뒤에 실행"을 보장하지 않습니다.
**"최소 1초가 지난 뒤, 그리고 콜스택이 비고 마이크로태스크가 다 처리된 뒤에야 실행될 수 있다"**가 정확한 표현입니다.
타이머가 만료되어도 콜백은 곧장 실행되는 게 아니라 매크로태스크 큐에 들어갈 뿐입니다. 그 시점에 무거운 동기 코드가 콜스택을 점유하고 있다면, 콜백은 그 코드가 끝날 때까지 계속 기다립니다.
const start = Date.now(); // 시작 시각 기록
setTimeout(() => {
// 0ms 후 실행되길 기대했지만...
const elapsed = Date.now() - start; // 실제 경과 시간 측정
console.log(`실제로는 ${elapsed}ms 뒤에 실행됨`);
}, 0);
// 콜스택을 오래 붙잡는 무거운 동기 작업
const heavyStart = Date.now();
while (Date.now() - heavyStart < 1000) {
// 1초 동안 콜스택을 점유하며 바쁘게 대기(busy-wait)
}
console.log('동기 작업 완료'); // 이 줄이 먼저 끝나야 콜스택이 빔출력은 대략 이렇습니다.
동기 작업 완료
실제로는 1000ms 뒤에 실행됨0ms를 줬지만 실제로는 약 1000ms 뒤에 실행됩니다.
타이머는 진작 만료되어 콜백을 매크로태스크 큐에 넣어 뒀지만, while 루프가 콜스택을 1초간 붙잡고 있었기 때문입니다.
이벤트 루프는 콜스택이 비기 전까지는 큐에서 아무것도 꺼낼 수 없습니다.
HTML 명세에는 타이머의 중첩 깊이가 5를 넘으면
delay가 4ms보다 작을 때 4ms로 끌어올리는(clamping) 규칙이 있습니다. 그래서setTimeout안에서 또setTimeout을 거는 식으로 깊게 중첩되면,setTimeout(0)이라도 환경에 따라 최소 4ms로 동작하게 됩니다.
이 동작은 React에서 상태 업데이트나 useEffect 타이밍을 디버깅할 때도 직결됩니다. 관련해서 useEffect 실행 타이밍 글도 참고할 만합니다.
정리
- 자바스크립트는 싱글 스레드이고, 지금 실행 중인 작업은 콜스택에 쌓입니다.
- 이벤트 루프는 콜스택이 빈 순간에만 큐에서 콜백을 꺼내 실행합니다. 실행 중에는 끼어들지 않습니다.
- 큐는 두 개입니다.
setTimeout등은 매크로태스크 큐,Promise.then등은 마이크로태스크 큐로 갑니다. - 처리 규칙이 비대칭입니다. 매크로태스크는 한 사이클에 하나만, 마이크로태스크는 큐가 빌 때까지 전부 실행합니다.
- 그래서
Promise.then이setTimeout(0)보다 항상 먼저 실행됩니다. 우선순위가 높아서가 아니라 큐를 비우는 규칙이 다르기 때문입니다. setTimeout의delay는 정확한 실행 시각이 아니라 최소 대기 시간이며, 무거운 동기 코드가 콜스택을 붙잡으면 그만큼 밀립니다.
출력 순서 문제를 만나면 이제 정답표를 외울 필요가 없습니다. "동기 코드 전부 → 마이크로태스크 전부 → 매크로태스크 하나 → 다시 마이크로태스크 전부" 라는 리듬을 손으로 따라 그리면, 어떤 변형이 와도 풀 수 있습니다. 긴 글 읽어주셔서 감사합니다.