V8은 구글에서 C++로 작성한 Chrome과 Node.js를 비롯한 여러 환경에서 작동하는 고성능 JavaScript 및 WebAssebly 엔진입니다.
V8은 ECMAScript 및 WebAssembly를 구현하며, Windows, macOS, Linux의 x64, IA-32, ARM 프로세서에서 실행됩니다. V8의 주요 기능은 다음과 같습니다.
JavaScript 소스 코드의 컴파일 및 실행
객체에 대한 메모리 할당
더 이상 필요하지 않은 객체에 대한 가비지 컬렉션
이러한 기능들을 수행해야 하는 이유에 대해서 자세하게 다루어 보겠습니다. 이에 대해서 다루기 전에 먼저 컴파일에 관한 내용부터 다루어 보겠습니다.
컴파일 방식은 어떤 것들이 있을까?
우리가 흔히 사용하는 Java, JavaScript등 프로그래밍 언어는 컴파일링(Compiling) 이 필요합니다. 고급언어는 사람이 이해하기 효율적인 언어이고 컴퓨터의 입장에서는 이해하기 어려운 언어이므로 컴퓨터가 쉽게 읽고 해석하기 위하여 저급언어, 바이트 코드, 어셈블리어로 변환을 하는 과정이 필요합니다.
이렇게 변환을 하는 과정을 분류할 경우 크게 두 가지로 분류할 수 있습니다. AOT(Ahead-of-Time) 컴파일과 인터프리터 (Interpretation) 로 구분할 수 있습니다.
AOT(Ahead-of-Time) 컴파일러
AOT 컴파일의 경우 소스코드를 미리 컴파일 하여 기계어로 번역한 뒤 실행하는 정적 컴파일 방식입니다. 소스 코드를 미리 컴파일하기 때문에 실행 속도가 빠르다는 장점이 있지만 유연하지 못하고 최적화를 적용하기에 취약하다는 단점이 있습니다.
이러한 이유는 실행 환경과 동적인 특성을 미리 파악할 수 없기 때문입니다. AOT는 코드가 실행되기 이전에 컴파일이 진행되기 때문에 코드가 실행되는 환경에 대해서는 전혀 고려하지 않고 컴파일이 진행됩니다. 이러한 이유로 인해서 실행 시점에서 얻을 수 있는 정보, CPU, 실제 실행 빈도등에 대한 정보를 얻을 수 없기 때문에 최적화를 진행하는데 한계가 있을 수 밖에 없습니다.
하지만 AOT는 빌드 시점에서 모든 템플릿과 컴포넌트가 미리 컴파일이 되므로 초기 로딩 속도가 빠르다는 장점이 있습니다. 또한 불필요한 컴파일러 코드가 포함되지 않으므로 최종 배포 파일의 크기가 줄어들어 성능 최적화에 도움이 됩니다. 이러한 이유로 Angluar의 경우 build 시에는 AOT 컴파일러를 사용하고 dev 시에는 JIT를 활용하여 컴파일링을 진행하는 방식을 사용하고 있습니다.
인터프리터(Interperter)
인터프리터는 소스 코드를 바로 실행하기도 하고, 내부적으로 바이트코드와 같은 중간 표현을 생성하여 동작하기도 합니다. 인터프리테이션의 경우 프로그램을 한 줄씩 해석해가며 실행하는 방식을 이야기 합니다. 이러한 특징으로 인해서 인터프리터는 디버깅에 유리하다는 특징을 갖고 있습니다.
인터프리터는 한 줄씩 해석하고 실행하기 때문에 위의 코드와 같이 오류가 발생한 즉시 확인할 수 있다는 특징을 갖고 있습니다. 그리고 개발을 하는 과정에서 아래와 같은 오류 코드를 많이 보셨을 것입니다.
위의 오류 코드는 정확하게 main.js:1 에 해당하는 줄에서 오류가 발생 했음을 정확하게 파악할 수 있습니다. 이렇게 표기가 가능한 이유가 코드의 라인 단위로 작동하기 때문에 특정 라인에 오류가 발생 했을 경우 빠른 시간에 오류를 찾아낼 수 있습니다.
하지만 인터프리터는 기계어를 저장하지 않기 때문에 이전에 컴파일을 진행했던 코드더라도 계속해서 반복하여 해석을 진행해야 한다는 단점이 있습니다. 이러한 특징 때문에 실행과 컴파일을 동시에 진행해야 하기 때문에 실행 속도가 느리다는 단점이 존재합니다.
JavaScript 소스 코드의 컴파일 및 실행
V8은 우리의 코드를 우선 Paser(파서) 에게 코드를 넘겨 우리의 코드를 아래와 같이 AST, 추상 구문 트리로 변환을 수행합니다.
AST는 컴파일 과정에서 소스 코드의 복잡한 구문 구조를 간결하고 명확하게 표현해줌으로써, 의미 분석, 최적화, 그리고 코드 생성을 효과적으로 수행할 수 있는 기반을 제공합니다. 또한, 모듈화된 구조 덕분에 컴파일러의 유지 보수와 확장성을 높이는 데 큰 역할을 하기 때문입니다.
Ignition
그 이후에는 Ignition , Baseline 컴파일러를 사용하여 최적화를 진행합니다. 함수를 실행되면서 점점 더 자주 호출되기 시작하면, JIT 컴파일러는 이를 감지하고 해당 함수를 컴파일하여 저장합니다.
우선 소스 코드를 빠르게 바이트코드(Byte Code) 로 컴파일한 뒤, 인터프리터가 이 바이트코드를 한 줄씩 읽으며 실행합니다. 이 단계에서 타입, 실행 빈도와 같은 피드백 정보를 수집합니다.
모니터가 특정 함수가 여러 번 호출되는 것을 감지하면, 해당 함수를 Baseline 컴파일러로 보내서 Stub(작은 코드 블록) 의 형태로 네이티브 코드를 생성합니다. 바이트코드를 한 줄당 하나의 스텁을 만들고, 이후 실행 중 동일한 바이트코드가 같은 변수 타입과 함께 반복될 경우, JIT는 이미 컴파일이 된 Stub을 즉시 가져와 실행합니다.
여기서 추가로 만약 해당 코드가 매우 자주 실행된다면, 더 강력한 최적화를 수행합니다. 이 과정은 과거에서는 Ignition -> TurboFan 단계로 이루어지면서 성능을 개선하였지만 오늘 날에는 Ignition -> (Sparkplug | Maglev) -> TurboFan 단계로 이루어지며 성능을 개선하게끔 변경 되었습니다.
이러한 변경사항이 이루어진 이유는 TurboFan과 같은 고도로 최적화된 컴파일러만으로는 충분하지 않았기 때문입니다. 이러한 이유는 TurboFan은 기계어 수준에서 복잡한 최적화, 인라인화, 루프 변환, 데드 코드 제거등을 수행하기 때문에 컴파일 자체에 수십에서 수백 밀리초가 걸릴 수 있습니다.
그렇다보니 웹 페이지 로딩이나 CLI 툴처럼 한 번 실행해서 곧 종료 되는, 짧은 세션에서 실행되는 환경에서는 TurboFan이 코드를 최적화하기 전에 애플리케이션이 이미 종료되어 있을 수 있습니다.
뿐만 아니라, Ignition은 Hot Code가 모여야 최적화의 대상임을 판단할 수 있는데 이 과정 자체가 시간이 걸리기 때문에 TurboFan 만으로는 충분치 않다는 문제가 있었습니다.
이러한 단점을 해결하기 위해 등장한 추가적인 컴파일러들이 Sparkplug, Maglev 컴파일러들입니다.
Sparkplug
Sparkplug는 매우 빠르게 컴파일되도록 설계되었습니다. Sparkplug가 기존의 작업을 훨씬 빠르게 수행할 수 있는 이유를 크게 두 가지 트릭으로 정리해볼 수 있습니다. 모두 "불필요한 단계를 제거"하고, 이미 "수행된 작업을 적극 활용"하는 데서 나옵니다.
Sparkplug는 컴파일하고자 하는 코드를 이미 바이트 코드로 컴파일이 되어 있고, 변수해석, 괄호가 화살표 함수인지, 구조 분해 할당 등의 어려운 작업들을 이미 수행해 두었기 때문에 다시 수행하지 않습니다.
Sparkplug는 JavaScript 소스 대신 바이트코드에서 컴파일을 수행하므로, 그러한 부분을 고민할 필요가 없습니다. 단일 선형 패스로 바이트코드를 순회하면서, 그 바이트코드가 실행과 일치하는 기계어를 곧바로 생성합니다. 기존에는 바이트코드 → IR → 기계어 단계로 컴파일이 이루어졌지만 바이트코드 → 기계어로 곧장 넘어가므로 컴파일 단계가 극도로 단순해졌습니다. 이러한 과정에 전체 컴파일 시간을 크게 단축시키게 됩니다.
이러한 특징으로 인해 언제든지 tier-up 가능하다는 특징을 갖게 되었습니다. Sparkplug는 Ignition 인터프리터 다음 단계에서 “함수가 뜨거워졌다” 판단만 되면 즉시 컴파일 단계로 넘어가 컴파일을 수행한 후에 TurboFan으로 전달하기 때문에 기존의 작업을 더 빠르게 수행할 수 있습니다.
Maglev
Sparkplug은 "언제든지 tier-up" 할 만큼 빠르지만, 내보내는 기계어는 1:1 매핑 위주의 비최적화 코드라 성능 향상에는 한계가 있다는 문제가 있었습니다. Sparkplug는 함수가 충분히 자주 호출되지만, TurboFan 최적화를 위해 요구하는 “꽉 찬” 핫 스팟이 되기엔 짧은 실행 시간이 필요하다는 문제를 해결할 수 있었습니다.
하지만, Sparkplug가 아주 뜨겁게 반복 실행되는 코드(핫 루프)에 들어가기 전이라도 어느 정도 반복되는 구간에서는 “조금 더 똑똑하게” 코드를 생성할 필요가 있기 때문에 성능이 덜 민감한 구간에서도 더 나은 코드 품질이 필요 했습니다.
이 지점에서 Maglev가 등장합니다. Maglev는 Sparkplug 코드보다 훨씬 빠른 코드를 생성하면서도, TurboFan보다 훨씬 더 빠르게 생성됩니다.
여기서 이야기하는 똑똑하게는 Sparkplug엔 IR 단계가 없어서, 다음과 같은 로컬 최적화 기회가 사라진다는 것을 이야기 합니다. 로컬 최적화를 수행할 경우 컴파일 시점에 알 수 있는 수식을 미리 계산하여 상수 전파(Constant Propagation) 할 수 있습니다. 또한 동일 표현식 재계산 방지하거나 임시값을 레지스터에 고정 배치해 메모리 접근 최소화할 수 있습니다.
Maglev이 도입된 핵심 배경은 “웹 애플리케이션이 점점 더 상태 중심(stateful), 상호작용 중심이 되어, 함수가 "충분히 뜨거워지지만" TurboFan 최적화 임계치에는 닿지 못하는 경우가 많아졌다는 점입니다. 이로 인해 Sparkplug만으로는 얻을 수 있는 성능 향상에 한계가 생겼고, TurboFan으로 넘어가기엔 컴파일 오버헤드가 너무 크다는 딜레마가 발생했기 때문에 Maglev가 등장하게 된 것입니다.
Turbofan
Optimizing 컴파일러, turbofan 는 기존 코드 실행 패턴을 분석하여 몇 가지 가정을 설정합니다. 이러한 가정들의 예시는 아래와 같습니다.
객체의 구조(Shape)가 항상 일정하다고 가정합니다. 어떤 생성자(Constructor)로 생성된 객체들이 항상 동일한 속성을 가지고 있고, 동일한 순서로 추가되었다면, 이를 기반으로 최적화할 수 있음. 즉, 객체의 형태가 고정되어 있다고 믿고, 불필요한 검사를 생략합니다.
이전까지 반복문 내에서 동일한 조건이 유지되었다면, 이후에도 동일할 것으로 가정합니다. 예를 들어, for 루프에서 arr[i]가 항상 Number 타입이었다면, 이후에도 계속 Number 타입일 것으로 가정하고 최적화 수행합니다.
하지만 이러한 가정이 JavaScript의 경우 무조건 맞지 않기 때문에 추가적으로 Deoptimization(최적화 해제, Bailout) 또한 수행하는데 최적화 코드 폐기 및 Interpreter로 롤백, JIT 컴파일러는 자신이 틀렸다고 판단하고, 최적화된 코드를 폐기 후 코드를 Interpreter 또는 baseline 컴파일된 코드로 되돌리고 이 과정을 최적화 해제, Deoptimization이라고 합니다.
이러한 방법이 성능을 개선한 이유
V8엔진에서 활용하는 JIT(Just-in-time)컴파일러는 JavaScript의 성능 향상에 많은 도움을 기여한 컴파일러입니다. 과거의 JavaScript는 다른 언어에 비해서 속도가 느리다는 단점을 갖고 있었습니다. 이러한 이유는 무엇일까요? 성능 저하에 가장 많은 영향을 준 것은 JavaScript가 동적 타이핑 언어라는 것입니다.
위의 간단한 구문을 예시로 들어 보겠습니다. 위의 로직은 너무나도 간단하기 때문에 number가 어떤 타입의 언어이고 결과적으로 어떤 결과가 나오는지 생각할 수 있습니다. 그렇다면 변수 number의 타입이 언제나int 라는 것을 보장할 수 있을까요? 전체 코드를 보고 결과를 추론할 수 있는 우리는 보장할 수 있을 것이라고 생각할 것입니다. 하지만 컴퓨터의 입장에서는 보장할 수 없습니다. 이러한 이유는 무엇일까요?
위의 단순한 for 문을 동작 시킬 경우, arr 변수가 100 개의 정수로 이루어진 배열이라고 가정합시다. 일단 코드가 워밍업 되면 baseline 컴파일러는 함수의 각 오퍼레이션에 대한 stub을 생성합니다. 따라서 정수의 가산 연산(+=)을 처리하는 sum += arr [i]에 대한 stub이 만들어집니다.
하지만 문제는 여기서 발생합니다. JavaScript는 동적 타이핑 언어이기 때문에 sum와 arr[i]가 정수형임을 보장하기가 어렵습니다. 수행 도중에 타입이 변환될 수 있기 때문입니다.
정수 덧셈과 문자열 연결은 매우 다른 연산이며, 각각의 연산은 전혀 다른 기계어(machine code)로 컴파일됩니다. JIT 컴파일러는 이를 처리하기 위해 여러 개의 baseline stub을 컴파일합니다.
만약 특정 코드가 모노모픽(monomorphic), 즉 항상 같은 타입의 값과 함께 실행된다면, 하나의 Stub만 생성됩니다. 반면, 코드가 폴리모픽(polymorphic), 즉 실행될 때마다 서로 다른 타입의 값과 함께 호출된다면, 그때마다 다른 타입 조합에 맞는 Stub이 생성됩니다. JavaScript는 폴리모픽에 해당하기 때문에 아래와 같은 질의 응답을 반복해야 합니다.
sum은 int 타입이야?
arr은 배열이야?
i 는 int야?
arr[i]는 int야?
JIT는 코드 라인이 실행될 때마다 타입을 계속 확인해야 합니다. 그래서 루프를 반복할 때마다, 동일한 질문을 해야만 합니다. 만약 JIT가 이러한 검사를 반복할 필요가 없다면 코드는 훨씬 더 빨리 실행될 수 있습니다. 이것이 최적화 컴파일러가 하는 일 중 하나입니다.
최적화 컴파일러는 전체 함수를 한 번에 컴파일을 진행합니다. 이 과정에서 타입 검사는 반복문 안에서 수행되는 것이 아니라, 반복문이 실행되기 전에 미리 처리되도록 이동이 됩니다. turbofan은 JavaScript 코드를 실행하면서 hot code path, 자주 실행되는 코드 경로 를 감지하고 최적화하여 실행 속도를 높입니다.
마치며
지금까지 JavaScript 코드가 어떻게 실행되고 어떻게 성능을 향상 시키고 있는지에 대해서 자세하게 다루어 보았습니다. V8 엔진은 JavaScript의 동적 특성을 고려하여, JIT 컴파일러를 통해 실행 시점에 최적화를 수행함으로써 성능을 극대화합니다. 이러한 과정은 JavaScript가 웹에서 빠르고 효율적으로 동작할 수 있도록 하는 핵심 요소 중 하나입니다.
main.js:1 Uncaught ReferenceError: React is not defined at main.js:1:676856 at main.js:1:676896
console.log("Step 1");let x = 10;console.log("Step 2");let y = a + 5; // 'a' is not defined → 여기서 오류 발생console.log("Step 3"); // 실행되지 않음
function square(n) { return n * n}// 위의 코드를 트리 구조의 데이터 스트럭쳐로 변환{ "type": "Program", "body": [ { "type": "FunctionDeclaration", "id": { "type": "Identifier", "name": "square" }, "params": [ { "type": "Identifier", "name": "n" } ], "body": { "type": "BlockStatement", "body": [ { "type": "ReturnStatement", "argument": { "type": "BinaryExpression", "operator": "*", "left": { "type": "Identifier", "name": "n" }, "right": { "type": "Identifier", "name": "n" } } } ] } } ], "sourceType": "module"}
let number = 0;for (let i = 0; i < 32; i++) { number += 1;}
function arraySum(arr) { var sum = 0; for (var i = 0; i < arr.length; i++) { sum += arr[i]; }}