본문 바로가기

# Lang/NodeJS C++ Addon

[DeepDive] NodeJS C++ Addon 깊게 입문하기


Node.JS Foundation

구성요소

Native Extension에 대해 설명하기에 앞서, 먼저 Node.JS가 어떻게 구성되어 있는지에 대해 알아야 합니다.


JavaScript

Node.JS에서 실행가능한 프로그래밍 언어입니다.


V8

자바스크립트 언어로 작성된 코드를 실행해주는 엔진입니다. 자바스크립트의 객체를 생성하거나, 함수를 호출하는 방법과 같은 매커니즘을 정의합니다.


Libuv

비동기 실행과 라이프 사이클을 제공하는 C 라이브러리입니다. Node.JS의 모든 비동기 동작은 LibUv가 관리합니다.


기타 라이브러리

Node.JSV8이나 LibUv를 포함한 수많은 라이브러리의 집합체이며, 이러한 라이브러리들이 정적으로 링크되어 Node.exe를 이룹니다. Node.JS가 어떤 라이브러리를 사용하는지 궁금하다면 Node.JS 레포지터리에 있는 deps폴더를 살펴보세요.


Node.JS 핵심 구현

정적 링크 라이브러리들을 사용하여 Node.JS를 구현합니다. fs, os, buffer, assert같은 빌트인 모듈도 포함됩니다.


익스텐션이란?

동작방식

Node.JS는 결국 V8 엔진에 의하여 동작하기 때문에, Node.JS가 의존하는 V8엔진의 헤더와 소스코드를 그대로 사용하여 코드를 작성하면, Node.JS에서 사용가능한 C/C++코드가 완성됩니다.


이렇게 완성된 C/C++코드를 포함시켜 Node.JS를 다시 컴파일해도 상관이 없지만, 일반적으로는 동적 링크 라이브러리Dynamically Linked Library, DLL의 형태로 컴파일한 뒤 require()함수를 사용하여 동적으로 불러오는 방식을 취하며, 이렇게 만들어진 DLLNative Extension이라고 합니다.


구체적으로 Native Extension이 작동하는 방식은 다음과 같습니다.

  1. fs 모듈의 핵심 구현은 C언어로 구현된 Native Extension에 담겨있다.
  2. Native Extension를 메모리에 바인딩하여 fs 모듈을 사용할 준비를 마친다.
  3. fs 모듈이 require되고, Native Extension의 메소드를 호출한다.
  4. 호출된 메소드는 LibUv에 의해 동기 또는 비동기로 수행된다.
  5. Native ExtensionV8의 메소드를 사용하면서 Node.JS와 통신한다.

Native Extension이 수행되는 순서

Native Extension을 작성하기 위한 환경을 세팅하는 것은 그렇게 어려운 일이 아닙니다. Node.JS 레포지터리에 있는 deps폴더를 살펴보면 V8이라는 폴더가 무게감있게 자리잡고 있고, 이 안에 있는 include폴더를 외부 라이브러리 디렉토리로 지정하면 V8의 모든 헤더파일을 사용할 수 있습니다.


간단하죠? 이것만으로 Native Extension를 만들 준비가 끝난 것 입니다. 대형 프로젝트이기 때문에 문서화도 잘 되어 있습니다.

//
// Native Extension ver.
#include <v8.h>

...

문제점

하지만 Native Extension으로 개발된 라이브러리는 특정 V8버전에 종속된다는 심각한 단점을 함께 가지고 있습니다. Node.JS이 버전업되면서 사용하는 V8엔진도 함께 업데이트되는 경우, V8의 내부코드가 달라질 가능성이 큽니다.


즉, 새로운 V8버전에서는 기존의 라이브러리가 동작하지 않게 될 가능성이 매우 높다는 것입니다.

Native Extension의 모형도

위의 문제를 해결하기 위해 NAN이 등장하였는데, 이것은 바로 다음 장에서 설명합니다.


NAN

문제해결?

위와같은 문제를 해결하기 위해 추상화된 Native ExtensionNative Abstractions for Node가 만들어졌습니다. 인터페이스에 가까운 형식으로 추상화를 지원하는데, 절대 바뀌지 않을 인터페이스를 정한 뒤에, 각 V8버전에 맞춘 NAN가 만들어지는 식입니다.


간단하게 말하자면, 각 V8엔진의 버전마다 1:1로 대응되는 NAN이 존재하고 있으며, 이 NAN들은 모두 동일한 인터페이스를 유지하고 있으니, 나중에 V8의 버전이 바뀌더라도 그에 대응되는 NAN갈아끼우면 된다는 것입니다.


특히 NANNode.JS의 초창기부터 함께해온 생태계이기 때문에, NAN으로 작성된 코드는 모든 Node.JS버전에서 사용할 수 있다는 것을 보장합니다. 따라서, 모든(이전) 버전에서 동작가능한 Native Extension 코드을 만들기 위해서는 반드시 NAN를 사용하여 작성되어야 합니다.

//
// NAN ver.
#include <nan.h>

...

아직 문제는 남아있다

이렇게만 설명하면 NAN은 마법같은 무언가라고 생각되지만, 심각한 디메리트 도 있다는 것을 알 수 있습니다. V8의 버전이 바뀔 때 마다, 그 V8에 맞게끔 NAN을 바꿔끼우고 다시 컴파일해야 합니다. 말 그대로 코드만 호환되는 것입니다.


이것은 버전 호환성이 깨진다는 것을 의미하기 때문에, 개발자가 설정한 V8 버전을 사용하지 않는 사용자는 반드시 에러가 발생합니다.

NAN을 사용하면 발생하는 버전 호환성 이슈


N-API

문제종결

다시 위와같은 문제를 해결하기 위해 N-APINext Generation Node API가 만들어졌습니다. 2016년에 논의되었고, 2017년에 실험적으로 운용되었으며, 2018년에 이르러서야 정식으로 출시되었습니다. Node.JS의 초창기부터 함께해온 NAN와 비교하면 엄청난 후발주자가 아닐 수 없습니다.


하지만 NAN와 달리 N-APINode.JS자체에 포함되어 있으며, 바이너리 수준에서 호환성을 유지시켜주는 ABIApplication Binary Interface라는 것이 엄청난 장점입니다. 한번 N-API를 사용하여 컴파일된 DLL파일은 바이너리 호환성Binary Compatibility를 지니기때문에, 몇 년이 지나도 최신버전에서 안정적으로 동작할 수 있음을 보장하기 때문입니다.


또한 NAN보다 더 직관적인 인터페이스를 채택하고, Node-Addon-API라고 불리는 C++ 프로그래머를 위한 래핑 버전까지 제공합니다. 물론 NANC++래핑을 지원하지만 Node-Addon-API가 훨씬 더 사용하기 쉽습니다. 반드시 이전 버전도 지원해야하는 경우가 아니라면 N-API를 사용하여 개발하는 것이 정신건강에 이롭습니다.

//
// N-API ver.
// Node Addon API ver.
#include <napi.h>
...

동작방식

한번 작성된 DLL파일이 앞으로 나올 최신버전에서도 모두 사용될 수 있다니 N-API는 마법의 산물일까요? 프로그래머는 마법같은 것을 믿지 않기 때문에, 그 원리를 파헤쳐야합니다. N-API가 이런 마법같은 일을 벌일 수 있는 기본원리는 매우 간단합니다. 바로 V8의 밑바닥를 추상화하는 것이죠.


V8코드도 N-API를 사용하고, 우리가 작성할 C/C++코드도 N-API를 사용하기 때문에, 새로운 V8이 나오더라도 그 엔진이 N-API를 사용하도록 노드팀이 재설계해준다면, 우리는 아무것도 할 필요가 없어지는 것이죠. 이것이 V8의 밑바닥을 추상화Abstraction of the underlying JavaScript engine하는 것의 의미이고, N-API의 기본 원리이며, Node.JS에 빌트인된 이유입니다.

N-API의 모형도


NAN와 비교하기

위에서 나온 N-API와 NAN의 특징을 짤막하게 정리하겠습니다.


공통점

  • 기본적인 역할은 애드온 작성에 도움을 주는 추상화된 헬퍼 함수를 제공합니다.
  • 추상화된 헬퍼 함수를 호출할 때 마다, Node.JS의 메모리와 상호작용합니다.
  • 따라서 새로운 노드 버전이 나오더라도 코드를 재작성하지 않게 도와줍니다.
  • 둘 다 C++로 래핑된 버전을 지원합니다.

차이점

  • NANV8를 추상화하고, N-APIV8의 더 아래를 추상화합니다.
  • NAN모든 버전에서 사용할 수 있고, N-API는 미래의 모든 최신 버전에서 사용할 수 있습니다.
  • NAN은 사용법이 어렵지만, N-API는 사용하기 쉽습니다.
  • N-API한 번 컴파일하면, 여러 노드버전에서 실행할 수 있습니다.

Getting Start

여기서는 N-APIC++래핑인 Node-Addon-API를 사용하여 Addon을 만드는 과정을 살펴보겠습니다.


훑어보기

binding.gyp

컴파일할 소스 파일들의 경로와 컴파일 옵션을 지정하며 JSON포맷을 따릅니다.
프로젝트의 루트에 위치해야 합니다.


node-gyp

루트폴더에 있는 binding.gyp파일을 읽고, 타겟팅된 소스 파일들을 컴파일하는 프로그램입니다. Release(default) 또는 debug 모드로 컴파일할 수 있습니다. 디버그 모드로 컴파일하면 성능은 많이 떨어지지만 브레이크 포인트를 잡을 수 있습니다. 컴파일 작업이 성공적으로 끝나면 루트에 build 폴더가 만들어지는데, release 또는 debug폴더 안에 있는 확장명이 .node인 파일을 찾으면 됩니다. 이것이 DLL파일입니다.


준비물 챙기기

먼저 아래의 프로그램들을 설치해야 합니다.


빌드 도구 :

  • python 2.7 이상
  • gcc / g++ / make (리눅스 사용자만 해당)
  • windows-build-tools (윈도우 사용자만 해당)
npm install -g windows-build-tools

노드 패키지 (글로벌 또는 개발 의존으로 설치해야 합니다) :

여기서는 글로벌로 설치하겠습니다.

  • node-gyp
npm install -g node-gyp

노드 패키지 (프로젝트 안에 로컬로 설치해야 합니다) :

  • bindings
  • node-addon-api

개발 시작하기

최종적으로는 아래와 같은 형태가 되어야 합니다.

root
├─ binding.gyp
├─ hello_world.cpp
├─ index.js
├─ node_modules
├─ package-lock.json
└─ package.json

  1. 프로젝트 폴더를 생성하고 npm을 초기화합니다.
npm init -y
npm install bindings
npm install node-addon-api

  1. 다음과 같이 루트폴더에 binding.gyp을 작성합니다.
# binding.gyp
{
  "targets": [
    { 
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "include_dirs" : [
        "<!@(node -p \"require('node-addon-api').include\")"
      ], 
      "target_name": "./hello_world",

      # 여기서 타겟 소스파일을 지정합니다.
      "sources": [ "hello_world.cpp" ],
      'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ]
    }
  ]
}

  1. C++ 코드를 작성합니다.
//
// NAPI 헤더입니다.
// npm을 통해 node-addon-api를 로컬로 설치하면 ./node_modules 폴더에 같이 딸려옵니다.
// 이것을 설치하지 않고, 먼저 작업하면 include에 빨간줄이 그어집니다.
#include <napi.h>

//
// 자바스크립트의 String 객체를 반환하는 함수입니다.
// 파라미터는 info[n] 형태로 얻어올 수 있습니다.
Napi::String SayHi(const Napi::CallbackInfo& info) {
  //
  // info에는 현재 스코프 정보(env)도 들어있습니다.
  // 자바스크립트 객체를 생성하려면 반드시 이것부터 가져와야합니다.
  Napi::Env env = info.Env();

  //
  // 현재 스코프 정보(env)와 std::string을 사용하여,
  // "Hi!"라는 자바스크립트 문자열을 반환합니다.
  return Napi::String::New(env, "Hi!");
}

//
// 애드온 이니셜라이져입니다.
// 자바스크립트 오브젝트(exports)에 함수를 하나씩 집어넣고,
// 다 집어넣었으면 리턴문으로 반환하면 됩니다.
Napi::Object init(Napi::Env env, Napi::Object exports) {
    //
    // 위의 함수를 "sayHi"라는 이름으로 집어넣습니다.
    exports.Set(Napi::String::New(env, "sayHi"), Napi::Function::New(env, SayHi));

    //
    // 다 집어넣었다면 반환합니다.
    return exports;
};

//
// 애드온의 별명과, 이니셜라이져를 인자로 받습니다.
NODE_API_MODULE(hello_world, init);
  1. node-gyp를 사용하여 빌드합니다.
node-gyp rebuild
  1. 다음과 같이 index.js를 작성합니다.
//
// hello_world.node 애드온을 찾아 가져옵니다.
const addon = require("bindings")("hello_world");

//
// 애드온 함수를 실행합니다.
// "Hi!" 라는 문자열을 가져오게 됩니다.
const str = addon.sayHi();

//
// "Hi!" 문자열이 콘솔에 출력됩니다.
console.log(str);

(옵션) CMake로 빌드하기

이제는 굳이 node-gyp를 사용하지 않더라도, cmake를 통해 NAPI를 빌드할 수 있습니다.


  1. 먼저 아래의 도구들을 설치해주세요.
  • 운영체제에 맞는 cmake, make
  • npm module : cmake-js
npm install -g cmake-js

  1. 루트 폴더에 CMakeLists.txt를 작성합니다.
cmake_minimum_required(VERSION 2.8.12.2)
set (CMAKE_CXX_STANDARD 11)



# 프로젝트의 이름입니다.
# 이 예제에서는 my_addon.node라는 이름으로 DLL이 생성됩니다.
project (my_addon)
include_directories(${CMAKE_JS_INC})



# 타겟 소스파일을 지정합니다.
file(GLOB SOURCE_FILES "hello.cpp")
add_library(${PROJECT_NAME} SHARED ${SOURCE_FILES} ${CMAKE_JS_SRC})
set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node")
target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB})



# N-API 래퍼를 불러옵니다.
execute_process(COMMAND node -p "require('node-addon-api').include"
        WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
        OUTPUT_VARIABLE NODE_ADDON_API_DIR
        )
string(REPLACE "\n" "" NODE_ADDON_API_DIR ${NODE_ADDON_API_DIR})
string(REPLACE "\"" "" NODE_ADDON_API_DIR ${NODE_ADDON_API_DIR})
target_include_directories(${PROJECT_NAME} PRIVATE ${NODE_ADDON_API_DIR})



# N-API 버전을 설정합니다.
# 이후버전은 이전버전도 지원하니 최신버전으로 설정해주세요.
# 2020년 3월 기준으로 5가 최신입니다.
#
# (2020-03-13 수정)
# 이전버전은 이후버전에서 안정적으로 동작하므로,
# 안정성을 원한다면 낮은 버전이 더 좋은 선택일 수 있습니다.
# 단 1, 2는 초창기에 사용된 실험용 버전이므로 사용하면 안됩니다.
add_definitions(-DNAPI_VERSION=3)
  1. cmake-js로 컴파일합니다.
cmake-js compile

(옵션) 크로스플랫폼 지원

기본적으로 Node.JS는 크로스플랫폼이지만, C++은 그렇지 않습니다. 간단한 sleep함수조차도 윈도우냐 리눅스냐에 따라서 헤더파일과 시그너쳐가 다르기 때문이죠. 만약 N-API로 작성된 라이브러리를 npm에 배포하고 싶다면 어떤 운영체제를 지원할 것인지에 대해 충분히 고민해야 합니다.


크로스플랫폼을 지원하기로 결정했다면 각 환경에 맞는 소스코드를 전부 만든다음, 사용자의 운영체제마다 선택적으로 소스코드를 컴파일하도록 해야합니다. 이런 기능은 cmake나 node-pre-gyp에 포함되어 있습니다.


생각보다 어렵지않으니 크로스플랫폼에도 한번 도전해보세요 : )


(2020-03-15 추가)

크로스플랫폼 관련 포스팅을 추가했습니다.


참고자료

기본원리 참고

https://www.slideshare.net/nasottola/next-generation-napi
https://nodejs.org/api/addons.html
https://itnext.io/a-simple-guide-to-load-c-c-code-into-node-js-javascript-applications-3fcccf54fd32
https://www.nan-labs.com/blog/native-extensions-for-nodejs
https://stackoverflow.com/questions/54740947/node-js-addons-nan-vs-n-api


빌드과정 참고

https://lofty87.github.io/nodejs/20180914/nodejs-native-addon-module
https://github.com/nodejs/node-addon-api/blob/master/doc/cmake-js.md
https://github.com/nodejs/node-addon-examples/tree/master/build_with_cmake