JS와 컴파일

12542 단어 JavaScriptJavaScript

이 글은 크롬 자바스크립트 엔진 V8을 기준으로 작성했다.

서문

나는 “어휘적 스코프(lexical scope)가 무엇인가”에 대한 호기심으로 구글을 켰다가 삼천포로 빠져서 이 삽질을 시작했다.

결론부터 말한다.
어휘적 스코프는 어휘 분석(lexical analysis) 과정에서 정의된 스코프다. 그게 끝이다.

“그러면 어휘 분석은 뭔데?”

이건 컴파일을 어느 정도 알아야만 해결된다.

컴파일(Compile)

컴파일은 번역이다.
C, JavaScript 같은 고급 언어를 컴퓨터가 실행할 수 있는 언어로 바꾼다.

개발자가 이런 식으로 개발을 할 순 없는 노릇이다.
기계어와 고급 언어 사이를 조율할 중개자는 꼭 필요하다.

JIT(Just-In-Time) 컴파일

크롬은 자바스크립트를 실행하기 직전에 필요에 따라 컴파일한다.
이를 JIT 컴파일이라고 부른다.

사실 인터프리터처럼 그때그때 통으로 기계어로 번역해서 하는 방식이 빠르다고 할 수는 없다.
그러면 당연한 의문이 하나 생긴다.

“그런데 실행 직전마다 컴파일을 하는 게 빠른가? 매번 컴파일하면 그게 더 시간 걸리지 않나?”

그래서 캐시가 등장한다.

V8 uses just-in-time compilation (JIT) to execute JavaScript code. This means that immediately prior to running a script, it has to be parsed and compiled — which can cause considerable overhead. As we announced recently, code caching is a technique that lessens this overhead. When a script is compiled for the first time, cache data is produced and stored. The next time V8 needs to compile the same script, even in a different V8 instance, it can use the cache data to recreate the compilation result instead of compiling from scratch. As a result the script is executed much sooner.
...
Producing cache data comes at a certain computational and memory cost. For this reason, Chrome only produces cache data if the same script is seen at least twice within a couple of days. This way Chrome is able to turn script files into executable code twice as fast on average, saving users valuable time on each subsequent page load.
V8은 JS를 실행할 때 JIT 컴파일을 합니다.
즉, 스크립트를 실행하기 직전에 파싱과 컴파일을 하기 때문에 오버헤드를 많이 일으킬 수 있는데요.
이런 오버헤드를 줄이기 위해 코드 캐싱을 합니다.
최초로 스크립트를 컴파일하면 캐시 데이터를 만들어서 저장합니다. 그 이후에 V8이 똑같은 스크립트를 컴파일할 땐 다른 V8 인스턴스에서 캐시를 가져오기 때문에 처음부터 컴파일하지 않고, 컴파일 결과를 바로 내보낼 수 있습니다. 따라서 스크립트를 더 빠르게 실행할 수 있습니다.
...
캐시를 생성하려면 계산 과정과 메모리 비용이 발생합니다. 따라서 크롬은 이틀 내에 동일한 스크립트를 봤을 때에만 캐시를 생성합니다. 이렇게 하면 크롬은 스크립트 파일을 평균 2배 이상 빠르게 실행 가능한 코드로 바꾸기 때문에 페이지 로딩 시 유저의 시간을 아낄 수 있습니다.

컴파일을 할 때 자주 쓰이는 변수, 함수 등의 코드를 hot하다고 표현한다.
예컨대 while문을 써서 특정 변수를 100000번 사용하는 코드가 있다고 치자.
이 변수가 딱히 큰 변화를 가지지 않는다면 굳이 100000번마다 굳이 새로 할당을 할 필요는 없다.
따라서 이런 코드는 뜨거운 코드이며 최적화 대상이다.

V8의 프로파일러 스레드는 코드가 뜨거워지면 TurboFan(최적화 컴파일러)를 시켜서 해당 코드를 네이티브 코드로 컴파일한다.
대다수의 JS 정규 표현식 또한 irregexp engine을 사용해서 네이티브 코드로 컴파일한다.
이렇게 해서 V8은 런타임에 가용 메모리를 할당한다.

장점만 있는 기술은 없는데, JIT 컴파일도 단점이 있다.

V8 블로그에서는 다음과 같이 소개한다.

But in some situations it can be desirable to run V8 without allocating executable memory:
Some platforms (e.g. iOS, smart TVs, game consoles) prohibit write access to executable memory for non-privileged applications, and it has thus been impossible to use V8 there so far; and
disallowing writes to executable memory reduces the attack surface of the application for exploits.
...
V8’s new JIT-less mode is intended to address these points. When V8 is started with the --jitless flag, V8 runs without any runtime allocation of executable memory.
어떨 땐 메모리 할당 없이 실행하는 게 바람직할 수도 있는데요.
일부 플랫폼(iOS, 스마트 TV, 게임 콘솔 등)은 가용 메모리에 접근할 수 없습니다. 즉, V8를 사용할 수 없습니다.
가용 메모리 작성을 금지하면 어플 악용을 위한 공격 표면(attack surface)을 줄일 수 있거든요.
...
V8의 JIT-less 모드는 이를 위한 모드입니다. jitless 플래그로 V8을 시작하면 런타임에 가용 메모리를 할당하지 않고 사용합니다.

다시금 말하면 아래와 같다.

  • 가용 메모리에 접근하기 때문에 공격 표면이 생긴다.
  • 성능으로는 좋지만 메모리와 CPU 사이클을 추가로 요구해서 무겁다.

JIT와 대비되는 개념으로는 AOT(Ahead-of-Time) 컴파일이 있다.
(JIT 컴파일한 네이티브 코드를 HTTP 캐시에 저장한 뒤에 사용.)

JIT와 AOT의 가장 큰 차이는 컴파일을 브라우저에서 하느냐(JIT), 서버에서 하느냐(AOT)다.
사실 브라우저 입장에선 서버에서 미리 컴파일해서 건네주면 실행 속도가 더 빠르다.
대신 AOT는 서버 입장에서는 빌드 시간이 길어지고, 미리 번역해서 주는 거라 파일의 크기도 커진다.

이제 컴파일 순서에 대해 간단하게 짚어보자.

어휘 분석(Lexical Analysis)

렉싱(Lexing)이라고 부르기도 한다.
이 단계를 담당하는 곳을 어휘 분석기(Lexical Analyzer) | 스캐너(Scanner) | 렉서(Lexer) | 토크나이저(Tokenizer)라고 부르곤 한다.

어휘 분석은 소스 코드를 하나의 문자열로 보고 문자를 차례대로 스캔해서 문법적으로 유의미한 최소 단위(토큰이라고 부른다)들로 하나하나 쪼개는 작업이다.

예를 들어 아래를 보자.

let a = b + 10;

위 코드는 아래와 같은 토큰으로 나뉘어진다.

일반 형태 토큰특수 형태 토큰
alet
b=
10+
;

한 글자마다 다 분할하는 게 아니라 기준을 가지고 분할한다.
공백, 연산자, 세미콜론, "", let, const같은 keyword(지정어) 등..

※ 지정어(keyword)란?
그 언어체계에서 의미를 가진 단어를 뜻한다.
일반적으로 지정어는 예약어(reserved word, 식별자로 사용 못하게 등록한 단어)를 겸한다.
(단, 지정어 !== 예약어다.)

예시

let은 지정어 & 예약어라서 식별자(변수 이름)으로 사용할 수 없다.

var let도 파싱 에러는 나지만 사용은 가능하다.
하지만 척 봐도 에러라고 띄우는 걸 굳이 사용할 일은 없을 것이다.

이마저도 use strict 모드를 쓰면 차단된다.

어휘 분석을 통해 생성된 토큰은 고유 번호와 고유 값을 가지며 구문 분석할 파서에게 (토큰 번호, 토큰 값) 순서쌍을 전달한다.
이 때 index값을 가지고 전달된다. (파싱 과정에서 빠르게 찾고 처리하기 위해서다.)
그리고 자바스크립트는 이 때 식별자(변수, 함수 등)의 어휘적 범위(lexical scope)를 정해준다.

구문 분석(Syntax Analysis)

흔히 파싱(parsing)이라고 부르는 작업이다.
토큰을 입력받아서 처리하는데 이 때 문법 에러(SyntaxError)가 있으면 에러 메시지를 출력한다.

예시

세미 콜론 말고 콜론을 적으니 SyntaxError: Unexpected token ' : '이 발생한다.
해당 토큰은 표준 토큰이 아니기 때문에 에러가 나는 것.

입력들이 올바르면 구문 구조(syntatic structure)를 만든다.
모든 구문 검사가 정상처리되면 구분자, 지정어 등을 제거하고 필요한 정보만 담아서 트리로 만든다.
이런 트리를 AST(abstract syntax tree, 추상 구문 트리), 혹은 의미 트리(semantic tree)라고 부른다.
AST는 고수준 언어와 기계어 사이인 중간 언어이다.
우리가 아는 TypeScript, ESLint, 프리티어, BABEL 등도 AST를 사용한다.

예시

const a = 1;

위의 코드는 아래와 같은 AST로 만들어진다.

const a:number = 1;

타입스크립트는 아래와 같은 AST가 만들어진다.

중간 언어 생성(intermediate code generation)

AST를 받아서 중간 언어인 Byte code를 생성하고 의미 검사(semantic checking)을 실행한다.
이 때 중요한 작업이 타입 체크다.
예컨대 문자열을 할당하는 변수에 숫자를 할당하려고 하는지 등을 검사하는 것.

※ 단, 자바스크립트는 동적 타입 언어라서 런타임, 그러니까 실행중에 타입을 체크하고 에러를 낸다.

Byte code는 Java 컴파일러가 JVM으로 만드는 중간 언어이기도 하다. (컴퓨터가 바로 이해할 수 있는 저수준 언어가 아니다.)

  • 여담 1
    AST도 중간 언어이고, Byte code도 중간 언어이다.
    왜 고수준 언어 -> 중간 언어 -> 중간 언어 -> 저수준 언어를 쓸까?
    V8 엔진은 초기에는 AST까지 변환한 뒤에 바로 기계어로 해석해서 사용했으며 효율은 좋았다고 한다. 그러나 효율이 좋으면 메모리 점유가 많을 수 있다.
    핸드폰에서도 크롬을 사용하게 됐는데 크롬이 핸드폰 메모리를 많이 잡아먹는 문제가 생겼고, 이를 해결하기 위해 현재의 아키텍처로 리팩토링하게 됐다.

  • 여담 2
    TypeScript의 경우 ByteCode가 아니라 JavaScript로 변환한다.

실행

Ignition은 크롬이 쓰는 인터프리터다.
Ignition이 Bytecode를 받아서 실행한다.

  • 여담
    옛날에는 파싱 이후 컴파일 과정을 메인 스레드에서 전담했으나, 요즘에는 백그라운드 스레드에서 컴파일을 맡고 있다.
    top-level code, 즉시 실행 함수(IIFE)는 이 때 백그라운드 스레드에서 컴파일하며, 그 외 함수들은 메인 스레드에서 맡는다.

코드 최적화(code optimization)

참고로 크롬의 V8 엔진은 멀티 스레드로 돌아간다.

  • 메인 스레드 : 우리가 아는 그 JS 메인 스레드
  • 컴파일 스레드 : 코드를 최적화하고 메인 스레드를 돌아가게 한다.
  • 프로파일러 스레드 : 어떤 메소드가 사용자 시간을 많이 쓰는지 보고 런타임에게 알린다. 터보팬은 이를 보고 코드를 최적화한다.

터보팬 : JS를 실행하며 수집한 정보를 기반으로 최적화 코드를 만드는 컴파일러

옛날에는 풀코드젠, 크랭크샤프트라는 컴파일러를 사용했다.
2017년부터는 이그니션 + 터보팬으로 변경.
2021년부터 이그니션 + 스파크플러그 + 터보팬으로 변경.

Since version 41, Chrome has supported parsing of JavaScript source files on a background thread via V8’s StreamedSource API. This enables V8 to start parsing JavaScript source code as soon as Chrome has downloaded the first chunk of the file from the network, and to continue parsing in parallel while Chrome streams the file over the network. This can provide considerable loading time improvements since V8 can be almost finished parsing the JavaScript by the time the file has finished downloading.
크롬은 41 버전부터 V8의 StreamedSource API로 백그라운드 스레드의 js 파싱을 지원했습니다.
이를 통해 V8은 크롬이 네트워크에서 파일의 첫 번째 청크를 다운로드하는 순간부터 js 코드 파싱을 시작하고, 크롬이 스트리밍하는 동안에도 병렬적으로 파싱할 수 있었죠.
덕분에 파일 다운로드가 끝날 쯤에는 파싱도 거의 동시에 끝나서 로딩 시간을 단축했습니다.

However, due to limitations in V8’s original baseline compiler, V8 still needed to go back to the main thread to finalize parsing and compile the script into JIT machine code that would execute the script’s code. With the switch to our new Ignition + TurboFan pipeline, we are now able to move bytecode compilation to the background thread as well, thereby freeing up Chrome’s main-thread to deliver a smoother, more responsive web browsing experience.
하지만 V8의 베이스라인 컴파일러의 한계로 인해, V8은 여전히 메인 스레드로 돌아가서 파싱을 마무리하고 스크립트를 JIT 머신 코드로 컴파일해서 실행할 필요가 있었습니다.
이번에 우리는 Ignition + TurboFan 파이프라인으로 전환하면서 바이트 코드 컴파일을 백그라운드 스레드에게 넘겨줄 수 있게 되었고, 덕분에 메인 스레드는 더 부드럽고 응답성이 좋은 브라우징 환경을 제공하게 됐습니다.

2021년에는 비최적화 컴파일러 스파크플러그를 추가로 도입해서 이그니션 - 스파크플러그 - 터보팬 구조로 업데이트했다.

스파크 플러그는 네이티브 코드를 만들지만 js 실행중에 수집한 코드와 의존성을 가지지 않기 때문에 이그니션과 터보팬 사이에서 균형을 잡는다고 어필한다.
또한 바이트코드로 컴파일을 하며 바이트코드같은 중간 표현intermediate representation(혹은 중간 언어)를 만들지 않고 기계 코드로 직접 컴파일하기 때문에 빠르다고 한다.

we start to hit limitations when optimising our interpreter. V8’s interpreter is highly optimised and very fast, but interpreters have inherent overheads that we can’t get rid of; things like bytecode decoding overheads or dispatch overheads that are an intrinsic part of an interpreter’s functionality.
우리는 인터프리터 최적화의 한계에 부딪혔습니다.
V8의 인터프리터는 최적화가 매우 잘 되어있고, 아주 빠릅니다.
하지만 인터프리터는 우리가 해결할 수 없는 고유한 오버헤드를 발생시킵니다. 예를 들면 바이트코드 디코딩 오버헤드나 디스패치 오버헤드는 인터프리터 기능의 본질적인 부분이기 때문이죠.

With our current two-compiler model, we can’t tier up to optimised code much faster; we can (and are) working on making the optimisation faster, but at some point you can only get faster by removing optimisation passes, which reduces peak performance.
현재의 2-컴파일러 모델로는 더 빠른 최적화를 계층화할 수 없습니다.
최적화 속도를 올리도록 할 수는 있지만, 최고 퍼포먼스를 깎고 최적화 경로를 제거해야 속도를 올릴 수 있는 상황입니다.

Enter Sparkplug: our new non-optimising JavaScript compiler we’re releasing with V8 v9.1, which nestles between the Ignition interpreter and the TurboFan optimising compiler.
스파크플러그 도입 : 이그니션 인터프리터와 터보팬 최적화 컴파일러 사이에 넣은, V8 9.1버전부터 도입된 비최적화 JS 컴파일러

Sparkplug is designed to compile fast. Very fast. So fast, that we can pretty much compile whenever we want, allowing us to tier up to Sparkplug code much more aggressively than we can to TurboFan code.
스파크플러그는 컴파일을 빨리 하도록 디자인됐습니다. 엄청 빨리요. 너무 빠른 나머지 우리가 원하면 언제든 컴파일할 수 있기 때문에 터보팬 코드보다 더 적극적으로 코드를 계층화할 수 있습니다.

크롬은 2021년에 메모리 관련 이슈를 개선했다고 발표했다. 하지만 여전히 프로세스와 메모리를 많이 잡아먹고 싫어하는 사람이 많다.

실행 컨텍스트

실행 문맥은 렉싱하고 파싱하고 실행하는 과정을 추적하며 코드가 실행되도록 도와주는 환경이다.

프론트엔드가 이런 부분까지 알아야하냐고 물으면 "아니다."라고 답하고 싶다.
이건 그냥 순전히 내가 호기심을 못 참고 공부하다가 정리한 것이다.
이런 거 공부할 시간에 다른 거 공부하는 게 취직이나 커리어에는 더 도움될 것 같다.

V8 블로그
네이버 웨일의 크롬 컴파일 분석 및 웨일 개선

좋은 웹페이지 즐겨찾기