[Node.js 기여] 배열 하나를 공유했더니 성능이 좋아졌다
ArrayBuffer를 통해 성능을 개선하였다.
오픈 소스 기여
perf_hooks 모듈에 대해
브라우저에서 window.performance를 입력하면 현재 열려있는 window에 대한 성능을 확인할 수 있다.
Node.js도 자바스크립트를 실행시키는 런타임 환경으로 perf_hooks라는 모듈을 통해 현재 실행 중인 Node.js 인스턴스의 성능 정보를 측정하고 조회할 수 있다.
const { performance } = require('node:perf_hooks');
function doWork() {
let total = 0;
for (let i = 0; i < 1_000_000; i++) {
total += i;
}
return total;
}
performance.mark('start');
const result = doWork();
performance.mark('end');
performance.measure('workPoint', 'start', 'end');
const [measurement] = performance.getEntriesByName('workPoint');
console.log(`실행 시간: ${measurement.duration.toFixed(2)} ms`);
// 실행 시간: 7.24 ms
간단한 예제로performance.mark()를 통해 시작과 끝을 표시하고, .measure()을 통해 두 지점 사이의 시간을 측정할 수 있다.
당연하게도 Node.js의 핵심이라고 할 수 있는 이벤트 루프에 대한 정보도 받아올 수 있다.
import { performance } from 'node:perf_hooks';
setImmediate(() => {
console.log(performance.nodeTiming.uvMetricsInfo);
});
// { loopCount: 1, events: 0, eventsWaiting: 0 }
-
loopCount: 이벤트 루프가 반복된 횟수 -
events: 이벤트 핸들러가 처리한 이벤트 수 -
eventsWaiting: 이벤트 처리 단계에 들어갈 때 대기 중이던 이벤트 수
여기서 Node.js의 이벤트 루프에 대해 간단히 이해하고 넘어가자.
Event Loop
Node.js의 이벤트 루프를 보면 다음과 같이 6가지의 Phase로 이루어진 구조를 자주 보았을 것이다.
┌───────────────────────────┐
│ timers │
└─────────────┬─────────────┘
│
v
┌───────────────────────────┐
┌─>│ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ timers │
└───────────────────────────┘
Node.js는 내부적으로 파일, 네트워크와 같이 무거운 작업들을 libuv에게 넘겨 비동기적으로 작업을 처리한다.
libuv에서는 이런 무거운 작업들을 Asynchronous&Non-blocking으로 처리하기 때문에 작업이 완료되면 작업에 대한 콜백을 용도에 맞는 phase의 큐에 저장하고, 이벤트 루프는 각 phase를 순회하며 큐 속의 콜백 함수를 호출하여 메인 스레드가 해당 작업의 완료를 인지할 수 있다.
예를 들어, 하나의 파일을 읽는 fs.readFile()함수는 다음과 같이 동작한다.
1. libuv의 스레드 풀에서 파일 읽기 작업 수행(비동기)
2. 작업이 완료되면 poll phase에 있는 큐에 완료된 콜백 함수를 저장
3. 이벤트 루프는 각 phase를 순회하며 poll phase의 큐 속에 있는 콜백 함수 호출
4. Node.js 메인 스레드는 파일을 읽었다는 콜백 함수를 호출받아 실행
결국 Node.js의 이벤트 루프는 비동기적인 I/O를 libuv에게 넘겨, 메인 스레드가 블로킹되지 않고 비동기적으로 작업을 수행할 수 있게 한다. 그리고 이벤트 루프를 통해 메인 스레드에서 완료된 I/O 작업을 인지하고 콜백 함수를 실행킨다.
uvMetricsInfo의 데이터
그러면 이제 아까전 perf_hooks에서 호출하였던 uvMetricsInfo에서 반환하는 객체 속성에 대한 의미를 이해할 수 있다.
import { performance } from 'node:perf_hooks';
setImmediate(() => {
console.log(performance.nodeTiming.uvMetricsInfo);
});
// { loopCount: 1, events: 0, eventsWaiting: 0 }
-
loopCount: libuv 이벤트 루프의 누적 반복 횟수 -
events: libuv가 계측하는 이벤트의 누적 처리 수 -
eventsWaiting: libuv가 I/O 이벤트를 확인하러 갔을 때 이미 처리할 준비가 되어 있던 이벤트의 누적 수
uvMetricsInfo를 호출하면 그 시점에 libuv가 내부적으로 기록하고 있던 카운터 값을 반환한다. 이 때 한 가지 혼란스러울 수 있는 점은 uvMetricsInfo는 libuv가 측정하는 이벤트의 누적 처리를 반환하는 것이며 이벤트 루프 내부에서 실행되는 모든 이벤트 콜백의 수를 의미하진 않는다.
import { performance } from 'node:perf_hooks';
import { stat } from 'node:fs';
const tick = () => new Promise(resolve => setImmediate(resolve));
async function measure(name, schedule) {
await tick();
const before = performance.nodeTiming.uvMetricsInfo;
let callbacks = 0;
for (let i = 0; i < 10; i++) {
await new Promise((resolve, reject) => {
schedule(err => {
if (err) return reject(err);
callbacks++;
resolve();
});
});
}
await tick();
const after = performance.nodeTiming.uvMetricsInfo;
return {
name,
callbacks,
loopCount: after.loopCount - before.loopCount,
events: after.events - before.events,
eventsWaiting: after.eventsWaiting - before.eventsWaiting
};
}
const results = [
await measure('setTimeout', done => setTimeout(done, 0)),
await measure('fs.stat', done => stat(process.execPath, done)),
];
console.table(results);
// Result
┌─────────┬──────────────┬───────────┬───────────┬────────┬───────────────┐
│ (index) │ name │ callbacks │ loopCount │ events │ eventsWaiting │
├─────────┼──────────────┼───────────┼───────────┼────────┼───────────────┤
│ 0 │ 'setTimeout' │ 10 │ 11 │ 0 │ 0 │
│ 1 │ 'fs.stat' │ 10 │ 10 │ 10 │ 10 │
└─────────┴──────────────┴───────────┴───────────┴────────┴───────────────┘
대표적으로 setTimeout()의 콜백은 timers phase에서 실행되지만, 그 콜백 실행 자체는 uvMetricsInfo에 집계되지 않는다. 반면 fs 모듈의 비동기 함수들은 libuv에서 처리하기 때문에 이벤트에 집계된다. 이 두 개의 비동기 함수를 실행시켜 실제 uvMetricsInfo가 이벤트로 집계하는지의 여부를 확인해볼 수 있다.
함수는 다음과 같이 흘러간다.
-
setImmdiate()는 check phase에서 실행되기 때문에tick()을 실행시켜 이벤트 루프의 check phase 실행 단계를 기다린다. -
메트릭 정보를 측정한다.
-
비동기 작업 10번을 수행한다.
-
다시
tick()으로 check 단계까지 기다려, 완료된 작업의 이벤트 집계가 메트릭에 반영한다. -
메트릭 정보를 재측정하여 그 전과 비교한다.
두 작업 모두 콜백은 10회 실행됐지만, 이 실험에서는 fs.stat() 작업을 수행한 구간에서만 events가 증가했다. 이 지표가 모든 JS 콜백의 실행 횟수를 세는 것은 아니라는 점을 확인할 수 있다.
공유 버퍼로 개선
한 가지를 솔직하게 말하자면, 이번 글의 주제인 Node.js에 기여를 이해하는데 이벤트 루프에 대해 이해할 필요가 없다. 😐 그럼에도 이렇게 설명하고 넘어가는 이유는 내가 기여한 내용을 계속 유지보수하려면 해당 API가 어떤 데이터를 제공하는 지 알아야 하니, 공부한 내용을 스스로 다시 정리하고 싶었다.
uvMetricsInfo 는 어떻게 이벤트 루프 성능을 가져오나
그래서 본격적으로 기여한 내용에 대해 살펴보자!
이벤트 루프에 대한 카운트 집계를 가져오는 흐름은 그리 복잡하지 않다.
libuv에서 지표 수집
↓
C++ 네이티브 함수에서 지표 조회
↓
JavaScript에서 반환 객체 구성
↓
사용자에게 지표 객체 반환
위 예제에서 우리는 node:perf_hooks 모듈에서 제공하는 uvMetricsInfo API를 호출하면 이 자바스크립트는 위 호출 순서에 따라 데이터를 받아 반환하게 된다.
이벤트 루프는 libuv 기반으로 동작하기 때문에 이벤트 루프에 대한 카운트도 libuv가 내부적으로 카운트하며 관리하고 있다. 이 때 사용자가 이벤트 루프에 대한 성능 조회를 요청하면 내부적으로 C++ 네이티브 함수는 다음과 같이 libuv로부터 성능 데이터를 복사해온다.
void UvMetricsInfo(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
uv_metrics_t metrics;
CHECK_EQ(uv_metrics_info(env->event_loop(), &metrics), 0);
Local<Value> data[] = {
Integer::New(isolate, metrics.loop_count),
Integer::New(isolate, metrics.events),
Integer::New(isolate, metrics.events_waiting),
};
Local<Array> arr =
Array::New(env->isolate(), data, arraysize(data));
args.GetReturnValue().Set(arr);
}
env는 환경변수가 아니라 현재 이 C++함수가 실행되는 Node.js 환경을 가져오며 isolate은 그 환경이 사용하는 V8 인스턴스를 가져온다.
기본적으로 Node.js는 V8을 통해 JS와 C++을 연결한다. V8은 네이티브 함수 호출, 값 참조 및 반환, GC 등을 담당하기 때문에 함수를 호출할 때, 실행 환경과 V8 인스턴스를 미리 가져와 함수 내부에서 사용하는 것이다.
uv_metrics_info()는 libuv가 집계하고 있는 이벤트 루프의 정보를 복사하는 함수다.
int uv_metrics_info(uv_loop_t* loop, uv_metrics_t* metrics) {
memcpy(metrics,
&uv__get_loop_metrics(loop)->metrics,
sizeof(*metrics));
return 0;
}
metrics 변수에 libuv에서 이벤트 루프에 대한 메트릭 정보를 복사하고 0을 반환한다.
그렇게 가져온 데이터는 현재 metrics 변수 안에 저장되어 있으며 이를 다시 JS에 반환해주기 위해 새로운 배열을 생성한다.
data 안에는 각각 loop_count, events, events_waiting을 담고 이걸 JS 배열로 담아, V8에서 제공하는 arg를 통해 반환하여 JS에서도 해당 값을 받을 수 있도록 한다.
get: () => {
const metrics = uvMetricsInfo();
return {
loopCount: metrics[0],
events: metrics[1],
eventsWaiting: metrics[2],
};
},
이걸 이제 JS에서는 getter의 형태로 해당 함수를 호출하여 배열의 형태로 metrics 변수에 담고 이걸 다시 새로운 객체로 선언하여 반환한다.
개선할 수 있는 점
Node.js에서는 JS와 C++이 함께 참조 할 수 있는 메모리 공간을 제공하는 버퍼가 존재한다.
AliasedBuffer는 Node.js 내부 유틸리티로, JS와 C++에서 서로 값을 주고 받을 필요없이 해당 버퍼에 값을 적고 읽는 것만으로도 데이터를 공유할 수가 있다.
기존 uvMetricsInfo에서는 한 번의 조회를 호출할 때마다 C++에서는 새로운 배열을 만들어 반환하고, JS에서는 이걸 배열로 받아 객체로 반환한다.
이에 대해 새로운 자료구조를 생성하여 전달하지 않고 두 언어가 공유할 수 있는 버퍼를 통해 데이터를 참조하면 성능적으로 개선할 수 있을 것이라 생각하였다.
aliased_buffer는 어떻게 메모리를 공유하는가?
C++은 메모리 주소를 통해 변수에 접근이 가능하지만 JS에서는 메모리 주소를 통해 접근하는 것이 불가하다. 하지만 두 영역 모두 공통된 메모리 주소를 참조하여 데이터를 조회할 수 있는 이유는 V8을 통해 메모리 상 버퍼를 생성할 수 있기 때문이다.
간단하게 이해해보면 다음과 같다.
const v8Memory = new ArrayBuffer(20);
const js = new Int32Array(v8Memory);
const cpp = new Int32Array(v8Memory);
cpp[0] = 10;
console.log(v8Memory)
// byteLength: 20
// detached: false
// maxByteLength: 20
// resizable: false
console.log(js[0]);
//10
ArrayBuffer 객체는 원시 이진 데이터로 이루어진 버퍼의 정보를 담은 객체다. 이 버퍼안에 저장된 데이터를 조회하려면 View를 통해 조회가 가능한데, 그 View의 역할을 하는 것이 Int32Array 객체다. 버퍼 안에 있는 데이터를 i32, 4개의 바이트의 부호있는 정수로 나눠서 읽겠다는 의미다.
이 원리와 비슷하게 V8에서 메모리 영역을 지정하여 버퍼를 생성하고, 해당 버퍼를 JS와 CPP에서 조회가 가능하도록 메모리 주소를 연결해주면 두 영역이 공유할 수 있는 버퍼로 활용할 수 있다.
// allocate v8 ArrayBuffer
v8::Local<v8::ArrayBuffer> ab = v8::ArrayBuffer::New(isolate_, size_in_bytes);
buffer_ = static_cast<NativeT*>(ab->Data());
// allocate v8 TypedArray
v8::Local<V8T> js_array = V8T::New(ab, byte_offset_, count);
js_array_ = v8::Global<V8T>(isolate, js_array);
ab는 V8로 만든 ArrayBuffer이 C++에게 실제 데이터가 저장된 메모리 주소를 넘겨주고, JS에서도 해당 버퍼 객체에 대한 View를 생성하여 반환한다.
이 때 NativeT와 V8T는 각 언어에 대해 해당 버퍼 객체를 어떤 타입으로 읽을 지 정해지는 부분이다. 만약 i32로 읽는다면, 해당 ArrayBuffer에 대한 i32 View가 만들어지는 것이다.
개선하는 과정
그러면 실제로 ArrayBuffer를 활용해서 어떻게 개선했을까?
먼저 PerformanceState의 기존 버퍼에 메트릭을 저장할 영역을 추가해주었다.
class PerformanceState {
public:
AliasedInt32Array uv_metrics;
private:
struct performance_state_internal {
int32_t uv_metrics[3];
}
}
PerformanceState 객체가 생성될 때 내부적으로 v8::ArrayBuffer::New()를 통해 메모리를 확보한다. 이 때, uv_metrics를 초기화하며 메모리에 접근이 가능하도록 C++과 JS를 연결해준다.
C++에서는 uv_metrics의 메모리 주소값을 계산하여 사용하면 되고, JS에서도 참조가 가능하도록 uvMetricsBuffer라는 속성에 해당 View를 저장하여 접근이 가능하도록 한다.
// c++에서 참조가 가능하도록 해당 View 반환
uv_metrics(isolate,
offsetof(performance_state_internal, uv_metrics),
3,
root,
MAYBE_FIELD_PTR(info, uv_metrics)),
// JS에서 참조가 가능하도록 해당 View 반환
target
->Set(context,
FIXED_ONE_BYTE_STRING(isolate, "uvMetricsBuffer"),
state->uv_metrics.GetJSArray())
.Check();
이렇게 연결하면 C++과 JS에서 같은 버퍼의 데이터를 참조할 수 있다.
이제 C++에서는 libuv를 통해 받아온 이벤트루프 메트릭 정보를 단순히 해당 버퍼안에 쓰기만 하면 된다.
AliasedInt32Array& buffer =
env->performance_state()->uv_metrics;
buffer[0] = static_cast<int32_t>(metrics.loop_count);
buffer[1] = static_cast<int32_t>(metrics.events);
buffer[2] = static_cast<int32_t>(metrics.events_waiting);
const { uvMetricsInfo, uvMetricsBuffer } =
internalBinding('performance');
...
get: () => {
uvMetricsInfo();
return {
loopCount: uvMetricsBuffer[0],
events: uvMetricsBuffer[1],
eventsWaiting: uvMetricsBuffer[2],
};
},
기존에는 C++에서 반환한 배열을 metrics 변수로 받아, 각 값을 결과 객체에 담았다. 이제는 uvMetricsInfo()를 호출해 버퍼를 갱신한 뒤, uvMetricsBuffer에서 값을 읽어 결과 객체에 담을 수 있게 되었다.
성능 측정
배열의 생성 비용을 제거하고 공유 메모리를 통해 데이터를 참조하여 반환하는 방식으로 수정하였다. 그렇다면 이 함수의 성능은 얼마나 개선되었을까?
먼저 비동기 파일 작업(events) 을 각각 1회, 1,000회, 10,000회 수행했다. 이후 uvMetricsInfo를 100만 번 조회하며 변경 전후의 조회 성능을 비교했다.
confidence improvement accuracy (*) (**) (***)
perf_hooks/nodetiming-uvmetricsinfo.js events=1 n=1000000 *** +158.99 % ±12.43% ±16.73% ±22.15%
perf_hooks/nodetiming-uvmetricsinfo.js events=1000 n=1000000 *** +167.65 % ±6.95% ±9.32% ±12.29%
perf_hooks/nodetiming-uvmetricsinfo.js events=10000 n=1000000 *** +162.19 % ±7.61% ±10.22% ±13.51%
-174.6% 0% +174.6%
perf_hooks/nodetiming-uvmetricsinfo.js events=1 n=1000000 |█████████████████░░ +158.99% ***
perf_hooks/nodetiming-uvmetricsinfo.js events=1000 n=1000000 |██████████████████░ +167.65% ***
perf_hooks/nodetiming-uvmetricsinfo.js events=10000 n=1000000 |██████████████████ +162.19% ***
이 측정 환경에서는 세 입력 조건 모두에서 메트릭 조회 처리량이 약 159~168% 증가해, 기존의 약 2.6배를 기록했다.
마무리
실제로 개선하는 과정보다 내가 개선하려는 모듈이 무엇인지, 어떻게 동작하는 지에 대해 이해하는 과정이 훨씬 오래 걸렸던 것 같다.
실제로 이번 글에서 기여한 내용만 보면 생각보다 너무 간단하다.
항상 배열을 생성해서 반환함 -> 공유 버퍼를 통해 배열 생성 비용을 제거함
그리고 이걸 기여하기 위해 이벤트 루프에 대해 이해할 필요도, 해당 버퍼가 어떻게 메모리를 생성하고 연결하는 지에 대해 이해할 필요도 없다.
하지만 AI와 함께 개선한 이 코드가 가령 문제를 일으키지 않다고 하더라도 무책임하게 코드의 내용을 이해하지 못한 체로 PR을 올리고 싶지는 않았다. 최근 오픈 소스에 기여하면서 점차 느껴가는 것은 **‘나의 PR이 병합되었다는 것이 끝이 아니라는 것’**이다. 오히려 병합이됨으로써 그 코드에 대한 책임은 내가 져야하고 계속해서 유지보수가 가능해야 한다는 생각이 든다.
최근에 Node.js에서도 AI를 통해 자동화 PR이 너무 많아지다보니, Agent Scan을 활용하여 의심이 가는 PR에 대해 라벨을 붙이거나 AI use policy and guidelines 과 같은 문서를 통해 AI 사용에 대한 규정을 철저하게 관리하는 모습을 보이기 시작하였다. 특히, PR에서 보이는 몇몇 리뷰들을 보면 “reviewed by human”이라는 말을 계속 강조하는 것도 많이 보였다.
회사의 내부 자산이라면 누구나 코드 한 줄에 대한 책임감을 크게 가지겠지만, 오픈 소스라는 개방된 환경에서는 자칫하면 기여했다는 성취감만 가져간 체 그 책임감은 이행하지 않으려는 생각이 빠지기 쉬운 것 같다.
그래서 하나하나의 기여가 느리더라도 내가 하는 기여가 명확히 어떻게 바뀌었는지, 그 흐름에 지장을 주진 않는지, 더 나아가 그 기능은 어떻게 동작하는지에 대해 깊이 있기 이해하는 것이 해당 기여에 대해 이의를 제기하더라도 나의 의견을 명확히 얘기할 수 있는 소스가 되어줄 것이라 생각한다.