왜 나는 Node.js API 게이트웨이에서 stream.pipe()를 제거했는가

발행: (2026년 3월 28일 AM 01:09 GMT+9)
12 분 소요
원문: Dev.to

Source: Dev.to

번역하려는 전체 텍스트를 제공해 주시면, 원본 형식과 마크다운을 유지하면서 한국어로 번역해 드리겠습니다.

Introduction

제가 Torus라는 멀티코어 Layer 7 Edge API Gateway를 Node.js로 처음 만들 때, 들어오는 네트워크 요청을 표준 웹 애플리케이션에서 보통 보는 방식대로 처리했습니다:

let body = '';
req.on('data', (chunk: Buffer) => {
  body += chunk.toString();
});
req.on('end', () => {
  forwardToBackend(body);
});

가벼운 테스트에서는 완벽히 동작했습니다. 하지만 동시에 많은 부하와 큰 페이로드를 프록시를 통해 전달하기 시작하자, 서버가 점점 버벅이기 시작했습니다. CPU 사용량이 100 %까지 치솟고, 이벤트 루프가 지연되며, 메모리 사용량이 통제되지 않게 증가해 결국 프로세스가 크래시되었습니다.

저는 고전적인 아키텍처 함정에 빠졌습니다: 원시 TCP 페이로드 바이트를 그대로 V8 JavaScript 엔진의 메모리 힙으로 끌어들인 것이었습니다.

V8 힙은 엄격한 메모리 제한을 가지고 있기 때문에, 대용량 페이로드를 사용자 공간 메모리로 가져오면 Node.js 가비지 컬렉터(GC)가 과도하게 작업하게 됩니다. GC는 할당된 메모리를 정리하기 위해 단일 스레드인 이벤트 루프를 멈추게 되며, 이는 프록시 내의 다른 모든 활성 네트워크 연결을 실질적으로 정지시킵니다.

교훈: 프록시는 데이터를 읽어서는 안 되고, 단순히 전달만 해야 합니다.

프로덕션 수준의 게이트웨이를 만들기 위해서는 V8 힙을 완전히 우회하고, 데이터를 원시 C++ 메모리 블록에 보관한 뒤 운영 체제 수준에서 이동시켜야 했습니다. 라우팅 로직을 리팩터링하던 중, 표준 Node.js 스트림 API에 숨겨진 치명적인 결함을 발견했고, 이 때문에 전체 테스트 스위트가 멈춰버렸습니다.

첫 번째 진화: .pipe() 로 V8 우회하기

아키텍처 수정을 위해서는 데이터를 바라보는 방식에 근본적인 변화를 줘야 했습니다. 페이로드를 정적인 변수로 취급하는 것을 멈추고, 흐르는 물처럼 다루어야 했습니다.

전체 50 MB 파일을 메모리에 로드한 뒤에 전달할 필요가 없었습니다. 몇 킬로바이트 정도만 임시 버퍼에 담고, 목적지로 플러시한 뒤 그 메모리 공간을 재사용하면 충분했습니다.

Node.js에서는 바로 node:stream 모듈과 Buffer 객체가 이런 용도로 설계되어 있습니다.

  • Buffer는 메모리를 V8 JavaScript 엔진 외부에 할당합니다. 운영 체제에 직접 매핑된 순수 C++ 메모리 블록을 사용합니다.
  • 네트워크 청크를 그대로 Buffer 로 유지하면 페이로드가 JavaScript 힙에 들어가지 않으므로 V8 가비지 컬렉터가 전혀 관여하지 않습니다.
  • 이벤트 루프는 다른 연결을 처리할 여유를 유지합니다.

이를 연결하기 위해 저는 기본 제공 .pipe() 메서드를 사용해 읽기 가능한 클라이언트 스트림을 쓰기 가능한 백엔드 스트림에 연결했습니다:

// Connects the incoming ReadableStream directly to the outgoing WritableStream
clientReq.pipe(proxyReq);

이 한 줄은 OS 수준의 배관 시스템 역할을 합니다. 그것은:

  • 들어오는 TCP 스트림에서 순수 C++ 버퍼를 읽어옵니다.
  • 백프레셔를 자동으로 관리해(빠른 클라이언트가 느린 백엔드에 과부하를 주는 것을 방지)
  • 바이트를 직접 라우팅 풀로 내보냅니다.

CPU 사용량은 급격히 감소했고, 메모리 사용량은 페이로드 크기에 관계없이 일정하게 유지되었습니다. 마치 스케일링 문제를 완전히 해결한 듯했죠—하지만 숨겨진 취약점을 발견하기 전까지는 말이었습니다.

플롯 트위스트: 조용한 소켓 누수

프록시는 빠르고, CPU는 유휴 상태이며, 메모리는 평탄했습니다. 그 후 통합 테스트 스위트를 실행했습니다.

모든 어설션은 통과했지만 터미널이 멈췄습니다. Jest는 종료를 거부하고 결국 다음 경고를 출력했습니다:

Jest did not exit one second after the test run has completed.

처음에는 일반적인 teardown 문제라고 생각하고 proxyServer.close()와 모든 Redis 클라이언트가 연결 해제되는지를 확인했습니다. 그러나 테스트는 여전히 멈춰 있었습니다.

이벤트 루프를 살아 있게 만드는 원인을 찾기 위해 OS 수준까지 내려가야 했습니다. Node.js에서는 활성 I/O 핸들(예: net.Socket)이 큐에 존재하는 한 이벤트 루프가 종료되지 않습니다. 무언가가 소켓을 계속 살아 있게 만들고 있었습니다.

문제의 원인은 .pipe()였습니다.

Jest 테스트가 프록시를 통해 더미 요청을 보낸 뒤 클라이언트 측 소켓을 정상적으로 닫았지만, .pipe() 수명 주기 이벤트를 전파하지 않습니다. 데이터는 무작정 전달하지만 errorclose 이벤트를 대상 스트림으로 전달하지 않습니다. 그 결과 백엔드 연결이 열려 있는 상태로 남아, 절대 도착하지 않을 바이트를 기다리게 됩니다.

그 결과: 반쯤 열려 있는 소켓이 생성되는 머신이 되었습니다. 프로덕션 환경에서는 클라이언트가 끊길 때마다 파일 디스크립터(FD)가 영구적으로 잠기게 됩니다. 결국 OS가 FD 한도에 도달하고, Node는 EMFILE 오류와 함께 충돌하게 됩니다.

The Fix: stream.pipeline()

Node.js 코어 유지관리자들은 .pipe()가 프로덕션 인프라에서 위험하게 순진하다는 것을 인식하고, 그래서 stream.pipeline()을 도입했습니다.

데이터를 한 소켓에서 다른 소켓으로 무작정 전달하는 대신, pipeline()은 전체 스트림 체인을 모니터링하는 통합 상태 머신으로 동작합니다. 그것은:

  • 오류, close, end 이벤트를 파이프라인 전체에 전파합니다.
  • 체인 내 어느 스트림이 실패하거나 예외를 발생시키거나 갑자기 종료될 경우(예: 클라이언트가 ECONNRESET으로 연결을 끊는 경우) 모든 스트림을 파괴합니다.
  • 잡을 수 있는 단일 오류를 반환하여 정리 로직을 단순화합니다.

모든 .pipe() 사용을 pipeline()으로 교체함으로써, 제가 작성했던 수십 개의 수동 .on('error') 핸들러를 없앨 수 있었고, 소켓 해제가 Node.js 코어 네트워킹 스택에 의해 올바르게 처리되도록 보장했습니다.

Source:

양방향 파이프라인을 이용한 Raw TCP 프록시

Raw TCP 프록시에는 양방향 데이터 흐름이 필요하므로, 기존 구현을 두 개의 병렬 순차 파이프라인으로 교체했습니다:

try {
  await Promise.all([
    pipeline(clientSocket, backendSocket),
    pipeline(backendSocket, clientSocket)
  ]);
} catch (err: any) {
  // 어느 한 쪽이 끊어지면 파이프라인이 예외를 발생시키고, 우리는 네이티브하게 정리합니다.
  clientSocket.destroy();
  backendSocket.destroy();
}

이 아키텍처로 전환하고 통합 테스트 스위트를 실행한 순간, 터미널이 멈추지 않았습니다. Jest는 18개의 네트워크 테스트를 모두 실행하고 2.7 초 만에 정상적으로 종료되었습니다. 이벤트 루프는 즉시 비워졌으며, 조용히 발생하던 소켓 누수도 완전히 사라졌.

터미널 스크린샷: 2.7초 만에 18개의 Jest 네트워크 테스트가 정상적으로 통과한 모습.

결론: 아무것도 믿지 말고, 모든 것을 이해하라

멀티‑코어 Edge Gateway를 처음부터 구축하면서 고수준 추상화를 무조건 신뢰해서는 안 된다는 것을 깨달았습니다.

stream.pipe()는 표준 웹 튜토리얼에서는 우아해 보이지만, 원시 TCP 네트워킹 현장에서는 거대한 위험 요소가 됩니다. 수천 개의 동시 연결을 처리하는 인프라를 구축한다면 파일 디스크립터와 소켓의 운영체제 수준 라이프사이클을 반드시 이해해야 합니다. 그렇지 않으면 시스템이 부하 하에서 서서히 죽어가고, 로그조차 원인을 알려주지 못합니다.

구현 코드를 직접 보고 싶으신가요?
Torus Proxy on my GitHub 의 원시 소스 코드를 확인해 보세요.

0 조회
Back to Blog

관련 글

더 보기 »

TypeScript에서 의미 있는 도메인 모델

내가 프로덕션에서 본 대부분의 bugs는 잘못된 algorithms이나 나쁜 infrastructure 때문에 발생한 것이 아니다. 그것들은 invalid state—items가 없는 order, paid…