자바스크립트 엔진은 간단히 말해선 자바스크립트 코드를 실행하는 컴퓨터 프로그램이다.
이 엔진이 사람이 읽을 수 있는 자바스크립트 코드를 컴퓨터 하드웨어가 실행할 수 있는 기계가 읽을 수 있는 명령어로 변환하는 역할을 담당한다.
우리가 자바스크립트 코드를 작성하고 브라우저에서 실행을 시킨다고 코드가 바로 우리의 컴퓨터가 실행하는 것은 아니다. 대신, 자바스크립트 엔진이 코드와 기기 사이의 중개자 역할을 하면서 상호작용을 한다.
모든 브라우저는 자신의 JS Engine을 가지고 있다. 하지만 가장 잘 알려진 것은 구글의 v8 엔진이다. v8엔진은 크롬과 서버측 어플리케이션을 빌드하는 데 사용되는 Node.js를 구동한다.
이번 글에서는 자바스크립트 엔진의 정의와 내부 동작 방식을 알아보려고 한다.
자바스크립트 엔진의 동작 방식
모든 자바스크립트 엔진은 Call Stack과 Heap을 포함하고 있다.
Call Stack은 실행 컨텍스트의 도움을 받아 코드가 실행되는 영역이다. 그리고 Heap은 어플리케이션에 필요한 모든 객체를 메모리에 저장하는 비정형 메모리 풀이다.
우리는 이제 코드가 어디서 실행되는지 이해했다. 하지만 코드가 어떻게 기계어로 컴파일되어 실행될 수 있는지 의문이 남아있다. 이를 이해하려면 컴파일과 인터프리테이션(interpretation)에 대해 알아보아야 한다.
Compilation vs Interpretation
컴파일은 전체 코드를 한번에 기계어로 변환하여, 컴퓨터에서 실행 가능한 바이너리 파일로 만든다.
자바스크립트 코드는 컴파일을 통해 컴퓨터가 이해할 수 있는 기계어로 변경되고 프로그램으로 실행된다.
인터프리테이션은 인터프리터가 소스 코드를 실행하여 한 줄씩 실행한다.
코드를 여전히 기계어로 변환해야 하지만 이번에는 프로그램을 실행하는 동안 한 줄씩 변환된다.
자바스크립트는 인터프리터로 구성된 언어이다. 하지만 최신 자바스크립트 엔진은 컴파일과 인터프리터를 혼합해서 사용하는데, 이를 "Just In Time" 컴파일이라고 한다.
JIT 컴파일은 실행 중 필요한 부분을 실시간으로 컴파일하여 즉시 실행된다.
그렇다면, JIT 컴파일과 컴파일의 차이점이 무엇인지 궁금할 것이다.
한 가지 큰 차이점이 있다면 컴파일 후에는 기계어가 이식 가능한 파일에 저장된다는 점이 있다. 컴파일 프로세스가 끝난 직후 서두를 필요가 없으므로 언제든지 실행할 수 있다.
하지만, JIT의 경우 컴파일이 끝나자마자 기계어가 실행되어야 한다.
JIT 와 JavaScript
이제 자바스크립트에서 JIT가 구체적으로 어떻게 동작하는이 알아보자.
자바스크립트 코드가 엔진에 입력될 때마다 첫 번째 단계는 코드를 구문 분석하는 것이다. 이 구문 분석 과정에서 코드는 AST ( 추상 구문 트리, Abstract Syntax Tree )라는 데이터 구조로 구문 분석된다. 이 작업은 각 줄의 코드를 의미 있는 조각 (const 또는 함수 키워드)으로 나눈 다음 구조화된 방식으로 트리에 저장하는 방식으로 작동한다.
이 단계에서 구문 오류가 있는지 확인하고 결과 트리는 나중에 기계어를 생성하는데 사용된다.
예를 들어, 다음 코드 줄에 대한 AST를 살펴보자. :
const greet = "Hello";
{
"type": "Program",
"start": 0,
"end": 201,
"body": [
{
"type": "VariableDeclaration",
"start": 179,
"end": 200,
"declarations": [
{
"type": "VariableDeclarator",
"start": 185,
"end": 200,
"id": {
"type": "Identifier",
"start": 185,
"end": 190,
"name": "greet"
},
"init": {
"type": "Literal",
"start": 193,
"end": 200,
"value": "Hello",
"raw": "\"Hello\""
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
당장은 AST가 어떻게 동작하는지 알거나 이해할 필요는 없다. 그저... 이렇게 변환이 된다는 사실만 이해해두자.
다음 단계는 컴파일이다. 여기서 엔진은 AST를 가져와 기계어로 컴파일을 진행한다. 이 기계어는 JIT를 사용하기 때문에 즉시 실행되며, 이 실행은 Call Stack에서 발생한다.
이게 끝은 아니다. 최신 자바스크립트 엔진은 프로그램을 최대한 빠르게 실행하기 위해 비효율적인 기계어를 생성한다.
그후 이미 실행 중인 프로그램의 컴파일 된 코드를 가져와 최적화하고 다시 컴파일을 한다. 이 모든 최적화는 백그라운드에서 이루어진다.
자바스크립트 엔진에 대해서는 이해하게 되었다. 다음으로 자바스크립트 런타임과 브라우저의 런타임에 대해서 알아보려고 한다.
JavaScript Runtime
자바스크립트 런타임은 자바스크립트 코드를 실행할 수 있는 포괄적인 환경이다. 자바스크립트 애플리케이션의 실행을 용이하게 하기 위해 함꼐 작동하는 다양한 컴포넌트로 구성되어 있다.
자바스크립트가 실행되는 위치(웹 브라우저 또는 Node.js를 사용하는 서버측)에 따라 환경별 기능이 있을 수 있다.
예를 들어, 브라우저에서는 이벤트처리, DOM 엑세스, 브라우저별 기능과의 상호 작용과 관련된 기능이 있을 수 있다.
정리하면 자바스크립트 런타임은 자바스크립트를 실행하는 데 필요한 모든 것을 포함하는 큰 상자라고 생각하면 된다.
자바스크립트 엔진도 자바스크립트 런타임에 속한다.
하지만 엔진만으로는 자바스크립트를 실행하기 충분하지 않다. 제대로 작동하려면 웹 API가 필요하다.
특히 웹 브라우저의 경우 JS 런타임에는 핵심 자바스크립트 언어 외에 추가 기능을 제공하는 Web API가 함께 제공된다. 이러한 API는 DOM, Fetch API, 타이머 등과 같은 상호작용이 포함된다.
Web API는 자바스크립트 기능을 확장하여 브라우저 환경과 상호 작용하고 웹 페이지 구조 조작, 사용자 이벤트 처리, 네트워크 요청 등의 작업을 처리할 수 있게 해준다.
이러한 Web API는 엔진에 제공되는 기능이지만 자바스크립트 언어 자체의 일부가 아니다. 자바스크립트는 window 객체를 통해 이러한 API에 접근할 수 있다.
자바스크립트에서 사용자 입력 처리나 네트워크 요청과 같은 비동기 작업의 콜백 함수를 사용한다. 이 함수들은 콜백 큐(Callback Queue 또는 마이크로큐- Micro Queue)라는 대기열에 배치되어 실행을 기다린다. 콜백 큐는 비동기 작업들이 체계적으로 처리되도록 보장해준다.
예를 들어, Promise의 then, catch 콜백이나 MutationObserver 같은 작은 단위의 작업이 포함된다.
다음으로 비동기 작업이나 DOM 이벤트 등은 테스크 큐 (Task Queue)의 대기열에 배치되어 실행을 기다린다.
JS Engine, Web APIs, Callback Queue, Task Queue에 대해서 정리를 했는데, 예시를 통해서 마지막으로 정리를 해보자.
서버로부터 데이터를 받아오는 작업을 진행한다고 하자.
1. 이벤트 핸들러 함수가 Task Queue에 들어간다.
이벤트가 발생하면 해당 이벤트들은 Task Queue에 쌓이게 된다.
2. Call Stack이 비어 있다면 Task Queue에 있는 함수를 전달해주고 실행된다.
Call Stack이 비어있기 때문에 Get API를 호출하는 함수가 Call Stack으로 들어가고 실행이 된다.
호출하는 함수는 서버 요청이 들어있기 때문에 Fetch 함수를 가지고 있다.
3. fetch 함수가 Web API에 네트워크 요청을 위임하고, 나머지 작업을 계속한다. 그리고 이후 Callback 함수는 Callback Queue에 들어가 대기한다.
fetch는 비동기 함수이기 때문에 실제 네트워크 요청을 브라우저의 Web API에 위임한다.
4. 네트워크 요청이 완료되면, Callback Queue에 있는 함수를 Call Stack으로 가지고 와서 실행한다.
이러한 일련의 작업을 이벤트 루프라고 한다.
여기선 예시를 위해서 then 을 이야기 했지만 만약 await을 사용한다면 await 뒤에 있는 모든 작업은 Callback Queue로 들어가게 된다.
async function fetchData() {
console.log('Before fetch');
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
console.log('After fetch');
const data = await response.json();
console.log('After parsing JSON');
return data;
}
console.log('Start');
fetchData();
console.log('End');
다음과 같은 작업이 있다고 생각해보자. 순서는 다음과 같을 것이다 :
- console.log('Start')는 동기적으로 출력이 된다.
- fetchData() 함수가 호출되고, console.log('Before fetch')가 동기적으로 실행된다.
- await fetch()는 Promise를 반환하며, 네트워크 요청을 브라우저의 Web API에 위임하고, fetch 이후 작업은 일시 중단된다.
- 이 시점에서 Call Stack은 비어있고, 이벤트 루프는 다른 작업을 처리할 수 있게 된다
- console.log('End')가 동기적으로 출력된다.
- 네트워크 요청이 완료되면, await fetch() 뒤의 코드가 Callback Queue에 들어가고, Call Stack이 비면 실행된다.
- console.log('After fetch')가 출력된다.
- await resonse.json()도 비동기 함수이기 때문에 다시 Promise가 반환되고, JSON 파싱 작업이 끝나면 Callback Queue에 들어간다.
- console.log('After parsing JSON')이 출력된다.
복잡하기도 한 이벤트 루프이지만 작업이 실행되는 순서는 중요하기 때문에 이해하는 것이 필요하다.
'JavaScript' 카테고리의 다른 글
Callback Queue, Task Queue (1) | 2024.11.06 |
---|---|
Scope Chain과 Closure, HOF (2) | 2024.10.16 |
RequestAnimationFrame (6) | 2024.10.12 |
Transition & Animation 이벤트 (3) | 2024.10.09 |
스크롤 위치에 따른 오브젝트 조작 (1) | 2024.09.29 |