JavaScript는 싱글 스레드(single-threaded) 언어다.
하지만 브라우저 환경에서 우리는 비동기 네트워크 요청, 타이머, 사용자 이벤트 등
수많은 작업을 동시에 처리하는 것처럼 느낀다.
이 착시를 가능하게 만드는 핵심 메커니즘이 바로 이벤트 루프(Event Loop) 와 태스크 큐(Task Queue) 다.
이 글에서는 이벤트 루프의 동작 구조를 기반으로
마이크로 태스크(Microtask)와 매크로 태스크(Macrotask)가 어떤 순서로 실행되는지,
그리고 이 차이가 실무에서 왜 중요한지까지 다룬다.
1. JavaScript 실행 환경의 기본 구조
브라우저에서 JavaScript가 실행될 때, 내부적으로는 다음과 같은 구성 요소들이 협력한다.
- Call Stack
- Web APIs
- Task Queue
- Macrotask Queue
- Microtask Queue
- Event Loop
이 중 JavaScript 엔진(V8 등)이 직접 관리하는 영역은 Call Stack 뿐이며,
비동기 작업은 브라우저(Web APIs)에 위임된다.
2. Call Stack과 비동기 처리의 한계
Call Stack은 한 번에 하나의 작업만 처리할 수 있다.
function a() {
b();
}
function b() {
c();
}
function c() {
console.log('hello');
}
a();
이 코드는 스택에 a → b → c 순서로 쌓이고, c가 종료되면 역순으로 빠져나간다.
문제는 시간이 걸리는 작업이다.
setTimeout(() => { console.log('timeout') }, 1000)
만약 setTimeout이 Call Stack을 점유한다면, 그동안 UI는 멈춰버린다.
그래서 브라우저는 이런 작업을 Web APIs로 위임한다.
3.Web APIs와 Task Queue의 역할
비동기 API(setTimeout, fetch, DOM 이벤트 등`)는 실행 즉시 Web APIs로 넘어간다.
- Call Stack에서 비동기 함수 호출
- 브라우저(Web APIs)가 작업을 처리
- 작업 완료 후 콜백을 Task Queue에 등록
- Event Loop가 Call Stack 상태를 감시
- Call Stack이 비어 있으면 큐에서 작업을 가져옴
여기서 중요한 포인트는
Task Queue가 하나가 아니라는 것이다.
4. 매크로 태스크(Macrotask)
일반적인 비동기 작업 단위
대표적인 예
- setTimeout
- setInterval
- setImmediate
- DOM 이벤트 핸들러
- 메시지 채널
setTimeout(() => { console.log('macrotask') }, 0)
매크로 태스크는 한 번에 하나씩 실행된다.
5. 마이크로 태스크(Microtask)
현재 실행 컨텍스트가 끝난 직후, 즉시 실행되어야 하는 작업
대표적인 예
- Promise.then
- Promise.catch
- Promise.finally
- queueMicrotask
- MutationObserver
Promise.resolve().then(() => { console.log('microtask') });
마이크로 태스크는 큐가 빌 때까지 전부 실행된다.
6. 이벤트 루프의 실제 실행 순서
이벤트 루프는 다음 규칙을 따른다.
- Call Stack이 비어 있는지 확인
- Microtask Queue를 먼저 모두 실행
- Macrotask Queue에서 하나 실행
- 다시 Microtask Queue 확인
- 반복
이 순서를 코드로 확인해보자.
console.log('start');
setTimeout(() => {
console.log('timeout')
}, 0);
Promise.resolve().then(() => {
console.log('promise')
});
console.log('end');
실행결과
start
end
promise
timeout
이유
- 동기 코드가 먼저 실행
- Call Stack 종료 후 Microtask Queue 실행
- 그 다음 Macrotask 실행
7. Microtask가 우선되는 이유
마이크로 태스크는 상태 일관성을 보장하기 위해 존재한다.
대표적인 예가 Promise 체인이다.
fetch('/api')
.then(res => res.json())
.then(data => {
// 여기서 DOM 업데이트
});
이 로직이 매크로 태스크로 분리된다면,
중간에 다른 이벤트가 끼어들어 예측 불가능한 상태가 된다.
그래서 ECMAScript 명세에서는
Promise 후속 처리 작업을 반드시 Microtask로 처리하도록 규정한다.
8. Microtask 남용이 초래하는 문제
마이크로 태스크는 큐가 빌 때까지 실행되므로, 무한히 추가하면 렌더링이 지연될 수 있다.
function loop() {
Promise.resolve().then(loop);
}
loop();
이 코드는 Call Stack은 비워지지만,
Microtask Queue가 비워지지 않아 UI가 멈춘다.
실무에서 다음 상황을 특히 주의해야 한다.
- 대량의 Promise.then 체인
- 상태 변경을 반복하는 비동기 로직
- 렌더링과 직접 연결된 Microtask
9. 렌더링(Rendering)과 이벤트 루프
브라우저 렌더링은 Macrotask 사이에서 수행된다.
정확히는:
- Macrotask 실행
- Microtask 전부 처리
- 필요 시 렌더 트리 계산 및 페인팅
- 다음 Macrotask
그래서 DOM 변경을 Microtask에서 연속으로 하면 중간 렌더링 없이 한 번에 반영된다. 이 특성은 성능 최적화에도 활용된다.
10. 실무 관점 요약
- JavaScript는 싱글 스레드지만 이벤트 루프로 비동기를 처리한다
- Microtask는 Macrotask보다 항상 먼저 실행
- Promise 후속 처리는 Microtask
- Microtask는 큐가 빌 때까지 실행
- 과도한 Microtask는 렌더링 블로킹을 유발
- DOM 렌더링은 Macrotask와 Macrotask 사이에서 발생
마무리
이벤트 루프는 단순한 이론이 아니라
비동기 버그, 렌더링 지연, 성능 이슈의 근본 원인과 직결된다.
Promise가 왜 먼저 실행되는지,
왜 setTimeout(0)이 즉시 실행되지 않는지,
왜 특정 상태 업데이트가 한 프레임에 묶이는지
이 모든 질문의 답은 이벤트 루프와 태스크 큐의 우선순위 규칙에 있다.
프론트엔드에서 비동기를 다룬다면 이 구조는 선택이 아니라 필수 지식이다.