시작하기에 앞서
사실 JavaScript는 꽤 빠르고 생산성도 높은 스크립팅 언어입니다. 특히 병렬성이 높은 작업에 대해서는 때때로 컴파일 언어보다 높은 성능을 보여주곤해서, 동시성이 높은 웹서버를 만들기 위한 선택지로 Node.JS + Express.js가 먼저 고려되는 경우가 많습니다.
하지만 병렬성을 요구하지 않는 연산에서는 약한 모습을 보여주는데, 수학적 연산이 집중된 암호학 분야는 특히 NodeJS가 꺼려하는 대표적인 분야 중 하나입니다.
그러나 이러한 종류의 연산은 C++ Addon을 사용하면 보완할 수 있다고 알려져있죠. 그렇다면 NodeJS에 C++ Addon을 도입하면 성능이 얼마나 향상될까요? 아무때나 C++ Addon을 도입하면 되는걸까요? 이 포스팅에서는 C++ Addon의 성능에 관한 다양한 벤치마크를 진행하고 결론을 도출하고자 합니다.
목차
벤치마크 주제는 다음과 같으며, 실제 벤치마크 코드는 깃허브에서 확인할 수 있습니다.
- 배열
- 배열에 원소 쓰기
- 배열의 원소 읽기
- 배열 정렬하기
- 수학
- 덧셈 연산
- 곱셈 연산
- 모듈러 연산
- 비트 연산
- 자료구조
- 스택
- 맵
- 함수호출
- 재귀호출이 적용된 피보나치
- 동적계획이 적용된 피보나치
- 문자열
- 문자열 생성하기
- 문자열 반복하기
- 문자열의 각 문자에 쓰기
- 문자열의 각 문자를 변경
- 문자열 읽기
- 파일 입출력
- 파일 쓰기
- 파일 읽기
선행지식
벤치마크를 이해하기 위한 기본적인 선행지식을 짚고 넘어가겠습니다.
TypedArray는Array보다 훨씬 빠르다.
Array는 무엇이든지 담을 수 있지만 TypedArray는 특별한 타입만 저장할 수 있으며 배열의 크기를 변경할 수 없습니다. 하지만 제약이 빡센만큼 쓰기는 Array와 비교될 수 없을 정도로 빠릅니다. DataView라는 버퍼를 사용해서 연산을 하기 때문이죠. TypedArray의 종류는 다음과 같습니다.
Int8Array();
Uint8Array();
Uint8ClampedArray();
Int16Array();
Uint16Array();
Int32Array();
Uint32Array();
Float32Array();
Float64Array();
BigInt64Array();
BigUint64Array();- 정수는
53bit만 사용하여 표현된다.
NodeJS의 Number 자료형은 IEEE_754, Double-precision floating-point 포맷을 사용하여 표현됩니다. 가수부분이 53bit이므로, 53bit로 표현할 수 있는 정수보다 작으면 가수부분만을 사용하여 Integer로 취급하고 그것보다 크다면 Double로 사용됩니다.
즉, 53bit를 넘어가는 정수에 대해서는 사실상 Double이므로 손실이 발생하게됩니다. 데이터의 손실이 발생하지 않는 정수의 최대값은 Number.MAX_SAFE_INTEGER에 저장되어 있습니다. 더 자세한 내용은 모질라 도큐먼트를 참조해주세요.
- 문자열은 변경될 수 없다.
NodeJS의 String 자료형은 불변Immutable속성을 가지고 있습니다. 문자열의 데이터를 멋대로 조작할 수 없다는 이야기입니다. 극단적인 예시로 1MB의 문자열에서 1글자만 변경하고 싶다 하더라도 이미 존재하는 문자열은 변경될 수 없으므로, 1글자만 변경된 1MB의 문자열이 다시 생성됩니다.
NAPI의 메소드는NodeJS의 메모리와 상호작용한다.
NAPI에서 제공되는 헬퍼 메소드를 호출하면 NodeJS의 메모리와 상호작용이 이루어집니다. 이것은 서로다른 두 언어의 통신이므로, NodeJS에서 빌트인으로 제공되는 연산을 사용하는 것 보다 훨씬 값비싼 비용을 치뤄야합니다. 즉, NAPI의 함수를 최대한 적게 사용해야 성능적으로 좋습니다.
벤치마크
배열
원소 쓰기
for(let i=0; i<N; i++){
arr[i] = i;
}시나리오는 다음 중 하나입니다.
Array+ADDONArray+NODEInt32Array+ADDONInt32Array+NODE

단순히 TypedArray를 사용함으로써 성능을 향상시킬 수 있었습니다. 특히 NAPI에서 TypedArray가 사용된 경우의 성능향상이 매우 극적인데, 이것은 NAPI가 TypedArray의 내부버퍼를 NodeJS의 개입없이 다이렉트로 액세스할 수 있기 때문입니다.
원소 읽기
for(let i=0; i<N; i++){
arr[i];
}시나리오는 다음 중 하나입니다.
Array+ADDONArray+NODEInt32Array+ADDONInt32Array+NODE

Array + Addon 조합이 매우 압도적인 오버헤드를 자랑합니다. Array는 무엇이든 담을 수 있기 때문에 V8에서 한번 보호장치를 거쳐가기 때문입니다. 반면 TypedArray는 다른 개입없이 내부버퍼에 직접 액세스할 수 있기 때문에 빠릅니다.
원소 정렬
Node에서는 Array.prototype.sort()를 사용하고,NAPI에서는 std::sort()를 사용합니다.
시나리오는 다음 중 하나입니다.
Addon + Array(단, 정렬된 배열을 반환하지 않음)Addon + TypedArrayNode + ArrayNode + TypedArray
배열의 상황은 다음 중 하나입니다.
- 정렬된 경우.
- 정렬되지 않은 경우.
- 전부 같은값인 경우.



여전히 TypedArray가 강세를 보이고있습니다. Addon + Array은 정렬된 배열을 반환하지 않는다고 가정했으므로, 값을 반환했어야 한다면 Node + Array와 비슷하거나 더 느린 퍼포먼스를 보였을 것입니다.
수학연산
덧셈
let sum = 0;
for(let i=0; i<N; i++){
sum += i;
}
일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.
곱셈
let typedArray = new Int32Array(N)
for(let i=0; i<N; i++) {
typedArray[i] = i * 1.5;
}
일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.
모듈러
let arr = new Int32Array(N);
let out = new Int32Array(N);
for(let i=0; i<N; i++){
out[i] = arr[i] % 7;
}
일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.
비트 연산
const arr = new Int32Array(N);
const ans = new Int32Array(N);
for (let i = 0; i < N; i++) {
let v = i;
v |= i;
v &= i;
v <<= 1;
v = ~v;
v >>= 1;
ans[i] = v;
}
일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.
자료구조
스택
for(let i=0; i<N; i++){
stack.push(i);
stack.top();
}스택은 다음의 조합으로 만들었습니다.
Addon + 포인터배열 + Napi::ObjectWrapNode + ArrayNode + TypedArray + Class

C++로 구현한 스택의 경우에는 push()또는 pop()이 호출될 때 마다, 오버헤드가 발생되어 심각한 병목지점으로 작용되었습니다. Node + TypedArray + Class의 경우에는 굳이 클래스로 만들지 않는다면 성능이 더 좋아집니다만, 객체지향에는 맞지 않습니다. Trade-Off를 생각하면서 적용하는것이 좋습니다.
맵
let map; // key: int, value: int
for(let i=0; i<N; i++){
map.set(i, i);
map.get(i);
}맵은 다음 조합으로 만들었습니다.
Addon + 포인터배열 + Napi::ObjectWrapNode + MapNode + ObjectNode + TypedArray + Class

C++로 구현한 맵의 경우에는 set()또는 get()이 호출될 때 마다, 오버헤드가 발생되어 심각한 병목지점으로 작용되었습니다. Node + TypedArray + Class의 경우에는 굳이 클래스로 만들지 않는다면 성능이 더 좋아집니다만, 객체지향에는 맞지 않습니다. Trade-Off를 생각하면서 적용하는것이 좋습니다.
함수호출
재귀호출
function fibo(n: number): number {
if (n === 1) return 0;
if (n === 2) return 1;
if (n === 3) return 1;
return fibo(n - 1) + fibo(n - 2);
}
이것으로 함수호출 스택을 쌓는 비용을 알 수 있었습니다. C++의 함수호출이 훨씬 더 가볍네요.
메모이제이션
이번에는 캐시를 두어 피보나치를 수행해볼까요. 각 테스트케이스는 캐시를 비우고 시작했습니다.

동적계획을 적용한것 뿐인데 퍼포먼스가 엄창나게 향상되었습니다. 프로그램의 성능은 먼저 알고리즘에 달려있다는 것을 알 수 있습니다.
문자열
모든 문자를 순회
C++
size_t charSum = 0;
for(const char &ch : str){
charSum += ch;
}NodeJS
const len = str.length;
let charSum = 0;
for (let i = 0; i < len; i++) {
charSum += str.charCodeAt(i);
}
문자열이 커질수록 C++의 성능이 더 좋아지지만, 미세합니다. 어느 것을 사용해도 큰 차이가 없습니다.
각 문자를 변경
이진수 형태의 문자열이 주어지면, 각 숫자가 반전된 문자열을 출력해보겠습니다.
C++
for(char &ch : str){
ch = (ch == '0' ? '1' : '0');
}NodeJS - Using Array
const arr = [];
const len = str.length;
for (let i = 0; i < len; i++) {
const ch = str.charAt(i);
arr.push(ch === "0" ? "1" : "0");
}
str = arr.join();NodeJS - Using Buffer
const buf = Buffer.from(str);
const ch0 = "0".charCodeAt(0);
const ch1 = "1".charCodeAt(0);
for(let i=0; i<buf.length; i++){
buf[i] = (buf[i] === ch0) ? ch1 : ch0;
}
buf.toString();
NodeJS에서 문자열은 불변이기 때문에 C++처럼 인덱스 기반으로 문자열을 수정할 수 없습니다. 문자열을 한 글자씩 잘라내어 작업하거나, Buffer로 변환하여 작업하는 방법이 있을 수 있습니다. 또한 Buffer를 사용한 방법이 C++에 매우 근접한 성능을 보여주고 있음을 알 수 있습니다.
문자열 배열 생성하기
C++
auto arr = Napi::Array::New(env, N);
for(auto i=0; i<N; i++){
arr[i] = Napi::String::New(env, std::to_string(i));
}NodeJS
const arr : string[] = [];
for(let i=0; i<N; i++){
arr[i] = String(i);
}
C++에서 문자열을 생성하는 것이 매우 비싼 작업임을 알 수 있습니다. 다음 함수들이 병목지점으로 작용하고 있네요.
- 숫자를
C++문자열로 변경하는 함수의 비용 C++문자열을NodeJS문자열로 변환하는 함수의 비용NodeJS문자열을NodeJS배열에 삽입하는 함수의 비용
숫자를 문자열로 변환하는 함수를 튜닝한다 치더라도 나머지 2개는 치명적입니다. 대량의 문자열을 만드는 작업은 NodeJS에서 진행하는것이 더 낫겠네요.
그러나 1개의 커다란 문자열을 생성하는 경우에는 빠져나갈 방법이 있습니다. Uint8Array의 형태로 문자열을 전달하는 것이죠. 그렇게하면, 먼저 std::string을 napi::string으로 변환하는 작업을 피할 수 있고, NodeJS의 메모리에 직접적으로 읽기/쓰기가 가능하므로 assign의 성능도 향상됩니다.
문자열 반복하기
주어진 문자열을 N배 만큼 반복한 문자열을 만들겠습니다. 각각 시간 복잡도가 O(n), O(log n)인 방법을 사용하였습니다.
O(n)
let repeated = "";
for (let i = 0; i < N; i++) {
repeated += str;
}O(log n)
let repeated = "";
while (N !== 0) {
if (N & 1) {
repeated += str;
}
str += str;
N >>= 1;
}※ 0.5us미만의 값은 0us으로 표기되었습니다.

같은 log N알고리즘을 사용하였음에도 Addon과 NodeJS의 성능차이가 매우 심한것에 주목해주시기 바랍니다. 특히 C++에서는 반복 문자열을 생성하는 시간보다 다른 작업들이 더 오래 걸림을 알 수 있습니다. 아마 큰 문자열을 Napi::string으로 변환하는데 비싼 비용을 치뤘다고 생각됩니다.
그렇다쳐도 반복 문자열을 생성하는 작업이 NodeJS에서 더 빨리 끝나는 것이 의문입니다. Node의 문자열은 C++의 문자열과 다르게 동작하는 것 같습니다. 이에 대해서는 추후 조사하고 포스팅하겠습니다.
NAPI에 문자열 전달하기
NAPI에 문자열을 넘기기 위해 다음과 같은 방법을 생각할 수 있습니다.
napi::string으로 넘겨서std::string으로 읽는다.Uint8Array로 넘겨서char*으로 읽고std::string으로 변환한다.Uint8Array로 넘겨서char*으로 읽는다.

벤치마크만 봐서는 항상 Uint8Array으로 넘기는 것이 유리한 것 같지만, NodeJS에서 String을 Buffer로 변환하는 작업의 비용도 같이 생각해야합니다. 따로 측정한 벤치마크의 결과를 아래에 적습니다.
- N === 1e4 ) 80us
- N === 1e5 ) 1,000us
- N === 1e6 ) 12,220us
위의 벤치마크도 같이 생각하면, 2번 이상 NAPI에 전달해야 할 때만 Uint8Array가 효과적임을 알 수 있습니다.
파일 입출력
파일 쓰기
Addon은 fopen, fwrite, fclose를 사용했고,NodeJS는 fileWriteSync를 사용했습니다.

파일 읽기
Addon은 fopen, fread, fclose를 사용했고,NodeJS는 fileReadSync를 사용했습니다.

'# Lang > NodeJS C++ Addon' 카테고리의 다른 글
| [튜토리얼] C++ 크로스플랫폼 애드온 제작하기 (0) | 2020.03.14 |
|---|---|
| [DeepDive] NodeJS C++ Addon 깊게 입문하기 (4) | 2020.03.11 |