V8 Javascript Engine
부릉부릉 v8 엔진 겉핥기

서론
자바스크립트는 웹 브라우저에서 사용자 인터랙션과 동적인 웹페이지를 구현하는 데 필수적인 언어입니다. 이 자바스크립트를 빠르게 해석하고 실행하기 위해 브라우저는 자바스크립트 엔진을 내장하고 있으며, 그 중 대표적인 것이 크롬과 Node.js에서 사용되는 V8 엔진입니다.
V8 엔진이란?
V8은 구글이 개발한 오픈소스 자바스크립트 엔진으로, 주로 Chrome 브라우저와 Node.js에서 사용됩니다. C++로 작성되어 있으며, 자바스크립트 코드를 기계어로 직접 컴파일하여 매우 빠르게 실행할 수 있도록 설계되었습니다.
V8의 주요 목적은 두 가지입니다
- 자바스크립트의 빠른 실행
- 메모리 관리 및 최적화
V8엔진의 동작 흐름
자바스크립트 코드는 V8 엔진을 통해 아래와 같은 과정을 거쳐 실행됩니다
[JS 코드 입력]
↓
[Parser] → [AST(Abstract Syntax Tree) 생성]
↓
[Ignition (인터프리터)] → 바이트코드 생성 및 실행
↓
[TurboFan (JIT 컴파일러)] → 최적화된 머신 코드 생성
↓
[실행]
위의 사진만 봐서는 모든 바이트코드를 터보팬 컴파일러가 기계어로 변환하는 듯 하지만, 모든 자바스크립트 코드를 기계어로 컴파일 하는 것은 아닙니다.
또한, 한 번 터보팬으로 최적화된 머신코드를 계속 메모리에 유지하지도 않습니다. 머신 코드를 다시 바이트 코드로 Deoptimization 하기도 합니다.
런타임 과정에서 프로파일링을 통해 자주 실행되는 코드만 기계어로 변환하는데, 이러한 방식을 AJITC
(Adaptive Just In Time Compiler), 적응형 JIT 컴파일러 방식이라고 합니다.
AJIT 컴파일 방식
AJIT컴파일 방식은 JVM에서 사용되는 방식으로 알고있습니다. AJIT 컴파일은 모든 바이트 코드를 네이티브 코드로 변환하지 않고 프로파일링을 통해 최적화가 필요한 코드만 네이티브 코드로 변환하는 방식입니다.
왜 모든 자바스크립트 코드를 네이티브 코드로 컴파일 하지 않을까요? 모든 코드를 기계어로 컴파일하면 실행 속도가 훨씬 빨라질탠데요…(아님)
모든것은 돈.. 돈 모든게 돈 세상
TurboFan 같은 고급 JIT 컴파일러는 복잡한 최적화를 하므로, 컴파일 자체에 비용이 큽니다.
자바스크립트는 이벤트 기반 언어입니다. 극단적으로 예시를 들면 많은 함수가 단 한 번만 호출될 수 있기 때문에 이런 코드까지 컴파일을 하면 컴파일 비용(시간 + 메모리) 낭비입니다.
그리고 모든 자바스크립트 코드를 터보팬으로 한 번에 컴파일하면 오히려 초기 실행이 느려질 수 있습니다.
또한, 터보팬은 실행 시점에 수집한 타입 정보와 호출 패턴을 바탕으로 가정을 세운 후 컴파일 합니다.
만약 이 가정이 깨진다면 Deopt
(최적화 실패)가 발생하게 되고, 성능이 저하됩니다.
따라서 바이트코드를 인터프리터로 먼저 실행하다가 핫코드를 감지하면 JIT 컴파일을 진행하여 최적화하는게 훨씬 효율적이고 안정적이라고 합니다.
V8 엔진의 구성 요소
Parser
V8 엔진은 맨 처음, 자바스크립트 소스 코드를 Parser로 보냅니다.
Parser는 이름에 걸맞게 JS코드를 파싱하여 소스 코드를 분석한 후, AST로 변환합니다.
이 과정에서 변수 스코프, 함수 구조 등을 분석하고 최적화를 위한 데이터를 수집합니다.
Ignition
AST를 바이트코드로 변환하고, 이 바이트코드를 인터프리팅 방식으로 실행합니다.
바이트코드는 기계어보다는 추상적이고, 실행하기 쉬운 형태의 명령어 집합입니다.
초기 실행 속도가 빠르고, 프로파일링 정보를 수집하여 추후 TurboFan
으로 넘길 준비를 합니다.
예를 들어 아래와 같은 js코드가 있다면,
function sum(a, b) {
return a + b;
}
sum(1, 2);
Ignition을 통해 아래와 같은 바이트코드로 변환됩니다.
Lda a
Lda b
Add
Return
TurboFan
Ignition이 자주 실행되는 코드로 판단한 부분(hot code
, hot spot
등으로 불립니다)을 기계어로 변환합니다.
터보팬의 특징 중 하나는 Sea of Nodes라는 그래프 기반 구조를 사용하는 것입니다.
트리 기반 IR vs 그래프 기반 IR
트리 기반 IR (대부분의 컴파일러)
- 연산이 위에서 아래로 흐르는 트리 구조
- 각 노드가 하나의 부모만 가짐
- 코드의 명령 순서(Statement order)를 강하게 따릅니다.
그래프 기반 IR (Sea of Nodes)
- 모든 연산을 동등한 노드로 처리
- 중복 연산 노드를 병합 가능
- 연산 순서를 고정하지 않음 → 최적화 자유도 높음
터보팬이 Sea of Nodes
를 사용하는 이유는 순서를 갖지 않는 연산 노드의 집합을 사용하여 데이터의 흐름과 제어 흐름을 모두 표현할 수 있고, 최적화 과정에서 노드를 병합하거나 재배치하기에 유리하기 때문이라고 합니다.
function example(a, b) {
let x = a + b;
let y = x * 2;
return y;
}
예를 들어 위와 같은 js코드는 Sea of Nodes로 다음과 같이 표현됩니다.
a──┐
├──> + ──┐
b──┘ ├──> * ──> return
2──┘
각각의 연산이 모두 노드로 표현되며, 데이터의 흐름(의존성)은 엣지로 연결됩니다.
명령 순서를 따르지 않아도 되며 노드들간의 연결 관계만으로 코드를 최적화 할 수 있습니다.
또한 노드의 연산 결과를 여러 곳에서 재사용 할 수 있습니다.
반면 트리 기반IR로 표현하면 다음과 같이 표현됩니다.
return
|
*
/ \
+ 2
/ \
a b
연산 순서가 고정되어있고, 중간 연산 결과(예를 들면 +)를 다른 곳에서 재사용 하기 어렵습니다.
터보팬은 이러한 그래프 기반 IR구조를 사용하여 다양한 최적화를 진행합니다.
찾아보니 gcc가 해주던 잘 알려진 최적화는 거진 다 지원하는 것 같습니다.
당연합니다. c++로 만들어졌으니… 컴파일러니…
함수 인라이닝
자주 호출되는 함수를 해당 호출 지점에 코드블럭으로 삽입하여 최적화하는 것 입니다.
function double(x) {
return x * 2;
}
function calc(n) {
return double(n) + 1;
}
→ 함수 호출의 오버헤드를 제거
function calc(n) {
return (n * 2) + 1; // double 함수 인라인됨
}
Dead Code 제거
사용되지 않는 변수, 조건문, 분기 제거합니다.
function compute(x) {
let y = x * 1000; // 사용 안 됨
return x + 1;
}
→ let y = x * 1000;
제거됨
타입 기반 연산 단순화
실행 중 수집한 타입 정보를 기반으로 변수, 연산자, 객체 속성의 타입을 추론해줍니다.
JS는 동적 타입이지만, 실행 중 대부분 특정 타입으로 고정되기 때문에 이를 활용해 타입 체크 제거가 가능합니다.
function add(a, b) {
return a + b;
}
add(1, 2); // 숫자만 들어온다!
a와 b가 항상 number인 경우, 터보팬은 +
를 string concat이 아닌 숫자 덧셈으로 고정하여 타입 검사를 제거합니다.
루프 최적화
루프 내에서 반복되지 않아도 되는 계산을 루프 밖으로 이동시켜 최적화합니다.
for (let i = 0; i < n; i++) {
console.log(Math.sqrt(100)); // 루프마다 불필요하게 반복
}
→ 외부 스코프에 변수를 만들어 해당 변수 사용
let x = Math.sqrt(100);
for (let i = 0; i < n; i++) {
console.log(x);
}
이 외에도 참조되지않는 변수의 메모리를 할당하지 않거나 변수, 조건문, 분기 등을 제거해 주는 등 다양한 최적화를 진행해줍니다.
터보팬은 실행 중에 최적화된 머신 코드의 가정을 위반 했을 때, 엔진이 해당 코드를 안전한 바이트 코드 실행으로 되돌리는 과정을 진행합니다. 이를 Deoptimization
라고 합니다.
Deoptimization
최적화된 머신 코드가 잘못된 가정을 기반으로 만들어졌을 때 (타입 변경 등) 발생합니다.
이 경우 V8은 해당 기계어 코드를 포기하고 다시 Ignition 인터프리터(바이트코드)로 되돌아갑니다.
function add(x, y) {
return x + y;
}
add(1, 2); // 정수, 숫자 덧셈으로 최적화
add("1", "2"); // 문자열, 가정이 깨짐 -> Deopt
터보팬은 a
와 b
가 항상 number일 거라 가정하고 기계어를 생성했는데, 문자열이 등장하면 이 가정이 깨지므로, Deoptimization이 발생합니다.
이후 다시 해당 코드가 Hot…해진다면 re-optimization
을 수행합니다.
Garbage Collection
V8은 Generational GC를 사용하여 메모리를 관리합니다
- New Space (Young Generation): 짧게 사용되는 객체들 저장합니다.
- Old Space (Old Generation): 오래된 객체 저장합니다.
- Mark-and-Sweep + Scavenge 방식의 하이브리드 GC 알고리즘으로 메모리를 정리합니다.
Inline Caching (IC)
자주 참조되는 속성 접근을 캐싱하여 반복 연산 성능을 향상시킵니다.
회고
V8.. 2년 전에 처음 리액트를 공부할 때 동료 개발자 분께서 설명해주셔서 잠깐 공부했었습니다.
하지만 역시 기록하지 않았더니 다 까먹었습니다. 분명 그 때는 이 거보다 더 자세하게 공부했었던 거 같은데 기억이 하나도 안납니다.
그 때 보던 영상을 캡쳐해서 그 분께 “새벽 3시에” 보냈더니 바로 읽어서 까무라쳤었던 기억만 남아있습니다.
요민님.. 요민님 얘기입니다.
저는 그 분처럼 내부를 까 본 10인에는 들지 못하더라도 겉핥기로 알고있기 위해 정리하겠습니다.
너무 내용이 부실하여 추가적으로 퇴고하겠습니다. 역시 잘못되거나 이상한 점이 있다면 알려주시길 바랍니다…