-
[책집필] 자바스크립트 엔진 V8 코드 해석 과정Front-end 개발 2023. 10. 2. 11:32
일반적으로 Low Level 의 V8 엔진 작동원리의 내용까지 깊게 신경쓸 필요는 없다.
그러나 정말 JavaScript 의 최적의 성능을 사용하고 싶다면, V8 엔진에서 어떤 식으로 코드가 해석되고 실행되는 지에 대해 어느 정도 이해가 필요하다. 잘 짜여진 JavaScript 는 인터프리터 언어임에도 불구하고 컴파일 언어인 C++에 근사한 성능을 낼 수 있다고 한다.
1. 크롬 브라우저의 V8 엔진에서 코드를 기계어까지 해석하는 과정
V8 JavaScript 엔진은 JavaScript 를 바이트코드(bytecode)로 컴파일(compile)하고 실행하는 방식을 사용합니다. 특히 적응형 JIT(AJIT : Adaptive Just In Time) 컴파일 방식을 채택하여, 코드가 실행될 때 기계어로 컴파일 됩니다. 추가적인 성능 향상을 위해 히든 클래스(hidden class)와 인라인 캐싱(IC : Inline Caching)과 같은 최적화 기법이 적용했습니다. V8이 JavaScript 소스 코드를 해석하고 실행하는 과정은 파싱할 JavaScript 소스 코드를 가져오고, 파서(Parser)를 통해 소스코드를 분석한 뒤 추상 구문 트리(AST : Abstract Syntax Tree)로 변환합니다. 그리고 Ignition 인터프리터(Interpreter)를 통해 AST 를 기반으로 코드 라인 실행 시 바이트코드로 컴파일 합니다. 생성된 바이트코드는 호출 빈도가 높을 경우 TurboFan 컴파일러(Compiler)를 통해 기계어(Machine code)로 최적화되어 실행됩니다.
V8 JavaScript 엔진은 C++로 작성된 구글의 오픈소스 JavaScript 및 WebAssembly 엔진입니다. 현재 구글 Chrome / CHromium 웹 브라우저, NodeJS를 지원합니다. V8, Ignition, TurboFan의 어원을 이해하면 V8 엔진이 코드를 해석하고 실행하는 과정을 이해하는데 큰 도움이 됩니다.
V8 엔진의 V8은 8기통 엔진을 의미하는 자동차 용어에서 따왔습니다. Ignition 은 엔진의 시동에 사용되는 점화기입니다. 그리고 TurboFan은 자주 사용되어 뜨거워진 엔진이 과열되지 않도록 코드 최적화를 통해 식혀주는 냉각기인 셈 입니다. V8 JavaScript 엔진의 코드 해석 과정을 아래 그림과 같이 도식화 할 수 있습니다.
- 바이트코드(Bytecode)는 고급 언어와 기계어 사이의 중간 언어(IR: InterLanguage)이다. 특정 하드웨어가 아닌 V8 엔진과 같은 가상 머신(VM)에서 실행 가능한 이진 표현법으로 보통 기계어보다 더 추상적이다. 바이트코드는 직접 CPU 내의 레지스터(CPU가 가지고 있는 고속 메모리, Register)와 누산기(계산한 중간 결과를 저장하기 위한 레지스터, Accumulator)를 사용하는 명령문이나 마찬가지기 때문에 컴퓨터의 해석 속도가 고급언어에 비해 훨씬 빠르다.
- V8 엔진은 코드 라이인이 처음 실행될 때 JavaScript 소스 코드를 바이트코드로 전부 변환해놓기 때문에 최초 실행에서는 조금 시간이 걸리겠지만 그 이후로는 거의 컴파일 언어에 가까운 성능을 보일 수 있다.
- 자주 사용되는 코드는 TurboFan으로 보내져 최적화 기계어(Optimized Machine Code)로 다시 컴파일된다. 그러다가 다시 사용이 줄어들면 역최적화(Deoptimizing) 하기도 한다.
- NodeJS 를 실행할 때 --print-bytecode 옵션을 주면 런타임 때 코드가 최적화되는 것을 확인할 수 있다.
node --print-bytecode filename.js
- Ignition이라는 용어는 자동차에서 넘어 왔다. 애초에 V8 엔진의 V8도 8기통 엔진을 의미하며, Ignition 은 엔진의 시동에 사용되는 점화기이다. TurboFan 은 자주 사용되어 뜨거워진 엔진이 과열되지 않도록 코드 최적화를 통해 식혀주는 냉각기인 셈이다.
- V8의 v5.9 이전까지 JavaScript 실행을 위해 Ignition과 TurboFan의 일부 기능을 수행하던 Full-codegen과 Cranckshaft 는 더이상 사용되지 않는다. 이를 통해 V8 은 훨씬 더 단순하고 유지보수 가능한 컴파일 파이프라인 아키텍처를 가지게 된다.
2. Ignition 인터프리터
- Ignition 은 V8의 고속 JavaScript 인터프리터이다.
- 기존의 Full-codegen 을 완벽히 대체한다. Full-codegen은 모든 소스 코드를 한번에 컴파일하는 반면, Ignition은 JavaScript의 동적 타이핑 언어 특성에 맞춰 최적화할 수 있도록 한줄 한줄 실행될 때마다 해석하는 인터프리트 방식을 채택한다.
- V8 JavaScript 엔진에 인터프리터를 적용한 이유
1) 메모리 사용량 감소 (Reduced Memory usage)
- 기계어가 아닌 간결한 바이트코드로 컴파일
2) 파싱 오버헤드 감소 (Reduced Parsing overhead)
- 바이트코드는 간결하여 JS 소스를 즉시 컴파일 가능
3) 컴파일러 파이프라인 복잠성 감소 (Reduced compiler pipeline complexity)
- 바이트코드는 최적화/역최적화를 위한 단일 진실 공급원이다.
* 단일 진실 공급원(SSOT : Single Source of Truth)은 정보 시스템 설계 및 이론 중 하나로 정보와 데이터 스키마를 오직 하나의 출처에서만 제어 또는 편집하도록 하는 방법론이다.
- 컴파일러가 인터프리터보다 훨씬 빠르지만 V8 엔진이 컴파일러 대신 인터프리터를 사용하는 이유는 메모리 사용량 때문이다. 전체 프로그램을 컴파일하는 컴파일러와 달리 인터프리터는 필요한 라인만 컴파일 하여 메모리 사용량을 줄 일 수 있다. Ignition 인터프리터는 코드를 처음 실행할 때만 동작한다.
- V8은 JavaScript를 최적화하는데 사용되지만 C++ 로 적성되었으며 다중 스레드 방식을 사용하여 모든 작업을 한번에 관리한다.
3. TurboFan 최적화 컴파일러
- V8 JavaScript 엔진은 인터프리터 방식과 함께 JIT(Just In Time) 컴파일러를 지원한다. 즉, Ignition 인터프리터가 변환한 바이트코드를 실제 바이트코드 실행 시점에서 TurboFan 최적화 컴파일러를 통해 기계어(네이티브 코드)로 변환한다.
- JIT 없는 모드(JIT-less V8 Mode) 또한 지원한다. JIT 없는 모드는 TurboFan 최적화 컴파일러를 비활성화하므로 성능이 저하된다. 하지만 런타임에 V8을 빠르게 만드는 요소 중 하나인 실행 가능한 메모리를 할당하지 못하는 특정 상황에 도움이 될 수 있다. 보안 측면에서 권한이 없는 어플리케이션의 실행 가능 메모리에 대한 쓰기를 허용하지 않는 일부 플랫폼(예: iOS, 스마트 TV, 게임 콘솔)의 경우에 해당한다.
node --jitless filename.js
- TurboFan은 V8 엔진의 버전 5.9 이전에 사용하던 Crankshaft 컴파일러를 완전히 대체한 코드 최적화 컴파일러이다.
- V8 엔진의 런타임 중 Profiler 를 통해 최적화 수행의 근거가 되는 함수나 변수들의 호출 빈도와 같은 데이터를 모은다.
- 여러가지 최적화 기법을 사용하며 대표적으로 히든 클래스(Hidden Class)와 인라인 캐싱(ICs : Inline Caching) 기법이 있다.
- 최적화 대상이 되는 기준으로는 자주 호출되며 코드가 변하지 않는 경우, 인터프리팅된 바이트코드의 길이가 특정 임계점을 넘기지 않는 작고 단순한 함수(Small Function)인 경우가 있다.
- NodeJS 를 실행할 때 --trace-opt 옵션을 주면 런타임 때 코드가 최적화되는 것을 확인할 수 있다.
node --trace-opt filename.js
4. 추상 구문 트리 (AST: Abstract Syntax Tree)
추상 구문 트리는 컴파일러에서 소스 코드의 추상 구조를 구축하는데 사용된다. 거의 모든 프로그래밍 언어는 AST 를 이용하여 상위 수준의 코드 표현을 하위 수준의 표현으로 변환한다. AST 는 코드의 각 라인에 대한 키값 쌍을 정의한다. 초기 타입 식별자는 AST가 프로그램에 속한다고 정의한 다음 모든 코드 라인의 객체의 배열인 본문 내부에 정의된다. 모든 함수 선언, 변수 선언, 이름, 타입 등이 라인별로 정리되어 있고 주석은 무시된다.
온라인 구문 분석 도구 esprima 를 이용해 생성한 AST.
{ "type": "Program", "body": [ { "type": "FunctionDeclaration", "id": { "type": "Identifier", "name": "addition" }, "params": [ { "type": "Identifier", "name": "x" }, { "type": "Identifier", "name": "y" } ], "body": { "type": "BlockStatement", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "answer" }, "init": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Identifier", "name": "x" }, "right": { "type": "Identifier", "name": "y" } } } ], "kind": "var" }, { "type": "ExpressionStatement", "expression": { "type": "CallExpression", "callee": { "type": "MemberExpression", "computed": false, "object": { "type": "Identifier", "name": "console" }, "property": { "type": "Identifier", "name": "log" } }, "arguments": [ { "type": "Identifier", "name": "answer" } ] } } ] }, "generator": false, "expression": false, "async": false }, { "type": "ExpressionStatement", "expression": { "type": "CallExpression", "callee": { "type": "Identifier", "name": "addition" }, "arguments": [ { "type": "Literal", "value": 10, "raw": "10" }, { "type": "Literal", "value": 20, "raw": "20" } ] } } ], "sourceType": "script"
5. JavaScript 코드 최적화를 위한 히든 클래스
- JavaScript 언어는 동적 타입 언어이다. 그러나 객체의 속성을 추가하거나 제거하면서 증가하는 동적 조회는 JavaScript 의 성능을 떨어뜨리게 된다.
- V8 엔진은 히든 클래스를 사용해 위와 같은 문제를 해결하고 JavaScript 실행을 최적화한다.
- JavaScript 코드의 성능 향상을 최대화하려면 동적 프로퍼티 추가를 줄여야 한다.
- 예를 들면 NodeJS 에서 반복문을 실행할 때 객체에 동적으로 프로퍼티를 변경하면 반복문 내부에 성능 차이가 나타난다. 반복문 내부에서 프로퍼티를 동적으로 추가하는 대신 반복문 외부에서 프로퍼티를 만들어 사용함으로써 기존의 히든 클래스를 재사용하여 성능이 훨씬 향상된다.
- V8 엔진은 최적화된 프로세스와 AST 사용 외에도 JavaScript 성능을 향상시키기 위해 히든 클래스 트릭을 사용한다.
- JavaScript 는 동적 타입 언어이기 때문에 객체에 속성을 즉시 추가하거나 제거할 수 있다.
- 그러나 이 접근 방식은 JavaScript 의 성능을 떨어뜨리는 동적 조회를 더 필요하게 된다.
- V8 엔진은 히든 클래스를 사용해 동적 조회로 인한 성능 저하 문제를 해결하고 JavaScript 실행을 최적화한다.
- 새 객체를 생성할 때 V8 엔진은 이에 대한 새로운 히든 클래스를 생성한다. 그런 다음 새 프로퍼티를 추가해 동일한 객체를 수정하면 V8 엔진에서 이전 클래스의 모든 프로퍼티가 포함된 새 히든 클래스를 만들고 새 프로퍼티를 포함한다.
- 이렇게 하면 컴파일러는 프로퍼티 이름에 접근할 때 사전형 탐색(dictionary lookups)을 우회할 수 있으며 유사한 객체가 생성되거나 수정될 때 이미 생성된 클래스를 재사용할 수 있다. 이 객체에 다른 프로퍼티를 추가하면 동일한 과정이 수행된다. 또 다른 히든 클래스가 생성되고 이전 프로퍼티와 새 프로퍼티가 모두 오프셋으로 포함된다.
- JavaScript 엔진 내부의 Hidden Class 라는 개념을 이용하여 각 객체에 대한 속성 값의 포인터만 가지고, 해당되는 구조를 참조한다.
6. Inline Caching
Inline Caching은 반복문 내의 객체 접근 시 '조회' 작업을 생략 함으로써 반복되는 참조를 최소화하여 성능 향상을 도모하는 기법이다. 인라인 캐싱은 Hidden Class에서 참조하는 Offset을 캐싱하는 것이다.
인라인 캐싱에서 두 가지 가정이 바탕에 깔려 있다.- 동적인 언어이지만 실제로 중간에 바뀌지 않는 것이 더 많다.
- Loop 내에서는 변화가 없다.객체의 형태가 동적으로 변할 수 있는 환경이지만, 실제로는 첫 형태에서 변경되어 사용하는 객체가 많지 않다고 가정한다. 그리고 Loop 문 내에서 변할 일을 거의 없을 것이라고 생각한다.
이 가정을 두고 캐싱하여 더 빠른 Hidden Class 참조로 성능을 끌어올린다.
7. 사전개념 - 가상 머신(VM)
가상 머신이란 물리적 컴퓨터 시스템 안에 구축되어 물리적 컴퓨터와 동일한 기능을 제공하는 소프트웨어 컴퓨터 또는 가상 컴퓨팅 환경 가상 머신은 바이러스에 감염된 데이터에 액세스하고 운영 체제를 테스트하는 등, 호스트 환경에서 수행하기에 위험한 특정 작업을 수행하기 위해 생성됩니다. 가상 머신은 다른 시스템에서 sandbox화되므로, 가상 머신 내의 소프트웨어는 호스트 컴퓨터를 변조할 수 없습니다. 가상 머신은 서버 가상화 등의 다른 목적으로도 사용할 수 있습니다. V8 엔진은 이러한 가상 머신의 한 종류로, 자바스크립트 코드를 독립적으로 실행할 수 잇는 환경을 제공함
8. 추상 구문 트리 (AST: Abstract Syntax Tree)에 대해 설명해주세요.
추상 구문 트리는 컴파일러에서 소스 코드의 추상 구조를 구축하는데 사용된다. 거의 모든 프로그래밍 언어는 AST 를 이용하여 상위 수준의 코드 표현을 하위 수준의 표현으로 변환한다. AST 는 코드의 각 라인에 대한 키값 쌍을 정의한다. 초기 타입 식별자는 AST가 프로그램에 속한다고 정의한 다음 모든 코드 라인의 객체의 배열인 본문 내부에 정의된다. 모든 함수 선언, 변수 선언, 이름, 타입 등이 라인별로 정리되어 있고 주석은 무시된다. 온라인 구문 분석 도구 esprima 를 이용해 생성한 AST.
출처1. 자바스크립트 성능의 비밀 (V8과 히든 클래스)
출처2. V8
출처3. V8 엔진은 어떻게 내 코드를 실행하는 걸까?
출처4. Google Chrome V8 엔진을 파헤쳐보자
'Front-end 개발' 카테고리의 다른 글
프로젝트 4일차 - 공통 기능 개발 Button (1), git 브랜치 동기화 (0) 2023.10.21 [책집필] 면접 질문 - DNS (0) 2023.10.14 [멋쟁이사자처럼] json-server 이용한 JS비동기 통신 특강 (0) 2023.09.27 [책집필] REST API (0) 2023.09.27 [책집필] 기술면접 질문 - CORS, Proxy (0) 2023.09.24