자바스크립트는 어떻게 동작하는가?

김민기·2022년 8월 31일
0

JavaScript-Study

목록 보기
2/12

자바스크립트 엔진

자바스크립트 엔진이란 자바스크립트 코드를 실행하는 프로그램 혹은 인터프리터를 말한다.
가장 많이 사용되는 브라우저인 크롬은 오픈소스이며 구글에서 C++로 만든 V8 엔진을 사용한다. V8 엔진 뿐만아니라 다른 엔진들도 많이 있다.
List of ECMAScript engines

자바스크립트 엔진 구현 방법

자바스크립트 엔진은 표준적인 인터프리터로 구현될 수도 있고 혹은 자바스크립트 코드를 바이트 코드로 컴파일하는 저스트인타임(just-in-time) 컴파일러로 구현할 수 있다.

V8 엔진

V8엔진은 다른 엔진들과 달리 Node.js의 런타임으로도 사용된다.
V8엔진은 웹 브라우저 내부에서 자바스크립트 수행 속도의 개선을 목표로 처음 고안되었다. 속도 향상을 위해 V8은 인터프리터를 사용하는 대신 자바스크립트 코드를 더 효율적인 머신코드로 번역한다.
저스트인타임 컴파일러를 구현함으로써 코드 실행 시 자바스크립트 코드를 머신 코드로 컴파일하는데, 이는 다른 현대적인 자바스크립트 엔진에서도 마찬가지다. 하지만 V8의 경우 바이트코드와 같은 중간 코드를 생산하지 않는 다.

V8 엔진 컴파일러의 변화

5.9버전 이전에서는 V8에서는 두 개의 엔진을 사용했다.

  • 풀코드젠(Full-codegen) : 최적화되지 않은 코드를 생성하는 빠른 컴파일러
    자바스크립트 코드를 처음 수행할 때, V8은 풀코드젠(Full-codegen)을 이용해서 파싱된 자바스크립트 코드를 변형 없이 직접 머신 코드로 번역한다. 이를 통해 머신 코드의 실행을 매우 빠르게 시작할 수 있게 되는 것이다. 이와같이 V8은 중간 바이트코드를 이용하지 않기 때문에 인터프리터가 필요 없다.
  • 크랭크샤프트(Crankshaft) : 빠르고 최적화된 코드를 생성하는 느린 컴파일러
    풀코드젠(Full-codegen)이 생성한 코드를 크랭크샤프트(Crankshaft)가 검사하여, 최적화가 필요하다고 판단되면 코드를 변경한다. 자바스크립트의 추상구문트리를 고수준 정적단일할당(SSA)로 번역하는데 이를 하이드로젠이라고 부른다. 크랭크샤프트는 또한 하이드로젠 그래프를 최적화하고자 노력하기도 한다.

V8 엔진은 내부적으로 여러 개의 쓰레드를 사용한다.

  • 메인 쓰레드: 코드를 가져와서 컴파일하고 실행하는 곳
  • 컴파일을 위한 쓰레드: 컴파일을 별도의 쓰레드에서 처리하기 때문에 최적화하는 동안 메인 쓰레드는 멈추지 않고 코드를 수행할 수 있다.
  • 프로파일러 쓰레드: 어떤 메소드에서 사용자가 많은 시간을 보내는지 런타임에게 알려주어 크랭크샤프트가 이들을 최적화 할 수 있게 해준다.
  • 가비지 컬렉터를 위한 쓰레드: 가비지 컬렉터 스윕을 처리하기 위한 몇 개의 쓰레드가 존재한다.

크랭크샤프트의 최적화 방법

  1. 인라이닝
    첫 번째 최적화는 미리 가능한 많은 코드를 인라이닝하는 것이다. 인라이닝이란 호출 지점(함수가 호출된 곳의 코드 위치)을 호출된 함수의 내용으로 바꾸는 과정이다. 이러한 단순한 과정으로 이후의 최적화가 더욱 큰 의미를 가지게 된다.

히든 클래스

  1. 히든 클래스
    자바스크립트는 프로토타입 기반의 언어이기 때문에 클래스라는 것은 없으며 객체는 복제 과정을 통해 생성된다. 자바스크립트는 동적 언어이기 때문에 객체가 생성된 이후에도 속성을 쉽게 추가하거나 삭제할 수 있다.
    대부분의 자바스크립트 인터프리터가 딕셔너리 구조와 유사한 구조(해쉬함수 기반)를 이용해 객체 속성 값의 위치를 메모리에 저장한다. 이러한 구조 때문에 자바스크립트의 속성 값을 가져오는 것은 자바나 C# 보다 더 비싼 비용이 들게 된다.
    자바에서는 모든 객체 속성이 컴파일 전에 고정된 객체 레이아웃에 의해 결정되고 런타임에 동적으로 추가되거나 제거할 수 없다. 따라서 속성 값은 고정된 오프셋을 가진 연속적인 버퍼로 저장될 수 있고 오프셋의 길이는 속성 타입에 따라 쉽게 결정될 수 있다.
    하지만 이런것들이 속성 타입이 동적으로 변경될 수 있는 자바스크립트에서는 불가하다.
    딕셔너리를 이용해서 메모리 상에서 객체 속성의 위치를 찾아내는 것은 매우 비효율적인 일이기 때문에 V8에서는 다른 방법을 이용한다. '히든 클래스' 히든 클래스는 자바와 같은 언어에서 사용되는 고정 객체 레이아웃과 유사하게 동작하는데 다만 런타임에 생성된다는 차이점이 있다

    function Point(x, y) {
      this.x = x;
      this.y = y;
    }
    var p1 = new Point(1, 2);

    new Point(1,2) 코드가 실행되면 V8은 C0라는 히든 클래스를 생성한다.

    아직 Point에 아무 속성도 정의되지 않았음으로 C0는 비어 있다.
    첫 번째 구문인 this.x = x가 실행되면 V8은 C0 내부를 기반으로 두번 째 히든 클래스 C1을 생성한다. C1에는 x속성이 들어 있다. 이때 x의 오프셋은 0이 된다. (p1[0])
    V8은 또한 C0를 클래스 전환으로 업데이트하는데 여기에는 만약 x 속성이 Point 객체에 추가되면 히든 클래스 C0에서 C1으로 전환되어야 한다는 내용이 있다.
    두 번째 구문인 this.y = y가 시행될 때도 반복된다. C2라는 히든 클래스가 생성되고 C1 클래스전환이 추가되며 여기에는 y 속성이 Point 객체(C1일 때)에 추가되었다는 내용이 명시되어 있다. 그 다음 히든 클래스는 C2로 변경되어야 하며 포인트 객체의 히든 클래스는 C2로 업데이트 된다.

    히든 클래스 전환은 속성이 객체에 추가되는 순서에 의존적이다.

    function Point(x, y) {
        this.x = x;
        this.y = y;
    }
    var p1 = new Point(1, 2);
    p1.a = 5;
    p1.b = 6;
    var p2 = new Point(3, 4);
    p2.b = 7;
    p2.a = 8;

    p1과 p2에 대해 같은 히든 클래스와 전환이 사용되지 않는다. p1에서는 속성 a가 추가되고 속성 b가 추가된다. p2의 경우 이와 반대의 순서로 추가되고 있다. 따라서 p1과 p2는 서로 다른 히든 클래스를 사용하게 되고 결국 전환 경로도 달라진다.

    이와 같은 경우 히든 클래스의 재사용성을 위해 동적 속성을 같은 순서로 초기화 하는 것이 좋다.

  2. 인라인 캐싱
    인라인 캐싱은 같은 메소드에 대한 반복되는 호출은 같은 타입의 객체에서 이루어진다는 관찰 결과에 의존한다. V8에서 인라인 캐싱은 어떻게 작동할까? V8은 최근 메소드 호추에 파라미터로 전달된 객체 타입의 캐시를 유지하고 이 정보를 이용해 앞으로 파라미터로 넘어올 객체의 타입에 대한 가정을 한다. 만약 V8이 메소드에 전달될 객체 타입에 대한 가정을 잘 할 수 있으면 객체의 속성에 접근할 방법을 알아내는 과정을 수행하지 않아도 됨 그 대신 객체의 히든 클래스에 대해 이전에 찾아서 저장했던 정보를 사용할 수 있다.
    히든 클래스와 인라인 캐싱은 어떤 관련이 있는가? 특정 객체에 메소드가 호출될 때마다 V8엔진은 특정 속성에 접근하기 위한 오프셋을 계산하기 위해 해당 객체의 히든 클래스를 뒤져봐야 한다. 동일한 히든 클래스의 동일한 메소드에 대해 두 번의 성공적인 호출을 마치고나면 V8은 히든 클래스를 찾는 것을 생략하고 단순하게 스스로 해당 객체 포인터에 속성 오프셋을 더해 놓는다. 이후 해당 메소드에 대한 모든 호출에 대해 V8은 히든 클래스는 변하지 않았다 가정하고 이전에 찾아 두었던 오프셋을 이용해 직접 메모리 주소로 점프한다. 이를 통해 실행 속도는 크게 증가한다.

    인라인 캐싱은 같은 타입의 객체가 히든 클래스를 공유하는게 중요한 이유이기도 하다. 만약 타입은 같고 히든 클래스는 다른 두 객체를 만들면 V8은 인라인 캐싱을 사용할 수 없을 것이다.

이그니션과 터보팬

2017년 초 V8.5.9의 배포와 더불어 새로운 실행 파이프라인이 소개되었다. 이 새로운 파이프라인은 더 큰 성능 향상을 가져오며 실제 자바스크립트 응용프로그램에서 현저하게 메모리를 절약할 수도 있다. 새로운 실행 파이프라인은 V8의 인터프리터인 이그니션과 새로운 최적화 컴파일러인 터보팬 위에 만들어졋다.

  • 이그니션은 기존의 풀코드젠을 대체하는 인터프리터이다. 기존의 풀코드젠은 한번에 머신코드로 컴파일 하면서 메모리 사용량이 컸는데, 이 문제를 해결하기 위해 인터프리터 방식으로 돌아와 자바스크립트 코드를 한줄씩 실행할 때마다 바이트 코드로 상태를 컴파일한다. 이를 통해 메모리 사용 감소 뿐만아니라, 파싱하기도 편해졌으며 터보팬에서 최적화를 할 때에 바이트코드만 고려하면 됐기 때문에 터보팬에서의 최적화 성능 또한 좋아졌다고 한다.

  • 터보팬의 경우 이그니션에서 만들어진 바이트 코드를 기반으로, 크랭크샤프트에서 최적화 단계에서 사용하던 히든 클래스와 인라인 캐싱을 통해 최적화를 진행한다.(최적화는 프로파일러 쓰레드가 메인 쓰레드를 감시하다가 일정 기준 이상을 동일한 함수가 호출되면 시작한다.)

V8.5.9 버전 출시 이후, 더 이상 풀코드젠과 크랭크샤프트는 V8에서 자바스크립트 실행에 사용되지 않는다.

마치며

자바스크립트의 기초를 배우면서 V8 엔진이라는 것을 들어는 보았지만 별로 깊게 관심을 가져보지는 않았다. 이번 기회에 자바스크립트를 실행시키는 엔진이 어떻게 동작하는지 알 수 있었고 자바스크립트라는 스크립트 언어가 어떻게 컴파일되고 실행되는지를 알 수 있는 좋은 기회였다. 또한 크랭크샤프트, 터보팬과 같은 자바스크립트 실행의 최적화를 향상시키는 방법에도 알게 되었다.
흥미로웠던 점은 V8이 다른 엔진들과 달리 풀코드젠 컴파일러를 사용해서 바이트코드와 같은 중간 코드를 만들지 않음으로 써 성능향상을 시도 했고 특징이 되었지만 결국은 메모리 문제로 인해 다시 인터프리터로 돌아갔다는 점이다. 성능향상과 최적화를 위해서 끊임 없이 발전해나가고 있는 구글이 부럽다...

출처

How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
자바스크립트는 어떻게 작동하는가: V8 엔진의 내부 + 최적화된 코드를 작성을 위한 다섯 가지 팁
V8엔진의 과거, 현재 구조

0개의 댓글