자바스크립트는 기본적으로 싱글 스레드
기반 언어로, 한번에 하나의 일만 처리할 수 있습니다.
만약 비용이 큰 작업이 동기적으로 실행된다면 브라우저는 다른 작업을 전혀 수행하지 못하고 정지하게 됩니다.
이에 자바스크립트는 비동기 처리를 위해 이벤트 루프
라는 매커니즘을 사용합니다.
자바스크립트 엔진은 이벤트 루프를 통해 작업들을 순차적으로 처리하고 대기열(큐)에 쌓인 이벤트들을 관리함으로써, 한 번에 하나의 작업만 실행하면서도 비동기 동작을 흉내낼 수 있습니다.
자바스크립트 런타임 환경은 자바스크립트 엔진
과 호스트 환경
이 결합된 형태입니다.
엔진은 실제로 자바스크립트 코드를 해석하고 실행하는 역할을 하고, 호스트 환경(브라우저나 Node JS등)은 엔진이 접근할 수 있는 전역 객체와 API들을 제공합니다. 예를 들어, 브라우저에서는 window
나 document
와 같은 브라우저 전역 객체와 fetch
, setTimeout
등의 Web API를 사용할 수 있고, Node 환경에서는 global
객체와 파일시스템 같은 Node 전용 API를 사용할 수 있습니다. 런타임 환경이 다르면 자바스크립트 코드가 접근할 수 있는 기능이 달라지며 동작 방식에도 차이가 생깁니다.
자바스크립트 엔진은 자바스크립트 코드를 파싱(구문분석)
하고 바이트코드 또는 머신 코드로 컴파일/인터프리트
하여 실제로 실행하는 프로그램을 말합니다. 대표적으로, V8 엔진(Chrome), SpiderMonkey(파이어폭스), JavaScriptCore(사파리)등이 있습니다.
엔진은 자바스크립트 언어 자체를 실행하는 역할을 하며, 콜스택을 관리하고 힙 메모리를 할당하는 등 언어 차원의 기능을 제공합니다. 하지만 엔진 자체에는 DOM 같은 웹 환경 요소나 I/O 처리 기능이 없으며, 이러한 부분은 호스트 환경이 제공합니다. 예를 들어, 엔진은 자바스크립트의 fucntion
이나 Promise
가 어떻게 동작하는지 알고 있지만, document.querySelector
나 setTimeout
이 무엇인지는 알지 못 합니다.
현재 실행중이거나 호출된 함수들이 관리되는 공간입니다.
콜스택은 지금 어떤 함수가 실행 중이며, 그 함수가 다른 어떤 함수를 호출했는지 기록합니다.
새로운 함수를 호출하면 그 함수의 실행 컨텍스트가 스택에 추가되고 함수 실행이 끝나면 해당 컨텍스트에서 제거합니다.
동적으로 생성된 객체들이 저장되는 메모리 공간으로, 크기가 가변적이며 필요할 때마다 필요한 크기만큼 메모리를 확보하여 객체를 저장하고 더 이상 사용하지 않으면 가비지 컬렉터(GC)에 의해 해제됩니다.
브라우저 환경이 자바스크립트 엔진에 제공하는 각종 기능과 인터페이스를 말합니다.
setTimeout
, fetch
, DOM 조작을 위한 API
같은 비동기 API를 제공합니다. 이런 API들은 호스트 환경인 브라우저가 구현하여 엔진에게 붙여준 것으로, 자바스크립트 코드에서는 마치 내장 된 것 처럼 이를 호출할 수 있습니다.
이러한 Web API 호출들은 대부분 비동기적으로 동작할 수 있습니다. Web API들은 브라우저가 백그라운드에서 멀티스레드로 처리하거나, OS 커널 등의 도움을 받아 처리한 뒤, 자바스크립트 엔진에게 콜백 등의 형태로 결과를 알려줍니다.
Web API에서 완료된 비동기 작업의 콜백이 대기하는 공간으로, 콜 스택이 빌 때 이벤트 루프
에 의해 하나씩 꺼내져 실행됩니다.
Promise.then(), MutationObserver 등 즉시 실행해야 할 작업을 저장하며, 태스크 큐보다 높은 우선 순위를 가집니다.
이벤트 루프는 자바스크립트 런타임에서 콜 스택이 비어있는지를 계속 확인하고, 비었으면 큐에서 대기 중인 작업을 가져와 실행합니다.
즉, 콜 스택이 완전히 비어있는 순간을 이벤트 루프가 감지하면, 태스크 큐에서 가장 먼저 대기 중인 콜백을 꺼내어 콜 스택에 넣고 실행합니다.
이때 만약 마이크로태스크 큐에 대기 중인 콜백들이 있다면 태스크를 꺼내기 전에 그것들을 모두 처리한 후에야 태스크 큐에서 콜백을 가져옵니다.
이벤트 루프는 자바스크립트가 싱글 스레드임에도 불구하고 여러 비동기 이벤트를 효율적으로 처리할 수 있게 해줍니다.
console.log("start");
setTimeout(() => { // macro task queue
console.log("timeout callback");
}, 0);
Promise.resolve().then(() => { // micro task queue
console.log("promise callback");
});
console.log("end");
위 코드를 실행하면 다음과 같이 출력됩니다
start
end
promise callback
timeout callback
코드의 실행 순서를 분석해보면 다음과 같습니다.
"start"
가 콜 스택에 올라가 바로 출력됩니다.setTimeout
함수가 호출되면, 브라우저는 0ms 타이머와 함께 timeout callback
함수를 태스크 큐에 등록해둡니다. (0ms라고 해도 콜백은 즉시 실행되지 않고 큐를 거칩니다). setTimeout
호출은 곧바로 완료되어 콜 스택에서 사라집니다.Promise.resolve().then(...)
이 실행됩니다. 프로미스는 이미 resolve되었으므로 promise callback
함수가 마이크로태스크 큐에 등록됩니다. (.then
콜백은 마이크로 태스크로 처리됩니다)."end"
로그 출력이 콜 스택에서 실행되어 화면에 출력됩니다. 이 시점까지 콜 스택에서는 동기 코드 실행을 모두 마쳤고 비어 있게 됩니다.이제 콜 스택이 빈 상태이므로, 이벤트 루프가 대기 중인 작업을 처리하기 시작합니다.
우선, 마이크로 태스크 큐에 prmoise
로 등록된 “promise callback” 이 들어 있으므로 이를 즉시 콜 스택에 올려 실행합니다. 그 결과 “promise callback”을 콜스택에 넣어 실행합니다.
마이크로 태스크 큐를 모두 비우고 나면, 이벤트 루프는 태스크 큐를 확인합니다. 여기에는 setTimeout
으로 등록된 “timeout callback”이 대기 중이므로, 이를 콜스택에 넣어 실행합니다.
참고
JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue