4 min read
Node API를 통한 성능 최적화

Node API를 통한 성능 최적화

JavaScript의 한계

JavaScript는 웹 UI와의 상호작용을 위해 설계된 언어다. 런타임에 JS 엔진에 의해 해석되고 실행되는데, 원래는 보안상의 이유로 시스템 환경 접근이 불가능했다. 하지만 지금은 파일 시스템 같은 일부 시스템에 C/C++ 같은 저수준 코드를 호출해서 접근할 수 있게 되었다.

샌드박스란?

소프트웨어가 제한된 환경 안에서만 실행되도록 해서 시스템 전체의 안전성을 보장하는 보안 기법이다. 이렇게 하면 웹페이지를 통한 시스템 접근으로 인한 보안 이슈를 원천 차단할 수 있다.

성능 문제

  • 연산 작업 병목
    • 이미지 처리나 암호화 같은 CPU bound task를 처리하는 데 시간이 많이 걸린다
    • 메인 스레드를 점유해서 해당 Node.js 애플리케이션의 이벤트 루프가 블로킹되어 다른 요청이나 작업 처리가 지연되는 문제가 생긴다
  • WEB 2.0의 한계
    • WEB 2.0 시대가 도래하며 서버가 처리해야 할 동시 요청과 복잡한 연산 작업이 증가했고, 이때 순수 JavaScript만으로 CPU 집중적인 작업을 처리할 경우, 앞서 언급된 연산 작업 병목 현상으로 인해 전체적인 서비스 응답성이 저하될 수 있다

해결 방안

1. 멀티 스레드 구현

  • Web Worker (브라우저)
  • Worker Thread (Node.js)

2. 바이너리 코드 실행

  • WebAssembly

3. 네이티브 언어 활용

  • child_process
    • 별도의 프로세스를 생성하며, 표준 입출력 스트림(stdin, stdout, stderr)을 통한 통신이 가능하지만, 직접적인 함수 호출 방식에 비해 데이터 교환 및 제어가 번거로울 수 있다
    • 프로세스 생성 자체의 오버헤드가 존재한다
  • FFI (Foreign Function Interface)
    • 주로 동기적인 함수 호출 방식으로 동작하며, 데이터를 주고받을 때 마샬링/언마샬링 오버헤드가 발생할 수 있다
    • libffi와 같은 라이브러리를 통해 동적으로 공유 라이브러리의 함수를 바인딩한다
  • Node API (N-API)
    • 양방향으로 통신이 가능하다
    • 컴파일된 .node 모듈을 써서 오버헤드가 적다

Node API (N-API)

Node-API는 네이티브 애드온을 만들기 위한 API다. 이 API는 Node.js 버전이 바뀌어도 ABI(Application Binary Interface) 안정성을 보장한다.

네이티브 애드온이란?

JavaScript 코드에서 C, C++, Rust로 만든 네이티브 코드를 불러와서 실행할 수 있게 해주는 Node.js 모듈이다.

ABI vs API

  • API: 소스 코드 레벨에서 함수 호출, 데이터 구조, 프로토콜을 정의하는 인터페이스다
  • ABI: 컴파일된 바이너리 모듈 간의 기계어 호출 규약과 데이터 레이아웃을 정의한다

N-API는 엔진과 독립적이면서도 Node.js 버전과 충돌이 없어서, 네이티브 애드온을 다시 컴파일하지 않아도 다양한 버전과 엔진에서 잘 동작한다.

구현 예제

1. 프로젝트 초기화

npm init -y
npm install -g node-gyp
npm install node-addon-api

2. 빌드 설정 (binding.gyp)

{
  "targets": [
    {
      "target_name": "addon",
      "sources": [ "native/add.cpp" ],
      "include_dirs": [
        "<!@(node -p \\"require('node-addon-api').include\\")"
      ],
      "dependencies": [
        "<!(node -p \\"require('node-addon-api').gyp\\")"
      ],
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
    }
  ]
}

3. C++ 코드 작성 (add.cpp)

#include <napi.h>

Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  int a = info[0].As<Napi::Number>().Int32Value();
  int b = info[1].As<Napi::Number>().Int32Value();
  return Napi::Number::New(env, a + b);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("add", Napi::Function::New(env, Add));
  return exports;
}

NODE_API_MODULE(addon, Init)

4. 성능 벤치마킹

const addon = require("../build/Release/addon.node");

function jsAdd(a, b) {
  return a + b;
}

function benchmark() {
  const testCount = 1000000;
  const a = 123;
  const b = 456;

  console.time("JavaScript Add");
  for (let i = 0; i < testCount; i++) {
    jsAdd(a, b);
  }
  console.timeEnd("JavaScript Add");

  console.time("C++ Add");
  for (let i = 0; i < testCount; i++) {
    addon.add(a, b);
  }
  console.timeEnd("C++ Add");
}

benchmark();

벤치마킹 결과

단순 덧셈 연산의 경우, JavaScript가 더 빠르게 동작한다. 이는 세 가지 이유 때문이라고 추측할 수 있다!

  1. JIT 컴파일러가 반복되는 연산을 최적화해서 기계어로 컴파일한다.
  2. Node API를 호출할 때마다 JavaScript와 네이티브 코드 사이의 컨텍스트 스위칭이 발생하는데, 이 과정에서 오버헤드가 발생한다.
  3. N-API 호출 시 인수와 반환 값을 JavaScript 객체와 네이티브 타입 간에 변환하는 과정(마샬링/언마샬링)에서 추가적인 오버헤드가 발생한다.

단순 덧셈 연산 벤치마크 결과

반면 CPU-bound 작업의 경우, 이런 오버헤드보다 네이티브 코드의 성능 이점이 더 크기 때문에 훨씬 더 빠른 성능을 보인다!

CPU-bound 작업 벤치마크 결과

결론

  • 네이티브 코드를 쓰면 CPU-bound 태스크를 빠르게 처리할 수 있다
  • Node.js에서는 N-API를 통해 JavaScript와 네이티브 코드가 양방향으로 통신할 수 있고, 오버헤드도 적다.
  • 단순 연산은 JIT 컴파일러 덕분에 JavaScript가 더 빠를 수 있다
  • 복잡한 연산이나 대용량 데이터를 다룰 때는 네이티브 코드가 유리하다.

참고 자료