AST & JavaScript 컴파일러 동작 방식
AST란?
AST를 한국어로 한다면 추상 구문 트리이다.
본인은 어려운 용어를 이해할 때 그 말을 끊어서 뜻을 이해하는데 AST도 그렇게 해보자면,
- 추상
- 구체적인 소스 코드의 모든 세부사항을 담기보다는, 프로그램의 구조와 의미를 간결하게 표현하는 ‘요약적’이고 ‘개념적인’ 성격을 가진다.
- 구문
- 여기서 ‘구문’은 프로그래밍 언어의 문법적 구조, 즉 코드가 어떤 규칙에 따라 배열되어 있는지를 의미한다.
- 변수 선언, 함수 호출, 조건문, 반복문 등 코드의 문장과 그 내부 구성 요소를 말한다.
- 즉, ‘구문’은 코드의 ‘형식’과 ‘구조’를 나타낸다.
- 트리
- ‘트리’는 계층적 구조를 가진 자료구조로, 하나의 루트 노드에서 여러 자식 노드로 가지를 뻗어 나가는 형태를 가진다.
- AST에서는 각 노드가 코드의 한 요소(연산자, 변수, 함수 등)를 나타내며, 이들이 부모-자식 관계로 연결되어 전체 프로그램의 구조를 트리 형태로 표현한다.
→ 결론적으로, AST의 뜻은, ‘프로그래밍 코드의 구조와 의미를 요약적으로 트리 형태로 표현한 자료구조’라고 해석할 수 있다.
컴파일러는 왜 AST를 사용할까?
코드를 곧바로 바이트코드로 변환하면 분명 여러 가지 문제가 발생할 것이라는 가정에서 출발해, 이러한 문제를 해결하기 위해 AST가 도입되었다고 생각했다.
즉, 만약 중간에 AST 단계를 거치지 않고 바로 변환한다면 어떤 한계가 있을지 고민해보고,
그 결과 AST를 사용하는 이유와 필요성을 도출하게 되었다.
1. 코드의 구조와 의미를 명확하게 표현
- AST는 소스 코드의 구조적 의미를 트리 형태로 표현
- 예를 들어, if-else 문이나 반복문, 연산식 등 복잡한 코드 구조도 트리로 분기되어 표현되므로, 인터프리터나 컴파일러가 코드를 논리적으로 분석하고 처리하기에 매우 유리하다.
- 불필요한 문법적 요소(괄호, 세미콜론, 공백 등)는 생략하고, 핵심적인 구문만 간결하게 나타냄
- 코드 분석 및 변환의 오버헤드를 줄인다.
2. 에러 처리 및 진단에 도움
- AST에는 각 코드 요소의 위치 정보 등도 포함될 수 있어, 정확한 에러 메시지와 코드 진단에 활용된다.
3. 최적화
- 중복 연산 및 불필요 코드 제거
- AST는 코드의 논리 구조를 트리로 표현하므로, 중복된 연산이나 불필요한 코드를 쉽게 탐지하고, 최적화가 가능하다.
- 코드 변환 및 리팩토링
- AST는 코드 구조를 명확하게 파악할 수 있어, 코드 변환, 리팩토링, 트랜스파일링 등 다양한 코드 처리 작업에 활용될 수 있다.
컴파일러 동작 방식
전통적인 컴파일러 동작 방식과 JS 컴파일러를 비교하여 설명하겠다.
전통적인 컴파일러(C, C++, JAVA) 동작 과정
- Lexing (어휘 분석)
- 소스코드를 토큰 단위로 분해
- Parsing (구문 분석)
- 토큰을 기반으로 AST를 생성
- Semantic Analysis (의미 분석)
- 타입 체크 등 의미적 오류 검사를 진행
- IR(중간 표현) 생성
- AST를 기반으로 한 IR 생성
- Optimization(최적화)
- 타입 정보 등 정적 분석을 바탕으로 다양한 최적화를 수행
- 코드 생성
- 최적화된 IR을 타켓 머신의 기계어로 변환
- 실행
- 생성된 기계어를 실행
JavaScript 컴파일러의 동작 과정
- Lexing (어휘 분석)
- 소스코드를 토큰 단위로 분해
- Parsing (구문 분석)
- 토큰을 기반으로 AST를 생성
- Bytecode 생성
- AST를 바탕으로 바이트코드(IR) 생성
- 인터프리터 실행
- 바이트코드를 인터프리터로 실행하며 런타임에 타입 등 프로파일링 정보 수집
- JIT 컴파일
- 충분한 프로파일링 정보가 쌓이면 JIT 컴파일러가 동작, 타입 speculation(추측) 기반으로 최적화된 기계어 생성
- OSR/deoptimization
- 타입 추측이 틀리면 OSR(온스택 교체, deoptimization)로 일반 코드로 복귀
- 실행
- 최적화된 기계어를 실행, 필요 시 반복적으로 최적화/복귀 반복
왜 다른가?
JavaScript의 컴파일 과정은 왜 다를까?
정답은 바로 ‘타입’에 있다.
일반 컴파일러는 코드가 입력되면 타입 정보가 명확하게 주어지므로, 각 연산이 어떤 타입의 연산인지 미리 알 수 있고, 이에 맞는 최적화와 기계어 생성을 할 수 있다.
자바스크립트 컴파일러는 코드 입력 시점에는 타입이 정해지지 않아, 먼저 인터프리터로 실행하며 타입 등 런타임 정보를 수집한다. 이후 "이 변수는 대부분 int였다"는 식의 추측(speculation)을 바탕으로 JIT 컴파일러가 최적화된 기계어를 생성한다. 만약 실행 중 타입이 바뀌면, 최적화 코드를 버리고 일반 코드로 돌아가는(deoptimization, OSR exit) 과정을 반복한다.
JavaScript 컴파일 과정 자세히 살펴보기
JIT 컴파일과 OSR 과정을 자세히 살펴보자
JIT 컴파일(Just-In-Time Compilation)
Bytecode를 인터프리터로 실행하면서 자주 실행되는 코드를 런타임에 기계어로 변환하여 실행 속도를 높이는 기술
- JavaScriptCore 등 JS 엔진에서는 프로파일링을 통해 어떤 함수/루프가 많이 실행되는지 파악하고, 일정 임계치에 도달하면 JIT 컴파일러가 동작한다.
- JIT 컴파일러는 수집된 타입 정보와 실행 패턴을 바탕으로 "이 변수는 int일 것이다"와 같은 speculation(추측)을 적용해 최적화된 기계어를 생성한다.
Speculation(추측적 컴파일)의 동작
- 자바스크립트는 동적 타입 언어이므로, 컴파일 시점에 타입을 알 수 없다.
- JIT 컴파일러는 실행 중에 관찰된 타입 정보(예: a, b가 int로만 쓰임)를 바탕으로 "int 덧셈"에 최적화된 코드를 생성한다.
- 실행 중 타입이 바뀌면(예: a가 string이 됨) 기존 최적화 코드는 더 이상 안전하지 않으므로 OSR(On-Stack Replacement)로 일반 코드(인터프리터/비최적화 JIT)로 즉시 전환한다. → OSR exit
OSR(On-Stack Replacement)란?
실행 중인 함수/루프의 중간에 인터프리터(혹은 낮은 단계 JIT)에서 최적화된 JIT 코드로 실행을 갈아타는 기술
- 반대로, Speculation이 틀렸을 때(예상과 다른 타입 등장) OSR Exit을 통해 최적화된 JIT 코드에서 다시 인터프리터나 베이스라인 JIT로 돌아간다.
- 이 과정에서 현재 스택 프레임과 변수 상태를 새 코드 버전에 맞게 변환해 이어서 실행한다.
JIT 컴파일과 OSR은 자바스크립트 엔진이 동적 타입 언어의 한계를 극복하고, 실제로 자주 사용되는 타입과 실행 패턴에 맞춰 코드를 최적화할 수 있게 해준다.
이러한 구조 덕분에 자바스크립트는 빠른 시작과 높은 실행 성능을 동시에 달성할 수 있다.
만약 JIT 컴파일러의 타입 추측이 빗나가더라도, OSR을 통해 안전하게 일반 코드로 복귀할 수 있어 안정성과 성능을 모두 확보할 수 있다.