자바스크립트 엔진에서 자바스크립트를 읽는 방법

HTML과 CSS는 각자 DOM(Document Object Model)을 만들어서 렌더 트리를 구성한다고 합니다. 그런데 Javascript 로 쓰여진 코드를 읽을 때에는 다르고 복잡한 과정이 존재합니다. 이 글에서 다루어봅니다.

💡 이 글은 토큰과 식별자, 키워드라는 용어가 무엇인지 안다고 가정하고 진행됩니다.

먼저 간단하게 자바스크립트가 읽혀지는 과정을 그림으로 보여드립니다. 사진이 좀 어려워서 저도 처음에는 뭔가 했습니다. 천천히 설명하고 있으니 걱정마세요! 여기서 Network & Cache & Worker 부분은 우리가 작성한 코드라고 이해하시면 됩니다.

function greeting() {
  return "hi"
}

이런 코드를 만들었다고 합니다. 이 코드가 바로 소스코드이지요. 자바스크립트 엔진은 이 코드를 컴퓨터가 읽을 수 있는 코드로 변환하는 과정을 거칩니다.

1. 바이트 스트림 디코더 (scanner)

HTML 파서는 HTML 소스에서 script 태그를 만나게 됩니다. 가져온 코드는 바이트 스트림이며, 바이트 스트림 디코더(byte stream decoder)가 코드를 다운로드를 받으면서 동시에 디코딩을 진행합니다. 자바스크립트 코드를 이진 데이터로 변환하는 과정이지요.

아래의 코드를 마주쳤다고 합니다.

function greeting() {
  return "hi"
}

이 코드는 바이트 스트림 디코더를 통해 아래와 같이 변합니다.

바이트 스트림 디코더는 우리가 작성한 코드를 이진 데이터로 변환합니다.

0066은 f, 0075는 u, 006e는 n, 0063은 c, 0074는 t, 0069는 i, 006f는 o, 006e는 n

그리고 공백 문자열이 나오면 function이라는 단어가 되겠네요. 이 funtion이라는 것은 자바스크립트 키워드 입니다.

2. parser

파서는 이진 데이터를 읽어서 의미를 가지는 토큰으로 해독(decode)합니다.

[{value:'function', type:'keyword'},{value:'greeting', type:'identifier'}, {value:'(', type:'Puntuator'},
{value:')', type:'Puntuator'}, ...]

위에서 작성한 소스 코드는 이런 식으로 만들어질 것입니다. 키워드, 식별자, 연산자 등의 토큰 별로 구성이 되어 있습니다.

자바스크립트 엔진은 전처리 파서와 파서 두개의 파서를 사용합니다. 전처리 파서는 입력된 토큰이 구문 에러가 있는지 검토합니다. 이렇게 함으로써 파서가 코드를 처리하다가 에러를 만나게될 경우를 줄일 수 있습니다.

에러가 없다면 파서는 바이트 스트림 디코더에서 입력받은 토큰을 가지고 노드를 만듭니다. 이 노드를 가지고 AST (Abstract Syntax Tree 또는 추상 구문 트리)를 구성합니다.

3. AST (Abstract Syntax Tree)

제가 작성했던 코드에 대한 추상적인 tree를 만드는 부분입니다.

https://astexplorer.net/

위 사이트에서 제가 입력한 코드에 대한 AST 결과를 볼 수 있습니다. 아래와 같이 나오더군요.

body:  [
    FunctionDeclaration  {
        type: "FunctionDeclaration"
        start: 0
        end: 37
        id: Identifier  = $node {
        type: "Identifier"
        start: 9
        end: 17
        name: "greeting"
        }
        expression: false
        generator: false
        async: false
        params: [ ]
        body: BlockStatement  {
        type: "BlockStatement"
        start: 20
        end: 37
        body:  [
        ReturnStatement {type, start, end, argument}
        ]
        }
    }
]

이런 식으로 greeting이라는 식별자를 가지고 있으며, BlockStatement이고 반환값은 어떻다는 tree를 만들어줍니다.

4. 인터프리터와 바이트코드

다음은 인터프리터의 차례입니다. 인터프리터는 AST(추상 구문 트리)를 따라가면서 바이트 코드를 생성합니다. 바이트 코드가 완전히 생성되면 AST를 삭제하여 메모리를 청소합니다. 이제야 기계가 직접 다룰 수 있는 뭔가가 나왔습니다.

5. 컴파일러

바이트 코드 자체로도 충분히 빠르다고 할 수 있지만 더 빠르게 만들 필요가 있습니다. 자바스크립트의 성능을 비약적으로 향상시킬 수 있었던 이유는 엔진 내부에서 컴파일 과정 거치기 때문이지요.

Interpreter가 코드를 읽으며 실행합니다. 코드를 수행하는 과정에서 프로파일러가 지켜보며 최적화 할 수 있는 코드를 컴파일러에게 전달해줍니다. 주로 반복해서 실행되는 코드 블록을 컴파일(최적화)하지요. 그리고 원래 있던 코드와 최적화된 코드를 바꿔줍니다. 코드를 우선 인터프리터 방식으로 실행하고 필요할 때 컴파일 하는 방법을 JIT(Just-In-Time) 컴파일러 라고 부릅니다. 크롬의 V8 엔진을 포함해 Mozilla의 Rhino, Firefox의 SpiderMonkey도 같은 방법을 사용합니다.

결론은, 자바스크립트는 실행되는 플랫폼에 따라 인터프리팅과 컴파일이 혼합되어 사용됩니다. 이 방식은 자바스크립트의 성능을 크게 향상시켰습니다.

바이트 코드를 실행하면서 부가적인 정보를 수집합니다. 어떤 로직이 반복적으로 실행되는지 어떤 데이터가 자주 사용되는지에 대한 정보입니다. 타입 피드백(Type Feedback) 이라고 부르죠. 예를 들어, 어떤 함수가 수십번 실행된다면 그 함수를 최적화함으로써 속도를 향상시킬 수 있습니다!

타입 피드백은 바이트 코드와 함께 최적화 컴파일러(Optimizing Compiler)에게 보내집니다. 최적화 컴파일러는 들어온 입력을 바탕으로 아주 최적화된 기계 코드를 작성합니다. 자바스크립트는 동적 타입 언어입니다. 데이터의 타입이 계속해서 바뀐다는 의미죠. 그런데 자바스크립트 엔진이 어떤 변수가 품은 데이터의 타입을 매번 검토해야 한다면 매우 비효율적이겠죠.

그래서 인라인 캐싱(Inline Caching) 이라는 기법을 사용합니다. 코드를 메모리에 캐시하여 여러번 실행되는 동작에 대해서는 동일한 값을 바로 제공하는 것이죠. 이를테면 어떤 함수가 100번 실행이 되었고 지금까지 항상 같은 결과값을 만들어냈다면 101번째에도 같은 결과를 만들거라고 짐작할 수 있습니다.

6. 인라인 캐싱

자바스크립트에는 두 가지의 특징이 있습니다.

  • 자바스크립트는 프로토타입 기반 언어

  • 자바스크립트는 동적타입 언어

동적이라는 부분은 객체를 생성하면 property 를 추가/삭제를 자유롭게 할 수 있다는 뜻입니다. 프로토타입도 결국에는 object 이기 때문에 내용을 수정하거나 추가/삭제를 자유롭게 할 수 있지요.

자바, C++ 이랑은 다르게 자바스크립트에서는 클래스라는 개념이 존재하지 않습니다. 클래스에서 객체를 생성하면, 클래스에 정의된 속성과 메서드들은 어느 객체든 동일합니다.

하지만 JS에서는 객체를 생성하면, 속성/메서드를 언제든 추가 및 삭제가 가능합니다.

그러면 객체를 생성할 때마다 변경가능성 때문에 자바스크립트 엔진은 최적화하기 어려울텐데 어떤식으로 해결을 할까요?

자바스크립트 엔진에서는 Shape 라는 개념을 사용합니다.

(1) 동일한 객체는 동일한 Shape을 바라봅니다.

let a = {x : 5, y : 6};
let b = {x : 7, y : 8};

객체를 생성하면 Shape 라는 것을 생성합니다. 객체 프로퍼티가 같으니 같은 모양을 하나 공통으로 두는겁니다.

객체 프로퍼티가 동일한 객체가 여러개 생긴다고 가정할때 여러개 생성하면 중복이 많아지고 메모리 낭비가 될 것입니다. 그래서 이를 최적화 하고자 Shape 를 두는겁니다. (정확히는 property attribute 중복을 최소화합니다.)

Shape 는 해당 property 이름 / 위치정보 / property attribute 를 가지고 있습니다. Shape 에서는 value 속성 대신 offset 이라는 속성을 가지고 있습니다.

같은 Shape 를 가진 모든 자바스크립트 object 는 같은 Shape 를 정확히 가리킵니다.

자바스크립트 object 는 고유한 value 만 저장하면 됩니다. Shape 라고 했는데, 학술논문에서 hidden class 라고 합니다.

(2) 인라인 캐시로 인한 속도차이

// 예제 1
(() => { 
  const han = {firstname: "zzz", lastname: "xxxx"};
  const luke = {firstname: "aaa", lastname: "oooo"};
  const leia = {firstname: "bbb", lastname: "iiii"};
  const obi = {firstname: "ccc", lastname: "uuuu"};
  const yoda = {firstname: "", lastname: "Fuck"};
  const people = [
    han, luke, leia, obi, 
    yoda, luke, leia, obi 
  ];
  const getName = (person) => person.lastname;
  let ts = performance.now()
 
  for(var i = 0; i < 1000000; i++) { 
    getName(people[i & 7]); 
  }
  let te = performance.now();
  console.log("Call..  " + (te - ts) + ' ms'); // Call..  3.244999999878928 ms
})();



// 예제 2
(() => {
  const han = { lastname: "Solo", firstname: "Han", spacecraft: "Falcon"};
  const luke = { firstname: "Luke", lastname: "Skywalker", job: "Jedi"};
  const leia = { firstname: "Leia", lastname: "Organa", gender: "female"};
  const obi = { firstname: "Obi", lastname: "Wan", retired: true};
  const yoda = {lastname: "Yoda"};
  const people = [
    han, luke, leia, obi, 
    yoda, luke, leia, obi];
  const getName = (person) => person.lastname;
  
  let ts = performance.now()
  for(var i = 0; i < 1000000; i++) {
    getName(people[i & 7]);
  }
  let te = performance.now();
  console.log("Call2..  " + (te - ts) + ' ms'); // Call2..  7.424999999784632 ms
})();

예제1 코드가 더 빠르며, 예제2 코드는 느립니다.이유가 뭘까요? property 가 다르다는 것, 이거 하나만으로 성능이 이렇게 차이가 납니다.같은 형태의 객체를 사용할때만 최적화 코드로 인해 빠르게 실행이 됩니다.

ES6 에 클래스 문법을 쓰면 성능상 좋습니다.

type 변경도 일어나지 않게 하면 inline caching 성능을 높일수 있습니다.

(3) 타입 지정은 성능에 영향을 준다

function sum(a,b) {
  return a + b
}
sum(1,2);

위의 예에서의 코드는 숫자 두 개가 입력이 될 것이라 가정됩니다. 가정이 참인 경우, 동적 검토가 필요하지 않고 이미 알고있는 특정 메모리 영역에서 결과를 바로 읽을 수가 있습니다. 가정이 거짓이라면, 최적화된 코드를 버리고 원래의 바이트 코드로 돌아가게 됩니다.

예를 들어, 같은 함수를 다시 실행하여 숫자 대신 문자열을 넘기는 경우를 가정해 봅시다. 자바스크립트는 동적 타입 언어라서 이것이 문법적으로 문제가 없습니다!

function sum(a,b) {
  return a + b
}
sum('1',2);

위의 예에서 sum이라는 함수는 2라는 숫자는 형변환이 되면서 ‘1’이라는 문자열에 붙습니다. 그리고 12(숫자가 아니고 문자열입니다)라는 결과를 만듭니다.

이 경우 최적화전의 바이트 코드를 실행하고 타입 피드백의 정보를 갱신합니다.

따라서 '1'이라는 문자가 잘못 입력이 된 것이라면 불필요하게 메모리가 낭비되는 것입니다. 이래서 타입스크립트를 사용하는 것이 좋아보입니다.


출처
눈에 보이는 자바스크립트 엔진 동작원리
추상 구문 트리(Abstract Syntax Tree)
JavaScript, 인터프리터 언어일까?
자바스크립트 엔진 최적화기법

좋은 웹페이지 즐겨찾기