JavaScript를 위한 더 나은 Streams API가 필요합니다
(blog.cloudflare.com)- Web Streams 표준은 브라우저와 서버 간 일관된 데이터 스트리밍을 위해 설계되었지만, 현재는 복잡성과 성능 한계로 인해 개발자 경험이 저하되고 있음
- 기존 API는 락(lock) 관리, BYOB, 백프레셔(backpressure) 등 설계적 제약으로 인해 사용성과 구현 모두에서 불필요한 부담을 초래함
- Cloudflare는 비동기 반복(async iteration) 기반의 새로운 스트림 모델을 제안하며, 이 방식은 2배에서 최대 120배 빠른 성능을 보임
- 새 API는 단순한 async iterable 구조, 명시적 백프레셔 정책, 동기/비동기 병행 지원을 통해 효율성과 일관성을 높임
- 이 접근은 Node.js, Deno, Bun, 브라우저 등 모든 런타임에서 통합적 스트리밍 모델을 가능하게 하며, 향후 표준 논의의 출발점이 될 수 있음
Web Streams의 구조적 한계
- WHATWG Streams 표준은 2014~2016년에 개발되어 브라우저 중심으로 설계되었으며, 당시 async iteration이 존재하지 않아 별도의 reader/writer 모델을 도입함
- 이로 인해 락 관리, 복잡한 읽기 루프, BYOB 버퍼 처리 등 불필요한 절차가 생김
-
락(locking) 모델은 스트림을 독점적으로 점유해 병렬 소비를 막으며,
releaseLock()누락 시 스트림이 영구적으로 잠기는 문제가 발생함 - BYOB(Bring Your Own Buffer) 기능은 메모리 재사용을 목표로 했지만, 복잡한 버퍼 분리·전송 모델로 인해 실제 활용도가 낮고 구현 난이도가 높음
-
백프레셔(backpressure) 는 이론상 지원되지만,
desiredSize값이 음수여도enqueue()가 성공하는 등 실제 제어가 불가능한 구조임 - 각
read()호출마다 Promise 생성이 강제되어, 고빈도 스트리밍에서는 성능 저하와 GC 부하를 유발함
실무에서 드러난 문제들
-
fetch()응답 본문을 소비하지 않으면 연결 풀 고갈이 발생하며,tee()사용 시 무제한 메모리 버퍼링이 생김 -
TransformStream은 읽기 준비 여부와 무관하게 즉시 처리되어, 느린 소비자 환경에서 버퍼 폭증을 초래함 - 서버사이드 렌더링(SSR)에서는 수천 개의 작은 청크 처리로 인한 GC 쓰레싱이 발생해 성능이 급감함
- 각 런타임(Node.js, Deno, Bun, Workers)은 이를 완화하기 위해 비표준 최적화 경로를 도입했으나, 이로 인해 호환성과 일관성 저하가 발생함
- Web Platform Tests는 70개 이상의 복잡한 테스트 파일을 요구하며, 이는 과도한 내부 상태 관리와 비직관적 동작의 결과임
새로운 스트림 API 설계 원칙
-
스트림은 단순한 async iterable로 정의되어,
for await...of로 직접 소비 가능 - Pull-through 변환을 채택해 소비자가 데이터를 요청할 때만 처리 수행
- 명시적 백프레셔 정책(strict, block, drop-oldest, drop-newest) 을 제공해 메모리 폭주 방지
- 배치 청크(Uint8Array[]) 단위로 데이터를 전달해 Promise 생성 비용을 줄임
- 바이트 단위 전용 처리로 단순화하며, BYOB나 복잡한 컨트롤러 개념 제거
- 동기(synchronous) 경로 지원으로 CPU 중심 작업에서 Promise 오버헤드를 제거
새로운 API의 예시와 특징
-
Stream.push()로 간단히 writer/readable 쌍 생성,Stream.text()로 전체 텍스트 수집 가능 -
Stream.pull()은 지연(lazy) 파이프라인을 구성해 소비 시점에만 실행 -
Stream.share()와Stream.broadcast()는 명시적 다중 소비자 관리를 지원 -
Sync/Async 병행 API(
Stream.pullSync(),Stream.textSync())로 I/O 없는 연산에서 성능 극대화 - Web Streams와의 상호 운용을 위해 간단한 어댑터 함수로 변환 가능
성능 비교 및 전망
- Node.js 기준 벤치마크에서 최대 80~90배, 브라우저에서는 최대 100배 이상 빠른 처리 속도 확인
- 예: 3단 변환 체인에서 275GB/s vs 3GB/s
- 성능 향상은 비동기 오버헤드 제거, 배치 처리, pull 기반 설계에서 기인
- 이 구현은 순수 TypeScript/JavaScript로 작성되었으며, 네이티브 구현 시 추가 향상 가능성 존재
- Cloudflare는 이 접근을 표준 논의의 출발점으로 제시하며, 개발자 커뮤니티의 피드백을 요청함
결론
- Web Streams는 당시 제약 속에서 합리적이었지만, 현대 JavaScript의 언어 기능과 개발 패턴에 부합하지 않음
- 새로운 async iterable 기반 모델은 단순성, 성능, 명시적 제어를 모두 충족하며, 런타임 간 일관된 스트리밍 생태계 구축 가능성 제시
- Cloudflare는 GitHub의 jasnell/new-streams에서 참조 구현과 문서, 예제 코드를 공개함
- 목표는 새로운 표준 제정이 아니라, “더 나은 스트림 API”를 논의하기 위한 실질적 출발점 마련임
Hacker News 의견들
-
이 글에서 제안한 API보다 더 나은 Stream 인터페이스를 직접 설계했음
기존 제안은async iterator of UInt8Array형태인데, 나는next()가 동기 혹은 비동기 결과를 모두 반환할 수 있는 구조를 제안함
이렇게 하면
기존 구조보다 단일 반복자로 단순하게 순회 가능함
동기 입력에 동기 변환을 적용하면 전체 처리가 동기로 가능해 코드 중복을 줄일 수 있음
불필요한 Promise 생성이 줄어들어 성능 향상이 있음
동시성 제어가 가능해 async iterator의 한계를 극복할 수 있음- 네가 제안한 방식이 더 낫다고 하지만, 사실 상대방의 방식이 더 기초적인 원시 형태로서 우수하다고 생각함
네 방식으로는 그들의 구조를 쉽게 만들 수 없고, 반대로는 가능함
I/O 중심의 반복자는 T 단위의 청크를 반환해야 버퍼 낭비를 막을 수 있음 - 제안한 스트림 개념이 흥미롭지만, 그들의 설계는 AsyncIterator 호환성을 전제로 함
Uint8Array를 사용하는 이유는 OS 수준의 바이트 스트림과 맞추기 위함임
실제로 C 기반 프로젝트에서도 이런 구조가 가장 효율적이므로, 타입 정보를 가진 프로토콜은 그 위에 쌓이는 형태가 자연스러움 - Node 24에서 동기 함수 호출과 async 함수 호출의 속도 차이를 마이크로벤치마크로 측정했는데, 약 90배 정도 느림
예전 버전에서는 105배까지 차이 났음
async 처리 최적화가 Node 16에서 있었고, 그때 일부 테스트가 깨졌던 기억이 있음 Uint8Array라는 타입은 존재하지 않음
Uint8Array는 단순히 바이트 배열을 표현하는 원시 타입이며, 타입 정보는 프로토콜이 아닌 애플리케이션 레벨에서 다뤄야 함- 이 구조는 Clojure의 transducer 개념과 비슷함
참고: Clojure Transducers 문서
- 네가 제안한 방식이 더 낫다고 하지만, 사실 상대방의 방식이 더 기초적인 원시 형태로서 우수하다고 생각함
-
Async iterable도 완벽한 해결책은 아님
Promise와 스택 전환 오버헤드가 커서 작은 단위의 데이터를 다룰 때 성능이 나쁨
Lit-SSR에서는 이를 해결하기 위해 동기 iterable 안에 thunk를 포함하는 방식을 사용했음
async 작업이 필요할 때만 thunk를 호출하고 await하도록 하여, SSR 성능을 12~18배 향상시켰음
다만 Streams API는 이런 취약한 계약 구조를 채택하기 어렵기 때문에,write()와writeAsync()처럼 선택적 비동기 처리가 가능한 구조가 이상적이라 생각함- 네가 말한 문제를 내 stream iterator가 해결할 수 있음
동기 generator를 활용한 예시를 GitHub 코드로 공유함
핵심은step.value.then(value => this.next(value))부분임 - conartist6의 제안(
next(): {done, value: T} | Promise)이 마음에 듦
2013년의 “Do not unleash Zalgo” 논쟁 이후,MaybeAsync형태를 피하는 경향이 있었지만
이 공포가 너무 과장되어 빠르고 유연한 API 설계를 막고 있다고 생각함
여러 값을 한 번에 끌어오는 유틸리티도 만들 수 있고, generator 속도 문제는 실제로는 크지 않다고 느낌
- 네가 말한 문제를 내 stream iterator가 해결할 수 있음
-
Node.js에서 Web Streams를 다루는 건 고통스러움
브라우저 중심으로 설계되어 서버 환경에서는 불편함
단순한 변환에도 transform stream을 감싸야 하고,.pipe()처럼 직관적인 체이닝이 어려움
Async iterable 접근법이 훨씬 자연스럽고for-await-of와 잘 어울림
Web Streams 스펙은 너무 추상화 중심적이라 실용성이 떨어짐- Node에서 Web Streams를 실제로 쓰는 사람이 있다는 게 놀라움
나는 그게 단순히 클라이언트–서버 간 호환성용이라고 생각했음
- Node에서 Web Streams를 실제로 쓰는 사람이 있다는 게 놀라움
-
진짜 이점은 성능뿐 아니라 환경 간 일관성(convergence) 임
ReadableStream이 브라우저, Worker, 기타 런타임에서 동일하게 동작하면
코드 이식성과 backpressure 버그 감소 효과가 큼
스트림 계층의 표준화는 신뢰성 있는 스트리밍 시스템 구축의 핵심임- 맞음, 단순히 성능이 아니라 표준화의 가치가 큼
-
예전에 Repeater라는 추상화를 만들었음
Promise 생성자를 async iterable로 옮긴 개념으로, 이벤트를 push/stop으로 제어함
Repeater 라이브러리는 주간 650만 다운로드를 기록할 정도로 안정적임
최근에는 streams를 더 선호하지만,tee()관련 비판은 여전히 유효함
async iterable을 기본 추상화로 삼는 방향이 옳다고 생각함- Repeater의
stop이 함수이자 Promise로 동작하는 게 흥미로웠음
소스 코드를 보고 나서
전통적인 패턴과 다르지만, 인체공학적 설계를 위한 의도된 선택일 수도 있다고 생각함 - 주제와는 다르지만, Konami 코드 예시가 너무 반가웠음
이메일 서명에도 “Up, Up, Down, Down, Left, Right, Left, Right, B, A”를 쓸 정도로 향수가 깊음
- Repeater의
-
나도 AsyncIterable을 더 간결하게 쓰기 위한 래퍼를 만든 적 있음
fluent-async-iterator인데,
Lambda나 CLI 파이프라인에서 소규모 데이터 스트리밍에 유용했음
지금쯤은 더 나은 API가 나왔길 바랐음 -
ReadableStream.tee()의 backpressure 동작이 Node.js의pipe()와 반대라 혼란스러움
명세서에는 “가장 느린 출력이 속도를 결정해야 한다”고 되어 있는데, 실제 구현은 빠른 쪽이 소비되지 않아도 막힘
새로운 Stream API처럼 push 기반의 간결한 구조가 더 낫다고 생각함
Node와 Web Streams는 무한 큐를 두어 동기적으로res.write()를 남발할 수 있게 하지만,
이 API는 generator 기반의 yield 흐름을 강제해 더 안전함 -
Node.js에서 undici(fetch) 사용 시 커넥션 풀 고갈 문제가 생기는 건
가비지 컬렉션 언어의 한계 때문임
명시적으로 리소스를 닫지 않으면 GC 타이밍에 따라 누수가 발생함
C++의 RAII(reference counting) 접근이 오히려 더 안전함 -
리소스 해제 관련해서는
using/await using패턴이 점점 확산되길 바람
C#의using처럼 dispose/disposeAsync를 지원하는 구조를 DB 드라이버에 적용 중임 -
벤치마크 수치(예: 530GB/s)는 M1 Pro의 메모리 대역폭(200GB/s) 을 초과하므로 신뢰하기 어려움
구현 품질 관리가 부족한 vibe-coded 벤치마크일 가능성이 높음