네이티브 애드온으로 Node.js의 성능을 향상 시키는 방법

원문
https://medium.com/developers-writing/how-to-get-a-performance-boost-using-node-js-native-addons-fd3a24719c85#.buw8o2rrn

이상한그림

아마도 천 번 정도는 들어봤을 법한 Node.js(이하 노드)의 네이티브 모듈에 대해 그것이 무엇이고 왜 고려되어야 할지 다뤄본다.

노드 애드온은 C나 C++로 작성된 동적 링크 공유 객체로 노드의 require() 함수에 의해 로드 되어 일반적인 노드 모듈처럼 사용된다.

좋아 보이는데 왜 이미 익숙한 자바스크립트를 놔두고 대학시절에나 봤었던 C++로 코드를 작성해야 할까? 대답은 오직 하나 퍼포먼스 때문이다.

예제

온라인 회문(palindrome, 역: 앞으로 읽으나 뒤로 읽으나 똑같은 aba, abba 이런류의 문자열) 계산 도구를 만든다고 가정해보자. 자바스크립트는 고급 언어이기 때문에 빠르고 깔끔하게 문제를 해결할 수 있다.

function isPalindrome(str) {
  return str === str.split('').reverse().join('');
}

끝났다. 이제 코드를 프로덕션에 디플로이하고 잠이나 자러 가면 된다. 하지만, 시간이 흐른뒤에 위 알고리즘은 보기만큼 좋지 않고 실제로 엄청 느리다는 것을 깨닫게 된다. 그리곤 다른 방법을 찾아 해매게 된다.

노드 애드온 생태계

애드온을 만들기 위해서는 아래의 툴들이 필요하다.

  • node-gyp: 네이티브 애드온을 컴파일 하기 위한 크로스 플랫폼 cli
  • node-bindings: 작성한 네이티브 애드온 .node 파일을 불러오기 위한 헬퍼 모듈
  • nan: 노드버전별 애드온 개발을 쉽게 하기 위한 도구

아래의 명령으로 한번에 모두 설치할 수 있다.

$ npm i node-gyp -g && npm i node-bindings nan --save

이후 "gypfile": true 옵션을 package파일에 추가하고 binding.gyp 파일을 만든다.

binding.gyp

{
 “targets”: [
   {
     “target_name”: “palindrome”,
     “sources”: [ “palindrome.cc” ],
     “include_dirs”: [ “<!(node -e \”require(‘nan’)\”)” ]
   }
 ]
}

이제 C++로 palindrome 메소드를 작성하기 위한 준비가 끝났다. 아래의 코드를 살펴 보자.

#include <nan.h>

using namespace v8;

void IsPalindrome(const FunctionCallbackInfo<Value>& info) {
  String::Utf8Value sentence(info[0]->ToString());
  std::string str = std::string(*sentence);
  int len = str.length();
  int half = len / 2;
  int start = 0;
  int end = len - 1;
  int space = 32;
  bool isPal = true;

  while (half > 0 && isPal) {
    bool startSpace = str.at(start) == space;
    bool endSpace = str.at(end) == space;

    if (str.at(start) == str.at(end)) {
      start++;
      end--;
    } else if (startSpace || endSpace) {
      startSpace && start++;
      endSpace && end--;
    } else {
      isPal = false;
    }

    half--;
  }

  info.GetReturnValue().Set(isPal);
}

void Init(Local<Object> exports, Local<Object> module) {
  NODE_SET_METHOD(module, "exports", IsPalindrome);
}

NODE_MODULE(addon, Init);

아마 최상의 구현은 아닐지 모른다. 하지만 O(n)의 시간 복잡도를 가지고 있어 지금으론 충분하다. 이제 코드를 확인해보자. 우선 아래의 코드부터 시작한다.

void Init(Local<Object> exports, Local<Object> module) {
   NODE_SET_METHOD(module, exports, IsPalindrome);
}

NODE_MODULE(addon, Init);

위 코드는 추후 노드에서 사용될 수 있도록 노출해준다. NODE_MODULENODE_SET_METHOD 메소드의 소스코드는 한 번 살펴보도록 하자. 다음으로 확인해야 할 코드는 아래와 같다.

void IsPalindrome(const FunctionCallbackInfo<Value>& info) {
  String::Utf8Value sentence(info[0]->ToString());
  std::string str = std::string(*sentence);

여기서 우린 v8의 클래스를 사용하게 된다. FunctionCallbackInfo는 호출하는 컨텍스트에 대한 정보에 접근할 수 있게 해 주는데, 리시버 그리고 인자들의 개수와 값, 함수의 홀더에 대한 정보를 포함한다. 마침내 Utf8Value 클래스를 사용해 인자를 std string으로 변환하게 되고 이것들이 우리의 알고리즘 안에서 사용될 것이다. 두 가지의 구현의 퍼포먼스를 측정하기 위해서 benchmarkjs를 사용하는데 jsperf.com 내부에서 사용하는 라이브러리이다.

$ npm i --save benchmark
var Benchmark = require('benchmark');
var palindromeC = require('bindings')('palindrome.node');
var palindromeJs = require('./palindrome.js');
var suite = new Benchmark.Suite;
var str = 'a man a plan a cat a ham a yak a yam a hat a canal panama';

suite
.add('Javascript palindrome', function() {
  palindromeJs(str);
})
.add('C palindrome', function() {
  palindromeC(str);
})
.on('cycle', cycle)
.on('complete', complete)
.run({ 'async': true });

function cycle(event) {
  console.log(String(event.target));
}

function complete(a,b) {
  console.log('Fastest: ' + this.filter('fastest').map('name'));
  console.log('Slowest: ' + this.filter('slowest').map('name'));
}
C palindrome x 1,353,176 ops/sec ±1.98% (80 runs sampled)
Javascript palindrome x 293,383 ops/sec ±1.34% (87 runs sampled)

Fastest: C palindrome
Slowest: Javascript palindrome

C palindrome이 자바스크립트 구현보다 460% 빠르다. 미친 성능 향상이 아닌가? 하지만 여기엔 한가지 놓친 부분이 있다. 비교된 코드의 구현이 다르다. 자바스크립트의 구현이 C++의 구현보다 조금 더 비용이 비싼 구현이다. C++과 정확하게 같은 구현으로 자바스크립트 코드를 다시 만들어보자.

function isPalindrome(str) {
  var half = Math.round(str.length / 2);
  var start = 0;
  var end = str.length - 1;
  var palindrome = true;
  var SPACE = 32;
  var COMMA = 44;
  var startSpace, endSpace;

  while (half && palindrome) {
    startSpace = str.charCodeAt(start) === SPACE || str.charCodeAt(start) === COMMA;
    endSpace = str.charCodeAt(end) === SPACE ||  str.charCodeAt(end) === COMMA;

    if (str[start] == str[end]) {
      start++;
      end--;
    } else if (startSpace || endSpace) {
      startSpace && start++;
      endSpace && end--;
    } else {
      palindrome = false;
    }

    half--;
  }

  return palindrome;
}
C palindrome x 1,370,148 ops/sec ±1.32% (80 runs sampled)
Javascript palindrome x 3,326,042 ops/sec ±0.98% (82 runs sampled)

Fastest: Javascript palindrome
Slowest: C palindrome

놀랍게도 이제는 오히려 자바스크립트 구현이 240% 더 빨라졌다. 이게 말이 되나? 다행스럽게도 이를 해결할 방법이 있다.

nan 소개

노드의 네이티브 추상화

V8(그리고 약간의 노드코어)의 미친듯한 변경 사항 때문에 네이티브 애드온을 버전별로 컴파일을 해야한다. nan의 목적은 매번 NODE_MODULE_VERSION을 따로 조회하지 않고 네이티브 노드 애드온에는 필요한 로직만 담아 개발하게 하는 것이다.

우리의 네이티브 구현이 자바스크립트의 구현보다 느렸던 이유는 바로 아래의 코드 때문이다.

String::Utf8Value sentence(info[0]->ToString());
std::string str = std::string(*sentence);

여기서는 첫번째 인자를 std string으로 캐스팅하는것 외엔 아무것도 하지 않는다. 하지만 Utf8Value의 특별한 내부 동작 때문에 이 변경의 비용이 높아지게 된다. 자 이제 nan을 이용하면 어떻게 되는지 살펴보자.

Nan::Utf8String arg0(info[0]);
char *str = *arg0;

이제 v8의 Utf8Value대신 nan의 Uft8String을 사용하고 이후에 char의 array로 캐스팅할 것이다. 그외 char array와 동작하도록 코드의 몇몇 작은 부분을 수정했다.

#include <nan.h>

using namespace v8;

void IsPalindrome(const FunctionCallbackInfo<Value>& info) {
  Nan::Utf8String arg0(info[0]);
  char *str = *arg0;
  size_t len = arg0.length();
  int half = len / 2;
  int start = 0;
  int end = len - 1;
  int space = 32;
  int comma = 44;
  bool isPal = true;
  bool startSpace;
  bool endSpace;

  while (half > 0 && isPal) {
    startSpace = str[start] == space || str[start] == comma;
    endSpace = str[end] == space || str[end] == comma;

    if (str[start] == str[end]) {
      start++;
      end--;
    } else if (startSpace || endSpace) {
      startSpace && start++;
      endSpace && end--;
    } else {
      isPal = false;
    }

    half--;
  }

  info.GetReturnValue().Set(isPal);
}

void Init(Local<Object> exports, Local<Object> module) {
  NODE_SET_METHOD(module, "exports", IsPalindrome);
}

NODE_MODULE(addon, Init);
C palindrome x 5,753,415 ops/sec ±1.40% (84 runs sampled)
Javascript palindrome x 3,307,899 ops/sec ±1.28% (84 runs sampled)

Fastest: C palindrome
Slowest: Javascript palindrome

이제 자바스크립트보다 C++이 170% 나아졌다. thanks to nan :)

가장 큰 회문(palindrome)

이제 큰 문자열에서도 성능의 향상되었는지 아니면 달라진 게 없는지를 체크해 볼 수 있도록 가장 큰 회문으로 테스트를 해보자. (17,826 Word Palindrome)[http://norvig.com/pal17txt.html]으로 테스트를 해본뒤의 결과는 아래와 같다.

C palindrome x 4,636 ops/sec ±1.10% (83 runs sampled)
Javascript palindrome x 1,712 ops/sec ±1.22% (83 runs sampled)

Fastest: C palindrome
Slowest: Javascript palindrome

또다시 C++이 더 나은 퍼포먼스를 보여줬고 이번엔 270% 정도이다. 아마도 그 정도는 아닐지 몰라도 큰 문자열에서 얼마나 나아졌는지를 알 수가 있다.

결론

지금까지 노드 네이티브 애드온을 만드는 방법과 어떻게 벤치마크하는지를 알아봤다. 어쩌면 위의 예제들은 단지 스트링과의 작업만을 다루기 때문에 충분한 퍼포먼스의 향상을 얻지 못했을지도 모른다. 하지만 적어도 결과를 보여줬고 어떻게 하는지의 예제도 볼 수 있었다.

plaindrome 애드온의 모든 코드는 여기에서 확인 가능하고 사용된 벤치마크 예제는 여기에서 확인할 수 있다.

마지막으로 노드 애드온에 대한 예제나 적절한 문서를 찾는 것은 정말 어려웠다는 점을 밝히고 싶다. 아래에 내가 학습하면서 참고한 자료들을 공유한다.