자바스크립트의 가장 큰 특징 중 하나는 단일 스레드
기반의 언어라는 사실입니다. 스레드가 하나라는 말은 '동시에 하나의 작업만을 처리할 수 있다.' 라는 뜻입니다. 하지만 실제로 자바스크립트로 만들어진 웹 페이지를 보게 된다면 많은 작업이 동시에 이뤄지는 것을 볼 수 있습니다. 어떻게 스레드가 하나인데 여러 작업을 한꺼번에 처리할 수 있는걸까요?
이때 등장하는 개념이 이벤트 루프
입니다. Node.js에 대해 찾아보게 되면 '이벤트 루프 기반의 비동기 방식으로 Non-Blocking IO를 지원하고....' 와 같은 정의를 본 적이 있을 것입니다. 즉, 자바스크립트는 이벤트 루프를 이용해서 비동기 방식으로 여러 작업을 한꺼번에 처리합니다. 이벤트 루프에 대해 조금 더 자세히 알아보겠습니다.
브라우저 환경을 간단하게 그림으로 나타낸다면 다음과 같습니다. JS Engine, Web APIs, Task Queue로 구성되어 있습니다.
자바스크립트 엔진 중 가장 유명한 V8과 같은 엔진은 단일 호출 스택(Call Stack)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 호출 스택에 담아 처리할 뿐입니다.
Web API는 JS Engine이 아니라 브라우저에서 제공하는 API 입니다. 종류로는 DOM, Ajax, Timer 등이 있습니다. Call Stack에서 실행된 비동기 함수는 Web API를 호출하고, Web API는 콜백함수를 Callback Queue에 담아둡니다.
콜백 함수들이 대기하는 큐(FIFO) 형태의 배열입니다. 모든 비동기 API들은 작업이 완료되면 콜백 함수를 태스크 큐에 추가합니다.
function delay() {
for (var i = 0; i < 100000; i++);
}
function foo() {
delay();
console.log('foo!');
}
function bar() {
delay();
console.log('bar!');
}
function baz() {
delay();
console.log('baz!');
}
setTimeout(foo, 10);
setTimeout(bar, 10);
setTimeout(baz, 10);
위 코드를 실행한다면 아무런 지연 없이 setTimeout
함수가 세번 호출 된 이후에 실행을 마치고 call stack이 비워질 것 입니다.
비동기적으로 실행된 콜백함수가 보관되는 영역입니다. 예를 들어, setTimeout
에서 타이머가 완료 후 실행되는 함수나 혹은 addEventListener
에서 click event가 발생했을 때 실행되는 함수 등이 보관됩니다.
setTimeout(function() { // (A)
console.log('A');
}, 0);
Promise.resolve().then(function() { // (B)
console.log('B');
}).then(function() { // (C)
console.log('C');
});
// B -> C -> A 순서로 실행됩니다.
Promise
도 비동기로 실행된다고 볼 수 있으니 A -> B -> C
순서로 실행된다고 생각할 수 있지만 Promise
는 microtask를 사용하기 때문에 B가 먼저 실행됩니다.
마이크로 태스크는 쉽게 말해 일반 태스크보다 더 높은 우선순위를 갖는 태스크라고 할 수 있습니다. 태스크 큐에 대기중인 태스크가 있더라도 마이크로 태스크가 먼저 실행됩니다.
Event Loop는 Call Stack과 Callback Queue의 상태를 확인하여, Call Stack이 빈 상태가 되면, Callback Queue의 첫번째 콜백을 Call Stack으로 밀어넣는다. 이러한 반복적인 행동을 틱(tick)이라 부른다.
자바스크립트의 비동기적 특성을 잘 활용하기 위해서는 이벤트 루프를 제대로 이해하는 것이 중요합니다.
브라우저 환경에 따라 차이가 있고 더 복잡한 개념도 있지만, 기본적으로 자바스크립트는 스레드를 하나만 가지고 있고 이벤트 루프를 이용해서 Call Stack과 Task Queue의 상태를 확인한 뒤 우선순위를 정해서 다음 태스크를 처리할 수 있도록 비동기적으로 작동하고 있다는 사실을 기억하는 것이 중요합니다.