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) 를 살펴보겠습니다.