🗓️ 2025. 03. 16
⏱️ 33

libuv 기여를 통한 Node.js IPC 성능 개선기

10년 묵은 이슈 해결하기

개요

지난 2월, libuv에 기여해 macOS 환경의 Node.js IPC 성능을 최대 4배 이상 개선했습니다.
실무를 하던 중에 Node.js로 생성한 자식 프로세스의 표준입출력 성능이 비정상적으로 느린 것을 확인했고
원인을 찾다보니 libuv 차원의 문제, 더 정확히는 macOS의 XNU 커널로 인한 문제임을 알 수 있었습니다.

이 글에선 어쩌다 성능 문제를 발견했는지, 어떻게 원인을 파악하여 어떤 방식으로 해결했는지를 다룹니다.
다음 사전 지식들이 요구되므로 만약 익숙치 않으시다면 읽는 데 어려움이 있을 수 있습니다.

  • Node.js stream과 child process
  • Unix pipe와 Unix domain socket

사전 지식들 하나하나가 너무도 방대한 주제이므로 이번 글에선 개선기에 집중했습니다.
다음에 기회가 된다면 각 주제들에 대해서도 상세히 작성해보겠습니다.

만약 얼른 알아보고 싶으시다면 아래 링크를 통해 학습해보시길 권합니다.

Node.js v22를 기준으로 작성된 글입니다.

IPC

글의 주제가 'Node.js의 IPC 성능 문제'인만큼, 우선 IPC의 개념을 간략히 다뤄보겠습니다.
IPC는 Inter-Process Communication의 약자로, 프로세스 간 통신을 의미합니다.

우리는 시스템의 구조가 점차 커지고 복잡해지면 시스템을 책임별로 분리시키곤 합니다.
인증을 담당하는 파트, 주문을 담당하는 파트, AI 추론을 담당하는 파트처럼 말이죠.
여러 책임으로 나뉜 구성 요소가 네트워크로 연결되어 하나의 큰 시스템을 이루는 구조는 우리에게 익숙합니다.

그 구조를 네트워크가 아닌 로컬 PC 단위로 축소시킨 게 바로 IPC입니다.
내 PC에서 수행될 하나의 큰 작업을 책임에 맞게 여러 프로세스로 나누는 것이죠.

Node.js 개발자에게 가장 친숙한 예시는 Electron입니다.
IPC는 Electron을 이루는 핵심 기술 중 하나로서, 메인 프로세스와 렌더러 프로세스 간 통신을 이용해 애플리케이션을 구축합니다.
앞선 설명처럼 애플리케이션의 비즈니스 로직과 UI 렌더링이라는 책임을 각각의 프로세스에게 위임한 형태인 거죠.

뿐만 아니라 IPC는 웹브라우저, Docker, VSCode, 각종 데이터베이스에도 쓰입니다. 이처럼 로컬 PC에서도 시스템이 충분히 크고 복잡하거나, 내가 다루는 언어로 수행하기 어려운 작업이 있다면 IPC를 이용해 분리시키는 것이 보편적인 해결책 중 하나입니다.

저같은 경우엔 실시간 영상에 인공지능을 적용하는 작업에서 복잡한 비즈니스 로직과 I/O는 Node.js로, 인공지능 추론은 Python으로 책임을 나누어 프로젝트를 진행한 적이 있습니다. 이때 실시간 스트림 처리에 Pipe를 이용한 방식이 가장 적합하다고 판단했는데요, 정말 프로덕트에 적용해도 될지 검증하는 단계에서 성능 문제를 발견했습니다.

너무 느린데?

검증했던 작업을 간단히 요약하면 다음과 같습니다.

  1. Node.js에서 실시간 영상을 Python으로 넘긴다.
  2. Python은 영상 처리를 하고, 그 결과를 Node.js에게 다시 넘긴다.
  3. Node.js는 처리된 영상을 저장한다.

(이때 실시간 영상 수신이나 raw 데이터 변환하는 등의 작업은 ffmpeg을 이용했습니다.)

여기서 가장 먼저 검증해야 할 로직은 '원본 영상 수신 → raw 데이터 변환 → mp4 저장'입니다.
이 로직에 문제가 없어야 중간에 Python을 끼워넣는 검증도 수행할 가치가 있어지니까요.
그래서 일단 ffmpeg만을 이용해 테스트 해보고, 이상이 없다면 비즈니스 로직을 적용할 수 있게 Node.js에서 ffmpeg을 자식 프로세스로 띄워 구현할 예정이었습니다.

ffmpeg으로 구성된 스크립트는 다음과 같습니다.
(이 글에서 사용되는 ffmpeg 커맨드는 이게 전부입니다!)

ffmpeg -i 30fps.mp4 -f rawvideo -pix_fmt rgb24 pipe:1 |
ffmpeg -f rawvideo -pix_fmt rgb24 -s 1920x1080 -r 30 -i pipe:0 -f mp4 -pix_fmt yuv420p -c:v libx264 -movflags empty_moov bash.mp4

위 스크립트는 2개의 ffmpeg 프로세스를 띄워 아래 작업을 수행합니다.

  1. 영상을 rgb24 포맷의 raw 데이터 형태로 변환한 뒤 표준 출력으로 내보내기
  2. pipe를 통해 표준 입력으로 raw 데이터를 읽어들여 mp4 파일로 저장하기

5분 길이의 FHD 영상으로 테스트 시 약 38초가 소요됩니다. (맥북 M4 Pro, 24GB RAM 기준)

Node.js로 구현하면 다음과 같습니다.

const { spawn } = require('node:child_process');

console.time('conversion');

const srcArgs = ['-i', '30fps.mp4',
                 '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'];
const src = spawn('ffmpeg', srcArgs);

const destArgs = ['-f', 'rawvideo', '-pix_fmt', 'rgb24', '-s', '1920x1080', '-r', '30', '-i', 'pipe:0',
                  '-f', 'mp4', '-pix_fmt', 'yuv420p', '-c:v libx264', '-movflags', 'empty_moov', 'node.mp4'];
const dest = spawn('ffmpeg', destArgs);

src.stdout.pipe(dest.stdin);

src.on('close', () => {
  dest.stdin.end();
});

process.on('exit', () => {
  console.timeEnd('conversion');
});

별거 없죠? 그냥 동일한 인자로 자식 프로세스를 생성해서 pipe로 연결해둔 게 전부입니다.
그런데 이 코드를 실행하면 약 72초가 소요됩니다. shell 스크립트에 비하면 거의 2배 수준이죠.

Node.js로 인한 오버헤드가 이렇게까지 큰가 싶어 우선 데이터 처리량을 가늠해봤습니다.

  • 1 프레임 = 1920 * 1080 * 3 = 6,220,800bytes = 약 6.2mb
  • 30fps = 약 6.2mb * 30 = 약 186.6mb

I/O 작업에 특화된 Node.js가 로컬 환경에서 초당 186mb도 처리하지 못한다는 게 납득이 되질 않았습니다.
그래서 동일한 로직을 파이썬으로 테스트해봤습니다.

import subprocess
import time

start_time = time.time()

src_args = ["ffmpeg", '-i', '30fps.mp4',
            '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1']
src = subprocess.Popen(src_args, stdout=subprocess.PIPE)

dest_args = ["ffmpeg",'-f', 'rawvideo', '-pix_fmt', 'rgb24', '-s', '1920x1080', '-r', '30', '-i', 'pipe:0',
             '-f', 'mp4', '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-movflags', 'empty_moov', 'python.mp4']
dest = subprocess.Popen(dest_args, stdin=subprocess.PIPE)

chunk_size = 65536

while True:
    data = src.stdout.read(chunk_size)

    if not data:
        break

    dest.stdin.write(data)

dest.stdin.close()
src.wait()
dest.wait()

end_time = time.time()
print(f"conversion time: {end_time - start_time:.2f} seconds")

*chunk_size가 64kb인 이유는 Node.js 자식 프로세스의 표준입출력 highWaterMark가 64kb이기 때문입니다.

이번엔 약 40초가 소요됐습니다. shell 스크립트와 거의 동일한 성능이죠.
Node.js에 애정이 깊은 만큼 I/O 작업이 파이썬에 밀린다는 게 도무지 받아들여지질 않아 차분히 원인을 파악해봤습니다.

뭐가 문제일까?

Node.js stream에서 성능 문제의 원인은 대개 배압(backpressure)이나 적절하지 않은 highWaterMark입니다. 하지만 작성했던 예제 코드에서는 pipe로 스트림을 연결해주고 있기 때문에 배압을 알아서 관리해주므로 배압은 문제가 되지 않습니다.

또한 highWaterMark도 파이썬에서 동일한 크기(64kb)로 데이터를 읽었을 때 성능 문제가 없었으니 원인으로 보이진 않습니다. 그럼에도 highWaterMark를 키우면 성능이 개선되지 않을까하는 생각이 들었지만 이는 불가능했습니다. fs나 net 모듈과 달리 자식 프로세스에서는 highWaterMark 조절이 지원되지 않기 때문입니다.

분명 저와 같은 니즈가 이미 있었을 거라 판단해 관련 이슈를 찾아봤습니다. 이미 2022년에 동일한 요구사항이 제기된 적이 있었고, 해당 이슈는 아직 해결되지 않았지만 여기서 한 가지 힌트를 발견할 수 있었습니다. 논의 내역에서 libuv를 수정해 직접 highWaterMark의 크기를 늘려보는 내용이 있어 이를 참고해 버퍼의 크기(highWaterMark)를 128kb로 늘려봤습니다.

// deps/uv/src/unix/stream.c

static void uv__read(uv_stream_t* stream) {
  // ...
  
    //stream->alloc_cb((uv_handle_t*)stream, 64 * 1024, &buf);
    stream->alloc_cb((uv_handle_t*)stream, 128 * 1024, &buf);
    
  // ...
}

변경된 코드로 Node.js를 다시 빌드하여 테스트 해본 결과, 성능은 동일했습니다.
이또한 납득하기 힘든 결과입니다. 버퍼 크기를 조정했으니 분명 더 빨라지거나 느려져야 할 텐데 바꾸기 전과 동일한 결과가 나오다니요.

그래서 이번엔 버퍼에 정상적으로 데이터가 들어오고 있는지 직접 데이터의 크기를 확인해봤습니다.

// deps/uv/src/unix/stream.c

static void uv__read(uv_stream_t* stream) {
  // ...

    //stream->alloc_cb((uv_handle_t*)stream, 64 * 1024, &buf);
    stream->alloc_cb((uv_handle_t*)stream, 128 * 1024, &buf);

    // ...

    if (!is_ipc) {
      do {
        nread = read(uv__stream_fd(stream), buf.base, buf.len);
        printf("%d\n", nread); // <=== 읽은 데이터 크기 확인
      }
      while (nread < 0 && errno == EINTR);
    } 
    
  // ...
}

읽어들인 데이터의 크기(nread)를 확인해보니 8kb밖에 되질 않았습니다. 실제로 Node.js에서 이벤트 리스너를 등록해 확인해보니 8kb씩 데이터를 읽는 것을 확인할 수 있었습니다.

// ...
src.stdout.on('data', (data) => {
	console.log(data.length); // 8192
});

요컨대 실제 데이터가 8kb 밖에 들어오질 않아 성능이 비정상적이었던 거죠.
때문에 버퍼 크기를 8kb 밑으로 줄인다면 성능 변화가 있겠지만, 8kb를 넘는다면 아무리 늘려봤자 변화가 없는 게 당연합니다.

저는 이 시점에서 Node.js가 아닌 libuv 문제임을 인지하게 되어 문제의 방향성을 바꾸게 됩니다.
애초에 자식 프로세스의 표준 출력에서 8kb 밖에 못 읽어들이는 상황을 핵심 문제로 보고 관련 이슈를 한번 더 찾아봤습니다.

무려 10년 전, Node.js v4가 최신이던 2015년에 등록된 이슈에서 실마리를 찾을 수 있었습니다.
해당 이슈에선 문제 상황을 조금 더 명확히 제시합니다. 'macOS 환경에서' 자식 프로세스의 표준 출력 성능이 좋지 않다는 것입니다. 게다가 제가 발견했던 버퍼 크기에 대한 문제도 언급됐습니다. 정말 macOS가 문제인지 도커를 이용해 확인해보니 리눅스 환경에서 약 55초가 소요됐고, 컨테이너 환경임에도 호스트 환경보다 훨씬 더 빠른 것을 확인할 수 있었습니다.
(관련 이슈들에서 자식 프로세스의 표준 입력에 대한 얘기는 없어 같은 방식으로 테스트 해보니 동일한 문제를 발견할 수 있었습니다)

지금까지의 내용을 한 문장으로 정리하면 다음과 같습니다.
"libuv 단의 문제로 인해 macOS에서 자식 프로세스의 표준입출력 성능이 현저히 낮다."

어디가 문제일까?

문제점을 정확히 짚어내고 그에 대한 논의까지 이뤄진 이슈를 찾을 수 있었지만 몇 가지 문제가 남아있습니다.

  1. 10년 전을 기준으로 논의가 이뤄진 것
  2. 해결책이 제시됐으나 다른 Node.js 멤버에겐 효과가 없었다는 내용
  3. '우리가 충분히 시도해봤고, 쉬운 해결책이 없다'는 말로 논의가 마무리된 것
  4. 결정적으로 제가 libuv의 구현을 모르기 때문에 논의의 해결책이 제시된 흐름을 이해할 수 없는 것

그래서 저는 spawn 함수의 구현부를 시작으로 직접 libuv까지 도달해보기로 결정했습니다.

spawn 함수의 구현부에서는 ChildProcess의 인스턴스를 생성해 spawn 메서드를 호출합니다.

// lib/child_process.js

const child_process = require('internal/child_process');

function spawn(file, args, options) {
  // ...
  const child = new ChildProcess();
  child.spawn(options);
  // ...
}

ChildProcess의 구현부에서는 C++로 바인딩된 Process 클래스를 이용하고 있으므로 곧바로 Node.js의 C++ 바인딩까지 내려가보겠습니다.

// lib/internal/child_process.js

const { Process } = internalBinding('process_wrap');

function ChildProcess() {
  // ...
  this._handle = new Process();
  // ...
}

ChildProcess.prototype.spawn = function(options) {
  // ...
  const err = this._handle.spawn(options);
  // ...
}

C++ 바인딩에서는 자식 프로세스의 spawn 메서드가 호출되면 libuv의 uv_spawn 함수를 호출해 자식 프로세스를 생성합니다.
주된 목적이 libuv로 생성된 자식 프로세스와 Node.js를 연결짓는 것이므로 실질적인 관리는 libuv에서 이뤄짐을 알 수 있습니다.

// src/process_wrap.cc

namespace {

class ProcessWrap : public HandleWrap {
 public:
  static void Initialize(Local<Object> target,
                         Local<Value> unused,
                         Local<Context> context,
                         void* priv) {
    // ...
    SetProtoMethod(isolate, constructor, "spawn", Spawn);
    // ...
  }

  static void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
    registry->Register(New);
    registry->Register(Spawn);
    registry->Register(Kill);
  }

  static void Spawn(const FunctionCallbackInfo<Value>& args) {
    // ...

    if (err == 0) {
      err = uv_spawn(env->event_loop(), &wrap->process_, &options);
      wrap->MarkAsInitialized();
    }

	// ...
  }
}

벌써 libuv까지 도달했네요! 이제 uv_spawn 함수를 살펴보겠습니다.
구현 전반부에서 uv__process_init_stdio라는 함수가 호출됩니다.
고맙게도 표준입출력(stdio)을 초기화한다는 직관적인 이름 덕에 이 곳부터 살펴봐야겠다는 생각이 듭니다.
(이 함수는 10년 전 이슈의 말미에서 논의된 함수이기도 합니다.)

// deps/uv/src/unix/process.c

int uv_spawn(uv_loop_t* loop,
             uv_process_t* process,
             const uv_process_options_t* options) {
  // ...
  
  for (i = 0; i < options->stdio_count; i++) {
    err = uv__process_init_stdio(options->stdio + i, pipes[i]);
    if (err)
      goto error;
  }

  // ...
}

uv__process_init_stdio 함수에서는 uv_socketpair라는 함수로 파이프를 생성한다는 것을 알 수 있습니다.

// deps/uv/src/unix/process.c

static int uv__process_init_stdio(uv_stdio_container_t* container, int fds[2]) {
  // ...

  case UV_CREATE_PIPE: // <=== 여기!
    assert(container->data.stream != NULL);
    if (container->data.stream->type != UV_NAMED_PIPE)
      return UV_EINVAL;
    else
      return uv_socketpair(SOCK_STREAM, 0, fds, 0, 0);

  // ...
}

socketpair라는 함수는 C언어에서 Unix Domain Socket을 생성하는 함수입니다.
uv_socketpair는 libuv에서 정의한 일종의 wrapper 함수라고 보시면 됩니다.
Node.js에서 자식 프로세스를 사용할 땐 마치 Unix pipe인 것처럼 썼지만, 실제론 Unix Domain Socket으로 구현된 것이죠.

Node.js 자식 프로세스 공식 문서의 표준입출력 항목을 확인해보면 이에 대한 언급을 찾을 수 있습니다.

These are not actual Unix pipes and therefore the child process can not use them by their descriptor files

그런데 조금 이상합니다. OS 환경에 따른 조건부 컴파일이 있는 것도 아닌데 왜 성능 차이가 발생하는 걸까요?
macOS와 리눅스 환경에서 똑같이 Unix Domain Socket을 쓰고 있다면 성능도 동일해야 하는 거 아닐까요?

처음에 저는 같은 IPC 방식이더라도 OS에 따라 성능 차이가 있는 줄 알고 macOS에서 왜 Unix Domain Socket이 느린지 찾아봤습니다.
그러다 Why is a FIFO pipe on macOS ~8x slower than an anonymous pipe?라는 질문의 답변에서 그 이유를 알 수 있었습니다.
원인인즉슨 애초에 macOS 자체적으로 Unix Domain Socket의 버퍼 크기가 8kb밖에 되질 않기 때문에 데이터 송수신이 느린 것이었습니다. 이 내용은 10년 전의 이슈에서도 동일하게 언급됐으므로 이제 확실히 문제의 원인을 굳힐 수 있겠네요.

"libuv 단의 문제로 인해 macOS에서 자식 프로세스의 표준입출력 성능이 현저히 낮다."
"macOS에서 Unix Domain Socket의 기본 버퍼 크기가 작아 자식 프로세스의 표준입출력 성능이 현저히 낮다."

검증하기

문제의 원인이 특정됐으므로 더 엄밀하게 검증해보겠습니다.
아시다시피 macOS와 리눅스는 POSIX 호환성을 지키기 때문에 대부분의 경우 인터페이스는 동일합니다.
하지만 인터페이스가 같더라도 기본 버퍼 크기와 같은 세부 구현은 다를 수 있으므로 그 구현을 더 분명히 알아보려 합니다.
(리눅스는 도커 컨테이너 환경이며 Node.js 공식 도커 이미지를 사용했습니다)

# macOS
$ sysctl net.local.stream.sendspace
net.local.stream.sendspace: 8192

$ sysctl net.local.stream.recvspace
net.local.stream.recvspace: 8192
# linux
$ sysctl net.core.rmem_default
net.core.rmem_default = 229376

$ sysctl net.core.wmem_default
net.core.wmem_default = 229376

각각의 OS에서 Unix Domain Socket의 버퍼 크기를 출력해보면 macOS에서는 조사했던대로 8kb입니다.
게다가 리눅스쪽이 28배 더 크기도 하네요.

다음은 커널입니다.

# macOS
$ uname -v
Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:22 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6041

xnu 커널 11215 버전의 소스 코드를 확인해보면 코드 레벨에서 기본 버퍼 크기가 8kb로 설정돼있음을 확인할 수 있습니다.

// bsd/kern/uipc_usrreq.c

// ...

#ifndef PIPSIZ
#define PIPSIZ  8192
#endif
static u_int32_t        unpst_sendspace = PIPSIZ;
static u_int32_t        unpst_recvspace = PIPSIZ;
static u_int32_t        unpdg_sendspace = 2 * 1024;
static u_int32_t        unpdg_recvspace = 4 * 1024;

// ...

static int
unp_attach(struct socket *so)
{
  // ...

	if (so->so_snd.sb_hiwat == 0 || so->so_rcv.sb_hiwat == 0) {
		switch (so->so_type) {
		case SOCK_STREAM:
			error = soreserve(so, unpst_sendspace, unpst_recvspace);
			break;

		case SOCK_DGRAM:
			so->so_snd.sb_lowat = 1;
			error = soreserve(so, unpdg_sendspace, unpdg_recvspace);
			break;

	// ...
}

이제 리눅스 차례입니다.
Node.js 공식 도커 이미지는 Debian 기반이므로 리눅스 커널 6.12.15 버전의 소스 코드를 살펴보겠습니다.

# linux
$ uname -a
Linux 0f988a67649c 6.12.15-orbstack-00304-gd0ddcf70447d #60 SMP Tue Feb 18 19:55:47 UTC 2025 aarch64 GNU/Linux

리눅스에선 아래 코드에 나와있듯 계산식으로 기본 버퍼 크기가 결정됩니다. 리눅스 환경에서 Unix Domain Socket의 기본 버퍼 크기를 묻는 질문의 답변을 참고하면, 대부분의 경우 최소 160kb 이상일 것입니다. 위에서 확인했듯이 도커 컨테이너 환경에선 224kb였습니다.

// linux/6.12.15-1/include/net/sock.h

#define _SK_MEM_PACKETS		256
#define _SK_MEM_OVERHEAD	SKB_TRUESIZE(256)
#define SK_WMEM_MAX		(_SK_MEM_OVERHEAD * _SK_MEM_PACKETS)
#define SK_RMEM_MAX		(_SK_MEM_OVERHEAD * _SK_MEM_PACKETS)

이같은 Unix Domain Socket은 libuv에서 이용되며, libuv는 C언어로 작성됐습니다.
그렇기에 마지막으로 C언어로 작성된 코드를 통해 Unix Domain Socket을 만들었을 때 소켓의 버퍼 크기가 앞에서 살펴본 것과 일치하는지 확인해보겠습니다.

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

int main() {
  int sock = socket(AF_UNIX, SOCK_STREAM, 0);
  int recv_buf, send_buf;
  socklen_t optlen = sizeof(int);

  if (getsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recv_buf, &optlen) == 0) {
    printf("SO_RCVBUF: %d bytes\n", recv_buf);
  } else {
    perror("getsockopt(SO_RCVBUF)");
  }

  if (getsockopt(sock, SOL_SOCKET, SO_SNDBUF, &send_buf, &optlen) == 0) {
    printf("SO_SNDBUF: %d bytes\n", send_buf);
  } else {
    perror("getsockopt(SO_SNDBUF)");
  }

  close(sock);

  return 0;
}

위 코드를 각 환경에서 빌드 후 실행해보면 앞선 sysctl과 동일한 수치가 나옵니다.
똑같이 Unix Domain Socket을 만들더라도 커널 수준의 구현이 달라 소켓의 기본 버퍼 크기 역시 달라진다고 결론지을 수 있겠네요.

이제 문제와 그 원인이 모두 명확해졌습니다. 그럼 이 문제를 어떻게 해결할 수 있을까요?

해결하기

소켓의 기본 버퍼 크기가 작은 것이 원인이므로 해결책은 버퍼 크기를 늘려주는 것뿐입니다.
가장 간단히는 sysctl로 늘려줄 수 있겠지만 이 방법엔 여러 문제가 있습니다.

  1. 기본적으로 sysctl은 '일시적'으로 버퍼 크기를 조정해주므로 시스템이 재시작된 경우 버퍼 크기는 되돌아옵니다.
  2. 시스템 재시작시에도 버퍼 크기가 유지되려면 개발자들의 시스템 설정이 강제됩니다.

그렇다고 xnu 커널을 수정할 수도 없는 노릇이기에 저는 libuv에서 직접 버퍼 크기를 늘려주기로 했습니다.

수정한 코드는 다음과 같습니다.

// deps/uv/src/unix/process.c

static int uv__process_init_stdio(uv_stdio_container_t* container, int fds[2]) {
  int mask;
  int fd;
  int ret;
  int size;
  int i;

  mask = UV_IGNORE | UV_CREATE_PIPE | UV_INHERIT_FD | UV_INHERIT_STREAM;
  size = 64 * 1024;

  // ...

    else {
      ret = uv_socketpair(SOCK_STREAM, 0, fds, 0, 0);

      if (ret == 0)
        for (i = 0; i < 2; i++) {
          setsockopt(fds[i], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));
          setsockopt(fds[i], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));
        }
    }

  // ...
}

10년 전의 이슈를 끝까지 읽어보신 분은 아시겠지만 사실상 똑같은 해결책입니다.
다만 왜 하필 이 곳을 이렇게 고쳐야 하는지 저 스스로 납득하고 설명할 수 있게 됐을 뿐이죠.
버퍼 크기를 64kb로 늘렸을 때의 수행 시간은 약 39초였으며, 몇 줄 안 되는 저 작은 기여로 월등한 성능 향상을 체감할 수 있었습니다.
(콕 찝어 64kb로 설정한 이유는 자식프로세스의 highWaterMark가 64kb이기 때문입니다.)

모든 성능 지표를 그래프로 비교해보면 확연한 차이를 볼 수 있습니다.

지금까지의 모든 성능 지표지금까지의 모든 성능 지표

libuv에 기여하기

지금까지의 디깅 과정과 해결책을 요약하고, 테스트 코드도 작성하여 PR을 올렸으나... 처음엔 반려되었습니다.
제 PR의 첫 피드백은 "대체 왜 uv_pipe가 아닌 uv_socketpair를 쓰느냐?" 였습니다.
그 결정은 이번 PR이 아니라 기존 코드에서 비롯된 것이고, 제 수정사항은 그저 버퍼 크기를 늘리는 게 전부였기 때문에 일순 당황했으나 차분히 논의를 시작해봤습니다.

피드백을 줬던 멤버는 표준입출력이 단방향이므로 uv_pipe를 쓰는 게 옳다는 의견이었습니다. uv_pipe를 쓰면 기본 버퍼 크기 역시 다를 것이라면서 말이죠. 사실 system call을 알고 계신 분이라면 child_process 모듈에서 libuv까지 내려오는 과정에서 이미 의아해하셨을 겁니다. 실제로 지난 2018년에 Unix Domain Socket이 아니라 Unix Pipe로 구현이 바뀌어야 한다는 논의도 이뤄졌었구요.

하지만 단순히 uv_pipe로 바꾸는 것만으론 해결할 수 없었습니다. 이미 기존 기능들이 양방향을 기준으로 작성되었기에 수많은 테스트가 깨지기 때문입니다. 이 작업의 규모가 얼마나 될지, 그 영향이 어느 정도일지는 이제 막 libuv를 접한 저로서는 가늠하기 어렵습니다. 그래서 우선 멈춰선 후 멤버들에게 이 작업의 방향성에 동의하는지 먼저 물어보기로 했습니다. 만약 그렇지 않다면 헛일이 되는 거니까요.

결과적으로 호환성을 고려해 uv_socketpair를 유지하자는 쪽으로 의견이 정리되었으나, 제겐 여전히 성능 문제가 남아있었습니다.
uv_socketpair를 유지하더라도 버퍼 크기를 늘리는 건 여전히 유의미하다고 생각해 다시 한번 제 의견을 개진했고, 곧바로 받아들여져서 코드 리뷰에 따라 최종 수정을 거친 후 libuv에 반영되었습니다.

돌이켜보며

디깅 과정을 간략히 소개했지만 사실 사흘 밤낮을 꼬박 샜습니다.
자식 프로세스에 관여하는 JS, C++, C 코드 곳곳에 로그를 찍어가면서 말이죠.
(libuv 멤버들에게 피드백이 왔을 땐 심장이 너무 뛰어서 잠도 제대로 못 잤습니다.)

libuv를 잘 모르더라도 이런 여정을 거치면 작은 부분은 얼마든지 기여할 수 있음을 공유하고 싶었습니다.
고작 10줄 내외의 코드를 추가하기 위해 제가 겪은 지난한 경험이 여러분께 도움이 됐을지 모르겠네요.

이번 기여에서 제게 큰 도움이 됐던 건 평소 호기심으로 공부해뒀던 CS였습니다.
네트워크 통신은 어떻게 이뤄지는지, 그 이전에 하나의 컴퓨터 안에선 어떻게 데이터가 오가는지, Node.js는 어떻게 돌아가는지, 메모리는 어떻게 할당되고 해제되는지 등 이렇게 쓰일 줄 모르고 배워뒀던 모든 것들을 써먹을 수 있었네요.

여전히 libuv와 C++ 바인딩은 모르는 영역 투성이지만 저의 디버깅 단위가 꽤나 아래까지 내려온 데 큰 기쁨을 느꼈습니다. 뭣보다 Node.js에서 자식 프로세스를 쓸 때 느리다는 오해가 생기지 않게 된 게 더할 나위없이 기쁘구요 ㅎㅎ

마지막으로 ChatGPT를 언급하고 싶습니다. 정~말 적극적으로 사용했고 ChatGPT가 없었다면 아마 훨씬 더 많은 시간을 쏟아야 했을 겁니다. 해결책에 대한 의견을 구하거나 어떤 피드백이 예상되는지, 수정사항은 기여 가이드에 맞는지 같은 실질적인 도움부터 영어가 필요한 소통 문제를 전부 ChatGPT에게 맡겼거든요. 최종 PR 역시 한글로 전부 작성한 뒤에 적절한 톤과 정중함을 섞어 영어로 번역해달라고 요청했었습니다. 여러분도 오픈소스에 기여할 때 이런 느낌으로 활용해보시길 추천드립니다.

아무쪼록 이 글이 Node.js에 기여하고픈 모든 분들께 도움이 되길 바랍니다.

참고

돌아가기
© 2025 VERYCOSY.