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

Posted on 18 February, 2016

네이티브 애드온으로 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_MODULENODESETMETHOD 메소드의 소스코드는 한 번 살펴보도록 하자. 다음으로 확인해야 할 코드는 아래와 같다.

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의 목적은 매번 NODEMODULEVERSION을 따로 조회하지 않고 네이티브 노드 애드온에는 필요한 로직만 담아 개발하게 하는 것이다.

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

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 애드온의 모든 코드는 여기에서 확인 가능하고 사용된 벤치마크 예제는 여기에서 확인할 수 있다.

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

Buy Me A Coffee

© Sungho Kim2020