JavaScript의 Event Loop와 Task Queue, Microtask Queue는 어떻게 동작할까
JavaScript를 공부하다 보면 비동기 코드를 만나면서 거의 반드시 이런 순간이 옵니다.
setTimeout(..., 0)을 했는데 왜 바로 실행되지 않지?Promise.then()은 왜setTimeout보다 먼저 실행되지?async/await는 동기처럼 보여도 왜 실제로는 비동기처럼 동작하지?- 브라우저는 도대체 어떤 순서로 코드를 실행하는 거지?
이런 질문은 처음엔 개별 문법 문제처럼 보입니다.
setTimeout사용법Promise사용법async/await문법
하지만 실제로는 전부 하나의 실행 모델로 연결됩니다. 그 중심에 있는 개념이 바로 event loop입니다.
겉보기에는 "JavaScript는 싱글 스레드고, 큐에 쌓였다가 event loop가 실행한다" 정도로 설명되곤 합니다. 물론 빠르게 이해하는 데는 도움이 됩니다. 하지만 기본기 관점에서는 그것만으로 부족합니다. 중요한 것은 어떤 작업이 call stack에 올라가고, 어떤 작업이 queue로 밀리고, microtask는 왜 더 먼저 비워지는지, 그 결과 Promise와 setTimeout 순서가 왜 갈리는지를 이해하는 것입니다.
이 글에서는 JavaScript의 비동기 실행 모델을 아래 흐름으로 정리해보겠습니다.
- 왜
event loop가 필요한지 call stack은 어떤 역할을 하는지task queue와microtask queue는 무엇이 다른지Promise,setTimeout,async/await는 각각 어디에 들어가는지- 실무에서 자주 헷갈리는 포인트는 무엇인지
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
JavaScript는 한 번에 하나의 작업을 실행하는 싱글 스레드 실행 모델을 가집니다.- 지금 실행 중인 코드는
call stack에 올라갑니다. - 비동기 작업의 완료 후 실행할 콜백은 queue로 들어갑니다.
task queue보다microtask queue가 더 우선적으로 비워집니다.Promise.then,catch,finally,await이후 흐름은 보통microtask와 연결되고,setTimeout,setInterval같은 타이머 콜백은 보통task와 연결됩니다.
즉, 비동기 동작의 핵심은 "나중에 실행된다"보다 어느 큐에 들어가고, 어떤 우선순위로 stack에 다시 올라오는가입니다.
왜 event loop가 필요할까?
JavaScript는 기본적으로 한 번에 한 작업만 실행합니다.
예를 들어:
console.log('A');
console.log('B');
console.log('C');이 코드는 단순히 위에서 아래로 차례대로 실행됩니다.
문제는 현실의 웹 애플리케이션이 이렇게 단순하지 않다는 점입니다.
- 네트워크 요청을 기다려야 하고
- 타이머가 끝나길 기다려야 하고
- 사용자 클릭을 기다려야 하고
- 애니메이션 프레임을 기다려야 합니다
만약 이런 모든 기다림을 자바스크립트가 직접 붙잡고 멈춰 서서 처리한다면, 브라우저는 쉽게 멈춘 것처럼 느껴질 수 있습니다.
그래서 필요한 것이:
- 지금 당장 실행할 코드는 실행하고
- 시간이 걸리는 작업은 바깥 시스템에 맡기고
- 준비가 끝난 작업만 다시 가져와 실행하는 구조
입니다.
즉, event loop는 "JavaScript가 비동기를 지원한다"는 말의 배경에 있는 실행 조율 메커니즘에 가깝습니다.
먼저 call stack부터 봐야 한다
비동기를 이해할 때 가장 먼저 봐야 하는 것은 queue가 아니라 call stack입니다.
왜냐하면 자바스크립트는 결국 stack에서 실행되는 언어이기 때문입니다.
예를 들어:
function first() {
second();
}
function second() {
console.log('done');
}
first();이 코드를 감각적으로 보면:
Global
-> first
-> second순서로 stack에 쌓입니다.
그리고 second()가 끝나면:
second가 빠지고first로 돌아가고- 마지막에 전역 실행 흐름으로 돌아갑니다
즉, 자바스크립트는 먼저 stack 위의 현재 작업을 끝내야 다음 일을 볼 수 있습니다.
이 점이 중요합니다. 어떤 비동기 콜백도, stack이 비어 적절한 시점이 오기 전까지는 바로 실행되지 않습니다.
비동기 작업은 누가 처리할까?
여기서 많이 생기는 오해가 있습니다.
setTimeout, fetch, DOM 이벤트 같은 것이 모두 자바스크립트 엔진이 직접 처리한다고 느끼기 쉽습니다. 하지만 실제로는 보통 더 넓은 실행 환경과 연결됩니다.
브라우저라면:
- 타이머
- 네트워크
- DOM 이벤트
- 렌더링
같은 것들은 브라우저의 다른 시스템이나 Web APIs가 맡아 처리합니다.
즉, 흐름을 아주 단순화하면 이렇습니다.
- 자바스크립트가 비동기 작업을 등록
- 실행 환경이 그 작업을 기다리거나 처리
- 완료되면 관련 콜백을 queue에 넣을 준비를 함
- 적절한 시점에 stack으로 다시 가져와 실행
즉, JavaScript는 모든 비동기 작업을 혼자 처리한다기보다, 실행 환경과 협력해서 나중에 다시 실행할 콜백을 조율한다고 보는 편이 맞습니다.
task queue는 무엇일까?
보통 흔히 말하는 "콜백 큐"를 조금 더 넓게 보면 task queue라고 이해할 수 있습니다.
대표적으로 이런 것들이 여기와 자주 연결됩니다.
setTimeoutsetInterval- 일부 DOM 이벤트 콜백
- 메시지 이벤트
예를 들어:
setTimeout(() => {
console.log('timeout');
}, 0);많은 사람이 0ms니까 즉시 실행될 것처럼 느낍니다. 하지만 실제로는 그렇지 않습니다.
이 콜백은 바로 stack으로 올라오는 것이 아니라:
- 타이머 조건이 충족된 뒤
- task queue 쪽 대기열에 들어가고
- stack이 비고 자기 차례가 왔을 때
비로소 실행됩니다.
즉, setTimeout(..., 0)은 "즉시 실행"이 아니라 가능한 한 빠르게, 하지만 현재 동기 코드가 끝난 뒤 task로 실행에 더 가깝습니다.
microtask queue는 무엇일까?
여기가 비동기 기본기에서 정말 중요합니다.
microtask queue는 task보다 더 높은 우선순위로 비워지는 큐라고 이해하는 편이 좋습니다.
대표적으로:
Promise.thenPromise.catchPromise.finallyawait이후 이어지는 흐름queueMicrotask
같은 것들이 여기와 자주 연결됩니다.
예를 들어:
Promise.resolve().then(() => {
console.log('microtask');
});이 코드는 즉시 동기 실행되는 것은 아니지만, 보통 task보다 먼저 처리됩니다.
즉, microtask는 "조금 나중"이긴 하지만, 현재 실행 중인 stack이 끝난 직후 더 우선적으로 비워지는 작업에 가깝습니다.
왜 microtask가 더 먼저 실행될까?
이 부분이 순서 이해의 핵심입니다.
자바스크립트는 보통 한 번의 실행 턴이 끝날 때:
- 현재 stack의 동기 코드를 먼저 끝내고
microtask queue를 가능한 한 비우고- 그다음 다음
task를 처리합니다
즉, Promise.then()이 setTimeout(..., 0)보다 먼저 실행되는 이유는 대개 여기서 설명됩니다.
예를 들어:
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
console.log('end');출력 감각은 보통 이렇게 됩니다.
start
end
promise
timeout이유는:
start,end는 현재 동기 코드promise는 microtasktimeout은 task
이기 때문입니다.
즉, 비동기 순서를 읽을 때는 "비동기냐 아니냐"보다 microtask냐 task냐가 더 중요할 때가 많습니다.
event loop는 여기서 정확히 무슨 일을 할까?
아주 단순화하면 event loop는 계속 이런 질문을 한다고 볼 수 있습니다.
- 지금 stack이 비었는가?
- 비었다면 실행할
microtask가 있는가? - 그다음 실행할
task가 있는가?
즉, event loop는 작업을 직접 계산하는 존재라기보다, stack과 queue들 사이에서 다음 실행 대상을 고르는 조율자에 가깝습니다.
조금 더 감각적으로 쓰면:
현재 stack 실행
-> stack 비면 microtask 전부 처리
-> 그다음 task 하나 실행
-> 다시 microtask 확인
-> 다시 다음 task처럼 이해할 수 있습니다.
물론 실제 브라우저 동작은 더 세부 규칙이 있지만, 기본기 수준에서는 이 모델이 매우 중요합니다.
setTimeout(..., 0)은 왜 바로 실행되지 않을까?
이건 정말 자주 나오는 질문입니다.
답은 간단합니다. 0ms는 "지금 당장 stack을 끊고 실행"이 아니라, 최소 지연 조건이 충족되면 task로 넣을 수 있다는 뜻에 더 가깝기 때문입니다.
즉, 아래 코드에서:
setTimeout(() => {
console.log('timeout');
}, 0);
for (let i = 0; i < 1_000_000_000; i += 1) {}긴 동기 루프가 먼저 stack을 점유하고 있으면, 타이머 콜백은 기다려야 합니다.
즉, setTimeout은 정확한 즉시 실행 보장이 아니라 현재 동기 작업이 끝난 뒤 task 큐를 통해 다시 실행될 기회를 얻는 구조입니다.
Promise는 왜 더 빠르게 느껴질까?
정확히 말하면 "빠르다"보다 우선순위가 다르다고 보는 편이 맞습니다.
예를 들어:
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});이 경우 promise 쪽이 먼저 보이기 쉽습니다.
이건 Promise가 "더 좋은 비동기"라서가 아니라, microtask queue가 task queue보다 먼저 비워지기 때문입니다.
즉, Promise는 비동기 모델에서 특별한 문법이라기보다, 더 높은 우선순위의 후속 작업 흐름을 가진다고 이해하는 편이 자연스럽습니다.
async/await는 어디에 들어갈까?
async/await는 문법상 동기 코드처럼 보이기 때문에 더 헷갈립니다.
예를 들어:
async function run() {
console.log('A');
await Promise.resolve();
console.log('B');
}
run();
console.log('C');감각적으로 결과는 보통 이렇게 됩니다.
A
C
B왜냐하면 await를 만나면:
- 함수 실행이 잠깐 멈추고
- 이후 이어질 코드는 Promise 기반 후속 작업으로 넘겨지고
- 보통 microtask 흐름에서 다시 이어지기 때문입니다
즉, async/await는 비동기를 없애는 문법이 아니라, Promise 기반 비동기 흐름을 더 읽기 쉽게 표현하는 문법 설탕에 가깝습니다.
microtask는 언제 위험해질까?
이 부분은 기본기에서 꽤 중요합니다.
microtask는 우선순위가 높기 때문에 편리하지만, 너무 많이 이어지면 다음 task가 밀릴 수 있습니다.
예를 들어 microtask 안에서 계속 microtask를 만들면:
- 렌더링 타이밍이 밀리고
- 타이머 콜백이 늦어지고
- UI 응답성이 나빠질 수 있습니다
즉, microtask는 빠르다고 무조건 좋은 것이 아니라, 우선순위가 높기 때문에 오히려 과용 시 다른 작업을 굶길 수 있는 큐로도 볼 수 있습니다.
브라우저 렌더링과는 어떤 관계가 있을까?
이 부분도 중요합니다.
자바스크립트는 메인 스레드에서 많은 일을 합니다.
- 동기 코드 실행
- 이벤트 처리
- 일부 레이아웃/페인트 관련 작업 조율
즉, stack이 오래 막히거나 queue가 계속 비워지지 않으면 UI도 답답해질 수 있습니다.
그래서 실무에서는 단순히 "비동기니까 괜찮다"가 아니라:
- 동기 작업이 너무 길지 않은가
- microtask를 과하게 이어붙이지 않는가
- 렌더링이 끊기는 지점은 없는가
까지 같이 봐야 합니다.
즉, event loop를 이해하는 것은 단순 콘솔 출력 순서 문제를 넘어서 UI 응답성과 성능을 이해하는 문제와도 연결됩니다.
실무에서 자주 하는 실수
정리하면 아래 실수가 정말 자주 나옵니다.
setTimeout(..., 0)이 즉시 실행된다고 생각한다Promise와setTimeout을 둘 다 "그냥 비동기"로만 이해한다async/await가 동기로 바뀌었다고 오해한다microtask가 항상 좋은 것이라고 생각한다- call stack이 비워지기 전엔 어떤 콜백도 실행되지 않는다는 점을 놓친다
즉, 비동기 기본기에서 중요한 것은 API 이름 암기보다 실행 우선순위와 재진입 시점을 읽는 감각입니다.
실무 체크리스트
실제로 코드를 읽을 때는 아래 질문으로 빠르게 점검하면 도움이 됩니다.
- 지금 이 코드는 동기 실행인가, queue로 밀리는 작업인가?
- queue로 간다면
task인가,microtask인가? - 현재 stack이 언제 비워질까?
- 이
await이후 코드는 실제로 언제 다시 이어질까? microtask를 너무 많이 이어붙여 다른 작업을 밀고 있지는 않은가?
이 기준으로 보면 비동기 코드는 "나중에 실행되는 코드"가 아니라, 정해진 우선순위에 따라 다시 stack에 올라오는 코드로 보이기 시작합니다.
같이 보면 좋은 글
- JavaScript의 Execution Context란 무엇이고 왜 중요한가
- JavaScript의 Hoisting이란 무엇이고 왜 그렇게 동작할까
- JavaScript의 Closure란 무엇이고 왜 그렇게 동작할까
- JavaScript의 this란 무엇이고 왜 이렇게 헷갈릴까
결론
JavaScript의 비동기 동작은 단순히 "나중에 실행된다"가 아니라, 현재 실행 중인 stack, task queue, microtask queue, 그리고 이를 조율하는 event loop가 어떤 순서로 움직이는가를 이해하는 문제입니다.
짧게 정리하면:
- 현재 코드는 먼저
call stack에서 실행되고 - 비동기 후속 작업은 queue로 밀리며
microtask는task보다 먼저 비워지고Promise와async/await는 이 우선순위 차이 때문에setTimeout과 다르게 보입니다
결국 event loop를 이해하면 콘솔 출력 순서뿐 아니라, 브라우저가 왜 어떤 순간엔 부드럽고 어떤 순간엔 막히는지까지 훨씬 더 자연스럽게 설명할 수 있게 됩니다.
