JS의 비동기성에 관하여
JS는 싱글 스레드 언어입니다. 이는 다시 말해, JS는 하나의 콜 스택만을 활용하기 때문에, 한번에 하나의 코드만을 실행시킬 수 있다는 뜻입니다.
실제로, JS 엔진은 어떤 작업을 수행하고 있는 중에는 렌더링이나 새로운 이벤트에 대한 핸들링이 즉각적으로 일어나지 않습니다.
근데 이상하죠? 우리는 이미 JS의 콜백 함수나, 프라미스, async를 통해 비동기 함수들을 다루어왔는데 말이죠.
이것이 가능한 이유는 브라우저가 단순한 JS 런타임 그 이상을 갖추고 있기 때문입니다. JS 런타임은 실제로 싱글 스레드 언어이지만, 브라우저가 Web API와 같은 것들을 제공합니다. 이들은 JS에서 호출할 수 있는 스레드를 효과적으로 지원합니다.

Web API
Web API는 브라우저가 제공하는 JS 런타임 내 별도의 API입니다.
여기에는 setTimeout
이나, AJAX를 활용하는 fetch
등이 포함됩니다.
Web API에서 제공하는 이들 함수를 활용할 때, JS 엔진은, 해당 함수의 호출이 일어나면, Web API가 내부적으로 이들을 처리하도록 맡기고, 계속해서 코드를 진행해나갑니다.
태스크 큐
이후, Web API는 해당 함수를 처리하고, (예를 들어, setTimeout
을 실행하면, 정해놓은 시간이 지날때까지 기다리고, AJAX의 경우에는 적절한 응답을 받을때까지 기다립니다.) 이후 해당 함수에 전달했던 콜백을 태스크 큐로 넘깁니다.
이벤트 루프
이제 이벤트 루프가 활약할 차례입니다. 사실 이벤트 루프가 하는 일은 굉장히 간단합니다. JS 엔진의 콜 스택이 빌 때까지 기다렸다가, 비고 나면, 태스크 큐의 태스크들을 먼저 들어온 순서대로 콜 스택에 넘깁니다.
결국 여기에는 "콜 스택이 빌때까지" 기다리는 시간도 포함되기 때문에, setTimeout(cb, 1000)
은 결코 해당 cb
가 정확히 1초 이후에 실행된다는 것을 의미하지 않습니다.
매크로태스크와 마이크로태스크

기본적으로 setTimeout
, setInterval
, 그리고 이벤트 핸들러 등의 함수들은 매크로태스크에 해당됩니다.
반면, 마이크로태스크는 우리가 종종 사용하는 프로미스와 같은 것들이 해당합니다.
마이크로태스크와 매크로태스크의 차이는 해당 태스크들의 실행 시점에서 발생한다고 할 수 있습니다.
브라우저는, 마이크로태스크 -> 렌더링 -> 매크로태스크의 순서로 실행되며, 마이크로태스크는 결국, 브라우저의 렌더링이나 이벤트 핸들러의 처리 이전에 여러 마이크로태스크들이 실행되는 것을 보장합니다.
이것이 중요한 이유는, 마이크로태스크들이 모두 동일한 환경 내에서 처리되도록 보장하기 위해서입니다. 이를테면, 마우스 클릭 등의 다른 이벤트 핸들링에 의해서 마이크로태스크들을 처리하던 와중에 데이터의 변경이 일어나면, 여러 마이크로태스크들이 제각기 다른 환경에서 실행될 가능성이 있기 때문입니다.
이벤트 루프의 활용
이러한 이벤트 루프의 동작 방식을 실제 업무에서는 어떻게 활용할 수 있을까요?
1. 무거운 작업을 쪼개서 수행
let i = 0;
let start = Date.now();
function count() {
// 스케줄링 코드를 함수 앞부분으로 옮김
if (i < 1e9 - 1e6) {
setTimeout(count); // 새로운 호출을 스케줄링함
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert('처리에 걸린 시간: ' + (Date.now() - start) + 'ms');
}
}
count();
기본적으로, JS 엔진은 싱글 스레드 기반이기 때문에, 동기적인 방식으로 동작하는 함수가 처리에 너무 오랜 시간이 걸린다면 그 동안에 새로운 이벤트나 렌더링 자체를 막아버리면서 유저와의 상호 작용을 무시하는 문제가 발생합니다.
이 경우, 이러한 작업들을 setTimeout
등을 통해 여러 태스크들로 쪼개서 처리한다면, 그 동안에 처리해야 할 이벤트 핸들링 및 렌더링을 막지 않으면서 거대한 작업을 수행할 수 있습니다.
이를 통해서 게임 로딩 등에서 활용되는 프로그레스 바와 같은 것도 구현할 수 있습니다.
2. 이벤트 처리가 끝난 이후에 작업하기
menu.onclick = function () {
// ...
// 클릭한 메뉴 내 항목 정보가 담긴 커스텀 이벤트 생성
let customEvent = new CustomEvent('menu-open', {
bubbles: true,
});
// 비동기로 커스텀 이벤트를 디스패칭
setTimeout(() => menu.dispatchEvent(customEvent));
};
이벤트 핸들러 내에 이벤트 버블링이 끝난 이후에 작동해야만 하는 액션이 존재하는 경우, 이를 setTimout(cb, 0)
과 같이 콜백함수로 넘길 수 있습니다. 이 경우, 해당 이벤트의 버블링이 모두 완수된 이후에야 특정 콜백을 실행할 수 있게끔 할 수 있습니다.
Web Worker

setTimout
을 통해 여러 개의 태스크로 쪼개지 않더라도, 이벤트 루프를 막지 않아야 하는 거대한 작업의 경우에는 Web Worker를 사용할 수 있습니다.
이는 브라우저가 별도의 쓰레드를 통해, 백그라운드 상에서 코드를 실행할 수 있게끔 하는 Web API 스펙입니다.
Web Worker는 메인 쓰레드와 메시지를 교환하는 방식으로 소통할 수 있지만, 자신만의 변수와 이벤트 루프를 갖습니다.
또한, Web Worker는 DOM에 접근할 방법이 없기 때문에, 주로 여러 CPU 코어를 동시에 활용해야 하는, 계산적으로 버거운 작업을 처리해야 할때 유용합니다.