v8환경에서 javascript 최적화 하기

October 14, 2019

이글은 외국 포스트 (alligator.io/js/v8-engine) 을 번역한 글입니다.

V8엔진에서 javascript 변환 과정을 살펴보면, 상수와 같은 고정된 변수 등은 bytecode로 변환하되, 동적으로 변화하는 코드의 경우 ( function 포함) 인터프리터 (v8내에서는 ignition 으로 불립니다.) 를 통해 최적화 compiler (v8 내에서 turbofan) 을 통해서 코드를 최적화하게 됩니다. 이 포스트에서는 v8엔진 내에서의 코드 최적화에 대해서 살펴보고자 합니다.

Parsing javascript

compiler의 첫번째 임무는 코드를 구문별로 분석하는 단계입니다. 이러한 구문 분석에는 두가지 방법이 있습니다.

  • Eager (전체 구문분석): 각 줄을 즉시 전체 구문 분석을 실행합니다.
  • Lazy (사전 파싱) : 즉시 필요한 구문을 파싱하고, 나머지는 이후 파싱합니다.

어떤 코드가 더 좋을까요? 다음 예시를 살펴보겠습니다.

// 구문을 즉시 분석합니다.
const a = 1;
const b = 2;

// 현재에는 필요없는 구문이므로 이후에 분석합니다.
function add (a, b) {
	return a + b;
}

// 관련 구문이 필요하므로 다시 돌아가서 파싱합니다.
add(a, b);

해당 구문을 보면, 변수 선언의 경우 즉시 파싱되나, 다음 함수에는 Lazy법칙이 적용됩니다. 그러나 그다음 구문에서 함수 호출로 인하여 다시 add 함수로 돌아가 파싱을 하게 됩니다.

이러한 경우는 함수를 바로 파싱하는 것이 퍼포먼스상에 더 이득을 가져올 수 있습니다. 다음과 같이 코드를 수정할 수 있습니다.

// 구문을 즉시 분석합니다.
const a = 1;
const b = 2;

// 즉시실행 함수를 통해 해당 구문도 바로 분석합니다.
(function add (a, b) {
	return a + b;
})();

add(a, b);

사용하는 코드에 대한 파싱및 최적화를 검사하는 optimize-js 를 이용하여 살펴보면, lodash의 경우 해당 최적화가 잘되어 있는 것을 확인할 수 있습니다.

함수 중첩 피하기

다른 파싱 팁은 함수에 다른 함수를 중첩하지 않아야 합니다.

// 안좋은 예입니다.
function sumOfSquares(a, b) {
  // 아래 square는 return구문에 의해서 더 늦게 파싱될 여지가 있습니다.
  function square(num) {
    return num * num;
  }

  return square(a) + square(b);
}

이에 최적화 방법은 다음과 같습니다.

function square(num) {
  return num * num;
}

// good way
function sumOfSquares(a, b) {
  return square(a) + square(b);
}

sumOfSquares(a, b);

위와 같은 경우 한번만 Lazy 파싱을 실행합니다.

Function inlining

Chrome은 때때로 구문을 다시 작성하여 분석하게 되는데, 다음과 같은 예가 그렇습니다.

const square = (x) => { return x * x }

const callFunction100Times = (func) => {
  for(let i = 100; i < 100; i++) {
    // 해당 함수는 100번 실행됩니다.
    func(2)
  }
}

callFunction100Times(square)

위 코드는 v8엔진에 의해 다음과 같이 실행됩니다.

const square = (x) => { return x * x }

const callFunction100Times = (func) => {
  for(let i = 100; i < 100; i++) {
		
    return x * x
  }
}

callFunction100Times(square)

v8에서는 기본적으로 func 를 제거하고, square 함수를 inline화 시킵니다.

Funciton inline gotcha

const square = (x) => { return x * x }
const cube = (x) => { return x * x * x }

const callFunction100Times = (func) => {
  for(let i = 100; i < 100; i++) {
    func(2)
  }
}

callFunction100Times(square)
callFunction100Times(cube)

이번에는 square 함수를 100번 호출한 후, cube 함수를 100번 호출합니다. cube 함수를 인라인화 하려면, 이전에 호출한 square 함수를 먼저 인라인화 했으므로, callFunction100Times 의 최적화를 초기화 해야 합니다. 이와 같은 경우, square 함수는 cube 함수보다 빠른것 처럼 보이나, 최적화 해제 단계로 인해 실행시간이 길어집니다.

...