AST는 실행할 수 없다
이전 글에서 파서가 토큰을 AST로 변환하는 과정을 살펴봤습니다. AST는 코드의 구조를 정확하게 표현하지만, CPU가 이해할 수 있는 형태는 아닙니다.
V8은 AST를 직접 해석하지 않습니다. 대신 바이트코드 (bytecode) 라는 중간 표현으로 먼저 컴파일합니다. 이 작업을 담당하는 것이 Ignition 인터프리터입니다.
왜 바이트코드인가
AST를 직접 해석하거나, 곧바로 기계어로 컴파일하는 방법도 있을 텐데 왜 바이트코드라는 중간 단계를 거칠까요?
- AST 직접 해석, 트리를 순회하며 실행하는 것은 느립니다. 노드 간 점프가 많아 CPU 캐시 효율이 떨어집니다
- 즉시 기계어 컴파일, 빠르지만 컴파일 자체에 시간이 걸립니다. 한 번만 실행되는 코드에는 낭비입니다
- 바이트코드, 컴파일이 빠르고, 실행도 합리적으로 빠릅니다. 나중에 자주 실행되는 코드만 기계어로 최적화하면 됩니다
바이트코드는 빠른 시작과 점진적 최적화를 동시에 달성하는 전략입니다.
Ignition의 레지스터 머신
Ignition은 레지스터 기반 가상 머신입니다. 핵심 구성 요소는 세 가지입니다.
- 누산기 (accumulator), 현재 연산 결과를 담는 특별한 레지스터. 대부분의 연산은 누산기를 중심으로 이루어집니다
- 레지스터, 지역 변수와 매개변수를 저장하는 슬롯.
r0,r1... 은 지역 변수,a0,a1... 은 매개변수입니다 - 상수 풀, 문자열, 숫자 등 리터럴 값을 모아둔 테이블
변수 선언의 바이트코드
let message = "hello";가 어떤 바이트코드로 변환되는지 봅시다. 단계별로 누산기와 레지스터의 상태가 어떻게 변하는지 주목하세요.
단 4개의 명령어로 변수 선언이 완료됩니다.
- 상수 풀에서 값을 누산기로 로드 (LdaConstant)
- 누산기에서 레지스터로 이동 (Star0)
- 정리 (LdaUndefined, Return)
AST의 트리 구조가 선형적인 명령어 시퀀스로 펼쳐진 것이 핵심입니다.
함수의 바이트코드
add(3, 5)를 호출하면 어떤 바이트코드가 실행될까요?
놀랍도록 간결합니다. 명령어가 단 3개뿐입니다.
- 매개변수
b를 누산기로 로드 (Ldar) - 매개변수
a와 누산기를 더함 (Add) - 결과를 반환 (Return)
주요 바이트코드 명령어
Ignition의 명령어는 수백 개지만, 핵심 패턴은 몇 가지로 나뉩니다.
로드/저장
| 명령어 | 의미 |
|---|---|
LdaConstant | 상수 풀 → 누산기 |
Ldar | 레지스터 → 누산기 |
Star | 누산기 → 레지스터 |
LdaUndefined | undefined → 누산기 |
LdaZero | 0 → 누산기 |
LdaSmi | 작은 정수 → 누산기 |
산술/비교
| 명령어 | 의미 |
|---|---|
Add | 누산기 + 레지스터 |
Sub | 누산기 - 레지스터 |
Mul | 누산기 × 레지스터 |
TestEqual | 누산기 == 레지스터 |
TestLessThan | 누산기 < 레지스터 |
제어 흐름
| 명령어 | 의미 |
|---|---|
Jump | 무조건 점프 |
JumpIfTrue | 누산기가 true면 점프 |
JumpIfFalse | 누산기가 false면 점프 |
Return | 함수 종료, 누산기 반환 |
AST → 바이트코드 변환 규칙
컴파일러는 AST를 깊이 우선으로 순회하며 각 노드 타입에 맞는 바이트코드를 생성합니다.
- Literal →
LdaConstant또는LdaSmi - Identifier (읽기) →
Ldar - BinaryExpression → 좌변 평가 → 임시 저장 → 우변 평가 →
Add/Sub/... - VariableDeclarator → 초기값 평가 →
Star - ReturnStatement → 표현식 평가 →
Return - IfStatement → 조건 평가 →
JumpIfFalse→ then절 →Jump→ else절
실제로 바이트코드 보기
Node.js에서 --print-bytecode 플래그를 사용하면 V8이 생성하는 실제 바이트코드를 볼 수 있습니다.
node --print-bytecode --print-bytecode-filter=add script.js출력에서 위에서 살펴본 것과 같은 명령어들을 직접 확인할 수 있습니다.
다음 단계
바이트코드는 Ignition이 한 줄씩 해석하며 실행합니다. 하지만 같은 함수가 수천 번 호출되면 매번 해석하는 것은 비효율적입니다.
다음 글에서는 V8의 TurboFan 최적화 컴파일러가 "뜨거운" 바이트코드를 기계어로 컴파일하는 과정, 그리고 최적화가 실패했을 때 일어나는 역최적화 (deoptimization) 를 살펴보겠습니다.