본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

Node 스트림과 Web Streams 이어 쓰기

~12 min · streams, web-streams, async-iter

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Node에는 스트림 표면이 둘 있어. 어느 쪽을 받았는지 알아보고 경계에서 제대로 이어 주면, 둘을 외울 일이 아니라 하나의 흐름을 다루는 일이 돼."

Node에 스트림 표면이 두 개인 까닭

Node 스트림은 Web Streams보다 먼저 만들어졌고, 파일·HTTP·압축·암호화 같은 Node 생태계가 오래 사용해 왔어. 나중에 브라우저와 여러 런타임이 공유할 Web Streams 표준이 생겼고 Node도 그 표면을 구현했지. 그래서 fetch()의 응답 본문은 ReadableStream이고, node:fsnode:zlib의 많은 API는 Node 스트림을 돌려줘.

  • ReadableStream은 읽는 쪽이야.
  • WritableStream은 쓰는 쪽이야.
  • TransformStream은 입력을 받아 바꾼 뒤 출력해.

개념은 닮았지만 메서드 이름과 청크 형태가 완전히 같지는 않아. 먼저 어느 표면을 받았는지 확인하고, 필요할 때 Node가 제공하는 변환 함수를 쓰면 돼.

비동기 순회는 둘이 공유하는 다리야

Node에서 Web ReadableStreamfor 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의 읽기 스트림을 Web ReadableStream으로 바꿔.
  • 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의 고백

Web Streams는 브라우저에서만 쓰는 줄 알았어. 그런데 Node에서 fetch() 본문을 다루는 순간 이미 그 표면 위에 있더라. 아빠가 "런타임이 이어 준다고 경계가 사라지는 건 아니야"라고 짚어 줬어. 이제는 먼저 타입을 확인하고, 필요한 기능이 반대편에 있을 때만 변환해. 둘 중 하나를 이기는 게 아니라 흐름을 끊지 않는 게 목적이야.

Code

Web 읽기 스트림을 Node 스트림으로 바꿔 JSONL 읽기·javascript
// fetch 응답의 JSONL을 한 줄씩 읽어 객체로 바꾼다.
import { Readable } from 'node:stream';

const res = await fetch('https://api.example.com/events.jsonl');
if (!res.body) throw new Error('응답 본문이 없습니다.');

const nodeStream = Readable.fromWeb(res.body);

async function* parseJsonl(byteStream) {
  const decoder = new TextDecoder();
  let pending = '';
  for await (const chunk of byteStream) {
    pending += decoder.decode(chunk, { stream: true });
    const lines = pending.split('\n');
    pending = lines.pop();
    for (const line of lines) {
      if (line) yield JSON.parse(line);
    }
  }
  if (pending) yield JSON.parse(pending);
}

for await (const event of parseJsonl(nodeStream)) {
  console.log(event);
}
Web Streams만으로 변환한 응답 만들기·javascript
// Web Streams 표면만으로 텍스트를 바꾼다.
const res = await fetch('https://api.example.com/big.txt');

const upper = new TransformStream({
  transform(chunk, controller) {
    const text = new TextDecoder().decode(chunk).toUpperCase();
    controller.enqueue(new TextEncoder().encode(text));
  },
});

const transformed = res.body.pipeThrough(upper);
const out = new Response(transformed, { headers: res.headers });
console.log(await out.text());

External links

Exercise

큰 원격 응답을 gzip으로 압축해 파일에 저장하되, 응답 전체를 한꺼번에 메모리에 올리지 마. 먼저 Readable.fromWeb(res.body)pipeline()을 사용해 보고, 다음에는 CompressionStreamWritable.toWeb()을 사용해 봐. 두 구현의 코드 길이, 읽기 쉬운 정도, Node 전용 의존성을 비교해.
Hint
Node 스트림 방식은 pipeline(Readable.fromWeb(res.body), createGzip(), createWriteStream(...))로 만들 수 있어. Web Streams 방식은 res.body.pipeThrough(new CompressionStream("gzip")).pipeTo(Writable.toWeb(createWriteStream(...)))처럼 이어. 두 방식 모두 본문 전체를 먼저 버퍼로 만들 필요가 없어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.