자바스크립트의 작동 원리(Event Loop, Call Stack, Callback Queue, Microtask Queue, RAF Queue)

Noma·2021년 1월 31일
3

1.자바스크립트 엔진

자바스크립트 엔진의 대표적인 예로는 구글의 V8 엔진이 있고, 이는 Chrome 및 Node.js 내부에서 사용된다.

다음은 엔진이 어떻게 생겼는지 매우 간략하게 표현한 것이다.

자바스크립트 엔진은 그림과 같이 크게 메모리 힙과 콜 스택으로 이루어져 있다.

  • Memory Heap ㅡ 변수를 선언해서 오브젝트나 문자열 혹은 숫자를 할당하게 되면 그 데이터들이 전부 메모리 힙에 저장된다. 즉, 메모리 할당이 발생하는 곳으로 그림과 같이 구조적으로 정리된 자료구조는 아님

  • Call Stack ㅡ 함수를 실행하는 순서에 따라 차곡 차곡 쌓아 놓는 곳으로, 코드가 실행될 때 *스택 프레임이 들어가는 위치이다.

*스택 프레임(Stack Frame)이란? 콜 스택의 각 항목을 말함

2. 자바스크립트 런타임 환경

자바스크립트 '런타임 환경'(브라우저/Node)은 멀티 스레드를 제공한다.

이곳에는 DOM API, setTimeout, setInterval, fetch, eventListener 등과 같은 브라우저에서 제공되는 수많은 Web APIs과 이벤트 루프(Event Loop), 콜백 큐(Callback Queue)가 있다.

콜백 큐외에도 Microtask Queue, Animation frames도 있으나 나머지는 밑에서 보도록 하자.

2.1 Call Stack

콜 스택은 스택이라는 자료구조 중 하나로 LIFO(Last In First Out)을 따른다. 아래 예제를 통해 그 과정을 살펴보자.

function multiply(x, y) {
    return x * y;
}
function printSquare(x) {
    var s = multiply(x, x);
    console.log(s);
}
printSquare(5);

자바스크립트 엔진이 위의 코드들을 실행하기 시작하면, 콜 스택은 다음 순서를 따라 채워지고 또 비워진다.

콜 스택의 각 항목을 스택 프레임(Stack Frame)이라고 하며, 이는 예외(오류)가 발생했을 때 쌓여진 순서 그대로 스택 트레이스로 구성된다.

이는 아래 예제를 통해 직접 확인해 보도록 하자.

function foo() {
    throw new Error('SessionStack will help you resolve crashes :)');
}
function bar() {
    foo();
}
function start() {
    bar();
}
start();

위의 코드를 크롬에서 실행해 보면 (코드가 foo.js라는 파일에 있다고 가정) 다음과 같은 Stack Trace가 생성된다.

2.2 Call Stack 사이즈 초과

function foo() {
    foo();
}
foo();

엔진에서 위의 코드를 실행하면 먼저 foo 함수를 호출하는 것으로 시작된다. 그러나 이 함수는 재귀적이며 아무런 종료 조건 없이 자기 자신을 호출 하게 된다. 그로 인해 아래 그림처럼 foo()가 계속해서 콜 스택에 추가되는 것을 볼 수 있다.

이럴 경우 콜스택이 넘치게 되어 브라우저는 다음과 같은 에러를 던지게 됩니다.

3. 싱글 스레드의 한계

자바스크립트는 single threaded language죠. 싱글 스레드에서는 멀티 스레드 환경에서 발생하는 복잡한 시나리오(e.g. 교착 상태(Deadlock))를 처리할 필요가 없으므로 코드의 실행이 상대적으로 매우 쉽다.

하지만 자바스크립트는 콜 스택이 하나뿐이라 상당히 제한적이라는 단점도 존재한다. 만약, 콜 스택에 처리되기까지 많은 시간이 걸리는 함수가 있을 경우 어떤 일이 일어날까?

콜 스택이 실행 중인 함수를 가지고 있는 동안 브라우저는 실제로 다른 어떤 것도 할 수 없게 된다. 이는 브라우저가 렌더링도 할 수 없고, 다른 코드도 실행할 수 없음을 뜻한다.

이렇게 되면 사용자에게 좋은 user experience를 제공할 수 없다.

그렇다면, heavy한 코드를 실행하는 동안에도 브라우저가 응답 가능하며 UI가 차단되지 않게 하기 위해선 어떻게 해야 할까?

4. 자바스크립트의 비동기적 처리

자바스크립트 엔진은 기본적으로 하나의 스레드에서 동작한다. 하나의 스레드를 가지고 있다는 것은 하나의 stack을 가지고 있다는 의미와 같고, 하나의 stack이 있다는 의미는 동시에 단 하나의 작업만을 할 수 있다는 것을 의미한다.

따라서 자바스크립트 엔진은 하나의 코드 조각을 하나씩 실행하는 일을 하고, 비동기적으로 이벤트를 처리하거나 Ajax 통신을 하는 작업은 사실상 Web API에서 모두 처리된다.

자바스크립트가 동시에 단 하나의 작업만을 한다면, 어떻게 여러가지 작업을 비동기적으로 실행할 수 있을까?

4.1 Event Loop

자바스크립트는 Event Loop와 Queue들을 이용하여 비동기 작업을 수행한다.

물론 직접적인 비동기 작업들은 Web API에서 처리되지만, 그 작업들이 완료되면 요청시 등록했던 callback을 queue에 넣어주게 된다. 이 후 call stack과 queue 사이를 확인하고 있던 Event Loop가 queue에 있는 작업을 꺼내어 call stack에 넣어 처리해준다.

즉, Event Loop의 역할을 정리하자면 다음과 같다.

🌟Call stack과 queue 사이를 반복적으로 돌면서 작업들을 확인하다가 call stack이 비워졌을 때 queue에 있던 작업을 꺼내 call stack에 넣어준다.

4.2 Queue

큐에는 총 3가지가 있으며 각각 다음과 같은 특징을 가지고 있다.
( 물론 브라우저의 종류에 따라 더 많은 것이 존재할 수도 있음 )

4.2.1 Event Queue

모든 Web APIs가 task queue를 이용하는 것이 아니라, 지금 당장 작업을 수행하지 않아도 되거나 다른 이벤트의 발생을 기다리는 경우 event queue를 사용하게 된다. createElement, appendChild 등 보통 대게의 경우에는 queue를 이용하지 않고 바로 실행된다.

또한 Event Queue는 이벤트 루프가 방문할 때마다 '한 번에 하나씩만' call stack으로 보내지며, 이벤트 루프가 계속해서 순회할 수 있도록 바로 리턴을 해준다.

Event Queue, Callback Queue, Task Queue 모두 같은 말임

4.2.2 Microtask Queue

Microtask Queue에는 PromiseMutation observer에 등록된 콜백이 들어오게 되며, 이곳에는 이벤트 루프가 한 번 방문하면 큐안에 있는 '모든' 작업들을 수행하게 된다. (도중에 새로운 작업 들어오면 그것도 실행하고 넘어감)

즉, microtask queue가 완전히 비어질 때까지 이벤트 루프는 순회하지 못하고 머물러 있게 된다.

4.2.3 RAF Queue

Web API인 requestAnimationFrame()를 호출하게 되면 그 안에 등록된 콜백이 RAF Queue에 차곡 차곡 쌓여진다. 이는 Microtask Queue와 마찬가지로 이벤트 루프가 한 번 방문하면 큐 안에 들어 있는 '모든' 작업을 수행한 후 순회를 재게한다.

RAF는 보통 실시간으로 계속 화면에 업데이트 해야 하는 경우에(애니메이션이나 게임 같은) 많이 사용한다.
setInterval로 업데이트 하는 것보다 더 효율적임

4.2.4 Queue의 우선 순위

Event Loop는 stack에 처리할 작업이 없을 경우 우선적으로 microtask queue를 확인한다다. microtask queue에 작업이 있다면 거기에 있는 작업을 꺼내서 call stack에 넣는다.

이후 microtask의 queue가 비어서 더 이상 처리할 작업이 없으면 그 다음으로 task queue를 확인하게 되고, task queue의 작업도 꺼내서 call stack에 넣습니다.

이렇게 Event Loop와 Queue는 자바스크립트 엔진이 하나의 코드 조각을 하나씩 처리할 수 있도록 작업을 스케줄링 해주며 자바스크립트가 비동기 작업을 할 수 있도록 해준다.

microtask > task > requestAnimationFrame

5. 자바스크립트 처리 과정

다음 코드를 바탕으로 자바스크립트 처리과정을 살펴보자.

창을 두 개 켜서 코드를 동시에 보며 따라 가는 걸 권장

console.log("script start");

setTimeout(function() {
  console.log("setTimeout");
}, 0);

Promise.resolve().then(function() {
  console.log("promise1");
}).then(function() {
  console.log("promise2");
});

requestAnimationFrame(function {
    console.log("requestAnimationFrame");
})
console.log("script end");

위의 코드를 실행하면 다음과 같은 결과 화면을 얻을 수 있다.

script start
script end
promise1
promise2
requestAnimationFrame
setTimeout

간단해 보이는 이 코드는 실제 다음과 같은 과정을 거친다.

  1. ‘script 실행 작업’이 stack에 등록된다.
  2. console.log(‘script start’)가 처리된다.
  3. setTimeout작업이 stack에 등록되고, Web API에게 setTimeout을 요청한다. 이때 setTimeout의 callback 함수를 함께 전달한다. 요청 이후 stack에 있는 setTimeout은 제거된다.

  1. Web API는 setTimeout작업(0초 후)이 완료되면 setTimeout callback 함수를 task queue에 등록한다.

  2. Promise 작업이 stack에 등록되고, Web API에게 Promise 작업을 요청한다. 이때 Promise.then의 callback 함수를 함께 전달한다. 요청 이후 stack에 있는 Promise 작업은 제거된다.

  3. Web API는 Promise 작업이 완료되면 Promise.then의 callback 함수를 microtask queue에 등록한다.

  4. requestAnimationFrame 작업이 stack에 등록되고, Web API에게 requestAnimationFrame을 요청한다. 이때 requestAnimationFrame의 callback 함수를 함께 전달한다. 요청 이후 stack에 있는 requestAnimationFrame 작업은 제거된다.

  5. Web API는 requestAnimationFrame의 callback 함수를 animation frame ququq에 등록한다.

  6. console.log(‘script end’)가 처리된다.

  7. ‘script 실행 작업’이 완료되어 stack에서 제거된다.

  8. stack이 비워있어서 microtask queue에 등록된 Promise.then 의 callback 함수를 stack에 등록한다.

  9. 첫번째 Promise.then의 callback 함수가 실행되어 내부의 console.log(‘promise1’)가 처리된다.

  10. 첫번째 Promise.then 다음에 Promise.then이 있다면 다음 Promise.then의 callback 함수를 microtask queue에 등록한다.

    이것이 4.2.2 Microtask queue 에서 설명했던 내용
    이벤트 루프가 한 번 방문하면 큐안에 있는 '모든' 작업들을 수행하게 됩니다. (도중에 새로운 작업 들어오면 그것도 실행하고 넘어감)ㅡ 에 해당한다.

  1. stack 에서 첫번째 Promise.then의 callback 함수를 제거하고 microtask queue에서 첫번째 Promise.then의 callback 함수를 제거한다.
  2. 두번째 Promise.then의 callback 함수를 stack에 등록한다.

  1. 두번째 Promise.then의 callback 함수가 실행되어 내부의 console.log(‘promise2’)가 처리된다.

  2. stack 에서 두번째 Promise.then의 callback 함수를 제거한다.

  3. microtask 작업이 완료되면 animation frame에 등록된 callback 함수를 꺼내 실행한다.

  4. 이후 브라우저는 렌더링 작업을 하여 UI를 업데이트한다.

  5. stack과 microtask queue가 비워있어서 task queue에 등록된 callback 함수를 꺼내 stack에 등록한다.

  6. setTimeout의 callback가 실행되어 내부의 console.log(‘setTimeout’)이 처리된다.

  7. setTimeout의 callback 함수 실행이 완료되면 stack에서 제거된다.

5.1 정리

꽤나 복잡한 과정이었지만 꼭! 명심해야할 것이 있다.

첫째. 비동기적으로 등록되는 작업에는 task와 microtask, 그리고 animationFrame 작업으로 구분된다.

둘째. microtask는 task보다 먼저 작업이 처리된다.

셋째. microtask가 처리된 후 requestAnimationFrame이 호출되고, 이후 브라우저 렌더링이 발생한다.

Task queue

Task는 비동기 작업이 순차적으로 수행될 수 있도록 보장하는 형태의 작업 유형이다. 여기서 순차적으로 보장한다는 의미는 작업이 예약되어있는 순서를 보장한다는 것이다. task 다음에 바로 다음 task가 실행된다는 의미가 아니라는 것! 위의 예제에서 본 것처럼 task 사이에는 브라우저 랜더링과 같은 작업이 일어날 수 있다.

Microtask queue

Microtask는 비동기 작업이 현재 실행되는 스크립트 바로 다음에 일어나는 작업이다. 따라서 task보다 항상 먼저 실행된다.

RAF queue

requestAnimation는 브라우저가 60FPS를 보장하려고 노력한다. 하지만 현실적으로 raf가 돌더라도 raf의 동작시간 자체가 16ms가 넘어가면 뒤에 호출될 raf가 생략된다.

이때 가장 좋은 방법은 작업을 쪼개는 것이다. 동기식 작업을 쪼개서 처리하라는 이야기인데, 이는 비즈니스상 우선 중요도가 높은 작업부터 동기적으로 처리하고 이후에 처리될수 있는 것은 lazy하게 하라는 뜻이다.

Reference

profile
Frontend Web/App Engineer

0개의 댓글