자바스크립트의 콜백 실행 순서를 묻는 문제는 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는 정확한 실행 시각을 보장하지 않는 최소 대기 시간입니다. 무거운 동기 코드가 콜스택을 붙잡으면 그만큼 밀립니다.
출력 순서 문제는 정답을 외우지 않아도 풀 수 있습니다. "동기 코드 전부 → 마이크로태스크 전부 → 매크로태스크 하나 → 다시 마이크로태스크 전부"라는 규칙을 따라가면, 어떤 변형 문제가 나와도 답을 구할 수 있습니다. 긴 글 읽어주셔서 감사합니다.