브라우저 측 자바스크립트의 실행 흐름은 Node.js와 마찬가지로 이벤트 루프에 기반한다.
이벤트 루프는 자바스크립트로 하여금 비동기적인 프로그래밍을 가능케 한다.
이벤트 루프(event loop)는 태스크(task)가 들어오길 기다렸다가 태스크가 들어오면 이를 처리하고, 처리할 태스크가 없는 경우 잠드는, 끊임없이 돌아가는 자바스크립트 내 루프이다.
자바스크립트 엔진이 돌아가는 알고리즘(즉, 우리가 브라우저를 사용해 인터넷을 서핑할 때 돌아가는 알고리즘)은
웹 브라우저는 서버로부터 받아온 html, css, js 같은 애들을 실행시켜주는 프로그램인데, 자바스크립트 코드를 만나면 얘네들을 stack에 진부 집어 넣는다(자바스크립트는 stack이 하나인 단일 스레드 언어). 그리고 변수를 저장해 둔 heap에서 정보를 가져와서 맨 윗줄부터 하나하나 코드를 읽어가며 실행한다. 그러다가 Web APIs(DOM, Ajax, setTimeout 등등)를 만나면 얘네는 queue(이게 매크로태스크 큐)에 집어넣고 stack이 비어 있을 때만 차례대로 이 queue에 있는 애들을 stack에 넣어서 실행한다.
자바스크립트 엔진은 대부분 잠들어 있다가, 스크립트나 핸들러, 이벤트가 활성화될 때만 돌아간다.
엔진이 특정 태스크를 처리하는 동안엔 렌더링이 절대 일어나지 않는다. 태스크를 처리하는 데 걸리는 시간이 길지 않으면 이는 문제가 되지 않는다. 처리가 끝나는 대로 DOM 변경을 화면에 반영하면 되기 때문이다.
하지만, 태스크 처리에 긴 시간이 걸린다면 문제가 된다. 브라우저는 태스크를 처리하는 동안에 발생한 새로운 태스크들을 처리하지 못한다. 인터넷 서핑을 하다 보면 '응답 없는 페이지(Page Unresponsive) 경고창을 만나게 될 것이다. 브라우저는 이 경고창을 통해 사용자에게 페이지 전체와 함께 해당 태스크를 취소할 것인지를 선택하도록 유도한다.
⇒ 이벤트 루프를 막을 가능성이 있는 무거운 태스크는 어떻게 처리해야 될까?
2가지 방법이 있다. setTimeout
을 사용하거나 Web Worker
를 사용하거나
1-1. setTimeout
으로 단순히 태스크 쪼개기 (지연시간이 0인 setTimeout(f)를 사용해서 새로운 매크로태스크를 생성하는 방법)
CPU 소모가 많은 태스크가 있을 때, setTimeout를 중첩 호출해서 태스크를 쪼갤 수 있다. 무거운 태스크를 새로운 태스크 여러 개들로 쪼갠다. 지연 시간이 0인 setTimeout()에 담으면 새로운 매크로태스크가 되고, 매크로태스크 큐에 들어가게 된다.
아래 코드에서 코드 순서 상으로는 두번째 코드라인이 두번째로 실행되어야 할 것 같은데도 세번째로 실행되는 이유도 이와 같다. setTimeout에 감싸지는 순간 새로운 매크로태스크 큐에 쌓이게 되면서 뒤로 밀리기 때문이다.
console.log('첫번째 실행')
setTimeout(function (){ console.log('세번째 실행') })
console.log('두번째 실행')
다만 setTimeout 같은 타이머 메서드로 태스크를 쪼갤 때, delay를 0으로 주더라도 대기 시간이 발생할 수 있다. 브라우저 스펙에 의해 setTimeout 호출이 중첩될 경우 실제 대기 시간의 최소값이 4ms로 설정되기 때문이다. 물론 setTimeout과 setInterval을 이용한 호출 스케줄링을 사용하면 대기 시간을 단축할 수 있다.
1-2. setTimeout
으로 태스크 쪼개고 거기에 인디케이터를 같이 넣어주기.
하지만 setTimeout으로 태스크를 쪼개더라도 어쨌든 브라우저는 현재 작업 중인 태스크가 끝나야 DOM 변경분을 화면에 렌더링한다. 이런 브라우저 동작 방식은 완성되지 않은 '중간' 상태의 화면이 사용자에게 노출되는 걸 막아주지만, 반대로 '프로그레스 바'의 경우 중 간 상태를 보여줘야 하는 경우도 존재할 수 있다. 이때는 인위적으로 인디케이터(indicator)를 중간에 넣어주면 된다.
혹은 마이크로태스크를 생성해주는 queueMicrotask를 사용해도 된다.
1-3. setTimeout
를 사용해서 비동기적으로 처리하기. (지연시간이 0인 setTimeout(f)를 사용해서 새로운 매크로태스크를 생성하는 방법)
이벤트 핸들러를 만들다 보면 이벤트 버블링이 끝나 모든 DOM 트리 레벨에서 이벤트가 핸들링 될 때까지 특정 액션을 연기시켜야 하는 경우가 생긴다. 이때 이 액션과 관련한 코드를 setTimeout에 담으면, setTimeout이 이 실행코드를 가지고 큐에 들어가므로 비동기적으로 실행시킬 수 있다.
menu.onclick = function() {
// ...
// 클릭한 메뉴 내 항목 정보가 담긴 커스텀 이벤트 생성
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// 비동기로 커스텀 이벤트를 디스패칭
setTimeout(() => menu.dispatchEvent(customEvent));
};
웹 워커를 사용하면 별도의 백그라운드 스레드에서 코드를 병렬적으로 실행할 수 있다. 메인 스레드와 메시지를 교환할 수 있지만 웹 워커엔 메인 스레드와 연관 없는 고유한 변수들과 자체 이벤트 루프가 존재한다. 다만, 웹 워커는 DOM에 접근할 수 없으므로 여러 CPU 코어를 동시에 사용해야 하는 연산에 주로 사용한다.
자바스크립트 엔진을 활성화하는 태스크들 사례?
<script src="...">
가 로드될 때, 이 스크립트를 실행하는 것 setTimeout
에서 설정한 시간이 다 된 경우, 콜백함수를 실행하는 것 새로운 태스크는 엔진이 바쁠 때도 추가될 수 있다. 이때 이 태스크는 큐(V8 용어로 매크로태스크 큐macrotask queue라고 한다)에 추가된다.
태스크는 마이크로태스크(microtask)와 매크로태스크(macrotask)로 나뉜다. 마이크태스크는 코드를 사용해서만 만들 수 있으며, 주로 프로미스(promise)를 사용해 만든다. 프로미스와 함께 쓰이는 .then/catch/finally
핸들러가 마이크로태스크가 된다. 또는 await
을 통해 마이크로태스크를 만들기도 한다.
이외에도 표준 API인 queueMicrotask(func)
를 사용하면 함수 func를 마이크로태스크 큐에 넣어 처리할 수 있다.
자바스크립트 엔진은 매크로태스크 하나를 처리할 때마다 또 다른 매크로태스크나 렌더링 작업을 하기 전에 마이크로태스크 큐에 쌓인 마이크로태스크를 전부 처리한다.
위 그림과 같이 매크로태스크(script, mousemove, setTimeout 등) 하나가 처리되고 난 후 마이크로태스크 전부(microtasks)가 처리되고 그 이후 렌더링이 진행되는 것을 확인할 수 있다. 즉, 마이크로태스크는 매크로태스크보다 우선순위가 높다.
마이크로태스크(microtask)는 job queue라고도 하는 것 같다.
매크로태스크(macrotask)는 callback queue라고도 하는 것 같다.
관련해서 읽어보면 좋은 글 1
Tasks, microtasks, queues and schedules
관련해서 읽어보면 좋을 글 2
자바스크립트는 어떻게 약속을 지킬까?
자바스크립트 코드가 어떻게 동작하는지 확인할 수 있는 곳
Loupe
이벤트 루프를 설명하는 동영상
What the heck is the event loop anyway? | Philip Roberts | JSConf EU
자바스크립트 엔진은 자바스크립트 코드를 실행하는 프로그램이다. 자바스크립트 엔진의 인기 있는 예시가 Google의 V8 엔진이다.
V8 엔진은 C++로 작성된 오픈 소스이자 고성능 자바스크립트 및 웹 어셈블리 엔진이다. V8 엔진은 google chrome, node.js, electron 등에서 사용된다.
V8엔진과 같은 자바스크립트 엔진은 2가지 주요 구성 요소를 갖는데,
자바스크립트는 단일 스레드 프로그래밍 언어이다. 즉, 한번에 한 가지 작업만 수행할 수 있으며 하나의 호출 스택을 갖는다.
웹 브라우저는 Web APIs를 통해 V8 엔진에 추가 기능을 제공한다. Web APIs calls는 Call Stack에서 Web API Container로 추가된다. 이러한 Web APIs calls은 작업이 트리거될 때까지 Web API Container 내부에 남아 있게 된다.
자바스크립트의 런타임 모델은 코드의 실행, 이벤트 수집과 처리, 큐에 대기 중인 하위 작업을 처리하는 이벤트 루프에 기반하고 있으며, C 또는 Java 등 다른 언어가 가진 모델과 상당히 다르다.
1. 함수 호출은 프레임 스택을 형성한다.
2. 객체는 힙에 할당된다.
3. 이벤트 루프의 임의의 시점에, 런타임은 대기열에서 가장 오래된 메시지부터 큐에서 꺼내 처리하기 시작한다. 이를 위해 런타임은 꺼낸 메시지를 매개변수로, 메시지에 연결된 함수를 호출한다. 다른 함수와 마찬가지로, 호출로 인한 새로운 스택 프레임도 생성한다. (예를 들어 setTimeout 함수는 두 개의 매개변수를 갖는데, 첫번째는 큐에 추가할 메시지, 두번째는 시간 값(기본값 0)이다.)
4. 함수는 스택이 다시 텅 빌 때까지 계속된다. 그 후, 큐에 메시지가 남아 있으면 같은 방법으로 처리를 계속 진행한다.
다수의 런타임 간 통신
웹 워커나 cross-origin iframe은 자신만의 스택, 힙, 메시지 큐를 갖는다. 서로 다른 런타임은 postMessage 메서드를 통해 메시지를 주고 받으며 통신한다.
다른 언어들과 달리 자바스크립트의 이벤트 루프는 blocking을 하지 않는다. 무슨 의미냐면, 오래 걸리는 작업을 만났을 때 자바스크립트의 이벤트 루프는 기다리지 않고 큐에 넣어 놓고 나중에 실행한다.
예를 들어, 기본적으로 자바스크립트는 단일 스레드 언어이고, 항상 동기식(synchronous)으로 처리한다. 즉, 코드를 순서대로 실행한다. 하지만 Web APIs(setTimeout 등등)는 보통 시간이 오래 걸린다. 이들을 만났을 때 만약 동기식으로 처리해야 한다면 블로킹(blocking)이 발생할 것이다. 오래 걸리는 현재 코드가 다른 코드의 실행을 막고 있는 상태인 것이다.
하지만 자바스크립트의 이벤트 루프는 이때 동기식으로 처리하게 놔두지 않는다. 비동기식(asynchronous)으로 처리한다. 즉, 코드를 순서대로 처리하지 않는다. 오래 걸리는 Web APIs를 큐에 넣어 처리를 미룬다. 그렇게 되면 그 오래 걸리는 코드가 다음 코드의 실행을 막지 않게 된다. 즉, Non-blocking 상황이 되는 것이다.
그래서 보통 블로킹 메서드는 동기로 실행되고, 논블로킹 메서드는 비동기로 실행된다고 하는 것이다.
정리하자면 아래와 같다.
- 동기 : 코드가 순서대로 실행된다.
- 비동기 : 코드가 순서대로 실행되지 않는다.
- 블로킹(blocking) : 코드의 실행이 다른 코드의 실행을 막는다.
- 논블로킹(Non-blocking) : 코드의 실행이 다른 코드의 실행을 막지 않는다.
자바스크립트가 싱글 스레드 언어임에도 이벤트 루프로 인해 non-blocking 작업을 수행할 수 있는 것이다. (즉, 이벤트 루프로 인해 자바스크립트가 동기적임에도 비동기적으로 동작할 수 있게 된다.)
이벤트 루프와 매크로태스크, 마이크로태스크
How JavaScript Works: An Overview of JavaScript Engine, Heap, and Call Stack
How JavaScript Works: Web APIs, Callback Queue, and Event Loop
The event loop - JavaScript - MDN Web Docs - Mozilla
What is the Event Loop?
Overview of Blocking vs Non-Blocking - Node.js
JS 비동기와 논블로킹
How the JavaScript Event Loop Works
Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)