Java나 Python은 멀티 스레드를 지원하여 원하는 코드 로직을 동시에 수행 시키는 멀티테스킹이 가능하지만, 자바스크립트는 싱글스레드 언어로 한 번에 단 하나의 작업만 수행할 수 있습니다.
그런데 우리가 이용하는 많은 애플리케이션과 사이트에서는 클릭과 같은 이벤트가 파일 다운로드와 동시에 처리되고, 또 동시에 여러 다른 작업을 할 수 있습니다. 싱글스레드인 자바스크립트가 한 번에 단 하나의 작업만 수행한다면 어떻게 동시에 많은 작업들이 처리될 수 있는 걸까요?
자바스크립트 엔진의 주요 두 구성요소는 memory heap과 call stack입니다. memory heap은 메모리 할당이 일어나는 곳이고 call stack은 코드 실행에 따라 호출 스택이 쌓이는 곳입니다. 이벤트 루프를 이해하기 위해서는 콜스택을 이해하는 것이 가장 우선이므로 아래 콜스택에 대해 자세히 작성하겠습니다.
콜스택은 자바스크립트 실행 모델의 가장 핵심적인 부분으로, 현재 실행 컨텍스트를 추적할 수 있는 곳입니다.
콜스택은 스택 데이터 구조(LIFO:Last In, Firt Out)으로 되어 있고, 함수 호출의 순서를 저장하고 관리합니다. 함수가 호출될 때, 이 스택의 가장 상단에 쌓이고 실행이 종료되면 스택에서 제거됩니다.(popped) 앞서 확인한 것과 같이 자바스크립트의 콜스택은 한 번에 하나의 작업만 처리하는 동기적 처리 방식을 택하고 있습니다.
콜스택은 비동기 함수를 제외하고 각 함수가 다음 함수가 시작하기 전에 완료되도록 보장함으로써 실행 흐름을 관리하는 데 중요한 역할을 합니다. 멀티스레딩의 복잡성과 잠재적인 문제점을 피할 수 있다는 장점이 있습니다.
그러나 제한사항도 있는데요. 함수가 실행을 완료하는 데 너무 오래 걸리면 전체 콜 스택을 블록하여 브라우저가 응답하기 않게 되는 블로킹 현상이 발생할 수 있습니다. 이렇게 되면 유저는 멈춰버린 화면을 확인할 수 있는데요 이를 보통 web ui가 freezing 됐다고 하기도 합니다.
그렇다면 비동기적 처리는 누가 담당하고 어떻게 관리되고 있는 걸까요?
파일 다운, 네트워크 요청, 타이머, 애니메이션과 같이 오래 걸리고 반복적인 작업들은 위에서 언급했던 자바스크립트 엔진이 아닌 브라우저의 멀티 스레드인 Web APIs에서 비동기 + 논블로킹 방식으로 처리됩니다. 비동기 + 논블로킹은 메인 스레드가 작업을 다른 곳에 요청하여 대신 실행하고, 작업이 완료되면 이벤트나 콜백 함수를 받아 결과를 실행하는 방식을 말합니다. 마찬가지로 Node.js 환경에서는 libuv라는 내장 라이브러리가 이 역할을 대신한다고 하네요.
이벤트 루프는 비동기 테스크가 적절한 시간에 실행되도록 보장하며, 자바스크립트의 실행 흐름을 조율하는 역할을 합니다. 주요 역할은 크게 3가지로 나눌 수 있습니다.
콜 스택과 큐 사이에서 테스크를 조율하고 실행 순서를 관리합니다.
이벤트 루프는 콜 스택이 비어 있는지 지속적으로 확인하며 적절한 시점에서 테스크를 콜 스택으로 이동시킵니다.
비동기 작업을 효율적으로 처리합니다.
setTimeout이나 AJAX요청, 이벤트 리스너 등의 비동기 작업을 관리하며 백그라운드에서 실행되는 작업들의 완료 시점을 모니터링 합니다.
우선순위 기반 실행을 보장합니다.
마이크로태스크 큐에 있는 작업을 메크로테스크 큐보다 먼저 처리합니다. 이벤트의 실행 순서를 보장하여 예측 가능한 프로그램 동작을 만듭니다.
다시 말해, 이벤트 루프는 콜 스택이 비어 있는지 지속적으로 확인하고, 만약 콜스택이 비어 있다면 이벤트 루프는 먼저 마이크로테스크 큐를 확인합니다. 마이크로테스크 큐에 테스크가 있다면 마이크로테스크 큐가 비워질 때까지 하나씩 콜스택으로 이동시킵니다.
브라우저 환경에서 자바스크립트는 브라우저가 제공하는 함수와 다양한 기능들이 있는 Web APIs에 접근할 수 있도록 해줍니다. 이 Web APIs를 통해 비동기 처리가 가능해집니다.
자바스크립트 엔진 밖에서 작동하는 Web APIs는 네트워크 요청이나 타이머, DOM 이벤트 등 비동기 작업을 핸들링합니다. 비동기 함수들이 호출되면, 자바스크립트 런타임은 이를 Web APIs에 위임합니다. Web APIs가 작업을 완료하면 연관된 콜백함수를 큐로 보냅니다.
Web APIs를 통해 자바스크립트는 콜스택을 블록하지 않고 시간이 오래 걸리는 작업을 위임할 수 있습니다. 메인 스레드 외부에서 비동기 작업을 처리함으로써 Web APIs는 논블로킹 작업을 가능하게 하여 애플리케이션이 더 효율적이고 응답성이 높게 만듭니다.
콜백큐는 완료된 Web APIs 테스크의 콜백 함수들이 실행을 기다리는 곳입니다.
Web APIs가 비동기 작업을 완료하면, 연관된 콜백 함수를 콜백 큐로 보냅니다. 콜백큐는 FIFO(First In, First Out) 순서를 따르며, 작업들이 추가된 순서대로 실행됩니다.
이벤트 루프는 지속적으로 콜백 큐를 확인하고, 콜 스택이 비어 있다면 태스크를 콜 스택으로 이동시켜 실행할 수 있도록 합니다.
콜백큐에서는 작업은 FIFO로 처리되는데, 이는 개발자들이 비동기 태스크가 예측 가능한 순서대로 실행된다는 것을 알고 예측 가능하고 안정적인 코드를 작성하는 데 도움을 줍니다.
ES6에서 Promise가 새로 추가되면서 job queue도 함께 등장했습니다. 그 이전 콜백큐가 사실상 task와 job 두 종류로 나눠진 것인데요. job queue는 마이크로테스크큐를 말하고, 이 job queue가 처리하지 않는 나머지 작업들을 담당하는 곳을 매크로테스크큐라고 말합니다. 앞서 밝힌 것과 같이 job이 task에 우선해서 처리됩니다.
console.log('시작');
// 매크로태스크
setTimeout(() => {
console.log('타이머 완료');
}, 0);
// 마이크로태스크
Promise.resolve().then(() => {
console.log('프로미스 완료');
});
console.log('끝');
이 코드에서 이벤트 루프는 console.log('시작')과 console.log('끝')을 먼저 실행시키고, setTimeout은 Web APIs에 등록 후 매크로테스크 큐로 이동시킵니다. Promise는 마이크로테스크 큐로 이동시킨 뒤, 콜 스택이 비면 이 마이크로테스크를 먼저 처리합니다. 그리고 나서 매크로테스크 큐를 처리하게 됩니다.
따라서 콘솔에는 시작 -> 끝 => 프로미스 완료 -> 타이머 완료 순으로 찍히게 됩니다.
마이크로테스크 큐와 매크로테스크 큐가 처리하는 작업의 종류는 다음과 같습니다.
마이크로테스크 큐
- Promise의 then/catch/finally 핸들러
- process.nextTick
- queueMicrotask()
- MutationObserver
매크로테스크 큐
- setTimeout/setInterval 콜백
- requestAnimationFrame
- I/O작업
- UI렌더링
- 이벤트 리스너
위에서 봤던 코드를 살짝 변형해서 다시 한 번 마이크로테스크 큐와 매크로테스크 큐의 실행 순서를 알아보도록 하겠습니다.
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
console.log('6');
가장 먼저 1과 6이 출력될 것이고, 다음으로 마이크로테스크 큐에 있던 Promise.then 핸들러가 실행되어 4가 출력됩니다. 이 때 새로운 setTimeout이 매크로테스크 큐에 추가됩니다.
먼저 매크로테스크큐에 담겨있던 setTimeout 콜백이 실행되어 2가 출력되고, 이어 그 안의 Promise then 핸들러가 마이크로태스크큐에 들어가 바로 실행되어 3이 출력됩니다.
그 이후 가장 마지막에 등록됐던 '5'가 출력되면서 실행이 종료됩니다.
이벤트 루프의 역할로 더 정교한 비동기 제어가 가능하고 애플리케이션의 성능과 반응성이 좋아집니다. 또한 Promise 기반 코드의 예측 가능성이 높아져 개발자가 이를 활용할 수 있습니다.
싱글스레드인 자바스크립트가 어떻게 브라우저 환경에서는 멀티스레드처럼 코드를 실행시킬 수 있는지를 확인했습니다. 자바스크립트의 이벤트 루프의 다양한 구성요소들이 각각 고유한 역할을 하고 있으며, 효율적이고 비동기 + 논블로킹 실행환경을 만들고 있다는 것을 알게 됐습니다.
실무에서 스택을 바쁘게(?) 만든다거나, 마이크로테스크큐와 콜백큐를 바쁘게 만드는 방식으로 코드를 작성하면 애플리케이션 성능과 효율에 영향을 미칠 수 있습니다.
10초 이상 연산이 걸리는 함수를 짠다거나, 이벤트를 너무 많이 호출한다던가, 네트워크 요청을 너무 많이 한다던가 하는 방식의 코드가 왜 지양되어야 하는지 확인할 수 있었습니다.
참고문헌
https://medium.com/@skyshots/an-insight-into-the-javascript-event-loop-internals-and-functionality-97a0c31c2e25
https://wikidocs.net/251952
https://johnresig.com/blog/how-javascript-timers-work/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop
https://meetup.nhncloud.com/posts/89
https://stackoverflow.com/questions/52906975/call-stack-event-loop-why-waiting-for-empty-stack
https://javascript.plainenglish.io/understand-javascripts-event-loop-36c021f850f7