LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


V8의 히든 클래스 이야기

자바스크립트가 되어 그 기분을 헤아릴 수 있다면

안녕하세요? LINE Fukuoka의 프론트엔드 엔지니어 Yonehara입니다.

저는 프론트엔드 개발자로서 아직 웹 브라우저나 자바스크립트의 기분을 헤아려줄 만큼의 경지에는 올라가지 못했다고 생각합니다. 이로 인해 저희 서비스 사용자에게 원하는 만큼의 쾌적한 UX를 제공해 드리지 못할 때가 있어 괴로울 때가 있습니다. 그나마 다행인 것은, 우리가 이 자바스크립트의 속마음을 상당 부분 분석적으로 들여다볼 수 있다는 점입니다. Google이나 Mozilla가 그들의 자바스크립트 엔진 코드를 공개하고 있고, 여러 곳에서 엔진 설계에 대해 풀어 설명하고 있고, 또 트레이싱이나 프로파일링을 할 수 있는 수단도 넉넉히 준비되어 있기 때문이지요. 이번 포스팅에서는 여러분도 잘 아시는 Chrome의 자바스크립트 엔진인 V8에서 최적화를 위한 장치로 도입한 히든 클래스에 대해 살펴볼까 합니다.

동적 타이핑 언어로 구현되는 사전형 객체에 대한 제약

자바스크립트는 동적 타이핑(dynamic typing) 언어입니다. 즉 코드를 실행할 때의 상황에 따라 데이터의 타입이 정해지는데, 그러다보니 객체의 프로퍼티에 접근하는 속도 면에서는 정적 타이핑 언어의 코드와 비교했을 때 불리해질 수 있습니다. 정적 타이핑(static typing) 언어를 사용하면, 가변길이 배열(variable-length array)과 같은 동적인 데이터 타입을 사용하지 않는 이상, 프로퍼티(혹은 구조체의 멤버)의 메모리 오프셋을 컴파일 시에 결정할 수 있습니다. 이론상으로는 그렇습니다. 따라서 프로퍼티를 선언할 때 오프셋 값을 어딘가에 저장해 둔 뒤, 각 프로퍼티의 값이 필요할 때 오프셋 값을 그대로 사용하면 되는 것이지요.

반면에 데이터 타입이 동적으로 정해지는 코드로는 프로퍼티의 메모리 오프셋을 컴파일할 때 결정하는 것은 불가능합니다. 프로퍼티를 선언했을 때의 프로퍼티의 데이터 타입이나 순서가 실제로 프로퍼티 값을 접근할 때는 달라질 수 있기 때문이지요. 따라서 프로퍼티를 선언했을 때의 오프셋 값은 참조할 수 없게 되고, 이에 대한 대책이 따로 없는 한, 프로퍼티 값을 읽어야 할 때마다 프로퍼티를 찾아내야 합니다. 즉 동적 탐색(dynamic lookup)이 필요합니다. 자바스크립트처럼 사전형(dictionary) 형태의 객체를 이용한다면, 객체의 프로퍼티를 읽어들일 때 비용이 발생하며 이 비용은 구현 방식에 따라 달라집니다.

V8은 어떻게 동적 탐색을 회피할까?

V8은 히든 클래스를 이용하여 동적 탐색(Dynamic Lookup)을 회피하고 있습니다. 한마디로 말하자면, 프로퍼티가 바뀔 때 각각 그 프로퍼티의 오프셋을 업데이트한 뒤 그 값을 가지고 있는 방식입니다.

히든 클래스에는 다음과 같은 특징이 있습니다.

  • 객체는 반드시 하나의 히든 클래스를 참조한다.
  • 히든 클래스는 각 프로퍼티에 대해 메모리 오프셋을 가지고 있다.
  • 동적으로 새로운 프로퍼티가 만들어질 때, 혹은 기존 프로퍼티가 삭제되거나 기존 프로퍼티의 데이터 타입이 바뀔 때는 신규 히든 클래스가 생성되며, 신규 히든 클래스는 기존 프로퍼티에 대한 정보를 유지하면서 추가적으로 새 프로퍼티의 오프셋을 가지게 된다.
  • 히든 클래스는 프로퍼티에 대해 변경이 발생했을 때 참조해야 하는 히든 클래스에 대한 정보를 갖는다.
  • 객체에 새로운 프로퍼티가 만들어지면, 현재 참조하고 있는 히든 클래스의 전환 정보를 확인한 후, 현재 프로퍼티에 대한 변경이 전환 정보의 조건과 일치하면, 객체의 참조 히든 클래스를 조건에 명시된 히든 클래스로 변경시킨다.

위 내용을 바탕으로 히든 클래스의 생성 과정을 살펴보겠습니다. 객체가 생성될 때는 반드시 히든 클래스가 생성됩니다. 즉 다음과 같은 코드가 실행되면 히든 클래스가 생성되며, obj 객체는 생성된 히든 클래스와 연결됩니다. 이 단계의 히든 클래스를 편의상 C0라 부르기로 하겠습니다. 이때 히든 클래스에는 아직 아무런 정보도 없는 상태입니다. obj 객체가 프로퍼티를 갖고 있지 않으니까요.

var obj = {};

객체를 생성한 후 다음과 같이 obj.x 프로퍼티에 값을 대입합니다. 이때 또 다른 히든 클래스인 C1이 생성되며, x 프로퍼티에 대한 오프셋 값을 갖습니다. obj 객체는 원래 C0 클래스를 참조했었는데, x라는 프로퍼티가 생긴 후에는 C1 클래스를 참조합니다. 그리고 여기가 재미있는 부분인데요, 'x를 추가하면 참조하는 히든 클래스가 C1으로 전환(transition)된다'는 정보가 C0클래스에 추가됩니다.

var obj = {};
obj.x = 1;

이번에는 다음과 같이 obj.y 프로퍼티에 값을 대입합니다. 그러자 다시 새로운 히든 클래스, C2가 생성되고, obj 객체에 대응하는 히든 클래스가 C1에서 C2로 바뀝니다. C1 클래스에 y 프로퍼티의 오프셋 값을 추가하는 것이 아닙니다. 그리고 마찬가지로 C1 클래스에는 'y를 추가하면 참조하는 히든 클래스가 C2로 변경된다'는 정보가 더해집니다.

var obj = {};
obj.x = 1;
obj.y = 1;

이와 같은 히든 클래스간의 연쇄 고리가 만들어진 후, 가령 obj.y 프로퍼티에 대한 접근이 있었다고 가정해 보겠습니다. 이때 obj 객체와 연결되어 있는 히든 클래스를 찾습니다. 이때의 참조 히든 클래스는 어떤 클래스가 될까요? 우리의 예로서는 C2 클래스가 됩니다. C2 클래스에 적혀 있는 y 프로퍼티의 오프셋을 이용해서 y의 값을 참조하게 됩니다. 이것이 바로 V8이 사용하는 동적 탐색을 회피하는 기본적인 방식입니다.

히든 클래스의 효율을 높여주는 '전환 정보'란?

앞서 'C0 클래스에 전환(transition) 정보가 기록된다'고 했는데요, 자세히 살펴보겠습니다. 우선 다음의 자바스크립트 코드를 예로 보겠습니다.

function Person(name) {
 this.name = name;
}

var foo = new Person("yonehara");
var bar = new Person("suzuki");
console.log(bar.name);

var foo = new Person("yonehara");가 실행된 시점에는 다음과 같은 정보가 존재하며, foo 객체는 C1 클래스를 참조하고 있는 상태입니다.

  • 히든 클래스 C0
    • 프로퍼티의 오프셋 값 없음
    • 'name을 추가하면 C1으로 전환된다'는 정보가 있음
  • 히든 클래스 C1
    • name의 오프셋 값

이어서 bar 객체가 생성되는데, 이 과정에서 bar 객체가 C0 클래스를 참조했을 때 거기에는 이미 'name을 추가하면 C1클래스로 전환된다'는 정보가 있습니다. 이에 따라 bar 객체는 다음에 name을 추가할 때 새로운 히든 클래스를 생성하지 않고 C1 클래스를 참조하여, 자기 자신과 C1 클래스를 연결시킵니다. 이렇게 해서 히든 클래스를 쓸데없이 늘리지 않으면서 오프셋을 효율적으로 관리하는 목적을 달성합니다.

Chrome에서 동일 히든 클래스 여부 확인하기

여러 객체가 참조하고 있는 히든 클래스가 동일한지 아닌지를 알아보는 방법이 있습니다. V8의 디버거 겸 셸인 d8을 이용할 수도 있지만, Chrome의 개발자 도구를 통해 더욱 쉽게 확인할 수 있습니다.

먼저 Chrome을 실행시킨 뒤, 다음의 순서를 따라해 보세요.

  1. Chrome에서 개발자 도구를 실행시킨다.
  2. 콘솔에서 다음 코드를 실행한다.
    function Person(name) {
     this.name = name;
    }
    
    var foo = new Person("yonehara");
    var bar = new Person("suzuki");
  3. 스냅샷을 찍는다.
  4. Memory 탭에서 'Person'으로 검색한다.

위 코드를 실행한 직후의 메모리 상태가 다음과 같이 표시될 것입니다. 스냅샷에서 보시는 바와 같이, 두 Person 객체의 map이라는 항목의 ID가 동일하네요. 히든 클래스는 코드 상에서 Map이라 불립니다. 이 ID가 일치한다는 것은 두 객체가 참조하는 히든 클래스가 동일하다는 뜻입니다.

그럼 foo 객체에 다른 프로퍼티, job을 추가해 보겠습니다. 이론상으로 foo 객체는 새로운 히든 클래스를 참조하며, 기존의 히든 클래스는 새로운 히든 클래스를 참조하게 됩니다.

function Person(name) {
 this.name = name;
}

var foo = new Person("yonehara");
var bar = new Person("suzuki");
foo.job = "frontend";

다시 한번 스냅샷을 찍어 보겠습니다. 보시다시피 이제 각 Person 객체의 map ID가 달라졌습니다. 즉, foo 객체와 bar 객체가 서로 다른 히든 클래스를 참조하고 있다는 뜻이지요. map 항목 아래를 보시면 transition이라는 항목이 있습니다. 아쉽게도 조건까지는 알 수 없지만 bar 객체가 참조하는 히든 클래스(ID: Map @242289)가 내부적으로 foo의 히든 클래스(ID: Map @242289)를 참조한다는 사실도 확인할 수 있습니다.

히든 클래스 기반의 최적화 과정

히든 클래스라는 개념은 겉으로 드러나지 않지만, 프로퍼티 값을 참조할 때 발생하는 속도 저하를 막기 위한 하나의 장치임을 알 수 있습니다. 원래는 이 히든 클래스를 바탕으로 인라인 캐싱(Inline Caching)이라는 또다른 최적화 처리가 진행됩니다. 인라인 캐싱은 V8만의 고유한 방식은 아니며, 값이나 함수 등의 결과를 상황에 따라 캐싱하는 기법으로서 오래전부터 있었습니다. 히든 클래스가 있다고 해도 유지 중인 오프셋을 사용해서 메모리를 참조하는 작업은 여전히 남아있는데, 이 작업이 Hot(같은 조건 하에서 여러 번 반복됨)할 때, 결과를 캐싱해서 전달하자는 것이 인라인 캐싱의 개념입니다.

인라인 캐싱에 관해서도 자세히 적고 싶지만... 안타깝게도 알아볼 시간이 조금 부족했네요. 다음을 기약할까 합니다.