이 포스트의 내용은 구글의 V8엔진 엔지니어인 Benedikt Meurer와 Mathias Bynens 의 JavaScript Engines: The Good Parts 세션을 기반으로 작성하였습니다.
자바스크립트를 사용하면서, 그리고 Electron이나 Node.js로 개발하면서 그 근본이 되는 엔진이 어떻게 동작하는지 제대로 알아야겠다고 느꼈습니다. 그리고 이어지는 포스트에서는 가능하면 로우 레벨까지 세세하게 살펴보려 합니다.
먼저 자바스크립트 엔진의 다양한 종류와 그 엔진들의 공통적인 동작 파이프라인에 대해서 알아보겠습니다.
JS 코드를 실행하는 프로그램 또는 인터프리터를 말합니다.
자바스크립트는 웹 브라우저뿐만 아니라 Node.js, Electron, React Native 등의 프로젝트와 그 밖의 다양한 곳에서 동작합니다. 구글의 V8 뿐만 아니라 공통적인 동작을 설명하기 위해 다른 엔진들도 간단히 알아봅시다.
C++로 작성되었으며, 구글이 개발한 오픈소스입니다. Google Chrome, Electron, Node.js에서 사용합니다.
최초의 자바스크립트 엔진으로, JS의 창시자인 브랜던 아이크가 넷스케이프 브라우저를 위해 개발했습니다. 지금은 Mozilla 재단에서 관리하며, FireFox에서 사용합니다.
재미있는 사실은, Node.js에서도 SpiderMonkey를 fork하여 SpiderNode를 만들었습니다.
Mozilla 프로젝트 개발자의 글
마이크로소프트가 개발한 엔진이며, Edge 브라우저에 사용되었습니다.
Chakra 엔진의 중요 부분은 Chakra Core라는 오픈 소스로 구성되어 있습니다.
애플에서 개발한 JavaScriptCore는 처음에 WebKit 프레임워크를 위해 개발되었습니다. 최근에는 Safari와 React Native App에서 사용합니다.
엔진을 브라우저나 Node.js를 통하지 않고 직접 엔진 자체를 사용해보고 싶다면 jsvu
를 통해 각 엔진의 최신 버전을 설치할 수 있습니다.
설치
지원하는 엔진 종류
자바스크립트 엔진들이 소스 코드를 기계어로 만들기까지 공통적으로 수행하는 과정을 살펴봅시다.
먼저, 자신이 작성한 자바스크립트 소스 코드에서부터 시작합니다.
자바스크립트 엔진은 소스 코드를 파싱해서 Abstract Syntax Tree(AST) 로 만듭니다. 그리고 AST를 바탕으로, 인터프리터는 바이트 코드를 생성합니다. 여기까지가 자바스크립트로 작성된 코드를 실제로 엔진이 실행하는 부분입니다.
코드를 더 빠르게 실행하기 위해, 바이트코드는 프로파일링 된 데이터와 함께 최적화 컴파일러
(optimizing compiler)로 보내집니다. 이곳에서는 프로파일링 데이터를 기반으로 매우 최적화 된 기계어를 생성합니다. 만약 정확하지 않은 결과가 나왔다면 다시 deoptimizes하여 바이트 코드로 되돌립니다.
이제 노란 박스에 들어있는 과정을 자세히 봅시다!
인터프리터가 코드를 해석하고, 최적화 할 때 주요 자바스크립트 엔진들 사이에 어떤 차이가 있는지 알아봅시다. 일반적으로는 다음과 같은 공통된 파이프라인을 가집니다.
: 최적화되지 않은 바이트코드(bytecode)를 빠르게 생성합니다.
: 매우 최적화된 기계어 코드(machine code)를 약간 시간을 들여서 생성합니다.
이 과정에서 바이트코드는 중간 언어(IR, intermediate representation)입니다. 만약 interpreter
모드라면 바이트코드를 하나씩 읽어서 실행하고, JIT
모드라면 바이트 코드를 기반으로 컴파일하여 수행합니다.
여기서 잠깐 용어를 짚고 넘어갑시다.
바이트코드(Bytecode, portable code, p-code)는 특정 하드웨어가 아닌 가상 컴퓨터에서 돌아가는 실행 프로그램을 위한 이진 표현법이다. 하드웨어가 아닌 소프트웨어에 의해 처리되기 때문에, 보통 기계어보다 더 추상적이다.
JIT 컴파일(just-in-time compilation) 또는 동적 번역(dynamic translation)은 프로그램을 실제 실행하는 시점에 기계어로 번역하는 컴파일 기법이다. 이 기법은 프로그램의 실행 속도를 빠르게 하기 위해 사용된다.
이제 이 과정을 각 엔진들이 처리하는 방법에 어떤 차이가 있는지 살펴보겠습니다.
V8의 세부 파트는 이름 그대로 8기통 자동차 엔진이 떠오르는 네이밍을 하고 있습니다.
과정은 위의 공통 파이프라인과 거의 흡사합니다. 여기서 인터프리터
는 Ignition
이라고 부르며, 코드를 점화하여 바이트 코드를 생성 및 실행합니다. 바이트코드가 실행될 때 인터프리터는 프로파일링 데이터를 수집하여 나중에 실행 속도를 빠르게 할때 사용합니다.
가령, 특정 함수를 자주 사용한다고 해봅시다. 그래서 이 함수가 뜨거워지게 되면, 바이트코드와 프로파일링 데이터를 TurboFan
이라고 부르는 최적화 컴파일러
로 보내서 식혀줍니다. 그리고 이곳에서 프로파일링 된 데이터를 기반으로 매우 최적화된 기계어 코드를 만들어냅니다.
자주 반복돼서 수행된다는 뜻입니다.
최근의 JS 엔진들은 일괄적으로 최적화를 적용하는 JITC가 아닌 Adaptive Compilation 방식을 택하고 있습니다. 이는 반복 수행되는 정도에 따라 서로 다른 최적화를 적용하는 것입니다. 처음에 모든 코드는 인터프리터에 의해 바이트 코드로 변환되지만, 자주 반복되는 부분이 발견되면 여기에 대해서만 JITC를 적용하는 식입니다.
SpiderMonkey에서는 최적화 컴파일러가 두 개입니다. 인터프리터가 코드를 Baseline
컴파일러로 최적화하면, 이 결과로 약간 최적화 된 코드가 생성됩니다.
그리고 이는 코드를 실행하는 동안에 수집된 프로파일링 데이터와 합쳐져서 IonMonkey
라는 최적화 컴파일러로 보내져서 고도로 최적화 된 코드를 만듭니다. 만약에 추측성(speculative) 최적화가 실패하게 되면, IonMonkey는 이를 Baseline 코드로 되돌립니다.
여기서도 두 개의 최적화 컴파일러로 최적화를 진행합니다. 인터프리터가 SimpleJIT
로 보내 약간 최적화 된 코드를 만들고, FullJIT
에서는 이를 프로파일링 데이터와 함께 더욱 고도로 최적화 된 코드를 생성합니다.
JSC는 최대 세 번까지 최적화를 합니다! LLInt
(Low-Level Interpreter) 라는 인터프리터와 휴리스틱하게 동작하는 Baseline
컴파일러를 거치고 이후에 DFG
(Data Flow Graph)과 FTL
(Faster Than Light)라는 최적화 컴파일러를 사용합니다.
왜 어떤 엔진은 더 많은 최적화 컴파일러를 갖고 있을까요?
바로 트레이드 오프(trade-offs) 때문입니다. 인터프리터는 바이트코드를 빠르게 생성할 수 있지만 효율적인 코드가 아닙니다. 반대로 최적화 컴파일러는 시간이 조금 더 걸리지만 훨씬 효율적인 기계 코드를 생성합니다. 따라서, 어떤 엔진은 여러 개의 최적화 컴파일러를 선택함으로써 복잡해지는 비용을 감수하고 이러한 인터프리터와 컴파일러 사이의 균형을 필요에 따라 세부적으로 제어할 수 있도록 한 것입니다.
결국, 자바스크립트 엔진마다 구체적인 최적화 과정은 차이가 있으나, 파서와 인터프리터/컴파일러가 포함된 동일한 아키텍쳐로 구성된 것을 알 수 있습니다.
다음 포스트에서는 코드 레벨에서 엔진이 어떻게 최적화되어 동작하는지 알아보겠습니다.
좋은 포스트 감사합니다!