
이 포스트는 node.js의 코어 컨트리뷰트이며 Cloudflare Workers 팀 소속 개발자 James M Snell이 cloudflare 블로그에 올린 We deserve a better streams API for JavaScript 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.
스트림으로 데이터를 다루는 일은 애플리케이션을 만드는 방식의 핵심이다. 스트리밍이 어디서나 동작하도록 만들기 위해, WHATWG Streams Standard(비공식적으로 “Web streams”로 알려져 있음)는 브라우저와 서버 전반에서 공통으로 사용할 수 있는 API를 확립하려는 목적으로 설계되었다. 이는 브라우저에 탑재되었고, Cloudflare Workers, Node.js, Deno, Bun에 채택되었으며, fetch() 같은 API의 기반이 되었다. 이는 상당히 큰 작업이며, 이를 설계한 사람들은 당시의 제약과 도구 속에서 어려운 문제들을 해결하고 있었다.
하지만 Web streams 위에서 수년간 구축해 오면서—Node.js와 Cloudflare Workers 양쪽에서 이를 구현하고, 고객과 런타임을 위해 프로덕션 이슈를 디버깅하고, 개발자들이 너무 흔하게 빠지는 수많은 함정을 헤쳐 나가도록 돕는 일을 하면서—나는 표준 API가 근본적인 사용성 및 성능 문제를 갖고 있으며, 점진적인 개선만으로는 쉽게 고칠 수 없다고 믿게 되었다. 이 문제들은 버그가 아니다. 그것들은 10년 전에는 타당했을지 모르는 설계 결정의 결과이며, 오늘날 JavaScript 개발자들이 코드를 작성하는 방식과는 맞지 않는다.
이 글에서는 내가 Web streams에서 발견한 몇 가지 근본적인 문제를 살펴보고, JavaScript 언어의 기본 프리미티브를 중심으로 구축된 대안적 접근 방식을 제시한다. 이를 통해 더 나은 방식이 가능하다는 것을 보여주고자 한다.
벤치마크에서 이 대안은 내가 테스트한 모든 런타임(Cloudflare Workers, Node.js, Deno, Bun, 그리고 주요 브라우저들 포함)에서 Web streams보다 최소 2배에서 최대 120배까지 더 빠르게 동작할 수 있다. 이러한 개선은 영리한 최적화 때문이 아니라, 현대 JavaScript 언어 기능을 더 효과적으로 활용하는 근본적으로 다른 설계 선택에서 비롯된다. 나는 이전에 이루어진 작업을 폄하하려는 것이 아니다. 앞으로 무엇이 가능할지에 대한 논의를 시작하고자 할 뿐이다.
이 모든 여정의 시작
Streams Standard는 2014년부터 2016년 사이에 개발되었으며, “저수준 I/O 프리미티브에 효율적으로 매핑되는 데이터 스트림을 생성, 조합, 소비하기 위한 API”를 제공한다는 야심찬 목표를 가지고 있었다. Web streams 이전에는 웹 플랫폼에 스트리밍 데이터를 다루기 위한 표준적인 방법이 존재하지 않았다.
당시 Node.js에는 이미 자체적인 스트리밍 API가 있었고, 이것은 브라우저에서도 동작하도록 포팅되기도 했다. 그러나 WHATWG는 브라우저의 요구사항만을 고려하도록 규정되어 있었기 때문에 이를 출발점으로 사용하지 않았다. 서버 측 런타임들은 이후에야 Web streams를 채택했다. Cloudflare Workers와 Deno가 각각 Web streams를 1급 기능으로 지원하며 등장했고, 런타임 간 호환성이 중요해지면서 서버 환경에서도 채택이 이루어졌다.
Web streams의 설계는 JavaScript의 async iteration보다 먼저 등장했다. for await...of 문법은 Streams Standard가 처음으로 확정된 이후 2년 뒤인 ES2018에서야 도입되었다. 이러한 시점상의 차이 때문에, 이 API는 나중에 JavaScript에서 비동기 시퀀스를 소비하는 관용적인 방식이 될 기능을 처음부터 활용할 수 없었다. 대신 명세는 자체적인 reader/writer 획득 모델을 도입했으며, 이 결정은 API의 모든 측면에 영향을 미치게 되었다.

간단한 작업에 대한 과도한 절차
스트림을 사용할 때 가장 흔한 작업은 스트림을 끝까지 읽는 것이다. Web streams에서는 이를 다음과 같이 작성한다.
// 먼저, 스트림에 대해 배타적 lock을 가지는 reader를 획득한다...
const reader = stream.getReader();
const chunks = [];
try {
// 다음으로, read를 반복적으로 호출하고 반환된 promise를
// await 하여 데이터 chunk를 얻거나 스트림이 끝났음을 확인한다.
while (true) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
} finally {
// 마지막으로 스트림에 대한 lock을 해제한다
reader.releaseLock();
}이 패턴이 스트리밍에 본질적으로 필요한 것이라고 생각할 수도 있다. 그러나 그렇지 않다. reader 획득, lock 관리, 그리고 { value, done } 프로토콜은 단지 설계 선택일 뿐이며 필수 요구사항이 아니다. 이것들은 Web streams 명세가 작성된 시점과 방식의 산물이다. 시간에 따라 도착하는 시퀀스를 처리하기 위해 바로 async iteration이 존재하지만, streams 명세가 작성될 당시에는 async iteration이 아직 존재하지 않았다. 여기에서 보이는 복잡성은 본질적인 필요성이 아니라 순전히 API 오버헤드이다.
이제 Web streams가 for await...of를 지원한다는 점을 고려하면 다음과 같은 대안적 접근을 생각할 수 있다.
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}이 방식은 보일러플레이트가 훨씬 줄어든다는 점에서 더 낫다. 그러나 모든 문제를 해결해 주지는 않는다. async iteration은 원래 이를 위해 설계된 API가 아니라, 기존 API 위에 나중에 덧붙여진 기능이기 때문에 한계가 드러난다. 예를 들어 BYOB(bring your own buffer) 읽기 같은 기능은 iteration을 통해 접근할 수 없다. reader, lock, controller 같은 내부 복잡성도 여전히 존재하며 단지 숨겨져 있을 뿐이다. 문제가 발생하거나 API의 추가 기능이 필요해지는 순간, 개발자들은 다시 원래의 API 내부로 들어가야 한다. 그 과정에서 스트림이 왜 “locked” 상태인지, releaseLock()이 왜 기대한 대로 동작하지 않는지, 혹은 자신이 제어할 수 없는 코드 안에서 병목이 어디에서 발생하는지 등을 이해하려고 씨름하게 된다.
the locking problem
Web streams는 여러 consumer가 read를 교차(interleave)해서 수행하는 것을 막기 위해 locking 모델을 사용한다. getReader()를 호출하면 스트림은 locked 상태가 된다. locked 상태에서는 그 reader를 실제로 들고 있는 코드만이 스트림에서 읽을 수 있고, 그 외에는 스트림을 직접 읽거나 pipe 하거나 심지어 cancel 하는 것조차 할 수 없다.
이건 그럴듯하게 들리지만, 얼마나 쉽게 망가지는지 보면 생각이 달라진다.
async function peekFirstChunk(stream) {
const reader = stream.getReader();
const { value } = await reader.read();
// Oops — forgot to call reader.releaseLock()
// And the reader is no longer available when we return
return value;
}
const first = await peekFirstChunk(stream);
// TypeError: Cannot obtain lock — stream is permanently locked
for await (const chunk of stream) { /* never runs */ }releaseLock()를 빼먹으면 스트림이 영구적으로 망가진다. locked 프로퍼티는 스트림이 locked 상태라는 사실만 알려줄 뿐, 왜 locked 인지, 누가 lock을 잡고 있는지, 그 lock이 아직 사용 가능한지 여부는 알려주지 않는다. 또한 pipe는 내부적으로 lock을 획득하기 때문에, pipe 동작 중에 스트림이 “왜인지” 사용 불가능해지는 상황이 생기는데, 이런 점이 겉으로 잘 드러나지 않는다.
pending read가 있는 상태에서 lock을 해제하는 의미론도 수년 동안 불명확했다. read()를 호출해 놓고 await 하지 않은 채 releaseLock()을 호출하면 어떻게 되는가? 명세는 최근에서야 lock 해제 시 pending read를 취소하도록 명확히 했다. 하지만 구현체마다 동작이 달랐고, 이전의 “명세에 정의되지 않았던 동작”에 기대던 코드는 깨질 수 있다.
다만 lock 자체가 나쁜 것은 아니라는 점은 분명히 해야 한다. lock은 애플리케이션이 데이터를 올바르고 질서 있게 소비하거나 생산하도록 보장하는 데 실제로 중요한 역할을 한다. 핵심 문제는 getReader()와 releaseLock() 같은 API를 통해 사용자가 수동으로 이를 구현해야 했던 원래 방식에 있다. async iterable을 통한 자동 lock 및 reader 관리가 등장하면서, 사용자 관점에서 lock을 다루는 일은 훨씬 쉬워졌다.
구현자(런타임/플랫폼) 관점에서도 locking 모델은 만만치 않은 내부 bookkeeping을 추가한다. 모든 연산은 lock 상태를 확인해야 하고, reader를 추적해야 하며, lock·cancel·error 상태들 사이의 상호작용이 다양한 엣지 케이스 조합을 만들어내는데, 이 모든 경우를 정확히 처리해야 한다.
BYOB: 보상 없는 복잡성
BYOB(bring your own buffer) 읽기는 스트림에서 읽을 때 메모리 버퍼를 재사용할 수 있게 하려는 목적으로 설계되었다. 이는 고처리량 시나리오에서 중요한 최적화로 의도된 것이다. 아이디어 자체는 타당하다. 각 chunk마다 새 버퍼를 할당하는 대신, 사용자가 자신의 버퍼를 제공하고 스트림이 그 버퍼를 채우게 한다.
실제로는(물론 언제나 예외는 존재하지만) BYOB는 측정 가능한 이득을 거의 내지 못한 채로 드물게 사용된다. 이 API는 기본 읽기(default reads)보다 훨씬 복잡하며, 별도의 reader 타입(ReadableStreamBYOBReader)과 다른 특수 클래스들(예: ReadableStreamBYOBRequest)을 요구한다. 또한 버퍼 생명주기를 세심하게 관리해야 하고, ArrayBuffer detachment 의미론도 이해해야 한다. BYOB 읽기에 버퍼를 넘기면 그 버퍼는 detached—스트림으로 transfer—되고, 사용자는 잠재적으로 다른 메모리를 가리키는 “다른 뷰”를 돌려받는다. 이 transfer 기반 모델은 오류를 유발하기 쉽고 혼란스럽다.
const reader = stream.getReader({ mode: 'byob' });
const buffer = new ArrayBuffer(1024);
let view = new Uint8Array(buffer);
const result = await reader.read(view);
// 'view' should now be detached and unusable
// (it isn't always in every impl)
// result.value is a NEW view, possibly over different memory
view = result.value; // Must reassignBYOB는 async iteration이나 TransformStreams와도 함께 사용할 수 없기 때문에, zero-copy 읽기를 원하는 개발자들은 다시 수동 reader 루프로 돌아갈 수밖에 없다.
구현자 관점에서 BYOB는 상당한 복잡도를 추가한다. 스트림은 pending BYOB request를 추적해야 하고, 부분 채움(partial fills)을 처리해야 하며, 버퍼 detachment를 올바르게 관리해야 하고, BYOB reader와 underlying source 사이의 조정을 수행해야 한다. readable byte streams를 위한 Web Platform Tests에는 detached buffer, 잘못된 view, enqueue 이후 응답 순서(response-after-enqueue ordering) 등 BYOB 엣지 케이스만을 다루는 전용 테스트 파일들이 포함되어 있다.
결국 BYOB는 사용자와 구현자 모두에게 복잡하지만, 실제로는 채택이 거의 없다. 대부분의 개발자는 기본 읽기만 사용하고 할당 오버헤드를 감수한다.
사용자 영역(userland)에서 커스텀 ReadableStream 인스턴스를 구현할 때도, 보통 하나의 스트림에서 default와 BYOB 읽기 지원을 모두 “정확히” 구현하기 위해 필요한 모든 의식적 절차를 굳이 감당하지 않는다—그럴 만한 이유가 있다. 제대로 구현하기가 어렵고, 대부분의 경우 소비 코드가 결국 default 읽기 경로로 fallback 하기 때문이다. 아래 예시는 “올바른” 구현이 실제로 무엇을 해야 하는지 보여준다. 크고, 복잡하고, 오류가 나기 쉽고, 일반적인 개발자가 정말 다루고 싶어 하는 수준의 복잡도가 아니다.
new ReadableStream({
type: 'bytes',
async pull(controller: ReadableByteStreamController) {
if (offset >= totalBytes) {
controller.close();
return;
}
// Check for BYOB request FIRST
const byobRequest = controller.byobRequest;
if (byobRequest) {
// === BYOB PATH ===
// Consumer provided a buffer - we MUST fill it (or part of it)
const view = byobRequest.view!;
const bytesAvailable = totalBytes - offset;
const bytesToWrite = Math.min(view.byteLength, bytesAvailable);
// Create a view into the consumer's buffer and fill it
// not critical but safer when bytesToWrite != view.byteLength
const dest = new Uint8Array(
view.buffer,
view.byteOffset,
bytesToWrite
);
// Fill with sequential bytes (our "data source")
// Can be any thing here that writes into the view
for (let i = 0; i < bytesToWrite; i++) {
dest[i] = (offset + i) & 0xFF;
}
offset += bytesToWrite;
// Signal how many bytes we wrote
byobRequest.respond(bytesToWrite);
} else {
// === DEFAULT READER PATH ===
// No BYOB request - allocate and enqueue a chunk
const bytesAvailable = totalBytes - offset;
const chunkSize = Math.min(1024, bytesAvailable);
const chunk = new Uint8Array(chunkSize);
for (let i = 0; i < chunkSize; i++) {
chunk[i] = (offset + i) & 0xFF;
}
offset += chunkSize;
controller.enqueue(chunk);
}
},
cancel(reason) {
console.log('Stream canceled:', reason);
}
});호스트 런타임이 런타임 자체에서 byte 지향 ReadableStream을 제공하는 경우(예: fetch Response의 body)에는, 런타임이 BYOB 읽기에 대해 최적화된 구현을 제공하는 편이 종종 훨씬 쉽다. 하지만 그런 구현 역시 default와 BYOB 읽기 패턴을 모두 처리할 수 있어야 하며, 그 요구사항 자체가 상당한 복잡도를 동반한다.
Backpressure: 꿈은 높은데 현실은 시궁창
Backpressure—느린 consumer가 빠른 producer에게 속도를 줄이라고 신호를 보내는 능력—는 Web streams에서 1급 개념이다. 적어도 이론적으로는 그렇다. 실제로는 이 모델에 몇 가지 심각한 문제가 있다.
주요 신호는 controller의 desiredSize이다. 이 값은 양수(데이터를 원함), 0(용량 가득 참), 음수(용량 초과), 또는 null(닫힘)일 수 있다. producer는 이 값을 확인하고, 값이 양수가 아닐 때는 enqueue를 중단해야 한다. 하지만 이를 강제하는 장치는 없다. desiredSize가 크게 음수인 경우에도 controller.enqueue()는 항상 성공한다.
new ReadableStream({
start(controller) {
// Nothing stops you from doing this
while (true) {
controller.enqueue(generateData()); // desiredSize: -999999
}
}
});스트림 구현은 backpressure를 무시할 수 있고 실제로 그렇게 하기도 한다. 또한 명세에 정의된 일부 기능은 backpressure를 명시적으로 깨뜨린다. 예를 들어 tee()는 하나의 스트림에서 두 개의 분기를 만든다. 한 분기가 다른 분기보다 빠르게 읽으면 데이터가 제한 없는 내부 버퍼에 쌓이게 된다. 빠른 consumer는 느린 consumer가 따라잡을 때까지 메모리를 무한히 증가시킬 수 있으며, 느린 분기를 cancel 하는 것 외에는 이를 설정하거나 비활성화할 방법도 없다.
Web streams는 highWaterMark 옵션과 사용자 정의 가능한 size 계산을 통해 backpressure 동작을 조정할 수 있는 명확한 메커니즘을 제공한다. 하지만 이것들 역시 desiredSize처럼 쉽게 무시될 수 있으며, 많은 애플리케이션은 이를 제대로 고려하지 않는다.
같은 문제는 WritableStream 측에서도 존재한다. WritableStream 역시 highWaterMark와 desiredSize를 가진다. 또한 데이터 producer가 고려해야 하는 writer.ready promise가 있다. 그러나 실제로는 이를 무시하는 경우가 많다.
const writable = getWritableStreamSomehow();
const writer = writable.getWriter();
// Producers are supposed to wait for the writer.ready
// It is a promise that, when resolves, indicates that
// the writables internal backpressure is cleared and
// it is ok to write more data
await writer.ready;
await writer.write(...);구현자 관점에서 보면 backpressure는 보장 없이 복잡성만 추가한다. 큐 크기를 추적하고, desiredSize를 계산하며, 적절한 시점에 pull()을 호출하는 모든 메커니즘을 정확히 구현해야 한다. 그러나 이러한 신호들은 단지 권고(advisory)에 불과하기 때문에, 그 모든 구현 작업이 실제로 backpressure가 해결하려 했던 문제를 막아 주지는 않는다.
Promise의 숨겨진 비용
Web streams 명세는 여러 지점에서 promise 생성을 요구하며, 그중 상당수는 핫 패스(hot path)에 위치해 있고 사용자에게 보이지 않는다. 각 read() 호출은 단순히 하나의 promise를 반환하는 것에 그치지 않는다. 내부적으로 구현은 큐 관리, pull() 조정, backpressure 신호 처리를 위해 추가적인 promise들을 생성한다.
이러한 오버헤드는 버퍼 관리, 완료 처리, backpressure 신호를 promise에 의존하는 명세 설계에서 비롯된다. 일부는 구현체에 따라 달라질 수 있지만, 명세를 그대로 따르는 한 상당 부분은 피할 수 없다. 고빈도 스트리밍—예를 들어 비디오 프레임, 네트워크 패킷, 실시간 데이터—에서는 이러한 오버헤드가 상당히 크게 작용한다.
이 문제는 파이프라인에서 더 심화된다. 각 TransformStream은 source와 sink 사이에 또 하나의 promise 계층을 추가한다. 명세에는 동기적인 fast path가 정의되어 있지 않기 때문에, 데이터가 즉시 उपलब्ध한 경우에도 promise 처리 로직은 여전히 실행된다.
구현자 관점에서도 이러한 promise 중심 설계는 최적화 기회를 제한한다. 명세는 특정한 promise resolution 순서를 요구하기 때문에, subtle한 규격 위반을 피하려면 연산을 배치 처리하거나 불필요한 async 경계를 건너뛰는 최적화를 수행하기가 어렵다. 구현자들은 실제로 다양한 내부 최적화를 적용하지만, 이것 역시 복잡하고 정확하게 구현하기가 쉽지 않다.
이 블로그 글을 작성하는 동안, Vercel의 Malte Ubl은 Node.js의 Web streams 구현 성능을 개선하기 위해 Vercel이 진행하고 있는 연구 작업을 설명하는 블로그 글을 발표했다. 그 글에서는 모든 Web streams 구현이 직면하는 동일한 근본적인 성능 최적화 문제를 다음과 같이 설명한다.
“또는 pipeTo()를 생각해 보라. 각 chunk는 전체 Promise 체인을 통과한다: read, write, backpressure 확인, 그리고 이것을 반복한다. 각 read마다 {value, done} 결과 객체가 하나씩 생성된다. 에러 전파는 추가적인 Promise 분기를 만든다.
이 모든 것이 잘못된 것은 아니다. 이러한 보장은 브라우저 환경에서 중요하다. 스트림이 보안 경계를 넘나들고, cancellation 의미론이 완전히 안전해야 하며, 파이프의 양쪽 끝을 모두 제어할 수 없는 상황이 있기 때문이다. 하지만 서버에서는, React Server Components를 세 개의 transform을 거쳐 1KB chunk로 파이프할 때 그 비용이 누적된다.
우리는 1KB chunk 기준으로 native WebStream pipeThrough의 성능을 630 MB/s로 측정했다. 동일한 passthrough transform을 사용하는 Node.js pipeline()은 약 7,900 MB/s였다. 이는 12배의 차이이며, 그 차이의 대부분은 Promise와 객체 할당 오버헤드 때문이다.”
— Malte Ubl, https://vercel.com/blog/we-ralph-wiggumed-webstreams-to-make-them-10x-faster
그들의 연구의 일환으로, Node.js의 Web streams 구현에서 특정 코드 경로에서 promise를 제거하는 개선안이 제안되었으며, 이는 최대 10배까지의 성능 향상을 가져올 수 있다. 이것은 결국 한 가지 사실을 다시 보여준다. promise는 유용하지만, 상당한 오버헤드를 동반한다는 것이다. Node.js의 핵심 메인테이너 중 한 명으로서, 나는 Malte와 Vercel 팀이 제안한 개선 사항이 실제로 적용되도록 돕게 되기를 기대하고 있다.
최근 Cloudflare Workers에 적용된 업데이트에서도, 나는 내부 데이터 파이프라인의 특정 애플리케이션 시나리오에서 생성되는 JavaScript promise의 수를 최대 200배까지 줄이는 유사한 수정 작업을 수행했다. 그 결과 해당 애플리케이션에서는 성능이 여러 자릿수(order of magnitude) 수준으로 향상되었다.
시궁창같은 현실 사례 소개
1. 소비되지 않은 body로 인한 리소스 고갈
fetch()가 response를 반환하면, 그 body는 ReadableStream이다. 만약 status만 확인하고 body를 소비하거나 취소하지 않는다면 어떤 일이 발생할까? 구현체마다 결과는 다르지만, 흔한 결과 중 하나는 리소스 누수다.
async function checkEndpoint(url) {
const response = await fetch(url);
return response.ok; // Body is never consumed or cancelled
}
// In a loop, this can exhaust connection pools
for (const url of urls) {
await checkEndpoint(url);
}이 패턴은 Node.js에서 undici(Node.js에 내장된 fetch() 구현)를 사용하는 애플리케이션에서 connection pool 고갈을 일으킨 사례가 있으며, 다른 런타임에서도 유사한 문제가 나타난 바 있다. 스트림은 내부적으로 underlying connection에 대한 참조를 유지한다. 따라서 명시적으로 소비(consumption)하거나 취소(cancel)하지 않으면, 그 connection은 가비지 컬렉션이 발생할 때까지 유지될 수 있다. 하지만 높은 부하 상황에서는 가비지 컬렉션이 충분히 빠르게 일어나지 않을 수 있다.
문제는 스트림 분기를 암묵적으로 생성하는 API들 때문에 더 악화된다. Request.clone()과 Response.clone()은 body 스트림에 대해 암묵적으로 tee() 연산을 수행한다. 그러나 이 사실은 쉽게 간과된다. 로깅이나 재시도 로직을 위해 request를 clone하는 코드는, 자신도 모르게 각각 독립적으로 소비되어야 하는 분기 스트림을 생성하게 되고, 그 결과 리소스 관리 부담이 배로 증가한다.
물론 이러한 유형의 문제는 구현 버그라고 볼 수 있다. connection 누수 문제는 분명히 undici 구현에서 수정되어야 했던 부분이다. 그러나 명세 자체의 복잡성 때문에 이런 문제들을 다루는 일이 쉬운 것은 아니다.
“Node.js의 fetch() 구현에서 스트림을 clone하는 일은 생각보다 훨씬 어렵다. request나 response body를 clone하면 실제로는 tee()가 호출된다. 이는 하나의 스트림을 두 개의 분기로 나누며, 두 분기 모두 소비되어야 한다. 한 consumer가 다른 consumer보다 빠르게 읽으면, 느린 분기를 기다리면서 데이터가 메모리에 무제한으로 버퍼링된다. 두 분기를 모두 적절히 소비하지 않으면 underlying connection이 누수된다.
나의 source를 공유하는 두 reader 사이의 조정이 필요하기 때문에, 원래 request를 의도치 않게 망가뜨리거나 connection pool을 고갈시키기 쉽다. 겉보기에는 단순한 API 호출이지만, 내부 메커니즘은 매우 복잡하며 제대로 구현하기가 어렵다.”
— Matteo Collina, Ph.D., Platformatic 공동 창립자 & CTO, Node.js Technical Steering Committee 의장
2. tee 메모리 절벽 문제
tee()는 하나의 스트림을 두 개의 분기로 나눈다. 겉보기에는 단순하지만, 구현에는 버퍼링이 필요하다. 한 분기가 다른 분기보다 더 빠르게 읽으면, 느린 분기가 따라잡을 때까지 데이터는 어딘가에 보관되어야 한다.
const [forHash, forStorage] = response.body.tee();
// Hash computation is fast
const hash = await computeHash(forHash);
// Storage write is slow — meanwhile, the entire stream
// may be buffered in memory waiting for this branch
await writeToStorage(forStorage);명세는 tee()에 대해 버퍼 제한을 강제하지 않는다. 그리고 공정하게 말하자면, 명세는 tee()와 다른 API의 실제 내부 메커니즘을 구현자가 자유롭게 설계할 수 있도록 허용한다. 단, 명세가 요구하는 관찰 가능한 규범적 동작만 충족하면 된다. 그러나 구현이 streams 명세에서 설명된 방식 그대로 tee()를 구현한다면, tee()는 피하기 어려운 내장 메모리 관리 문제를 갖게 된다.
실제 구현체들은 이 문제를 처리하기 위해 각자 다른 전략을 발전시켜 왔다. Firefox는 초기에는 연결 리스트(linked list) 방식을 사용했는데, 이 방식은 소비 속도 차이에 비례하여 O(n) 메모리 증가가 발생하는 문제를 만들었다. Cloudflare Workers에서는 공유 버퍼(shared buffer) 모델을 사용하기로 선택했다. 이 방식에서는 가장 빠른 consumer가 아니라 가장 느린 consumer를 기준으로 backpressure 신호가 전달된다.

3. Transform backpressure gaps
TransformStream은 읽기(readable)와 쓰기(writable) 쌍을 만들고 그 사이에 처리 로직을 둔다. transform() 함수는 read 시점이 아니라 write 시점에 실행된다. 즉, 데이터가 들어오는 즉시 transform 처리가 수행되며, consumer가 준비되어 있는지 여부와 관계없이 실행된다. 그 결과 consumer가 느릴 때도 불필요한 작업이 수행되고, 두 측면 사이의 backpressure 신호 전달에 공백이 생겨 높은 부하 상황에서 무제한 버퍼링이 발생할 수 있다. 명세의 기대는 변환되는 데이터를 생성하는 producer가 transform의 writable 측에서 writer.ready 신호를 확인해야 한다는 것이지만, 실제로는 많은 producer가 이를 단순히 무시한다.
transform의 transform() 연산이 동기적이고 항상 즉시 출력을 큐에 더한다면(enqueue), downstream consumer가 느리더라도 writable 측으로 backpressure 신호가 전달되지 않는다. 이는 많은 개발자가 간과하는 명세 설계의 결과이다. 브라우저 환경에서는 일반적으로 단일 사용자와 소수의 스트림 파이프라인만 존재하기 때문에 이런 문제는 크게 드러나지 않는 경우가 많다. 그러나 수천 개의 동시 요청을 처리하는 서버나 edge 런타임에서는 성능에 큰 영향을 미친다.
const fastTransform = new TransformStream({
transform(chunk, controller) {
// Synchronously enqueue — this never applies backpressure
// Even if the readable side's buffer is full, this succeeds
controller.enqueue(processChunk(chunk));
}
});
// Pipe a fast source through the transform to a slow sink
fastSource
.pipeThrough(fastTransform)
.pipeTo(slowSink); // Buffer grows without boundTransformStream이 의도한 동작은 controller의 backpressure 상태를 확인하고, promise를 통해 이를 writer에게 전달하는 것이다.
const fastTransform = new TransformStream({
async transform(chunk, controller) {
if (controller.desiredSize <= 0) {
// Wait on the backpressure to clear somehow
}
controller.enqueue(processChunk(chunk));
}
});하지만 여기에는 또 다른 어려움이 있다. TransformStreamDefaultController에는 Writer와 같은 ready promise 메커니즘이 존재하지 않는다. 따라서 TransformStream 구현은 controller.desiredSize가 다시 양수가 되는 시점을 주기적으로 확인하는 polling 메커니즘을 직접 구현해야 한다.
이 문제는 파이프라인에서 더 악화된다. 여러 transform을 체인으로 연결할 때—예를 들어 parse, transform, serialize 단계—각 TransformStream은 자체적인 내부 readable 버퍼와 writable 버퍼를 가진다. 구현이 명세를 엄격하게 따를 경우 데이터는 push 기반 방식으로 버퍼들을 따라 흐른다. source가 transform A로 push하고, transform A는 transform B로 push하며, transform B는 transform C로 push한다. 그 결과 최종 consumer가 아직 pull을 시작하지 않았더라도 중간 버퍼들에 데이터가 계속 쌓이게 된다. transform이 세 개라면 내부적으로 여섯 개의 버퍼가 동시에 채워질 수 있다.
스트림 API를 사용하는 개발자들은 source, transform, writable destination을 생성할 때 highWaterMark 같은 옵션을 적절히 사용해야 한다고 기대되지만, 실제로는 이를 잊거나 단순히 무시하는 경우가 많다.
source
.pipeThrough(parse) // buffers filling...
.pipeThrough(transform) // more buffers filling...
.pipeThrough(serialize) // even more buffers...
.pipeTo(destination); // consumer hasn't started yet구현체들은 transform 파이프라인을 최적화하기 위해 여러 방법을 개발해 왔다. 예를 들어 identity transform을 제거하거나, 관찰 불가능한 경로를 단축(short-circuit)하거나, 버퍼 할당을 지연시키거나, 아예 JavaScript를 실행하지 않는 네이티브 코드 경로로 처리하는 방식 등이 있다. Deno, Bun, Cloudflare Workers는 이러한 “native path” 최적화를 구현해 많은 오버헤드를 제거하는 데 성공했다. 또한 Vercel의 최근 fast-webstreams 연구도 Node.js에서 유사한 최적화를 시도하고 있다. 그러나 이러한 최적화 자체도 상당한 복잡성을 추가하며, TransformStream이 사용하는 본질적으로 push 기반 모델에서 완전히 벗어나지는 못한다.

4. 서버 사이드 렌더링에서의 GC thrashing
스트리밍 서버 사이드 렌더링(SSR)은 특히 고통스러운 사례이다. 전형적인 SSR 스트림은 수천 개의 작은 HTML fragment를 렌더링하며, 각 fragment는 streams 메커니즘을 통과한다.
// Each component enqueues a small chunk
function renderComponent(controller) {
controller.enqueue(encoder.encode(`<div>${content}</div>`));
}
// Hundreds of components = hundreds of enqueue calls
// Each one triggers promise machinery internally
for (const component of components) {
renderComponent(controller); // Promises created, objects allocated
}각 fragment는 read() 호출을 위한 promise, backpressure 조정을 위한 promise, 중간 버퍼 할당, 그리고 { value, done } 결과 객체를 만들어낸다. 이들 대부분은 거의 즉시 garbage가 된다.
부하가 걸리면 이는 처리량을 망가뜨릴 정도의 GC 압력을 만든다. JavaScript 엔진은 유용한 일을 하는 대신, 수명이 짧은 객체들을 수집하는 데 상당한 시간을 쓰게 된다. GC pause가 요청 처리를 끊어 버리면서 latency는 예측 불가능해진다. 나는 SSR 워크로드에서 garbage collection이 요청당 총 CPU 시간의 상당 부분(50%에 달하거나 그 이상)을 차지하는 경우를 본 적이 있다. 이는 실제로 콘텐츠를 렌더링하는 데 쓸 수 있었던 시간이다.
아이러니하게도, 스트리밍 SSR은 콘텐츠를 점진적으로 보내 성능을 개선하려는 목적을 가진다. 하지만 streams 메커니즘의 오버헤드는 특히 작은 컴포넌트가 많은 페이지에서 그 이득을 상쇄할 수 있다. 개발자들은 때로 Web streams로 스트리밍하는 것보다 응답을 통째로 버퍼링하는 편이 오히려 더 빠르다는 사실을 발견하기도 하는데, 이는 애초의 목적을 완전히 무너뜨린다.
5. 끝없는 최적화의 굴레
실용적인 성능을 얻기 위해, 모든 주요 런타임은 Web streams에 대해 비표준 내부 최적화에 의존해 왔다. Node.js, Deno, Bun, Cloudflare Workers 모두 각자의 우회 방법을 개발했다. 이는 특히 시스템 수준 I/O에 연결된 스트림에서 두드러지는데, 이 경우 많은 내부 메커니즘이 사용자에게 관찰되지 않기 때문에 일부 경로를 단축(short-circuit)할 수 있다.
이러한 최적화 기회를 찾는 것 자체도 상당한 작업이 될 수 있다. 어떤 동작이 사용자에게 관찰 가능한지, 어떤 동작을 안전하게 생략할 수 있는지를 판단하려면 명세 전체에 대한 종단 간 이해가 필요하다. 그 이후에도 특정 최적화가 실제로 명세를 준수하는지 여부는 종종 불명확하다. 구현자들은 어떤 의미론을 완화해도 호환성이 깨지지 않을지를 스스로 판단해야 한다. 그 결과 런타임 팀은 단지 적절한 성능을 얻기 위해서라도 명세 전문가가 되어야 하는 압박을 받게 된다.
이러한 최적화는 구현하기 어렵고 오류가 발생하기 쉽다. 또한 런타임 간 동작 불일치를 만들어 낸다. Bun의 “Direct Streams” 최적화는 의도적으로 그리고 관찰 가능한 방식으로 비표준 접근을 취하여, 명세가 정의한 메커니즘의 상당 부분을 완전히 우회한다. Cloudflare Workers의 IdentityTransformStream은 단순 pass-through transform을 위한 fast-path를 제공하지만 Workers 전용 기능이며 표준 TransformStream과는 다른 동작을 구현한다. 각 런타임은 저마다의 트릭을 가지고 있으며, 자연스럽게 비표준 해결책으로 기울어지게 된다. 많은 경우 그것이 성능을 확보할 수 있는 유일한 방법이기 때문이다.
이러한 파편화는 이식성을 해친다. “표준” API를 사용하더라도 한 런타임에서 잘 동작하는 코드가 다른 런타임에서는 다르게 동작하거나 성능이 떨어질 수 있다. 런타임 구현자에게 가해지는 복잡성 부담은 상당하며, 이러한 미묘한 동작 차이는 여러 런타임 환경에서 효율적으로 동작해야 하는 프레임워크를 유지하는 개발자들에게 특히 큰 마찰을 만든다.
또한 많은 최적화는 사용자 코드에서 관찰할 수 없는 명세 영역에서만 가능하다는 점을 강조할 필요가 있다. Bun의 “Direct Streams”처럼 명세에서 정의된 관찰 가능한 동작을 의도적으로 벗어나는 접근을 택하지 않는 이상, 가능한 최적화는 제한적이다. 그 결과 최적화는 종종 “불완전한” 느낌을 준다. 어떤 시나리오에서는 동작하지만 다른 시나리오에서는 동작하지 않고, 어떤 런타임에서는 적용되지만 다른 런타임에서는 그렇지 않다. 이러한 사례가 하나씩 늘어날수록 Web streams 접근 방식의 전반적인 복잡성은 지속 불가능한 수준으로 커진다. 그래서 대부분의 런타임 구현자들은 conformance 테스트를 통과한 이후에는 streams 구현을 더 개선하는 데 큰 노력을 들이지 않는 경우가 많다.
구현자가 이런 복잡한 과정을 거칠 필요는 없어야 한다. 합리적인 성능을 얻기 위해 명세의 의미론을 완화하거나 우회해야 하는 상황이라면, 그것은 명세 자체에 문제가 있다는 신호다. 잘 설계된 스트리밍 API라면 기본적으로 효율적이어야 하며, 각 런타임이 자체적인 탈출구를 발명할 필요가 없어야 한다.
6. 명세 준수의 부담
복잡한 명세는 복잡한 엣지 케이스를 만들어 낸다. streams에 대한 Web Platform Tests는 70개가 넘는 테스트 파일로 구성되어 있다. 물론 포괄적인 테스트 자체는 좋은 일이다. 그러나 무엇이 테스트되어야 하는지를 보면 많은 것을 알 수 있다.
구현체가 통과해야 하는 비교적 덜 알려진 테스트 몇 가지를 살펴보자.
Prototype pollution 방어: 어떤 테스트는
Object.prototype.then을 패치하여 promise resolution을 가로채고, 그 다음pipeTo()와tee()연산이 prototype 체인을 통해 내부 값을 노출하지 않는지 확인한다. 이는 명세 내부가 promise 중심 구조이기 때문에 생기는 공격 표면을 방어하기 위한 보안 속성을 테스트하는 것이다.WebAssembly 메모리 거부: BYOB 읽기는 WebAssembly 메모리를 기반으로 하는 ArrayBuffer를 명시적으로 거부해야 한다. 이런 버퍼는 일반적인 버퍼처럼 보이지만 transfer할 수 없다. 이 엣지 케이스는 명세의 버퍼 detachment 모델 때문에 존재한다. 더 단순한 API였다면 이런 처리를 할 필요가 없었을 것이다.
상태 머신 충돌에 대한 crash 회귀 테스트: 어떤 테스트는
enqueue()이후에byobRequest.respond()를 호출했을 때 런타임이 crash 하지 않는지를 확인한다. 이 호출 순서는 내부 상태 머신에서 충돌을 만든다.enqueue()가 pending read를 충족시키면서byobRequest를 무효화해야 하지만, 구현체는 그 이후에 호출되는respond()도 메모리를 손상시키지 않고 정상적으로 처리해야 한다. 이는 개발자가 이 복잡한 API를 올바르게 사용하지 않을 가능성이 매우 높기 때문이다.
이러한 시나리오는 테스트 작성자가 완전히 상상으로 만들어 낸 것이 아니다. 명세 설계의 결과이며, 실제로 발생했던 버그들을 반영한 것이다.
런타임 구현자 입장에서 WPT 테스트 스위트를 통과하려면 대부분의 애플리케이션 코드에서는 거의 만나지 않을 복잡한 코너 케이스까지 처리해야 한다. 이 테스트들은 단순히 정상 경로(happy path)만 검증하는 것이 아니라 reader, writer, controller, queue, strategy, 그리고 이 모든 것을 연결하는 promise 메커니즘 사이의 상호작용 전체 조합을 검증한다.
더 단순한 API라면 개념의 수가 줄어들고, 개념 간 상호작용도 줄어들며, 올바르게 처리해야 할 엣지 케이스도 줄어든다. 그 결과 구현체가 실제로 일관된 동작을 한다는 확신도 더 커질 것이다.
간단 요약
Web streams는 사용자와 구현자 모두에게 복잡하다. 이 명세의 문제는 버그가 아니다. 그것들은 API가 설계된 그대로 사용했을 때 자연스럽게 나타나는 결과이다. 또한 단순한 점진적 개선만으로 해결될 수 있는 문제도 아니다. 이러한 문제들은 근본적인 설계 선택에서 비롯된 것이다. 상황을 개선하려면 다른 기반 위에서 다시 시작해야 한다.
더 나은 Streams API는 가능하다

여러 런타임에서 Web streams 명세를 여러 번 구현하고, 그 과정에서 문제 지점을 직접 경험한 후 나는 오늘날의 기준에서 처음부터 다시 설계한다면 더 나은 스트리밍 API가 어떤 모습일지 탐색해 볼 시점이라고 판단했다.
다음에 제시되는 내용은 하나의 proof of concept이다. 완성된 표준도 아니고, 프로덕션에서 사용할 준비가 된 라이브러리도 아니며, 새로운 무언가에 대한 구체적인 제안이라고 할 수도 없다. 이는 단지 논의를 시작하기 위한 출발점이다. Web streams의 문제는 스트리밍 자체에 내재된 것이 아니라, 특정한 설계 선택의 결과라는 점을 보여주기 위한 것이다. 이 API가 정확한 해답인지 여부보다 더 중요한 것은, 스트리밍 프리미티브에서 우리가 실제로 무엇을 필요로 하는지에 대해 생산적인 논의를 촉발할 수 있느냐이다.
애시당초 stream이란 뭘까?
API 설계에 들어가기 전에 먼저 이 질문에 대답해보자. stream이란 무엇일까?
가장 기본적으로 스트림은 시간이 지나면서 도착하는 데이터의 시퀀스일 뿐이다. 모든 데이터를 한 번에 가지고 있는 것이 아니다. 데이터가 도착할 때마다 점진적으로 처리한다.
이 아이디어를 가장 순수하게 표현한 예는 아마 Unix 파이프일 것이다.
cat access.log | grep "error" | sort | uniq -c데이터는 왼쪽에서 오른쪽으로 흐른다. 각 단계는 입력을 읽고, 자신의 작업을 수행하고, 출력을 쓴다. 획득해야 할 pipe reader도 없고, 관리해야 할 controller lock도 없다. downstream 단계가 느리면 upstream 단계도 자연스럽게 느려진다. backpressure는 별도의 메커니즘으로 배워야 하거나(혹은 무시해야 할) 것이 아니라, 모델 자체에 내재되어 있다.
JavaScript에서 “시간에 따라 도착하는 것들의 시퀀스”를 표현하는 자연스러운 프리미티브는 이미 언어에 존재한다. 바로 async iterable이다. 이것은 for await...of로 소비하며, iteration을 중단하면 소비도 멈춘다.
새로운 API가 보존하려는 직관은 바로 이것이다. 스트림은 iteration처럼 느껴져야 한다. 왜냐하면 실제로 그것이기 때문이다. Web streams의 복잡성—reader, writer, controller, lock, queuing strategy—은 이러한 근본적인 단순함을 가리고 있다. 더 나은 API라면 단순한 경우는 단순하게 유지하면서, 실제로 필요할 때에만 복잡성을 추가해야 한다.

디자인 원칙
나는 이 proof-of-concept 대안을 서로 다른 원칙 집합을 중심으로 구축했다.
스트림은 iterable이다.
숨겨진 내부 상태를 가진 커스텀 ReadableStream 클래스는 없다. readable stream은 단지 AsyncIterable<Uint8Array[]>일 뿐이다. for await...of로 소비한다. 획득해야 할 reader도 없고, 관리해야 할 lock도 없다.
Pull-through transform
transform은 consumer가 pull할 때까지 실행되지 않는다. eager evaluation도 없고, 숨겨진 버퍼링도 없다. 데이터는 source에서 transform을 거쳐 consumer로 온디맨드 방식으로 흐른다. iteration을 멈추면 처리도 멈춘다.
명시적 backpressure
backpressure는 기본적으로 엄격하다. 버퍼가 가득 차면 write는 조용히 누적되는 대신 reject된다. 공간이 생길 때까지 block, oldest drop, newest drop 같은 다른 정책을 설정할 수는 있지만, 명시적으로 선택해야 한다. 더 이상 조용한 메모리 증가(silent memory growth)는 없다.

배치된 chunk
iteration마다 chunk를 하나씩 yield하는 대신, 스트림은 Uint8Array[]—chunk들의 배열—을 yield한다. 이는 여러 chunk에 걸쳐 async 오버헤드를 분산(amortize)하여 핫 패스에서 promise 생성과 microtask latency를 줄인다.
바이트 전용
API는 오직 바이트(Uint8Array)만 다룬다. 문자열은 자동으로 UTF-8로 인코딩된다. “value stream”과 “byte stream”의 이원화는 없다. 임의의 JavaScript 값을 스트리밍하고 싶다면 async iterable을 직접 사용하면 된다. 이 API는 Uint8Array를 사용하지만, chunk는 불투명(opaque)한 것으로 취급한다. 부분 소비(partial consumption)도 없고, BYOB 패턴도 없고, 스트리밍 메커니즘 내부에서 바이트 단위 연산도 없다. chunk는 들어오고, transform이 명시적으로 수정하지 않는 한 그대로 나간다.
동기 fast path의 중요성
API는 동기 데이터 소스가 필요하며 흔하다는 사실을 인정한다. 애플리케이션은 제공되는 유일한 선택지가 비동기 스케줄링뿐이라는 이유로 항상 그 성능 비용을 감수하도록 강제되어서는 안 된다. 동시에 sync와 async 처리를 섞는 것은 위험할 수 있다. 동기 경로는 항상 선택 가능해야 하며, 항상 명시적이어야 한다.
새 API의 동작 방식
스트림 생성과 소비
Web streams에서는 단순한 producer/consumer 쌍을 만들려 해도 TransformStream, 수동 인코딩, 그리고 신중한 lock 관리가 필요하다.
const { readable, writable } = new TransformStream();
const enc = new TextEncoder();
const writer = writable.getWriter();
await writer.write(enc.encode("Hello, World!"));
await writer.close();
writer.releaseLock();
const dec = new TextDecoder();
let text = '';
for await (const chunk of readable) {
text += dec.decode(chunk, { stream: true });
}
text += dec.decode();이 비교적 “깔끔한” 버전조차도 다음이 필요하다. TransformStream, 수동 TextEncoder/TextDecoder, 그리고 명시적인 lock 해제.
다음은 새 API로 작성한 동등한 코드이다.
import { Stream } from 'new-streams';
// Create a push stream
const { writer, readable } = Stream.push();
// Write data — backpressure is enforced
await writer.write("Hello, World!");
await writer.end();
// Consume as text
const text = await Stream.text(readable);여기서 readable은 그냥 async iterable이다. 따라서 async iterable을 기대하는 어떤 함수에도 넘길 수 있으며, 전체 스트림을 수집하고 디코딩하는 Stream.text()에도 그대로 전달할 수 있다.
writer는 단순한 인터페이스를 가진다. write(), 배치 쓰기를 위한 writev(), 완료를 알리는 end(), 에러 처리를 위한 abort()가 전부다.
또한 Writer는 구체 클래스(concrete class)가 아니다. write(), end(), abort()를 구현하는 어떤 객체든 writer가 될 수 있다. 그래서 상속 없이도 기존 API를 쉽게 어댑트하거나 특화된 구현을 만들 수 있다. start(), write(), close(), abort() 콜백을 컨트롤러를 통해 조율해야 하는 복잡한 UnderlyingSink 프로토콜도 없다. (그 컨트롤러는 WritableStream과 결합되어 있으면서도 생명주기와 상태가 독립적이어서 추가 복잡성을 만든다.)
아래는 작성된 데이터를 모두 모으는 간단한 in-memory writer 예시다.
// A minimal writer implementation — just an object with methods
function createBufferWriter() {
const chunks = [];
let totalBytes = 0;
let closed = false;
const addChunk = (chunk) => {
chunks.push(chunk);
totalBytes += chunk.byteLength;
};
return {
get desiredSize() { return closed ? null : 1; },
// Async variants
write(chunk) { addChunk(chunk); },
writev(batch) { for (const c of batch) addChunk(c); },
end() { closed = true; return totalBytes; },
abort(reason) { closed = true; chunks.length = 0; },
// Sync variants return boolean (true = accepted)
writeSync(chunk) { addChunk(chunk); return true; },
writevSync(batch) { for (const c of batch) addChunk(c); return true; },
endSync() { closed = true; return totalBytes; },
abortSync(reason) { closed = true; chunks.length = 0; return true; },
getChunks() { return chunks; }
};
}
// Use it
const writer = createBufferWriter();
await Stream.pipeTo(source, writer);
const allData = writer.getChunks();확장해야 할 베이스 클래스도 없고, 구현해야 할 추상 메서드도 없고, 조율해야 할 controller도 없다. 단지 “올바른 형태(shape)”의 메서드를 가진 객체일 뿐이다.
Pull-through transform
새 API 설계에서 transform은 데이터가 소비되기 전까지 어떤 작업도 수행하지 않아야 한다. 이는 근본 원칙이다.
// Nothing executes until iteration begins
const output = Stream.pull(source, compress, encrypt);
// Transforms execute as we iterate
for await (const chunks of output) {
for (const chunk of chunks) {
process(chunk);
}
}Stream.pull()은 lazy 파이프라인을 만든다. compress와 encrypt transform은 output의 iteration을 시작하기 전까지 실행되지 않는다. 각 iteration은 필요할 때(on demand) 데이터가 파이프라인을 통과하도록 pull한다.
이는 Web streams의 pipeThrough()와 근본적으로 다르다. pipeThrough()는 pipe를 설정하는 즉시 source에서 transform으로 데이터를 적극적으로 펌핑하기 시작한다. pull 의미론에서는 언제 처리가 발생할지 사용자가 제어할 수 있으며, iteration을 멈추면 처리도 멈춘다.
transform은 stateless일 수도 있고 stateful일 수도 있다. stateless transform은 chunk를 받아 변환된 chunk를 반환하는 함수일 뿐이다.
// Stateless transform — a pure function
// Receives chunks or null (flush signal)
const toUpperCase = (chunks) => {
if (chunks === null) return null; // End of stream
return chunks.map(chunk => {
const str = new TextDecoder().decode(chunk);
return new TextEncoder().encode(str.toUpperCase());
});
};
// Use it directly
const output = Stream.pull(source, toUpperCase);stateful transform은 호출 간 상태를 유지하는 멤버 함수를 가진 단순한 객체로 표현한다.
// Stateful transform — a generator that wraps the source
function createLineParser() {
// Helper to concatenate Uint8Arrays
const concat = (...arrays) => {
const result = new Uint8Array(arrays.reduce((n, a) => n + a.length, 0));
let offset = 0;
for (const arr of arrays) { result.set(arr, offset); offset += arr.length; }
return result;
};
return {
async *transform(source) {
let pending = new Uint8Array(0);
for await (const chunks of source) {
if (chunks === null) {
// Flush: yield any remaining data
if (pending.length > 0) yield [pending];
continue;
}
// Concatenate pending data with new chunks
const combined = concat(pending, ...chunks);
const lines = [];
let start = 0;
for (let i = 0; i < combined.length; i++) {
if (combined[i] === 0x0a) { // newline
lines.push(combined.slice(start, i));
start = i + 1;
}
}
pending = combined.slice(start);
if (lines.length > 0) yield lines;
}
}
};
}
const output = Stream.pull(source, createLineParser());abort 시 정리가 필요한 transform이라면 abort 핸들러를 추가한다.
// Stateful transform with resource cleanup
function createGzipCompressor() {
// Hypothetical compression API...
const deflate = new Deflater({ gzip: true });
return {
async *transform(source) {
for await (const chunks of source) {
if (chunks === null) {
// Flush: finalize compression
deflate.push(new Uint8Array(0), true);
if (deflate.result) yield [deflate.result];
} else {
for (const chunk of chunks) {
deflate.push(chunk, false);
if (deflate.result) yield [deflate.result];
}
}
}
},
abort(reason) {
// Clean up compressor resources on error/cancellation
}
};
}구현자 관점에서는 start(), transform(), flush() 메서드와 controller 조율이 필요한 Transformer 프로토콜도 없고, 자체적인 숨겨진 상태 머신과 버퍼링 메커니즘을 가진 TransformStream 클래스도 없다. transform은 그냥 함수 또는 단순한 객체일 뿐이므로, 구현과 테스트가 훨씬 단순하다.
명시적 backpressure 정책
bounded buffer가 가득 찼는데 producer가 더 쓰려고 한다면, 할 수 있는 일은 몇 가지로만 한정된다.
Reject the write: 더 이상의 데이터를 받지 않는다
Wait: 공간이 생길 때까지 block한다
Discard old data: 이미 버퍼링된 데이터를 내보내(evict) 공간을 만든다
Discard new data: 들어오는 데이터를 drop한다
이게 전부다. 그 외의 반응은 사실상 이것들의 변형(예: “버퍼 크기 늘리기”는 선택을 미루는 것에 가깝다)이거나, 일반 스트리밍 프리미티브에 넣기엔 너무 도메인 특화된 로직이다. Web streams는 현재 기본값으로 항상 Wait를 선택한다.

새 API는 이 네 가지 중 하나를 명시적으로 선택하게 만든다.
strict (default): 버퍼가 가득 차고 pending write가 너무 많아지면 write를 reject한다. producer가 backpressure를 무시하는 “fire-and-forget” 패턴을 잡아낸다.
block: 버퍼 공간이 생길 때까지 write가 대기한다. producer가 write를 올바르게 await한다고 신뢰할 수 있을 때 사용한다.
drop-oldest: 공간을 만들기 위해 가장 오래된 버퍼 데이터를 drop한다. 오래된 데이터의 가치가 떨어지는 라이브 피드에 유용하다.
drop-newest: 가득 찼을 때 들어오는 데이터를 drop한다. 압도당하지 않으면서 현재 처리 중인 것을 유지하고 싶을 때 유용하다.
const { writer, readable } = Stream.push({
highWaterMark: 10,
backpressure: 'strict' // or 'block', 'drop-oldest', 'drop-newest'
});producer가 협조해 주길 “바라는” 방식이 아니다. 어떤 정책을 고르느냐가 버퍼가 찼을 때의 동작을 결정한다.
아래는 producer가 consumer보다 빠르게 write할 때 각 정책이 어떻게 동작하는지 보여준다.
// strict: Catches fire-and-forget writes that ignore backpressure
const strict = Stream.push({ highWaterMark: 2, backpressure: 'strict' });
strict.writer.write(chunk1); // ok (not awaited)
strict.writer.write(chunk2); // ok (fills slots buffer)
strict.writer.write(chunk3); // ok (queued in pending)
strict.writer.write(chunk4); // ok (pending buffer fills)
strict.writer.write(chunk5); // throws! too many pending writes
// block: Wait for space (unbounded pending queue)
const blocking = Stream.push({ highWaterMark: 2, backpressure: 'block' });
await blocking.writer.write(chunk1); // ok
await blocking.writer.write(chunk2); // ok
await blocking.writer.write(chunk3); // waits until consumer reads
await blocking.writer.write(chunk4); // waits until consumer reads
await blocking.writer.write(chunk5); // waits until consumer reads
// drop-oldest: Discard old data to make room
const dropOld = Stream.push({ highWaterMark: 2, backpressure: 'drop-oldest' });
await dropOld.writer.write(chunk1); // ok
await dropOld.writer.write(chunk2); // ok
await dropOld.writer.write(chunk3); // ok, chunk1 discarded
// drop-newest: Discard incoming data when full
const dropNew = Stream.push({ highWaterMark: 2, backpressure: 'drop-newest' });
await dropNew.writer.write(chunk1); // ok
await dropNew.writer.write(chunk2); // ok
await dropNew.writer.write(chunk3); // silently dropped명시적 멀티 consumer 패턴
// Share with explicit buffer management
const shared = Stream.share(source, {
highWaterMark: 100,
backpressure: 'strict'
});
const consumer1 = shared.pull();
const consumer2 = shared.pull(decompress);숨겨진 무제한 버퍼를 가진 tee() 대신, 명시적인 멀티 consumer 프리미티브를 제공한다. Stream.share()는 pull 기반이다. consumer들이 공유 source에서 pull하며, 버퍼 한계와 backpressure 정책을 사전에 설정한다.
push 기반 멀티 consumer 시나리오를 위한 Stream.broadcast()도 있다. 두 API 모두 consumer들이 서로 다른 속도로 동작할 때 무엇이 일어나는지 반드시 생각하게 만든다. 이는 현실적인 문제이며 숨기면 안 되는 문제이기 때문이다.
sync/async 분리
모든 스트리밍 워크로드가 I/O를 포함하는 것은 아니다. source가 in-memory이고 transform이 순수 함수라면, async 메커니즘은 이득 없이 오버헤드만 추가한다. 실제로는 기다릴 일이 없는데도 “대기”를 조율하기 위한 비용을 치르는 셈이다.
새 API는 완전히 병렬인 sync 버전을 제공한다. Stream.pullSync(), Stream.bytesSync(), Stream.textSync() 등이다. source와 transform이 모두 동기라면 promise 하나 없이 전체 파이프라인을 처리할 수 있다.
// Async — when source or transforms may be asynchronous
const textAsync = await Stream.text(source);
// Sync — when all components are synchronous
const textSync = Stream.textSync(source);아래는 compression, transform, consume까지 async 오버헤드 0으로 수행하는 완전한 동기 파이프라인 예시다.
// Synchronous source from in-memory data
const source = Stream.fromSync([inputBuffer]);
// Synchronous transforms
const compressed = Stream.pullSync(source, zlibCompressSync);
const encrypted = Stream.pullSync(compressed, aesEncryptSync);
// Synchronous consumption — no promises, no event loop trips
const result = Stream.bytesSync(encrypted);전체 파이프라인은 단일 call stack에서 실행된다. promise가 생성되지 않고, microtask 큐 스케줄링도 없으며, 수명이 짧은 async 메커니즘으로 인한 GC 압력도 없다. in-memory 데이터의 파싱, 압축, 변환 같은 CPU 바운드 워크로드에서는, 모든 구성 요소가 동기임에도 강제로 async 경계를 통과해야 하는 Web streams 코드보다 상당히 빠를 수 있다.
Web streams에는 동기 경로가 없다. source가 이미 데이터를 가지고 있고 transform이 순수 함수인 경우에도, 매 연산마다 promise 생성과 microtask 스케줄링 비용을 지불한다. promise는 실제로 “기다림”이 필요한 경우에는 훌륭하지만, 항상 필요한 것은 아니다. 새 API는 필요한 경우에만 async를 쓰고, 필요하면 sync 영역에 머물 수 있게 한다.
대안적 api와 Web streams 사이의 브릿지
async iterator 기반 접근은 이 대안과 Web streams 사이에 자연스러운 브릿지를 제공한다. ReadableStream에서 이 새 접근으로 넘어올 때는, ReadableStream이 byte를 yield하도록 설정되어 있다면 readable을 입력으로 그대로 넘기기만 하면 기대한 대로 동작한다.
const readable = getWebReadableStreamSomehow();
const input = Stream.pull(readable, transform1, transform2);
for await (const chunks of input) {
// process chunks
}반대로 ReadableStream으로 어댑트할 때는, 이 대안이 chunk의 배치를 yield하기 때문에 약간의 작업이 더 필요하다. 하지만 어댑터 계층은 간단하고 직관적으로 작성할 수 있다.
async function* adapt(input) {
for await (const chunks of input) {
for (const chunk of chunks) {
yield chunk;
}
}
}
const input = Stream.pull(source, transform1, transform2);
const readable = ReadableStream.from(adapt(input));대안적 접근이 앞서 살펴본 실제 문제들을 어떻게 해결하는가
소비되지 않은 body 문제: pull 의미론에서는 iteration이 시작되기 전까지 아무 일도 일어나지 않는다. 숨겨진 리소스 유지도 없다. 스트림을 소비하지 않으면 연결을 붙잡고 있는 백그라운드 메커니즘도 존재하지 않는다.
tee() 메모리 절벽 문제: Stream.share()는 명시적인 버퍼 설정을 요구한다. highWaterMark와 backpressure 정책을 처음부터 선택해야 한다. consumer 속도가 서로 다를 때 조용히 무제한으로 메모리가 증가하는 상황은 더 이상 발생하지 않는다.
Transform backpressure 공백: pull-through transform은 필요할 때만(on demand) 실행된다. 데이터가 중간 버퍼들을 따라 연쇄적으로 흐르는 일이 없고, consumer가 pull할 때만 흐른다. iteration을 멈추면 처리도 멈춘다.
SSR에서의 GC thrashing: 배치 chunk(Uint8Array[])는 async 오버헤드를 분산(amortize)한다. 또한 Stream.pullSync()를 사용하는 sync 파이프라인은 CPU 바운드 워크로드에서 promise 할당 자체를 완전히 제거한다.
성능
이러한 설계 선택은 성능에 영향을 미친다. 다음은 이 가능한 대안의 레퍼런스 구현을 Web streams와 비교한 벤치마크이다(Node.js v24.x, Apple M1 Pro, 10회 실행 평균).
Scenario | Alternative | Web streams | Difference |
Small chunks (1KB × 5000) | ~13 GB/s | ~4 GB/s | ~3× faster |
Tiny chunks (100B × 10000) | ~4 GB/s | ~450 MB/s | ~8× faster |
Async iteration (8KB × 1000) | ~530 GB/s | ~35 GB/s | ~15× faster |
Chained 3× transforms (8KB × 500) | ~275 GB/s | ~3 GB/s | ~80–90× faster |
High-frequency (64B × 20000) | ~7.5 GB/s | ~280 MB/s | ~25× faster |
연쇄 transform 결과는 특히 눈에 띈다. pull-through 의미론은 Web streams 파이프라인을 괴롭히는 중간 버퍼링을 제거한다. 각 TransformStream이 내부 버퍼를 eager하게 채우는 대신, 데이터는 consumer에서 source로 온디맨드 방식으로 흐른다.
물론 공정하게 말하면, Node.js는 아직 Web streams 구현의 성능을 완전히 최적화하는 데 큰 노력을 기울이지 않았다. Node.js의 결과는 핫 패스를 최적화하는 데 약간의 노력을 적용하는 것만으로도 상당한 개선 여지가 있을 것이다. 그렇다고 해도, Deno와 Bun에서 이 벤치마크를 실행해 보아도 이 대안적인 iterator 기반 접근은 그들의 Web streams 구현보다 역시 큰 성능 향상을 보인다.
브라우저 벤치마크(Chrome/Blink, 3회 실행 평균)에서도 일관된 이득을 보여준다.
Scenario | Alternative | Web streams | Difference |
Push 3KB chunks | ~135k ops/s | ~24k ops/s | ~5–6× faster |
Push 100KB chunks | ~24k ops/s | ~3k ops/s | ~7–8× faster |
3 transform chain | ~4.6k ops/s | ~880 ops/s | ~5× faster |
5 transform chain | ~2.4k ops/s | ~550 ops/s | ~4× faster |
bytes() consumption | ~73k ops/s | ~11k ops/s | ~6–7× faster |
Async iteration | ~1.1M ops/s | ~10k ops/s | ~40–100× faster |
이 벤치마크는 통제된 시나리오에서 처리량을 측정한 것이며, 실제 성능은 구체적인 사용 사례에 따라 달라진다. Node.js와 브라우저에서의 이득 차이는 각 환경이 Web streams에 대해 취하는 최적화 경로가 서로 다르기 때문이다.
주목할 점은, 이 벤치마크가 새 API의 순수 TypeScript/JavaScript 구현을 각 런타임의 Web streams 네이티브(JavaScript/C++/Rust) 구현과 비교한다는 것이다. 새 API의 레퍼런스 구현은 어떤 성능 최적화 작업도 수행하지 않았으며, 이득은 전적으로 설계에서 비롯된다. 네이티브 구현이라면 추가적인 개선이 더 나타날 가능성이 높다.
이 이득은 근본 설계 선택들이 어떻게 누적(compound)되는지를 보여준다. batching은 async 오버헤드를 분산(amortize)하고, pull 의미론은 중간 버퍼링을 제거하며, 데이터가 즉시 उपलब्ध할 때 구현이 동기 fast path를 사용할 수 있는 자유 역시 기여한다.
“우리는 Node streams에서 성능과 일관성을 개선하기 위해 많은 일을 해왔지만, 완전히 새로 시작하는 데에는 특별한 힘이 있다. New streams의 접근은 레거시 짐 없이 현대 런타임의 현실을 받아들이며, 이는 더 단순하고 성능이 좋으며 더 일관된 streams 모델로 가는 문을 연다.”
- Robert Nagy, Node.js TSC 멤버 및 Node.js streams 컨트리뷰터
앞으로 나아갈 길
나는 이 글을 하나의 논의를 시작하기 위해 공개한다. 내가 무엇을 제대로 짚었을까? 무엇을 놓쳤을까? 이 모델에 맞지 않는 사용 사례는 있을까? 이런 접근 방식으로의 마이그레이션 경로는 어떤 모습일까? 목표는 Web streams의 문제를 직접 겪어 본 개발자들로부터 피드백을 모으고, 더 나은 API가 어떤 모습이어야 하는지에 대한 의견을 듣는 것이다.
직접 사용해 보기
이 대안적 접근 방식의 레퍼런스 구현은 이미 공개되어 있으며 다음에서 확인할 수 있다.
https://github.com/jasnell/new-streams
API Reference: 전체 문서는 API.md에서 확인할 수 있다.
Examples: samples 디렉터리에는 일반적인 패턴을 보여주는 동작하는 예제가 포함되어 있다.
이슈, 토론, 그리고 PR을 환영한다. 내가 다루지 못한 Web streams 문제를 경험했거나 이 접근 방식의 빈틈을 발견했다면 알려 주길 바란다. 다만 다시 강조하지만, 이 글의 목적은 “이 끝내주는 새 객체를 모두 사용하자!”라고 말하는 것이 아니다. 현재의 Web Streams라는 상태에 머무르지 않고, 다시 기본 원칙(first principles)로 돌아가 보자는 논의를 시작하는 것이다.
Web streams는 아무것도 없던 시기에 웹 플랫폼에 스트리밍을 도입한 야심찬 프로젝트였다. 이를 설계한 사람들은 2014년의 제약 속에서 합리적인 선택을 했다. 그때는 async iteration도 없었고, 수년간의 프로덕션 경험을 통해 엣지 케이스가 드러나기도 전이었다.
하지만 우리는 그 이후로 많은 것을 배웠다. JavaScript도 진화했다. 오늘날 설계되는 스트리밍 API라면 더 단순하고, 언어와 더 잘 맞으며, backpressure나 multi-consumer 동작처럼 중요한 것들을 더 명확하게 표현할 수 있을 것이다.
우리는 더 나은 stream API를 가질 자격이 있다.
그러니 그것이 어떤 모습이어야 하는지 함께 이야기해 보자.
더 읽어보기
2026.03.13
Streams API 3. 바이트 스트림과 실전 파이프라인
앞선 두 글에서 우리는 Web Streams API의 표면과 의미론을 정리했다. 이제 남은 질문은 하나이다. 이 지식을 실제 어디에 써먹을 것인가. 이 질문에 답하는 순간 스트림 학습은 비로소 완성된다. 표준을 읽고 메서드를 아는 것만으로는 충분하지 않다. fetch() 응답 본문을 어…
2026.03.13
Streams API 2. 상태와 백프레셔의 의미론
스트림을 처음 배울 때는 읽고 쓰는 예제가 꽤 단순해 보인다. getReader()로 읽고, getWriter()로 쓰고, pipeTo()로 연결하면 끝나는 것처럼 느껴진다. 실제로 짧은 데모는 이 정도만 알아도 돌아간다. 그러나 실무에서 스트림 코드를 망가뜨리는 원인은 거의 언제나 더…
2026.03.13
Streams API 1. 읽기와 쓰기의 출발점
자바스크립트에서 비동기를 배울 때 우리는 대개 Promise와 async, await부터 익힌다. 이 조합은 한 번의 결과를 기다리는 문제에는 매우 강력하다. 그러나 데이터가 한 번에 끝나지 않고 계속 흘러들어오는 상황에서는 이야기가 달라진다. 네트워크 응답이 길게 이어지거나, 큰 파일…
2026.03.01
함수, 펑터, 그리고 모나드
복잡한 버그는 대개 거대한 기능이 아니라 사소한 데이터 변환 구간에서 시작된다. 문자열을 한 번 다듬고, 숫자를 한 번 바꾸고, 그 결과를 다음 단계로 넘기는 단순한 흐름이다. 그런데 조건이 조금씩 추가되는 순간 로직은 빠르게 복잡해진다. 값만 바꾸던 코드가 어느새 값의 부재, 비동기…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...