ysw's blog

    JavaScript Engine

    25 Dec 2018 |

    이 글은 https://velog.io/@godori/JavaScript-%EC%97%94%EC%A7%84-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-mdjowmjlcb에서 발췌한 내용이고 고급 개발자가 되기 위해서는 엔진이 어떻게 돌아가는지를 이해해야하기 때문에 자바스크립트 엔진이 어떤식으로 돌아가는지 공부하기위해 가져오고 정리하였습니다.

    자바스크립트 엔진이란?

    JS 코드를 실행하는 프로그램 또는 인터프리터를 말합니다.

    자바스크립트 엔진의 종류

    자바스크립트는 웹 브라우저뿐만 아니라 Node.js, Electron, React Native 등의 프로젝트와 그 밖의 다양한 곳에서 동작합니다.

    V8

    C++로 작성되었으며, 구글이 개발한 오픈소스입니다. Google Chrome, Electron, Node.js에서 사용합니다.


    <내용>



    자바스크립트 엔진 파이프라인

    자바스크립트 엔진들이 소스 코드를 기계어로 만들기까지 공통적으로 수행하는 과정을 살펴봅시다.


    <내용>


    먼저, 자신이 작성한 자바스크립트 소스 코드에서부터 시작합니다. 자바스크립트 엔진은 소스 코드를 파싱해서 Abstract Syntax Tree(AST)로 만듭니다. 그리고 AST를 바탕으로, 인터프리터는 바이트 코드를 생성합니다. 여기까지가 자바스크립트로 작성된 코드를 실제로 엔진이 실행하는 부분입니다.
    코드를 더 빠르게 실행하기 위해, 바이트코드는 프로파일링된 데이터와 함께 최적화 컴파일러(optimizing compiler)로 보내집니다. 이곳에서는 프로파일링 데이터를 기반으로 매우 최적화 된 기계어를 생성합니다. 만약 정확하지 않은 결과가 나왔다면 다시 deoptimizes하여 바이트 코드로 되돌립니다.

    인터프리터/컴파일러 파이프라인

    인터프리터가 코드를 해석하고, 최적화 할 때 주요 자바스크립트 엔진들 사이에 어떤 차이가 있는지 알아봅시다. 일반적으로는 다음과 같은 공통된 파이프라인을 가집니다.


    <내용>


    인터프리터(interpreter)

    : 최적화되지 않은 바이트코드(bytecode)를 빠르게 생성합니다.

    최적화 컴파일러(optimizing compiler)

    : 매우 최적화된 기계어 코드(machine code)를 약간 시간을 들여서 생성합니다.
    이 과정에서 바이트코드는 중간 언어(IR, intermediate representation)입니다. 만약 interpreter 모드라면 바이트코드를 하나씩 읽어서 실행하고, JIT 모드라면 바이트 코드를 기반으로 컴파일하여 수행합니다.
    바이트코드(Bytecode, portable code, p-code)는 특정 하드웨어가 아닌 가상 컴퓨터에서 돌아가는 실행 프로그램을 위한 이진 표현법이다. 하드웨어가 아닌 소프트웨어에 의해 처리되기 때문에, 보통 기계어보다 더 추상적이다.
    JIT 컴파일(just-in-time compilation)또는 동적 번역(dynamic translation)은 프로그램을 실제 실행하는 시점에 기계어로 번역하는 컴파일 기법이다. 이 기법은 프로그램의 실행 속도를 빠르게 하기 위해 사용된다.

    V8에서 처리하는 방법



    <내용>


    V8의 세부 파트는 이름 그대로 8기통 자동차 엔진이 떠오르는 네이밍을 하고 있습니다. 과정은 위의 공통 파이프라인과 거의 흡사합니다. 여기서 인터프리터는 Ignition이라고 부르며, 코드를 점화하여 바이트 코드를 생성 및 실행합니다. 바이트코드가 실행될 때 인터프리터는 프로파일링 데이터를 수집하여 나중에 실행 속도를 빠르게 할때 사용합니다.
    가령, 특정 함수를 자주 사용한다고 해봅시다. 그래서 이 함수가 뜨거워지게 되면, 바이트코드와 프로파일링 데이터를 TurboFan이라고 부르는 최적화 컴파일러로 보내서 식혀줍니다. 그리고 이곳에서 프로파일링 된 데이터를 기반으로 매우 최적화된 기계어 코드를 만들어냅니다.

    뜨거워진다(getting hot)의 의미?

    자주 반복돼서 수행된다는 뜻입니다.
    최근의 JS 엔진들은 일괄적으로 최적화를 적용하는 JITC가 아닌 Adaptive Compilation방식을 택하고 있습니다. 이는 반복 수행되는 정도에 따라 서로 다른 최적화를 적용하는 것입니다. 처음에 모든 코드는 인터프리터에 의해 바이트 코드로 변환되지만, 자주 반복되는 부분이 발견되면 여기에 대해서만 JITC를 적용하는 식입니다.

    처리 방법의 차이점과 공통점

    왜 어떤 엔진은 더 많은 최적화 컴파일러를 갖고 있을까요?
    바로 트레이드 오프(trade-offs) 때문입니다. 인터프리터는 바이트코드를 빠르게 생성할 수 있지만 효율적인 코드가 아닙니다. 반대로 최적화 컴파일러는 시간이 조금 더 걸리지만 훨씬 효율적인 기계 코드를 생성합니다. 따라서, 어떤 엔진은 여러 개의 최적화 컴파일러를 선택함으로써 복잡해지는 비용을 감수하고 이러한 인터프리터와 컴파일러 사이의 균형을 필요에 따라 세부적으로 제어할 수 있도록 한 것입니다.
    결국, 자바스크립트 엔진마다 구체적인 최적화 과정은 차이가 있으나, 파서와 인터프리터/컴파일러가 포함된 동일한 아키텍쳐로 구성된 것을 알 수 있습니다.

    JS 엔진이 객체 모델을 구현하는 방법

    객체의 정의

    객체는 JS 명세에 따르면 String으로 된 키와 이것으로 접근할 수 있는 값들을 가지고 있는 딕셔너리입니다. 이 키는 단순히 [[value]]에 맵핑되는 것 뿐만 아니라 속성 값(property attributes) 이라고 하는 스펙에도 매핑됩니다.


    <내용>




    속성 값(property attributes)

    객체는 기본적인 속성 값으로 [[Writable]], [[Enumerable]], [[Configurable]] 상태를 가집니다.


    <내용>


    Writable: 할당연산자를 통해 값을 바꿀 수 있는지를 기술합니다.
    Configurable: 이 속성 기술자는 해당 객체로부터 그 속성을 제거할 수 있는지를 기술합니다.
    Enumerable: 해당 객체의 키가 열거 가능한지를 기술합니다.
    어떤 객체나 속성이든 Object.getOwnPropertyDescriptor API를 이용해 이 값들에 접근할 수 있습니다.



    
    const object = { foo: 42 };
    Object.getOwnPropertyDescriptor(object, 'foo');
    // => {value: 42, writable: true, enumerable: true, configurable: true}
    





    JS의 배열

    배열은 조금 다르게 처리하는 특별한 객체라고 생각하면 됩니다. 객체와 다른 배열만의 특징은 다음과 같습니다.

    인덱스(index)가 존재한다.

    배열 인덱스는 무엇일까요? 그것은 제한된 범위가 있는 정수입니다. JS 명세에 따르면, 배열은 2³²-1 개 까지의 아이템을 가질 수 있습니다. 따라서 배열 인덱스는 0 부터 2³²-2 까지의 범위에서만 인덱스로 유효한 정수 값입니다.

    길이(length) 정보를 가집니다.

    length property는 마치 마법 같은 것이라고 할 수 있습니다. 배열에 아이템을 추가하면 length property는 저절로 늘어나기 때문입니다! 이 작업은 사실 엔진에서 자동으로 해주는 겁니다.


    
     const array = ['a', 'b'];
     array.length; // 2
     array[2] = 'c';
     array.length; // 3
     





    JS 엔진에서 배열을 다루는 방법

    기본적으로 객체와 비슷합니다. 배열은 인덱스를 포함하여 모두 string 키를 가집니다.
    아래 그림을 보면 인덱스인 '0' 은 'a'라는 값을 가지며, 값을 바꿀 수 있고(Writable), 열거 가능하고(Enumerable), 삭제 가능(Configurable) 합니다. 또 다른 프로퍼티인 length 의 값은 1이며, 값을 바꿀 수 있지만 열거와 삭제가 불가능 합니다.


    <내용>

    배열에 새로운 아이템을 추가하게 되면, JS엔진은 length의 속성 값 중 [[value]]를 자동으로 증가시킵니다.


    <내용>



    엔진의 프로퍼티 접근 최적화

    Shape == Hidden Class


    
    function logX(obj){
          console.log(obj.x);
    }
    
    const obj1 = { x:1, y:2 };
    const obj2 = { x:3, y:4 };
    
    logX(obj1);
    logX(obj2);
    



    동일한 프로퍼티 x와 y를 string 키로 가지는 두 객체가 있습니다. 이를 객체의 모양(shapes)이 똑같다고 합니다.
    함수 logX를 통해 두 객체 각각에서 같은 프로퍼티 x 에 접근한다고 합시다. JS 엔진은 프로퍼티 접근 시에 모양이 같은 점을 이용하여 최적화를 합니다.


    <내용>


    객체의 키 x, y는 각각의 속성 값(property attributes)을 가리킵니다. 예를 들어 x 프로퍼티에 접근하게 되면 JS엔진은 Object에서 x 키를 찾은 다음, 해당하는 속성 값을 불러오고 [[Value]] 값을 반환할 것입니다.
    여기서 5와 6 같은 데이터는 어디에 저장될까요?
    모든 객체 별로 정보를 저장하게 되면 낭비입니다. 만약 비슷한 모양의 객체가 더 많이 생긴다면, 그만큼의 중복할 발생할 것이고 필요없는 메모리 사용이 늘어나기 때문입니다.
    그래서 JS 엔진은 직접 값을 저장하는 방법 대신 다음 방법을 사용합니다.


    <내용>


    우선 엔진은 따로 Shape라는 곳에 프로퍼티 이름과 속성 값들을 저장합니다. 하지만 여기에 [[value]] 값 대신 JSObject의 어디에 값이 저장되어 있는지에 대한 정보인 Offset 을 Property information으로 가집니다.


    <내용>


    즉, 같은 모양을 가진 모든 JSObject는 동일한 Shape 인스턴스를 가리키게 됩니다. 이제 각 객체에는 고유한 값만 저장되므로, 더 이상 중복되지 않습니다. 그리고 같은 모양으로 생긴 더 많은 오브젝트가 있다 하더라도 오로지 하나의 Shape만 존재합니다.
    Shape의 정체 모든 JS엔진은 최적화를 위해 이 Shape를 사용하는데, 실제로 이것들을 Shape라고 부르지는 않습니다. 여기에 대해서 많은 연구가 존재하는데, 우리가 이제껏 Shape라고 불렀던 이것은 학술 용어로는 사실 Hidden Classes라고 부릅니다.
    혼란스럽겠지만, Structure 정도로 이해하면 편할 것입니다. 하지만 SpiderMonkey에서 부르는 방법처럼 그냥 Shape라고 생각해도 좋습니다.
    Shape에 새로운 객체를 추가하기 (Transition chains) 이런 Shape가 있다고 합시다.


    
    const o = {};
    o.x = 5;
    o.y = 6;
    



    새로운 프로퍼티를 추가할 때, JS 엔진은 어떻게 새로운 Shape를 찾을까요? JS 엔진은 내부에 transition chains라고 하는 Shape를 만듭니다.
    먼저, 비어있는 객체인 o가 있으며, 이는 비어있는 Shape를 가리키고 있습니다.


    <내용>


    여기에 5라는 값을 가진 x라는 프로퍼티를 추가하게 되면, 비어있던 Shape에서 x를 프로퍼티로 갖고 있는 새로운 Shape로 이동(transition)하게 됩니다. 다음과 같이 JSObject의 값이 추가되고, offset은 0입니다.


    <내용>


    새로운 프로퍼티 y를 추가해도 비슷하게 동작합니다. Shape(x,y)로 이동한 다음 값을 추가합니다.


    <내용>


    하지만 이런 방법을 모든 테이블에 항상 적용했다가는 많은 메모리 낭비를 일으키겠지요. 그래서 실제로 엔진은 이렇게 동작하지 않습니다.
    실제로 엔진에서 동작하는 방법 엔진은 추가되는 새로운 프로퍼티 정보를 저장하고, 이전 Shape로 가는 링크를 제공합니다. 만약 o.x를 찾을 때 값이 Shape(x,y) 에 없다면 이전의 Shape(x)에 가서 찾는 겁니다.


    <내용>


    두 객체에서 동일한 Shape를 사용하는 경우 (Transition Tree) 만약에 두 객체에서 동일한 Shape 를 사용한다면 어떻게 될까요? 먼저 하나의 객체 a 에 x = 5 라는 값을 가진 프로퍼티가 있다고 합시다.


    <내용>


    이번엔 객체 b에서 y라는 프로퍼티를 추가할 경우 Shape(empty)에서 가지를 뻗어 새로운 Shape(y) 를 만들게 됩니다. 결국 2개의 체인에 3개의 Shape를 가진 트리 체인이 생성됩니다.


    <내용>


    그렇다면 마치 java의 Object처럼 모든 객체의 트리를 거슬러 올라가면 무조건 Shape(empty)에 도달하는 걸까요? 꼭 그렇지는 않습니다.


    
    const obj1 = {};
    obj1.x = 6;
    
    const ob2 = {x: 6};
    



    ojb2 와 같이, JS에서는 Object Literal을 사용하여 시작부터 프로퍼티를 갖고 생성하도록 할 수 있기 때문입니다. 따라서 Shape(empty)가 아닌, 서로 다른 Root Shape가 생성됩니다.


    <내용>


    이 방법은 transition chain을 짧게 하고, 객체를 리터럴로부터 생성하여 보다 효율적입니다.
    이번엔 이런 상황을 한번 봅시다. point는 x,y,z를 3차원 공간의 좌표로 가지는 객체입니다.


    
    const point = {};
    point.x = 4;
    point.y = 5;
    point.z = 6;
    



    앞서 배운 것에 따르면, 총 3개의 Shape가 메모리에 생성 될 것입니다. (empty Shape 제외)


    <내용>


    만약 이걸 사용하는 프로그램에서 x 프로퍼티에 접근한다고 하면, JS 엔진은 가장 마지막에 생성된 Shape(x,y,z)부터 링크드 리스트를 따라올라가 맨 위에 있을 x를 찾아야 합니다.
    객체의 프로퍼티가 더 많을수록, 그리고 이 과정을 자주 반복한다면 프로그램은 상당히 느려질 것입니다.
    그래서 JS 엔진은 탐색 속도를 높이기 위해 내부적으로 ShapeTable 이라는 자료구조를 추가합니다. 이는 딕셔너리 형태로, 각각의 Shape를 가리키는 프로퍼티 키를 저장하고 있습니다.


    <내용>


    잠깐, 그럼 기껏 Shape가 나온 이유가 없는데요? 사실 엔진은 최적화를 위해 또 다른 방법인 Inline Cache(IC) 라는 것을 Shape에 적용합니다. Inline Cache에 대해서는 다음 포스트에서 알아보겠습니다.

    가져온곳: https://velog.io/@godori/JavaScript-%EC%97%94%EC%A7%84-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-mdjowmjlcb
    https://velog.io/@godori/JavaScript-%EC%97%94%EC%A7%84-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-2-pujpqum2ji