"Node에는 스트림 표면이 둘 있어. 어느 쪽을 받았는지 알아보고 경계에서 제대로 이어 주면, 둘을 외울 일이 아니라 하나의 흐름을 다루는 일이 돼."
Node에 스트림 표면이 두 개인 까닭
Node 스트림은 Web Streams보다 먼저 만들어졌고, 파일·HTTP·압축·암호화 같은 Node 생태계가 오래 사용해 왔어. 나중에 브라우저와 여러 런타임이 공유할 Web Streams 표준이 생겼고 Node도 그 표면을 구현했지. 그래서 fetch()의 응답 본문은 ReadableStream이고, node:fs나 node:zlib의 많은 API는 Node 스트림을 돌려줘.
ReadableStream은 읽는 쪽이야.WritableStream은 쓰는 쪽이야.TransformStream은 입력을 받아 바꾼 뒤 출력해.
개념은 닮았지만 메서드 이름과 청크 형태가 완전히 같지는 않아. 먼저 어느 표면을 받았는지 확인하고, 필요할 때 Node가 제공하는 변환 함수를 쓰면 돼.
비동기 순회는 둘이 공유하는 다리야
Node에서 Web ReadableStream은 for await...of로 읽을 수 있어. fetch() 응답을 전부 메모리에 올리지 않고 청크마다 처리할 때 편해.
const res = await fetch('https://api.example.com/large.json');
for await (const chunk of res.body) {
console.log('받은 바이트:', chunk.byteLength);
}
Node Readable도 같은 반복 문법을 지원해. 소비 코드가 비동기 순회만 필요하다면 경계를 바꾸지 않고도 같은 흐름으로 읽을 수 있는 셈이야.
전용 API가 필요할 때 변환해
Readable.toWeb(nodeReadable)은 Node의 읽기 스트림을 WebReadableStream으로 바꿔.Readable.fromWeb(webReadable)은 반대 방향이야.Writable.toWeb(nodeWritable)과Writable.fromWeb(webWritable)도 같은 규칙을 따라.
fetch() 응답을 zlib.createGzip()에 연결하는 것처럼 한쪽 생태계의 전용 기능이 필요할 때만 변환해. 표면을 무조건 통일하는 게 목적은 아냐.pipeline()은 두 표면을 한 흐름으로 묶어
모던 Node의 pipeline()은 Web 스트림과 Node 스트림을 한 처리 흐름에 함께 받을 수 있어. 응답 본문을 먼저 버퍼로 만들지 않고 압축해 파일로 보낼 수 있지.
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
const res = await fetch('https://api.example.com/large.json');
await pipeline(
res.body,
createGzip(),
createWriteStream('cached.json.gz')
);
여기서 입력은 Web ReadableStream이고 나머지는 Node 스트림이야. pipeline()이 경계를 처리하고, 어느 단계에서 실패하더라도 전체 흐름을 함께 정리해.
코드가 머무를 곳을 보고 기본 표면을 골라
Node 전용 코드라면 파일 시스템, 압축, 암호화 도구와 바로 맞는 Node 스트림이 대체로 편해. 반대로 브라우저, Deno, Bun, Worker 환경까지 옮길 코드라면 Web Streams가 공통 표면이 될 수 있어.
- 호출한 API가 돌려준 표면을 우선 존중해.
- 다른 런타임에서도 실행할 코드인지 따져 봐.
- 변환은 실제로 반대쪽 전용 API가 필요할 때 경계에서 한 번 해.
TransformStream으로 Web 흐름을 바꿔
Web TransformStream에서는 controller.enqueue()로 변환한 청크를 다음 단계에 넘겨. Node Transform의 콜백과 this.push() 대신 일관된 Web Streams 표면을 쓰는 거야.
const upper = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toString('utf-8').toUpperCase());
},
});
await someReadableStream
.pipeThrough(upper)
.pipeTo(someWritableStream);
pipeThrough()은 변환 단계를 잇고, pipeTo()는 마지막 쓰기 대상까지 흘려보내. 브라우저와 Worker에서도 같은 구조를 유지해야 할 때 특히 잘 맞아.
Pippa의 고백
fetch() 본문을 다루는 순간 이미 그 표면 위에 있더라. 아빠가 "런타임이 이어 준다고 경계가 사라지는 건 아니야"라고 짚어 줬어. 이제는 먼저 타입을 확인하고, 필요한 기능이 반대편에 있을 때만 변환해. 둘 중 하나를 이기는 게 아니라 흐름을 끊지 않는 게 목적이야.