5.0 컴파일러와 인터프리터 — 소스 코드는 어떻게 실행 가능한 형태가 되는가
한 줄의 소스 코드가 실행 결과나 도구의 경고로 돌아오기까지, 코드는 토큰·AST·IR·바이트코드·기계어라는 산출물들을 거친다. 이 인트로는 각 산출물을 미리 외우는 대신, 성능 문제와 도구의 오진 앞에서 어느 단계가 무엇을 관찰하고 무엇을 결정했는지 추적하는 관점 — 챕터 5 전체가 쓰는 지도를 세운다.
학습 목표
- 컴파일과 인터프리트를 언어의 속성이 아니라 실행 엔진이 조합하는 전략으로 구분한다.
- 소스에서 실행까지의 변환 파이프라인에서 각 산출물이 다음 단계에 제공하는 인터페이스가 무엇인지 설명한다.
- 성능 저하나 도구 경고 앞에서 번역·표현과 최적화·실행 전략·정적 분석 중 어느 단계를 관찰할지 고른다.
출발점: 같은 함수, 세 개의 속도
다음은 5.3에서 실제로 재현하는 현상이다. 숫자 두 개를 더하는 JavaScript 함수를 반복 호출하면:
- 처음 몇 번의 호출은 느리다. 아직 해석 실행 단계이기 때문이다.
- 수만 번 반복하면 눈에 띄게 빨라진다. 엔진이 "이 함수는 뜨겁고, 인자는 늘 작은 정수였다"는 관찰을 근거로 최적화된 기계어를 만들었다.
- 그 상태에서 문자열을 한 번 전달하면 다시 느려진다. "인자는 숫자"라는 가정 위에 만든 코드를 버리고(역최적화) 일반 경로로 돌아갔기 때문이다.
Node.js 24에서 --trace-opt --trace-deopt 플래그를 붙이면 이 세 국면이 로그로 그대로 보인다. 여기서 중요한 것은 수치가 아니라 질문의 전환이다. "JavaScript가 느리다/빠르다"는 문장은 이 현상을 설명하지 못한다 — 같은 언어, 같은 함수였다. 설명하는 질문은 이것이다: 실행 엔진의 어느 단계가, 무엇을 관찰해서, 어떤 결정을 내렸는가? 이 질문에 답하려면 소스 코드가 실행 가능한 형태로 바뀌는 동안 어떤 단계와 산출물이 존재하는지 알아야 한다.
"컴파일 언어 vs 인터프리터 언어"라는 이분법 해체
전통적 구분은 이렇다 — 컴파일러는 실행 전에 소스 전체를 기계어로 번역하고, 인터프리터는 소스를 즉시 실행한다. 구분 자체는 유효하지만, 이것을 언어의 속성으로 읽으면 현대 런타임 앞에서 틀린다.
- V8(JavaScript)은 소스를 파싱해 바이트코드로 컴파일하고, 그것을 해석 실행하다가, 뜨거운 함수를 기계어로 다시 컴파일한다(JIT).
- CPython은 소스를 바이트코드로 컴파일해
.pyc로 캐시하고 VM이 해석한다. - Java는 미리 바이트코드로 컴파일하고(javac), JVM이 해석과 JIT을 조합한다.
- C는 통상 미리 기계어로 컴파일하지만, C 인터프리터도 존재한다.
즉 컴파일과 인터프리트는 배타적 진영이 아니라 하나의 실행 엔진이 조합하는 전략이고, 조합 방식은 언어가 아니라 구현이 정한다. 같은 프로그램이라도 "번역에 언제 비용을 지불하고, 실행에서 언제 회수하는가"의 답이 구현마다 다를 뿐이다. 이 비용 구조의 비교가 5.3의 주제다.
파이프라인 — 각 산출물은 다음 단계의 인터페이스다
전형적인 변환 파이프라인은 다음과 같다. 모든 구현이 모든 단계를 갖는 것은 아니고(트리 워커는 IR 없이 AST를 직접 실행한다), 단계 이름도 구현마다 다르지만, 책임의 분해는 놀랄 만큼 공통적이다.
소스 텍스트
│ 렉싱 (5.1)
토큰열 ─ 어휘 단위 + 소스 위치
│ 파싱 (5.1)
AST ─ 문법 구조, 표면 구문 제거
│ lowering (5.2) ┌──▶ 정적 분석 (5.4)
IR·바이트코드 ─ 명시적 제어 흐름과 값 흐름 ─┘ 린터·타입 체커·컴파일러 진단
│ 최적화·코드 생성 (5.2) 또는 해석 실행·JIT (5.3)
기계어 실행 또는 VM 실행각 산출물을 "다음 단계가 필요로 하는 정보를 담은 인터페이스"로 읽는 것이 이 챕터의 관점이다.
| 산출물 | 만드는 단계 | 소비자에게 제공하는 것 | 버리는 것 |
|---|---|---|---|
| 토큰열 | 렉서 | 어휘 단위의 종류·값·소스 범위 | 공백, 주석(대개), 문자 단위 표현 |
| AST | 파서 | 연산자 우선순위가 반영된 계층 구조 | 괄호·구분자 같은 표면 구문 |
| IR·바이트코드 | lowering·컴파일 | 명시적인 제어 흐름과 값 정의 | 소스 문법의 다양성, 일부 소스 구조 |
| 기계어 | 코드 생성·JIT | 대상 CPU가 실행할 명령 | 타입·이름 등 고수준 정보 대부분 |
"버리는 것" 열이 중요하다. 각 단계는 다음 단계에 불필요한 정보를 버림으로써 단순해지는데, 무엇을 버려도 되는지는 소비자가 결정한다. 오류 메시지를 좋게 만들려면 토큰과 AST가 소스 위치를 보존해야 하고, 포매터가 소비자라면 주석과 괄호도 버릴 수 없으며, 역최적화가 필요하면 최적화된 기계어조차 원래 상태로 돌아갈 정보를 유지해야 한다. 성능 문제나 오진을 추적할 때도 마찬가지다 — 어느 산출물을 열어 봐야 하는지(파서의 AST인가, 최적화 로그인가, 분석기의 CFG인가)를 알면 추측이 관찰로 바뀐다.
네 개의 질문 — 학습 지도
본문 네 편이 파이프라인의 네 구간을 하나씩 맡는다. 각 문서는 독립적인 질문과 자체 완결적인 예제를 가지므로 필요한 것부터 읽어도 되지만, 용어와 예제 언어(최소 장난감 언어 Toy)를 이 순서로 쌓는다.
5.1 렉싱과 파싱 — 문자열에서 구조를 복원하기
컴파일러는 문자 나열을 어떻게 의미 분석에 적합한 구조로 바꾸며, 좋은 AST는 무엇을 보존해야 하는가? 토큰화의 책임, 연산자 우선순위가 AST 모양을 결정하는 방식, 재귀 하강과 LR 파서의 선택 기준, 그리고 오류 복구와 소스 위치 보존이 포매터·IDE·codemod 같은 도구의 품질을 결정하는 이유를 다룬다.
5.2 중간 표현과 최적화 — 프로그램을 분석하고 바꾸기 좋은 형태
AST만으로 최적화와 코드 생성이 어려운 이유는 무엇이고, IR과 SSA는 어떤 복잡성을 줄이는가? 기본 블록과 제어 흐름 그래프(CFG), SSA의 단일 정의 불변식, 대표 최적화 패스가 "분석 → 변환 → 지켜야 할 의미"로 구성되는 방식, 그리고 최적화가 관찰 가능한 동작을 보존해야 한다는 정확성 계약을 다룬다.
5.3 인터프리터와 JIT — 실행 비용을 언제 지불할 것인가
트리 워커, 바이트코드 VM, JIT는 같은 프로그램의 비용을 준비 시간·실행 시간·메모리 중 어디에 지불하는가? Toy 언어의 두 실행기를 실측으로 비교하고, V8의 계층화·추측 최적화·inline cache·역최적화를 관찰 로그로 확인한다. 출발점의 "세 개의 속도"가 여기서 해소된다.
5.4 정적 분석 — 실행하지 않고 프로그램을 추론하기
분석기는 모든 실행 경로를 돌지 않고 어떻게 사실을 추론하며, 경고를 어디까지 믿을 수 있는가? 추상 상태와 고정점 계산이라는 공통 구조를 세우고, 린터·타입 체커·컴파일러 경고를 건전성·완전성과 false positive·false negative의 교환 위에서 해석한다.
이 챕터는 파서 생성기의 테이블 구성, 특정 ISA의 명령 인코딩, Toy VM에 JIT을 붙이는 실습은 다루지 않는다. 언어의 의미 규칙(바인딩·스코프·타입 규칙)은 챕터 4가, 함수 호출의 네이티브 스택·객체 표현과 GC는 챕터 6이, 최적화된 코드가 하드웨어에서 빠른 이유(분기 예측·캐시)는 챕터 7이 맡는다.
정리
- 컴파일과 인터프리트는 언어의 속성이 아니라 실행 엔진이 조합하는 전략이다. 현대 런타임은 파싱·바이트코드 컴파일·해석·JIT를 한 프로세스 안에서 조합한다.
- 변환 파이프라인의 각 산출물(토큰열·AST·IR·바이트코드·기계어)은 다음 단계가 필요로 하는 정보를 담은 인터페이스이고, 무엇을 버려도 되는지는 소비자가 결정한다.
- "언어가 느리다"는 진단이 아니다. 어느 단계가 무엇을 관찰해 어떤 결정을 내렸는지 — 관찰할 산출물과 로그를 고르는 것이 이 챕터가 만드는 능력이다.
확인 문제
1. 동료가 "우리 서비스는 JavaScript라서 느린 거고, 컴파일 언어로 바꾸면 빨라진다"고 주장한다. 이 인트로의 관점에서 이 주장의 문제를 지적하고, 결론을 내리기 전에 확인해야 할 것을 두 가지 제시하라.
정답과 해설
"컴파일 언어"라는 분류가 이미 부정확하다 — 실행 전략은 언어가 아니라 구현이 정하고, V8은 바이트코드 컴파일과 JIT 컴파일을 조합한다. 확인할 것: (1) 워크로드의 수명과 상태 — 짧은 프로세스라면 JIT 워밍업 전 구간이, 장수명 서버라면 최적화된 코드가 실제 성능이므로, 어느 국면을 측정했는지부터 확인한다. (2) 병목의 위치 — 시간이 실제로 코드 실행에 쓰이는지(그중에서도 최적화가 유지되는지, 역최적화가 반복되는지), 아니면 I/O·GC·알고리즘에 쓰이는지 프로파일로 확인한다. 언어 교체는 이 관찰들이 "실행 전략이 병목"을 가리킬 때만 의미 있는 후보다.
2. 린터가 어떤 코드에 "이 변수는 초기화되지 않은 채 사용될 수 있다"고 경고했는데, 실제 실행에서는 문제가 재현되지 않는다. 파이프라인 지도에서 이 경고를 만든 단계는 무엇이고, "경고가 틀렸다"고 결론 내리기 전에 어떤 가능성을 고려해야 하는가?
정답과 해설
경고는 실행이 아니라 정적 분석 단계의 산출물이다 — 분석기는 AST나 CFG 위에서 실행을 근사하므로, 실제로는 도달하지 않는 경로까지 "가능하다"로 취급할 수 있다. 고려할 가능성: 분석기가 경로 조건의 상관관계를 추적하지 못해 안전한 코드를 오진했을 수도 있고(false positive), 반대로 드문 입력에서만 밟히는 진짜 결함을 실행 테스트가 아직 못 밟았을 수도 있다. 즉 "실행에서 재현 안 됨"은 경고를 기각할 증거로 충분하지 않다. 판단 기준은 5.4의 건전성·완전성 프레임이다.
참고 자료
- Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman, Compilers: Principles, Techniques, and Tools 2nd ed. (2006) — 변환 파이프라인 전체의 표준 서술. 챕터 5의 개념 지도로 활용한다.
- Keith D. Cooper, Linda Torczon, Engineering a Compiler 3rd ed. (2022) — 파이프라인 각 단계의 설계 판단을 구현자 관점에서 다룬다. 본문 네 편의 공통 참조다.
- V8 blog, Ignition: An Interpreter for V8 — 실제 엔진이 파싱·바이트코드·해석·JIT을 조합하는 구조의 1차 사례. 5.3의 예습 자료다.