이벤트 루프의 정의
이벤트 루프는 ‘자바스크립트는 싱글 스레드를 기반으로 동작한다.’라고 불리게 하는 언어의 핵심이며, 프로그램의 렌더링 주기와 콜스택과 밀접한 관련이 있다.
HTML Spec에서는 이벤트 루프에 대한 뜻을 명확히 하지 않지만 어디서 사용하는지 나타낸다.
to coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops.
이벤트, 사용자의 상호작용, 스크립트, 렌더링, 네트워킹 등을 조정하려면 유저 에이전트는 이벤트 루프를 반드시 사용해야 한다.
여기서 에이전트란 ECMAScript에 정의되어 있는데 그 정의는 다음과 같다.
An agent comprises a set of ECMAScript execution contexts, an excution context stack, a running execution context
에이전트는 ECMAScript의 실행 컨텍스트, 실행 컨텍스트 스택, 실행 중인 실행 컨텍스트를 하나로 통합한 것이다.
다시 말하면 에이전트는 ECMAScript 를 실행하는 실행 컨텍스트를 포함하여 그 외의 실행 컨텍스트에 관련된 모든 것을 통합하여 부르는 말이다.
자바스크립트가 ECMAScript의 사양을 따르고 있는 언어라는 것을 알고 있다면, 에이전트는 자바스크립트에서 얼마나 중요한 역할을 하는 지 알 수 있다.
다시 말하면 이벤트 루프는 에이전트가 자바스크립트에서 자신의 이벤트, 사용자의 상호작용, 스크립트, 렌더링, 네트워크를 적절히 조정할 때, 사용되는 매커니즘이라고 볼 수 있다.
이벤트 루프의 종류
이벤트 루프는 2가지 종류가 있는데, window event loop, worker event loop가 있다.
window event loop
Window 객체(브라우저의 전역 객체는 Window 객체이다.)가 있는 브라우저에서 사용되는 이벤트 루프를 말한다. 다른 말로 본다면, 브라우저에서 일어나는 대부분의 일(키 입력, 네트워크 요청 등)은 해당 이벤트 루프에서 처리된다고 볼 수 있다.
worker event loop
web worker나 service worker에서 사용되는 이벤트 루프를 말하며, 해당 이벤트 루프를 통해서 각 워커들은 비동기 작업을 처리할 때 사용한다. 다시 말하면 워커 스레드는 각 스레드마다 자신만의 이벤트 루프를 하나씩 가지고 있다.
자 그럼 보통 자바스크립트에서 사용되는 Node.js 런타임 환경에 대한 이벤트 루프는 어떠한 종류의 이벤트 루프일까?
아쉽게도 이 둘 어디에도 해당되지 않는다. Node.js는 HTML Spec에 적혀 있는 이벤트 루프의 두 가지 종류를 정확히 따르지 않고 자신만의 이벤트 루프를 구축해서 사용한다.
하지만 결과적으로 HTML Spec에 정의되어 있는 요소를 크게 벗어나지 않게 구현되어 있다.
Node.js — The Node.js Event Loop
이벤트 루프의 구성 요소
HTML Spec을 살펴보면 이벤트 루프는 하나 이상의 태스크 큐를 가지고 있다. 태스크 큐는 태스크들을 모아둔 집합을 말한다. 여기서 중요한 것은 큐가 아니라 set으로 구현되어 있다는 것이다. set으로 구현되어 있는 이유는 이벤트 루프의 프로세싱 모델이 첫 번째 동작가능한 태스크를 선택된 큐로부터 정보를 받아오는 것이지, 빼는 방식이 아니기 때문이다.
그리고 이벤트 루프는 그렇게 정보를 받아온 것을 저장하고 있다가, 콜스택이 빈 것을 확인하면 해당 정보를 콜스택에 넘겨주는 역할을 한다. 그리고 가지고 있던 정보를 비우는데, 이 때 다시 한 번 여러 개의 태스크 큐 중 하나를 선택해서 태스크를 가져온다.
또한 이벤트 루프는 마이크로태스크 큐도 가지고 있는데, 마이크로태스크 큐는 그냥 태스크 큐와 다르며, 여러 개를 가질 수 있는 태스크 큐와 달리 마이크로태스크 큐 하나만 가질 수 있다.(마이크로태스크 큐와 태스크 큐에서 보관되는 태스크는 나중에 설명한다.)
이것 외에도 마이크로태스크 큐와 관련된 마이크로태스크 체크포인트라는 boolean 타입의 프로퍼티 또한 가지고 있다. true일 경우 마이크로태스크 큐에서 작업을 가져와 실행하고 있다는 것을 나타내며, 한 개의 마이크로태스크 작업이 끝나기 전까지 마이크로태스크 큐에서 계속해서 태스크를 가져오는 것을 방지한다. 이러한 변수가 필요한 이유는 마이크로태스크 큐에 저장된 태스크들을 처리하는 방식에서 확인할 수 있는데, 이벤트 루프는 한 주기(브라우저에서는 16ms의 주기를 가지고 동작한다.)에 마이크로태스크 큐와 태스크 큐를 관찰하며, 콜스택이 비어있는지 확인 후 콜스택이 비어있다면 마이크로태스크 큐 → 태스크 큐의 우선 순위로 태스크를 꺼내와 작업을 하기 때문이다. 이때, 한 주기에서 마이크로태스크 큐는 큐가 비어질 때까지 태스크를 가져오고 처리하는 방식이며, 태스크 큐는 하나의 태스크만 꺼내와서 처리한다.
즉, 이벤트 루프는 한 주기에 마이크로태스크 큐 → 큐가 비워질 때까지 이벤트 루프가 태스크를 가져와서 처리, 태스크 큐 → 선택된 태스크 큐에서 태스크 하나만 꺼내서 처리를 한다.
이벤트 루프는 한 주기(브라우저에서는 16ms의 주기를 가지고 동작한다.)에 마이크로태스크 큐와 태스크 큐를 관찰하며, 콜스택이 비어있는지 확인 후, 콜스택이 비어있다면 태스크를 큐에서 꺼내와 콜스택에 올린다.
이것 외에도 이벤트 루프 종류별로 추가적으로 가지는 요소가 있는데, window event loop는 DOMHighResTimeStamp를 가지며, 해당 변수는 마지막 렌더 기회의 시간을 의미하며, 동시에 Idle 상태의 시작 시간을 나타내기도 한다.
이벤트 루프에 대한 설명은 여기서 마친다. 이정도만 이해해도 Node.js 런타임 환경에서 비동기 함수가 실제로 이벤트 루프를 통해 어떻게 동작하는지 알 수 있다.
주의할 점 Node.js는 Html Spec을 기반으로 하지만 정확히 Html Spec에서 명시된 내용처럼 구현되어 있지 않다는 점이다. 이 점을 꼭 유의해야 한다! 다행히 이번에는 Html Spec을 기반으로 어느 정도 설명해도 비동기 함수의 처리 방식을 이해하는데 전혀 무리가 없다.
비동기 함수의 처리 방식(async, promise, web api)
여기서는 비동기 함수의 정의나 콜백 지옥을 어떻게 벗어나는지에 대해 전혀 설명하지 않는다.
Promise 객체와 async 함수가 등장하게 된 변천사 및 사용법은 해당 링크에서 보는 것을 추천한다.
알아야 할 핵심은 다음과 같다.
- async function은 결국 new Promise 객체이다.(근데 자동 resolve 기능이 포함된) async function을 호출할 경우 new Promise(() ⇒ {async function 내부 코드})가 실행된다. 이렇게 안에서 실행되는 함수를 Executor 함수라고 부른다.
- 비동기 함수가 호출되면 await 키워드 부분을 만날 때까지는 콜스택에서 머무르며 동기적인 동작을 처리한다.
- await 키워드를 만났을 경우 해당 함수가 실행될 수 있는 위치로 이동하고 (web api, libuv 등) 콜스택에서 없어진다.
- await 키워드 뒤에 있는 함수가 실행되고 만약 거기에 Promise.then 객체가 있다면 마이크로태스크 큐로 전달된다.(다시 말해 promise에 관련된 콜백함수 then, catch, finally 등은 마이크로태스크 큐로 전달된다.) 만약, setTimeout같은 web api에 해당되는 함수라면, 해당 함수 내부에 있는 콜백함수는 태스크 큐로 전달된다.
- 여기서 재밌는 사실은 Promise 사양이 정의된 Promiseaplus에서는 해당 부분이 마이크로태스크 큐로 구현되거나 태스크 큐로 구현되어야 한다고 표현했다는 것이다. 하지만 마이크로태스크 큐에 넣어진 것을 보니, 결국 promise 객체의 콜백 함수는 마이크로태스크 큐에 넣어지는 것으로 결정한 것 같다.
- 만약 처음 실행됐던 비동기 함수의 뒷 부분이 남았다면, 해당 부분도 마이크로태스크 큐에 추가된다.(하지만 이것은 4번의 후속 함수(then, catch, finally)가 마이크로태스크 큐에 넣어진 다음 행해진다.)
- 이벤트루프는 마이크로태스크 큐와 태스크 큐가 비어있는지 확인하는데, 이때 마이크로태스크가 우선순위로 처리된다. 차이점은 마이크로태스크 큐는 이벤트 루프의 한 주기(16ms)에서 비어질 때까지 처리가되고 태스크 큐는 한 주기에 한 번만 처리된다는 것이다. 그리고 마이크로태스크 큐와 태스크 큐는 모두 한 주기에 처리된다. 여기서 처리된다는 말은 콜스택에 해당 콜백 함수를 올린다는 뜻이다.
- 올려진 콜백 함수는 다시 콜스택에서 동기적으로 실행된다.
실험 코드
async function asyncFunction() {
console.log('Async Function Start');
await new Promise((resolve) => {
console.log("test")
resolve();
}
).then(() => {
console.log("microfirst")
});
console.log('Async Function End');
}
console.log('Script Start');
asyncFunction().then(() => {
console.log('Async Function Complete');
});
console.log('Script End');

async function asyncFunction() {
console.log('Async Function Start');
new Promise((resolve) => {
console.log("test")
resolve();
}
).then(() => {
console.log("microfirst")
});
console.log('Async Function End');
}
console.log('Script Start');
asyncFunction().then(() => {
console.log('Async Function Complete');
});
console.log('Script End');
출처
ECMAScript® 2025 Language Specification
async function - JavaScript | MDN
자바스크립트와 이벤트 루프 : NHN Cloud Meetup
JavaScript의 queueMicrotask()와 함께 마이크로태스크 사용하기 - Web API | MDN