[웹 프론트엔드 인터뷰] #1. 자바스크립트 엔진은 어떻게 동작하나요?
웹 프론트엔드 개발자라면 어떤 회사에서, 어떤 프로젝트를 맡아서 개발을 하든지 누구나 궁금해 할 수 있고 누구나 물어볼 만 한 질문들을 골라서 이에 대한 나의 나름대로 최선의 대답을 정리해 보는 포스팅을 연재해 보려고 한다.(반응이 좋으면 계속 연재해 볼 생각)
현업에 계신 많은 웹 프론트엔드 개발자들 그리고 새로운 꿈을 찾아 취업을 준비하는 학생 및 예비 개발자 분들에게 조금이나마 도움이 되기를 바라는 마음으로 글을 작성해 본다. 아직 2년차 웹 프론트엔드 개발자라 잘못된 지식을 알고 있거나, 깊이가 부족할 수 있는데 이러한 부분에 대해서는 날카로운 피드백을 해 주시면 정말 감사할 것 같다.
JavaScript 엔진은 어떻게 동작하나요?
자바스크립트 엔진은 자바스크립트 코드를 실행하는 프로그램 혹은 인터프리터를 의미한다. 자바스크립트 엔진은 표준적인 인터프리터로 구현될 수도 있고 자바스크립트 코드를 바이트 코드로 컴파일하는 just-in-time 컴파일러로 구현할 수도 있다. 많은 사람들이 자바스크립트 엔진으로 V8 프로젝트를 알고 있지만, 이 외에도 SpiderMonkey(최초의 JS 엔진, 파이어폭스), Rhino(모질라 재단에서 관리, Java), Chakra(MS, IE, Edge) 등 여러가지 프로젝트가 있다.
가장 대표적인 V8을 중심으로 설명을 해 보도록 한다. V8 엔진의 경우 구글이 만들었으며, 오픈소스이고, C++로 이루어져 있다. 구글 크롬 브라우저에서 현재 이 엔진을 사용하고 있다. 덧붙여서 Node.js의 런타임으로도 V8 엔진은 사용된다. V8은 웹 브라우저 내부에서 자바스크립트의 수행 속도 개선을 목표로 고안되었으며, 인터프리터를 사용하는 대신 자바스크립트 코드를 더 효율적인 머신 코드로 번역한다. just-in-time 컴파일러로 구현되어 있으며 코드 실행 시 자바스크립트 코드를 머신 코드로 컴파일한다. 이는 SpiderMonkey나 Rhino와 같은 다른 엔진에서도 마찬가지로 사용된다.
자바스크립트 엔진 파이프라인
자바스크립트 엔진 파이프라인을 살펴보자. 자바스크립트 엔진은 소스 코드를 AST(Abstract Syntax Tree, 추상 구문 트리)로 파싱한다. AST를 기반으로 인터프리터는 바이트코드를 생산한다. 이 과정을 빠르게 하기 위해 바이트코드는 최적화 컴파일러로 프로파일링 데이터와 함께 보내지고, 최적화 컴파일러는 매우 최적화된 기계 코드를 만들어낸다. 만약 최적화 가정이 틀린 경우 deoptimize을 진행하여 다시 인터프리터로 돌아간다.
위의 이미지는 일반적인 자바스크립트 엔진의 파이프라인이며, V8의 경우 인터프리터는 Ignition 이라고 불린다. Ignition은 바이트코드를 만들고 실행하는 역할을 한다. 바이트 코드를 실행하면서, 프로파일링 데이터를 모으고 실행을 가속화한다. 함수가 일정 수준 이상으로 가동되면, 생성된 바이트 코드와 프로파일링 데이터는 TurboFan(최적화 컴파일러)으로 옮겨진다.
자세하게 설명하진 않겠지만, SpiderMonkey나 Chakra, JSC 등의 다른 자바스크립트 엔진도 기본적인 구조는 비슷하며 최적화 컴파일러가 조금 더 많이 있다. 왜 어떤 엔진은 최적화 컴파일러가 많은 반면, 다른 엔진은 그렇지 않을까? 이 부분은 트레이드 오프(trade-off) 부분이다. 인터프리터가 바이트코드를 빠르게 생산할 수 있으면, 일반적으로 바이트코드는 아주 효율적이지는 않다. 최적화 컴파일러는 반대로 더 오래 걸리는 경우, 더 효율적인 머신 코드를 만들어 낸다. 따라서 더 많은 최적화 컴파일러를 가진 엔진은 더 많은 시간/효율의 특성을 가지고 더 정제된 코드를 만들어 내는 방향을 선택한 것이다. 기억해야 할 것은 모든 자바스크립트 엔진은 공통적으로 파서와 인터프리터/컴파일러 파이프라인을 가지고 있다는 것이다.
두 개의 엔진, 그리고 최적화
전통적으로 V8에서 두 개의 엔진을 사용한다.
- 풀코드젠(full-codegen) : 간단하고 매우 빠른 컴파일러로서 단순하고 상대적으로 느린 머신 코드를 생산한다.
- 크랭크샤프트(Crankshaft) : 좀 더 복잡한 just-in-time 최적화 컴파일러로서 고도로 최적화된 코드를 생산한다.
또한 V8 엔진은 내부적으로 여러 개의 쓰레드를 사용한다.
- 메인 쓰레드는 독자들의 예상대로 코드를 가져와서 컴파일하고 실행한다.
- 컴파일을 위한 별도 쓰레드가 있어서 이 쓰레드가 코드 최적화를 하는 동안 메인 쓰레드는 쉬지 않고 코드를 수행한다.
- 프로파일러 쓰레드는 어떤 메서드에서 사용자가 많은 시간을 보내는지 런타임에 알려주어 크랭크샤프트가 이들을 최적화 할 수 있게 해준다.
- 그 외 가비지 컬렉터 스윕을 처리하기 위한 몇 가지 쓰레드가 있다.
자바스크립트 코드를 처음 수행할 때 V8은 풀코드젠을 이용하여 파싱된 자바스크립트 코드를 변형 없이 직접 머신 코드로 번역한다. 이를 통해 머신 코드의 실행을 매우 빠르게 시작할 수 있다. V8은 이와 같이 중간 바이트코드를 이용하지 않기 때문에 인터프리터가 필요가 없다. 코드가 얼마간 수행된 다음 프로파일러 쓰레드는 충분한 데이터를 얻게 되고 어떤 메서드를 최적화할 지 알 수 있게 된다. 그러면 크랭크샤프트가 다른 쓰레드에서 최적화를 시작한다. 크랭크샤프트는 자바스크립트의 추상구문트리를 고수준 정적단일할당(static single-assignment, SSA)으로 번역하는데 이를 하이드로젠(Hydrogen)이라고 한다. 대부분의 최적화는 이 수준에서 이루어진다.
자바스크립트 객체 모델(Object Model)
자바스크립트 엔진이 자바스크립트 모델을 어떻게 구현했는지 간략하게 살펴보도록 하자. 주요 엔진들이 자바스크립트 객체에 접근하는 속도를 높이기 위해 사용한 방법은 공통점이 많다.
ECMAScript 규격은 기본적으로 모든 객체를 dictionary(속성 값을 문자열로 매핑)로 정의한다.
자바스크립트 프로그램에서 속성에 접근하는 것이 가장 일반적인 방법이며, 자바스크립트 엔진은 속성에 빠르게 접근하는 것이 중요하다. 예를 들어 object.y 에 접근한다고 했을 때, 자바스크립트 엔진은 JSObject에서 키 'y'를 찾아 해당 속성 값을 로드한 후 [[Value]]를 반환한다.
이러한 속성값은 메모리 어느 곳에 저장해야 할까? 만약 JSObject의 일부로 저장한다면 나중에 이 shape의 object를 다시 사용하게 될 때 property 이름이 동일한 object가 반복되므로, property 이름과 attribute를 포함하는 전체 딕셔너리 구조를 JSObject 자체에 저장하는 것은 메모리 낭비가 될 수 있다. 그러므로 최적화로서 자바스크립트 엔진은 object의 shape를 별도로 저장한다.
아래 이미지에서 Shape는 [[Value]]를 제외한 모든 property 이름과 attribute들을 포함한다. 대신 Shape는 JSObject에 있는 value의 offset 값을 가지고 있어서, 자바스크립트 엔진이 value의 위치를 알 수 있다. 같은 shape를 가진 모든 JSObject는 같은 Shape 인스턴스를 정확히 가리킨다. 이제 모든 JSObject는 이 object에 고유한 value만 저장하면 된다.
여러 개의 object를 사용할 때 이런 구조의 장점이 극대화 된다. 아무리 많은 object가 있어도 같은 shape이 있는 한 shape와 property 정보를 한 번만 저장하면 된다. 모든 자바스크립트엔진은 shape를 최적화에 사용하지만 엔진에 따라서 부르는 용어는 다 다르다. 학술 논문에서는 Hidden Classes 라고 하고, V8은 Map, Chakra는 Types, 그리고 SpiderMonkey는 Shapes 라고 부른다.