
자바스크립트는 기본적으로 단일 스레드(single-thread)언어입니다. 즉, 한 번에 하나의 작업만 처리할 수 있다는 뜻이죠. 그럼에도 불구하고 실제 개발에서는 동시에 여러 작업이 돌아가는 것처럼 보이는 경우가 많습니다. setTimeout으로 타이머를 등록하고, fetch로 네트워크 요청을 날리고, DOM 이벤트를 처리하고, Promise와 async/await으로 비동기 로직을 관리할 수 있습니다.
이런 다양한 비동기 작업이 충돌하지 않고, 순서대로 실행될 수 있는 이유는 바로 이벤트 루프(Event Loop)와 태스크 큐(Task Queue)라는 메커니즘 덕분입니다. 이벤트 루프를 이해하지 못하면 자바스크립트의 실행 순서를 예측하기 어려워지고, 디버깅에 많은 시간을 허비할 수 있습니다.
이번 글에서는 이벤트 루프의 기본 원리와 태스크 큐의 구조, 그리고 실제 동작 과정과 실무에서의 주의점까지 차근차근 살펴보겠습니다.
이벤트 루프는 자바스크립트 엔진의 핵심적인 실행 메커니즘 중 하나입니다. 동기와 비동기 코드가 한 스레드 안에서 공존할 수 있도록 조율해 줍니다. 이 부분을 이해하려면 먼저 호출 스택과 단일 스레드 모델을 짚고 넘어가야 합니다.
자바스크립트는 단일 스레드 언어이므로, 동시에 여러 작업을 병렬로 처리하지 못합니다. 대신 호출 스택(Call Stack)에 함수를 하나씩 쌓아 실행하는 방식으로 동작합니다.
function a() {
console.log("a 실행");
b();
}
function b() {
console.log("b 실행");
}
a();
// 실행 순서
// 1) a() 호출 → 스택에 push
// 2) console.log 실행 후 b() 호출 → b() push
// 3) b() 실행 완료 후 pop
// 4) a() 실행 완료 후 pop
위 예제처럼 호출 스택은 동기적인 코드 실행을 순차적으로 관리합니다. 하지만 setTimeout, fetch 같은 비동기 코드는 스택에 오래 머물지 않고 외부(Web API나 Node.js 런타임)에 위임되며, 이후 이벤트 루프를 통해 다시 실행 흐름에 합류하게 됩니다.
이벤트 루프는 호출 스택과 태스크 큐 사이에서 끊임없이 순환하면서, 스택이 비는 순간 큐에 쌓인 콜백을 가져와 실행하는 관리자 역할을 합니다.
즉, 동기 코드는 호출 스택에서 즉시 실행되고, 비동기 코드는 외부 환경이 처리한 뒤 태스크 큐에 콜백을 넣습니다. 이벤트 루프는 스택이 비자마자 큐에서 하나씩 태스크를 꺼내 실행시킵니다. 이 단순한 원리 덕분에 단일 스레드임에도 불구하고, 마치 병렬 처리가 되는 것처럼 느껴집니다.
태스크 큐(Task Queue)는 이벤트 루프가 비동기 작업을 다시 실행 스택으로 옮길 때 사용하는 대기열입니다. 하지만 이 큐는 한 가지로만 구성되어 있지 않고, 매크로 태스크와 마이크로 태스크라는 두 가지로 나뉩니다.
매크로 태스크는 비교적 큰 단위의 비동기 작업을 의미합니다. setTimeout, setInterval, setImmediate(Node.js), DOM 이벤트 핸들러가 여기에 속합니다. 매크로 태스크는 실행 스택이 완전히 비어야 실행될 수 있습니다.
setTimeout(() => console.log("타이머 실행"), 0);
console.log("동기 코드 실행");
// 출력 순서
// "동기 코드 실행"
// "타이머 실행"
0ms를 설정해도 즉시 실행되지 않고, 반드시 스택이 비어야 큐에서 꺼내 실행됩니다. 이 점에서 많은 초보자가 혼란을 겪기도 합니다.
마이크로 태스크는 더 작은 단위의 비동기 작업을 의미하며, Promise의 then/catch/finally, queueMicrotask 등이 해당됩니다. 이벤트 루프는 매크로 태스크 하나가 끝날 때마다 반드시 마이크로 태스크 큐를 비운 뒤에야 다음 매크로 태스크를 실행합니다.
Promise.resolve().then(() => console.log("마이크로 태스크"));
setTimeout(() => console.log("매크로 태스크"), 0);
// 출력 순서
// "마이크로 태스크"
// "매크로 태스크"
이 덕분에 Promise는 항상 setTimeout보다 먼저 실행됩니다.
매크로 태스크와 마이크로 태스크의 차이를 직접 확인해 보겠습니다.
console.log("시작");
setTimeout(() => console.log("setTimeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("끝");
// 출력 순서
// "시작"
// "끝"
// "Promise"
// "setTimeout"
Promise 콜백이 setTimeout보다 먼저 실행되는 이유는, 이벤트 루프가 매크로 태스크와 매크로 태스크 사이에 마이크로 태스크를 반드시 처리하기 때문입니다.
여기까지 살펴본 실행 규칙을 그림으로 표현하면 더 직관적으로 이해할 수 있습니다. 아래 다이어그램처럼, 이벤트 루프는 콜 스택(Call Stack), 마이크로태스크 큐(Microtask Queue), 매크로태스크 큐(Macrotask Queue) 사이를 오가며 작업을 처리합니다.
즉, 콜 스택이 비면 이벤트 루프가 먼저 마이크로태스크 큐를 비운 뒤, 남아있는 매크로태스크를 실행한다는 점을 시각적으로 확인할 수 있습니다.
이제 이벤트 루프가 실제로 어떻게 동작하는지 구체적으로 살펴보겠습니다. 기본적인 사이클은 동기 코드 처리 -> 비동기 코드 등록 -> 큐에서 태스크 실행 -> 마이크로 태스크 정리입니다. 이를 코드 예제를 통해 단계별로 분석해 보겠습니다.
동기 코드는 호출 스택에 즉시 쌓였다가 실행 후 제거됩니다. 이벤트 루프가 특별히 개입하지 않습니다.
console.log("1");
console.log("2");
console.log("3");
// 출력
// 1
// 2
// 3
이처럼 동기 코드는 항상 작성한 순서대로 실행됩니다.
비동기 작업은 호출 스택에서 바로 실행되지 않고 외부 환경(Web API 등)에 위임됩니다. 예를 들어, setTimeout은 브라우저의 타이머 API가 카운트를 세고, 완료되면 콜백을 태스크 큐에 넣습니다.
console.log("시작");
setTimeout(() => console.log("타이머 종료"), 1000);
console.log("끝");
// 실행 순서
// 시작
// 끝
// (1초 대기)
// 타이머 종료
즉, 자바스크립트 엔진은 직접 시간을 재지 않고, 외부 API가 다 처리해 주며, 이벤트 루프는 단지 큐에서 콜백을 가져오는 역할만 합니다.
async/await는 문법적으로는 동기처럼 보이지만, 실제로는 Promise를 기반으로 동작합니다. await는 해당 위치 이후 코드를 마이크로 태스크로 등록해 둡니다.
async function fetchData() {
console.log("1");
await Promise.resolve();
console.log("2");
}
fetchData();
console.log("3");
// 출력
// 1
// 3
// 2
실행 과정을 풀어보면 다음과 같습니다.
1. fetchData 실행 → “1” 출력
2. await Promise.resolve() → 마이크로 태스크로 등록하고 함수 일시 중단
3. 동기 코드 “3” 출력
4. 이벤트 루프가 마이크로 태스크 처리 → “2” 출력
따라서 async/await 이후의 코드는 항상 setTimeout보다 먼저 실행됩니다.
이벤트 루프와 태스크 큐를 정확히 이해하면, 비동기 실행 순서를 예측할 수 있을 뿐 아니라 버그를 방지하고 성능을 개선할 수 있습니다. 실무에서 특히 자주 등장하는 세 가지 주제를 짚어보겠습니다.
많은 개발자가 setTimeout(fn, 0)을 즉시 실행이라고 착각하곤 하는데요, 실제로는 현재 실행 중인 스택과 마이크로 태스크가 모두 끝난 뒤에야 실행됩니다.
setTimeout(() => console.log("setTimeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("동기 코드");
// 출력
// 동기 코드
// Promise
// setTimeout
따라서 setTimeout(0)은 “지금 바로”가 아니라 “다음 이벤트 루프 사이클에서” 실행된다고 이해해야 합니다.
Promise는 항상 마이크로 태스크로 분류되므로 setTimeout보다 먼저 실행됩니다. 이 차이를 모르면 코드 실행 순서에서 혼란을 겪을 수 있습니다. 특히 UI 업데이트와 서버 요청이 동시에 일어나는 상황에서 Promise 기반 코드가 예상보다 빨리 실행되어 버그로 이어질 수 있습니다.
console.log("시작");
setTimeout(() => console.log("setTimeout 실행"), 0);
Promise.resolve().then(() => console.log("Promise 실행"));
console.log("끝");
// 예상 출력 순서
// 시작
// 끝
// Promise 실행
// setTimeout 실행
이 코드를 실행하면 먼저 “시작”과 “끝”이라는 동기 코드가 출력됩니다. 그다음 이벤트 루프가 마이크로 태스크 큐를 확인하면서 Promise.then() 콜백을 실행하고, 마지막으로 매크로 태스크 큐에서 setTimeout 콜백을 실행합니다. 즉, 두 콜백 모두 “비동기 코드”이지만 Promise가 항상 setTimeout보다 먼저 실행된다는 점이 핵심입니다.
실제 프레임워크들은 이벤트 루프를 활용해 렌더링 타이밍을 정교하게 제어합니다. 예를 들어 Vue의 nextTick은 내부적으로 마이크로 태스크 큐(Promise)를 활용해 상태가 변경된 후 DOM 업데이트가 완료될 때까지 기다린 뒤 콜백을 실행합니다. 이렇게 하면 코드가 실행되는 순간에는 변경 사항이 DOM에 반영되지 않았더라도, 다음 루프 사이클에서 갱신된 DOM을 안정적으로 참조할 수 있습니다.
React 역시 상태 업데이트가 즉시 반영되지 않고, 이벤트 루프 사이클을 고려하여 “배치 처리”가 이루어집니다. 이는 렌더링을 불필요하게 여러 번 반복하지 않도록 성능을 최적화하기 위한 전략으로, 만약 이벤트 루프와 태스크 큐 동작 원리를 제대로 이해하지 못하면, 왜 setState를 호출해도 바로 값이 바뀌지 않는지 혼란을 겪을 수 있습니다.
또한 무거운 연산을 한 번에 실행하면 호출 스택이 오래 점유되어 UI가 멈춘 것처럼 보이는 문제가 생길 수 있습니다. 이럴 때는 이벤트 루프를 활용해 작업을 여러 번으로 쪼개서 실행하는 것이 좋은 해결책이 됩니다.
예를 들어,대량의 데이터를 처리해야 한다면, setTimeout을 이용해 작은 덩어리로 나눠 실행하면 사용자 인터페이스가 중간중간 반응할 수 있어 “버벅임”을 크게 줄일 수 있습니다. 더 나아가 최신 브라우저에서는 requestIdleCallback API를 활용해 브라우저가 한가할 때 연산을 수행하도록 위임하는 방식도 유용합니다. 이는 애니메이션이나 스크롤과 같은 고빈도 작업이 있을 때 사용자 경험을 개선하는 데 특히 효과적입니다.
이벤트 루프와 태스크 큐는 자바스크립트의 비동기 실행 구조를 이해하는 핵심이 됩니다. 단일 스레드 언어임에도 마치 여러 작업이 동시에 처리되는 것처럼 보이는 이유는 바로 이 이벤트 루프 메커니즘 덕분이죠.
매크로 태스크와 마이크로 태스크의 차이를 명확히 구분하면, 코드 실행 순서를 혼동 없이 예측할 수 있고, setTimeout, Promise, async/await 같은 기능들이 어떤 순서로 실행되는지도 자연스럽게 이해할 수 있습니다. 이는 단순한 이론적 지식에 머무르지 않고, 실제 서비스 코드에서 디버깅과 성능 최적화, 그리고 사용자 경험 개선으로 이어집니다.
실무에서는 이벤트 루프를 잘 이해하는 것만으로도 예측 불가능한 동작이나 렌더링 지연 문제를 상당 부분 예방할 수 있는데요. Promise는 항상 setTimeout보다 먼저 실행된다는 규칙, DOM 업데이트가 다음 루프에서 반영된다는 특성, 그리고 무거운 연산은 태스크 큐를 활용해 쪼개야 한다는 원칙은 반드시 기억해야 할 실용적인 지식입니다.
결국 이벤트 루프와 태스크 큐는 자바스크립트가 가진 동시성 모델의 심장이라고 할 수 있습니다. 이 구조를 정확히 이해한다면 단순히 비동기 코드를 짜는 수준을 넘어, 안정적이고 반응성 높은 애플리케이션을 설계할 수 있을 겁니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.