자바스크립트는 인터프리터 언어일까? 컴파일 언어일까? 이 논쟁은 예전부터 이어진 주제다. YDKJS (You Don't Know JS) 저자는 컴파일 언어라고 생각한다고 결론을 지었다. 그렇게 생각한 이유를 알아보고자 조사한 결과, 나는 이미 제목에서부터 결론을 말했듯이 자바스크립트는 인터프리터 언어면서 동시에 컴파일 언어라는 결론을 내렸다.
요즘날의 자바스크립트는 다양한 엔진들에 의해 동작된다. 그 엔진들이 어떻게 자바스크립트를 실행하는지 살펴보면서 내가 왜 이런 결론을 내렸는지 알아보자.
사전 지식
JIT 컴파일러
전통적인 컴파일 방식이 프로그램을 실행전에 모든 코드를 컴파일하는 대신, JIT(Just-In-Time)은 프로그램 실행 중에 필요한 시점에 코드를 컴파일하여 실행하는 방식이다. 소스코드를 미리 전부 컴파일하지 않고, 실행 시점에 필요한 부분만 즉시 컴파일하여 성능을 최적화한다.
중간 표현 (intermediate representation, IR)
중간 표현(IR)은 컴파일러나 인터프리터가 소스코드를 처리하는 과정에서 내부적으로 사용하기 위해 만들어내는 중간 단계의 코드, 혹은 데이터 구조 형태이다. 소스코드를 직접 기계어로 변환하거나 실행하기 전에, 소스코드를 더 단순하고 추상화된 형태로 변환하여 컴파일러나 인터프리터가 쉽게 분석하고 최적화할 수 있도록 만든다.
V8
크롬의 V8 JavaScript 엔진은 1개의 인터프리터와 3개의 컴파일러로 동작한다. 어떻게 동작하는지 간단하게 살펴보자.
1. Ignition (Bytecode Interpreter)
기존 V8엔진은 모든 자바스크립트 코드에 대해 실행 직전에 JIT 컴파일 하여 네이티브 기계어 코드로 변환하여 속도를 높였다. 처음에는 Baseline 컴파일러를 이용해 빠르게 컴파일했다. Baseline 컴파일러를 통해 컴파일된 코드는 최적화 되지 않았기 때문에, V8의 실행 파이프라인을 통해서 고급 컴파일러인 TurboFan 등에 의해 재컴파일 된다.
이 접근 방식의 문제점은 단 한번만 실행되는 코드도 컴파일 하기 때문에 메모리적으로 낭비가 심했다. 이 오버헤드를 완화하기 위해 기존 Baseline 컴파일러를 대체하기 위한 Ignition이라는 새로운 JavaScript 인터프리터를 만들었다.
Ignition은 자바스크립트 코드를 먼저 간결한 바이트코드로 변환한 뒤, 이 바이트코드를 인터프리터가 실행하는 구조이다.
이와 관련한 발표 영상이 있으니 더 Deep Dive하고 싶은 사람들은 참조하면 좋겠다!
2. Sparkplug (Baseline JIT)
Ignition이라는 인터프리터를 만들어 성능을 개선했지만, 결국에는 인터프리터라는 특성의 한계에 부딪혔다. 그래서 2021년 Ignition과 고급 컴파일러인 TurboFan 사이에서 동작하는 Sparkplug라는 컴파일러를 만들었다.
Sparkplug는 자바스크립트를 컴파일 하는게 아닌, 인터프리터가 미리 만들어 놓은 바이트코드를 컴파일한다. 또한, 중간 표현(IR) 없이, 바이트코드를 한 번만 훑으면서 바로 기계어를 생성한다.
Sparkplug는 복잡한 최적화 과정을 거치지 않고, 단순히 빠르게 실행 가능한 코드를 만든다. 더 복잡한 최적화는 TurboFan이 담당한다.
3. Maglev (Fastest Optimizing JIT)
2023년 또 새로운 컴파일러가 등장했다. Ignition 인터프리터는 실행은 빠르지만 성능은 낮고, Sparkplug는 바이트코드를 거의 즉시 기계어로 변경하지만 최적화 수준이 제한적이고, TurboFan은 가장 고성능의 최적화된 코드를 만들어내지만 컴파일 시간이 오래 걸린다. 이러한 한계를 한 번 더 깨기 위해 Maglev라는 컴파일러를 만들었다.
Maglev는 구글의 V8 JavaScript 엔진에 도입된 최신 JIT 컴파일러로, 기존의 Sparkplug와 TurboFan 사이에 위치한다. Maglev의 목표는 “충분히 좋은” 최적화된 코드를 매우 빠르게 생성하는 것이다.
4. TurboFan (High-Level Optimizing JIT)
TurboFan은 V8의 최고급 최적화 컴파일러다. 가장 복잡하고 정교한 최적화를 수행해서 최고 성능의 기계어 코드를 만들어낸다.
하지만 TurboFan의 단점은 컴파일 시간이 오래 걸린다는 것이다. 그래서 정말 자주 실행되는 "HOT 🔥" 코드에만 적용된다. V8은 프로파일링을 통해 어떤 코드가 자주 실행되는지 추적하고, 그런 코드만 TurboFan으로 재컴파일한다.
이렇게 V8은 4단계의 실행 파이프라인을 통해 자바스크립트 코드의 특성에 맞는 최적의 실행 전략을 선택한다.
Webkit
Webkit도 마찬가지로 1개의 인터프리터와 3개의 컴파일러로 동작한다.
1. LLInt (Low Level Interpreter)
LLInt는 WebKit 자바스크립트 엔진의 첫 번째 실행 단계이다. 자바스크립트 코드를 바이트코드로 변환한 후 바이트코드를 한 줄씩 읽어 즉시 실행한다. 만약 특정 함수나 코드가 여러번 실행되면 ("HOT 🔥" 코드) Baseline JIT 에 의해 컴파일 시키는것을 시도한다.
2. Baseline JIT
LLInt에서 코드가 여러 번 실행되는 것이 감지되면 (함수 6번 , 특정 명령 100번) 바이트코드를 거의 그대로 기계어로 변환한다. 컴파일 속도가 매우 빠르기 때문에 실행 지연이 거의 없다. 최적화를 하지 않는다.
3. DFG JIT (Data Flow Graph JIT)
함수가 Baseline JIT에서 함수가 66번 이상 호출되거나 특정 명령이 1000번 이상 실행되면 동작한다. 바이트코드를 중간 표현(IR)로 변환한다. DFG JIT는 코드의 데이터 흐름을 분석해, 불필요한 연산을 제거하고, 변수 타입을 추론하며, 레지스터 할당 등 다양한 컴파일러 최적화를 수행한다.
4. FTL JIT (Faster Than Light JIT)
WebKit에서 사용하는 가장 고급 단계의 컴파일러이다. 자바스크립트 코드를 네이티브 코드로 변환하여 실행 속도를 극대화 한다.
SpiderMonkey
Firefox의 SpiderMonkey도 비슷한 방식으로 인터프리터와 여러 JIT를 전략적으로 선택하여 사용한다.
결론
제일 많이 쓰이는 3개의 엔진의 동작 방식을 살펴본 결과, 모든 엔진이 인터프리터방식과 컴파일 방식을 혼합하여 사용하고 있었다. 처음에는 인터프리터로 빠르게 시작하고, 필요에 따라 점진적으로 컴파일해서 성능을 높인다.
사실 "자바스크립트는 인터프리터 언어냐 컴파일 언어냐"라는 질문 자체가 현대의 자바스크립트 실행 환경에서는 적절하지 않다고 생각한다. 자바스크립트, 정확히 ECMAScript는 언어의 명세만을 제공하며, 실제 구현은 각 엔진에 따라 다양하게 이루어지기 때문이다.
따라서 현대의 자바스크립트는 "인터프리터와 컴파일러를 혼합해서 사용하는 언어"라고 표현하는 게 더 정확해보인다.
참조
- Interpreter (computing) - Wikipedia
- Firing up the Ignition interpreter · V8
- Maglev - V8’s Fastest Optimizing JIT · V8
- Sparkplug — a non-optimizing JavaScript compiler · V8
- Introducing the WebKit FTL JIT
- Introducing the B3 JIT Compiler
- The Baseline Interpreter: a faster JS interpreter in Firefox 70 – Mozilla Hacks - the Web developer blog
- Warp: Improved JS performance in Firefox 83 – Mozilla Hacks - the Web developer blog