WebStreams는 서버에서 오버헤드가 너무 컸습니다. 그래서 더 빠른 구현체를 직접 만들었습니다. Next.js 렌더링 벤치마크에서 10~14배 성능 향상을 달성한 방법을 소개합니다.
올해 초 Next.js 서버 렌더링을 프로파일링하기 시작했을 때, 플레임그래프에서 유독 눈에 띄는 것이 있었습니다. 바로 WebStreams였습니다. 스트림 안에서 실행되는 애플리케이션 코드가 아니라, 스트림 자체가 문제였습니다. Promise 체인, 청크마다 생성되는 객체 할당, 마이크로태스크 큐 전환 등이 원인이었죠. Theo Browne의 서버 렌더링 벤치마크에서 프레임워크 오버헤드에 얼마나 많은 연산 시간이 소비되는지 드러난 이후, 그 시간이 실제로 어디에 쓰이는지 추적하기 시작했습니다. 상당 부분이 스트림에서 소모되고 있었습니다.
알고 보니 WebStreams에는 매우 방대한 테스트 스위트가 이미 존재했고, 덕분에 AI 기반으로 순수하게 테스트 주도·벤치마크 주도 방식의 재구현을 시도하기에 최적의 대상이었습니다. 이 글에서는 우리가 수행한 성능 개선 작업과 그 과정에서 얻은 교훈, 그리고 Matteo Collina의 업스트림 PR을 통해 이 작업이 Node.js 자체에 반영되고 있는 과정을 다룹니다.
Node.js에는 두 가지 스트리밍 API가 있습니다. 오래된 API(stream.Readable, stream.Writable, stream.Transform)는 10년 넘게 사용되어 왔고 충분히 최적화되어 있습니다. 데이터가 C++ 내부를 거쳐 이동하고, 백프레셔(backpressure)는 불리언 하나로 처리되며, 파이핑은 함수 호출 한 번이면 끝납니다.
새로운 API는 WHATWG Streams API인 ReadableStream, WritableStream, TransformStream입니다. 웹 표준이죠. fetch() 응답 본문, CompressionStream, TextDecoderStream에 사용되고, Next.js나 React 같은 프레임워크의 서버 사이드 렌더링에서도 점점 더 많이 쓰이고 있습니다.
웹 표준으로 수렴하는 것이 올바른 방향입니다. 하지만 서버 환경에서는 필요 이상으로 느립니다.
그 이유를 이해하려면, Node.js의 네이티브 WebStream에서 reader.read()을 호출할 때 어떤 일이 일어나는지 살펴봐야 합니다. 데이터가 이미 버퍼에 들어 있는 상황에서도 다음과 같은 과정을 거칩니다:
콜백 슬롯 3개를 가진 ReadableStreamDefaultReadRequest 객체가 할당됩니다
요청이 스트림 내부 큐에 추가됩니다
새로운 Promise가 할당되어 반환됩니다
해결(resolution)이 마이크로태스크 큐를 거칩니다
이미 존재하는 데이터를 반환하는 데 할당 4번과 마이크로태스크 전환 1번이 필요한 셈입니다. 이걸 렌더링 파이프라인의 모든 트랜스폼을 통과하는 모든 청크에 곱해보면 비용이 어마어마해집니다.
pipeTo()도 마찬가지입니다. 각 청크가 완전한 Promise 체인을 거칩니다: 읽기, 쓰기, 백프레셔 확인, 반복. 읽기마다 {value, done} 결과 객체가 할당됩니다. 에러 전파 시에는 추가적인 Promise 분기까지 생성됩니다.
이 중 잘못된 것은 없습니다. 브라우저에서는 이런 보장이 중요합니다. 스트림이 보안 경계를 넘나들고, 취소(cancellation) 시맨틱이 완벽해야 하며, 파이프의 양쪽 끝을 모두 제어할 수 없는 환경이니까요. 하지만 서버에서 React Server Components를 1KB 청크로 세 개의 트랜스폼에 통과시킬 때, 이 비용은 무시할 수 없는 수준으로 쌓입니다.
네이티브 WebStream pipeThrough의 벤치마크 결과는 1KB 청크 기준 630 MB/s였습니다. 동일한 패스스루 트랜스폼을 적용한 Node.js pipeline()는 ~7,900 MB/s. 12배 차이이며, 그 차이의 대부분은 Promise와 객체 할당 오버헤드입니다.
우리는 fast-webstreams이라는 라이브러리를 개발해 왔습니다. WHATWG ReadableStream, WritableStream, TransformStream API를 내부적으로 Node.js 스트림으로 구현한 것입니다. API도 같고, 에러 전파도 같고, 스펙 준수도 동일합니다. 다만 일반적인 사용 사례에서 발생하는 오버헤드를 제거했습니다.
핵심 아이디어는 실제로 수행하는 작업에 따라 서로 다른 빠른 경로(fast path)로 분기하는 것입니다:
가장 큰 성능 개선 지점입니다. 빠른 스트림 간에 pipeThrough와 pipeTo을 체이닝하면, 라이브러리는 즉시 파이핑을 시작하지 않습니다. 대신 업스트림 링크만 기록해 둡니다:
source → transform1 → transform2 → ...
체인 끝에서 pipeTo()가 호출되면, 업스트림을 따라 올라가며 내부의 Node.js 스트림 객체들을 모아 pipeline() 호출 한 번으로 처리합니다. 함수 호출 한 번. 청크당 Promise는 0개. 데이터는 Node의 최적화된 C++ 경로를 통해 흐릅니다.
결과: ~6,200 MB/s. 네이티브 WebStreams 대비 약 10배 빠르고, Node.js 파이프라인의 원시(raw) 성능에 근접합니다.
체인 내 스트림 중 하나라도 빠른 스트림이 아닌 경우(예: 네이티브 CompressionStream), 라이브러리는 네이티브 pipeThrough이나 스펙 호환 pipeTo 구현으로 폴백합니다.
reader.read() 호출 시 라이브러리는 먼저 nodeReadable.read()를 동기적으로 시도합니다. 데이터가 있으면 Promise.resolve({value, done})를 즉시 반환합니다. 이벤트 루프를 한 바퀴 돌 필요도, 요청 객체를 할당할 필요도 없습니다. 버퍼가 비어 있을 때만 리스너를 등록하고 대기 중인 Promise를 반환합니다.
결과: ~12,400 MB/s, 네이티브 대비 3.7배 빠릅니다.
Next.js에서 가장 중요한 부분입니다. React Server Components는 특정한 바이트 스트림 패턴을 사용합니다. ReadableStream를 type: 'bytes'로 생성하고, start()에서 컨트롤러를 캡처한 뒤, 렌더링이 결과를 생성할 때마다 외부에서 청크를 enqueue하는 방식입니다.
네이티브 WebStreams: ~110 MB/s. fast-webstreams: ~1,600 MB/s. 프로덕션 서버 렌더링에서 실제로 사용되는 바로 그 패턴에서 14.6배 빠릅니다.
이 속도는 LiteReadable 덕분입니다. Node.js의 Readable를 대체하기 위해 바이트 스트림 전용으로 작성한 최소한의 배열 기반 버퍼입니다. EventEmitter 대신 직접 콜백 디스패치를 사용하고, 풀 기반 수요와 BYOB 리더를 지원하며, 생성 비용이 약 5마이크로초 더 저렴합니다. React Flight가 요청당 수백 개의 바이트 스트림을 생성하는 상황에서 이 차이는 큽니다.
위 예시들은 모두 new ReadableStream(...)로 시작합니다. 하지만 서버 환경에서 대부분의 스트림은 이런 식으로 시작되지 않습니다. fetch()에서 시작되죠. 응답 본문은 Node.js HTTP 레이어가 소유하는 네이티브 바이트 스트림이라 교체할 수 없습니다.
서버 사이드 렌더링에서 흔히 볼 수 있는 패턴입니다: 업스트림 서비스에서 데이터를 fetch하고, 응답을 하나 이상의 트랜스폼에 통과시킨 뒤, 결과를 클라이언트에 전달합니다.
네이티브 WebStreams에서는 이 체인의 각 단계마다 청크당 전체 Promise 비용이 발생합니다. 트랜스폼 3개면 청크당 대략 6~9개의 Promise가 생성됩니다. 1KB 청크 기준으로 ~260 MB/s 정도입니다.
라이브러리는 지연 해결(deferred resolution) 방식으로 이를 처리합니다. patchGlobalWebStreams()가 활성화된 상태에서 Response.prototype.body를 호출하면, 네이티브 바이트 스트림을 감싼 경량 fast 셸이 반환됩니다. pipeThrough() 호출 시에도 즉시 파이핑이 시작되지 않고, 링크만 기록해 둡니다. 체인 끝에서 pipeTo()나 getReader()가 호출되어야 비로소 전체 체인을 해결합니다: 네이티브 리더에서 Node.js pipeline()로의 브리지를 하나만 생성하여 트랜스폼 단계를 처리하고, 출력은 버퍼링된 데이터에서 동기적으로 읽어 제공합니다.
비용 모델은 이렇습니다: 데이터를 끌어오기 위한 네이티브 경계에서 Promise 하나. 트랜스폼 체인 전체에서 Promise 제로. 출력에서 동기 읽기.
결과: 3개 트랜스폼 fetch 패턴에서 ~830 MB/s로, 네이티브 대비 3.2배 빠릅니다. 트랜스폼 없는 단순 응답 포워딩에서는 2.0배 빠릅니다 (850 vs 430 MB/s).
모든 수치는 Node.js v22에서 1KB 청크 기준 처리량(MB/s)입니다. 높을수록 좋습니다.
연산 | Node.js streams | fast | native | fast vs native |
read loop | 26,400 | 12,400 | 3,300 | 3.7x |
write loop | 26,500 | 5,500 | 2,300 | 2.4x |
pipeThrough | 7,900 | 6,200 | 630 | 9.8x |
pipeTo | 14,000 | 2,500 | 1,400 | 1.8x |
for-await-of | — | 4,100 | 3,000 | 1.4x |
청크당 Promise 오버헤드는 체인 깊이에 비례하여 누적됩니다:
깊이 | fast | native | fast vs native |
트랜스폼 3개 | 2,900 | 300 | 9.7x |
트랜스폼 8개 | 1,000 | 115 | 8.7x |
패턴 | fast | native | fast vs native |
start + enqueue (React Flight) | 1,600 | 110 | 14.6x |
byte read loop | 1,400 | 1,400 | 1.0x |
byte tee | 1,200 | 750 | 1.6x |
패턴 | fast | native | fast vs native |
Response.text() | 900 | 910 | 1.0x |
응답 포워딩 | 850 | 430 | 2.0x |
fetch → 트랜스폼 3개 | 830 | 260 | 3.2x |
스트림 생성도 더 빨라졌으며, 수명이 짧은 스트림에서 특히 유의미합니다:
타입 | fast | native | fast vs native |
ReadableStream | 2,100 | 980 | 2.1x |
WritableStream | 1,300 | 440 | 3.0x |
TransformStream | 470 | 220 | 2.1x |
fast-webstreams는 Web Platform Tests 1,116개 중 1,100개를 통과합니다. Node.js 네이티브 구현은 1,099개를 통과합니다. 나머지 16개의 실패 항목은 네이티브와 공통되는 부분(미구현 상태인 type: 'owning' 전송 모드 등)이거나, 실제 애플리케이션에는 영향을 미치지 않는 아키텍처 차이에 해당합니다.
라이브러리를 통해 전역 ReadableStream, WritableStream, TransformStream 생성자를 패치할 수 있습니다:
패치는 Response.prototype.body도 가로채서 네이티브 fetch 응답 본문을 fast 스트림 셸로 래핑합니다. 덕분에 fetch() → pipeThrough() → pipeTo() 체인이 자동으로 파이프라인 빠른 경로를 타게 됩니다.
Vercel에서는 이 라이브러리를 전체 인프라에 점진적으로 적용할 계획입니다. 신중하고 단계적으로 진행할 것입니다. 스트리밍 프리미티브는 요청 처리, 응답 렌더링, 압축의 기반에 위치하기 때문입니다. 격차가 가장 큰 패턴부터 시작합니다: React Server Component 스트리밍, 응답 본문 포워딩, 다중 트랜스폼 체인. 프로덕션에서 측정 결과를 확인한 뒤 적용 범위를 확대할 예정입니다.
유저랜드 라이브러리가 장기적인 해답이 되어서는 안 됩니다. 근본적인 해결은 Node.js 자체에서 이루어져야 합니다.
이미 작업이 진행 중입니다. X에서의 대화를 계기로 Matteo Collina가 nodejs/node#61807 — "stream: add fast paths for webstreams read and pipeTo" PR을 제출했습니다. 이 PR은 본 프로젝트의 아이디어 두 가지를 Node.js 네이티브 WebStreams에 직접 적용합니다:
read() 빠른 경로: 데이터가 이미 버퍼에 있으면
ReadableStreamDefaultReadRequest 객체를 생성하지 않고 이미 해결된 Promise를 직접 반환합니다. read()는 어차피 Promise를 반환하고, 해결된 Promise도 마이크로태스크 큐에서 콜백을 실행하므로 스펙을 준수합니다.
pipeTo() 배치 읽기: 데이터가 버퍼에 있으면 청크별 요청 객체를 생성하지 않고 컨트롤러 큐에서 여러 건을 한꺼번에 읽습니다. 각 쓰기 후 desiredSize를 확인하여 백프레셔를 준수합니다.
이 PR의 결과는 버퍼링된 읽기에서 ~17-20% 향상, pipeTo에서 ~11% 향상입니다. 별도의 라이브러리 설치도, 패치도, 리스크도 없이 모든 Node.js 사용자가 그대로 누리는 개선입니다.
James Snell의 Node.js performance issue #134에는 추가적인 개선 기회가 정리되어 있습니다: 내부 소스 스트림을 위한 C++ 수준 파이핑, 지연 버퍼링, WritableStream 어댑터의 이중 버퍼링 제거 등이 있으며, 각각의 개선이 격차를 더 좁힐 수 있습니다.
앞으로도 아이디어를 업스트림에 계속 기여할 것입니다. fast-webstreams가 영원히 존재하는 것이 목표가 아닙니다. WebStreams 자체가 충분히 빨라져서 이 라이브러리가 필요 없어지는 것이 목표입니다.
스펙은 보이는 것보다 똑똑합니다. 수많은 지름길을 시도했지만, 거의 매번 Web Platform Test가 깨졌고, 대부분 테스트가 맞았습니다. ReadableStreamDefaultReadRequest 패턴, 읽기당 Promise 설계, 세심한 에러 전파 — 이 모든 것은 읽기 중 취소, 잠긴 스트림을 통한 에러 식별, thenable 가로채기 같은 실제 엣지 케이스가 존재하기 때문에 만들어진 것입니다.
Promise.resolve(obj)는 항상 thenable을 확인합니다. 이것은 언어 수준의 동작이라 피할 수 없습니다. 해결(resolve)하려는 객체에 .then 프로퍼티가 있으면 Promise 메커니즘이 이를 호출합니다. 일부 WPT 테스트는 의도적으로 읽기 결과에 .then를 넣어 스트림이 이를 올바르게 처리하는지 검증합니다. 핫 경로에서 {value, done} 객체가 생성되는 위치를 매우 주의 깊게 다뤄야 했습니다.
Node.js pipeline()는 WHATWG pipeTo를 대체할 수 없습니다. 모든 파이핑에 pipeline()를 사용하려 했으나, WPT 72개가 실패했습니다. 에러 전파, 스트림 잠금, 취소 시맨틱이 근본적으로 다릅니다. pipeline()는 전체 체인을 우리가 제어할 때만 안전하며, 그래서 업스트림 링크를 수집한 뒤 전부 fast 스트림인 체인에서만 사용합니다.
Reflect.apply이지, .call()가 아닙니다. WPT 스위트는 Function.prototype.call를 몽키패치한 뒤, 구현체가 사용자 제공 콜백을 호출할 때 이를 사용하지 않는지 검증합니다. Reflect.apply만이 유일하게 안전한 방법입니다. 이것은 실제 스펙 요구사항입니다.
이를 가능하게 한 두 가지가 있었습니다:
훌륭한 Web Platform Tests 덕분에 "뭔가 깨뜨린 건 아닌가?"라는 질문에 기계적으로 즉시 답할 수 있는 1,116개의 테스트가 확보되어 있었습니다. 그리고 초기에 벤치마크 스위트를 구축해 각 변경이 실제로 처리량을 개선하는지 측정할 수 있었습니다. 개발 루프는 이랬습니다: 최적화를 구현하고, WPT 스위트를 돌리고, 벤치마크를 실행합니다. 테스트가 깨지면 어떤 스펙 불변량을 위반했는지 알 수 있었고, 벤치마크 수치가 움직이지 않으면 되돌렸습니다.
WHATWG Streams 스펙은 길고 복잡합니다. 흥미로운 최적화 기회는 스펙이 요구하는 것과 현재 구현이 실제로 수행하는 것 사이의 간극에 존재합니다. read()는 반드시 Promise를 반환해야 하지만, 데이터가 버퍼에 있을 때 그 Promise가 이미 해결된 상태여서는 안 된다는 조항은 없습니다. 이런 종류의 관찰은 AI에게 알고리즘 단계를 분석하게 하여, 관찰 가능한 동작을 유지하면서 할당을 줄일 수 있는 지점을 찾을 때 매우 효과적입니다.
fast-webstreams는 npm에 공개되어 있으며 패키지명은 experimental-fast-webstreams입니다. "experimental" 접두어는 의도적입니다. 정확성에는 자신이 있지만, 아직 활발히 개발 중인 영역입니다.
서버 사이드 JavaScript 프레임워크나 런타임을 개발하면서 WebStreams 성능 한계에 부딪히고 계시다면, 의견을 들려주세요. 그리고 Node.js 자체의 WebStreams 개선에 관심이 있으시다면, Matteo의 PR이 좋은 출발점입니다.