자바스크립트의 Streams API

자바스크립트의 Streams API

웹 스트리밍이라고 하면 예전에는 유튜브 같은 동영상 서비스를 떠올리곤 했는데요. ChatGPT가 등장한 이후로는 텍스트 스트리밍도 아주 흔해졌습니다. ChatGPT에 질문을 던지면 답변이 한꺼번에 나오는 게 아니라 글자가 하나씩 흘러나오죠? 바로 이 뒤에서 Streams API가 일하고 있습니다.

이번 글에서는 자바스크립트의 Streams API가 무엇이고 어떻게 사용하는지 알아보겠습니다.

스트림이란?

스트림(stream)은 데이터를 한 번에 전부 가져오는 대신 조각(chunk) 단위로 나눠서 순차적으로 처리하는 방식입니다.

1GB짜리 파일을 다운로드한다고 생각해볼게요. 스트림 없이 처리하면 1GB 전체가 메모리에 올라올 때까지 기다려야 합니다. 반면 스트림을 쓰면 데이터가 도착하는 대로 조금씩 처리할 수 있어요. 메모리도 절약되고 사용자 입장에서는 훨씬 빨리 결과를 볼 수 있습니다.

자바스크립트의 Streams API는 이런 스트림 처리를 위한 웹 표준 인터페이스입니다. 브라우저와 Node.js, Deno, Bun 같은 런타임에서 모두 지원하고 있어요.

Streams API에는 세 가지 스트림이 있습니다.

  • ReadableStream — 데이터를 읽을 수 있는 스트림. fetch() 응답의 body가 대표적입니다.
  • WritableStream — 데이터를 쓸 수 있는 스트림. 파일 저장이나 네트워크 전송에 사용합니다.
  • TransformStream — 읽기와 쓰기 사이에서 데이터를 변환하는 스트림. 압축이나 인코딩 변환에 쓰입니다.

이 세 가지를 하나씩 살펴보겠습니다.

ReadableStream

ReadableStream은 가장 자주 접하게 되는 스트림입니다. Fetch API로 HTTP 요청을 보내면 응답 객체의 body 속성이 바로 ReadableStream이에요.

직접 만들어 보면 구조를 이해하기 쉽습니다.

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("안녕");
    controller.enqueue("하세요");
    controller.close();
  },
});

start() 함수 안에서 controller.enqueue()로 데이터 조각을 넣고, 다 넣었으면 controller.close()로 스트림을 닫습니다.

이 스트림에서 데이터를 꺼내 읽으려면 getReader()로 리더를 얻은 뒤 read() 메서드를 반복 호출합니다.

const reader = stream.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(value);
}
결과
안녕
하세요

read(){ done, value } 형태의 객체를 반환합니다. donetrue가 되면 스트림이 끝난 것이니 루프를 빠져나오면 됩니다. 이 패턴은 Streams API를 쓸 때 정말 자주 나오니까 눈에 익혀두세요. ReadableStream에 대해 더 깊이 알고 싶다면 자바스크립트에서 데이터 스트림 읽기를 참고하세요.

fetch 응답 스트리밍

ReadableStream이 가장 빛나는 순간은 fetch() 응답을 스트리밍할 때입니다.

보통 fetch()를 쓸 때는 response.json()이나 response.text()로 응답 전체를 한 번에 받는데요. response.body를 직접 읽으면 데이터가 도착하는 대로 처리할 수 있습니다.

const response = await fetch("https://example.com/large-data");
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  const text = decoder.decode(value, { stream: true });
  console.log(text);
}

response.body는 바이트 데이터(Uint8Array)를 내보내는 ReadableStream이라서 TextDecoder로 문자열 변환을 해줘야 합니다. decode() 호출 시 { stream: true } 옵션을 넘기는 것이 포인트인데요. 이 옵션이 없으면 멀티바이트 문자(한글 등)가 청크 경계에서 잘릴 때 깨질 수 있습니다.

이 패턴은 LLM API 응답을 스트리밍할 때도 동일하게 쓸 수 있어요. ChatGPT처럼 글자가 하나씩 나타나는 UI를 만들고 싶다면 위 코드에서 console.log(text) 대신 DOM에 텍스트를 이어붙이면 됩니다.

const output = document.getElementById("output");

// ... (위의 fetch + reader 코드)
const text = decoder.decode(value, { stream: true });
output.textContent += text;

WritableStream

WritableStream은 데이터를 받아서 어딘가에 쓰는 스트림입니다.

const stream = new WritableStream({
  write(chunk) {
    console.log("받은 데이터:", chunk);
  },
  close() {
    console.log("스트림 종료");
  },
});

write() 함수는 청크가 들어올 때마다 호출되고, close()는 스트림이 닫힐 때 호출됩니다.

데이터를 쓰려면 getWriter()로 라이터를 얻습니다.

const writer = stream.getWriter();

await writer.write("첫 번째 조각");
await writer.write("두 번째 조각");
await writer.close();
결과
받은 데이터: 번째 조각
받은 데이터: 번째 조각
스트림 종료

ReadableStream에서 getReader() + read()를 쓰는 것과 대칭적으로, WritableStream에서는 getWriter() + write()를 씁니다.

TransformStream

TransformStream은 읽기 쪽(readable)과 쓰기 쪽(writable)을 모두 가진 스트림입니다. 데이터가 쓰기 쪽으로 들어오면 변환 로직을 거쳐 읽기 쪽으로 나갑니다.

예를 들어 입력 문자열을 대문자로 변환하는 스트림을 만들어 볼게요.

const upperCase = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  },
});

transform() 함수에서 들어온 chunk를 가공한 뒤 controller.enqueue()로 내보냅니다. ReadableStreamstart()에서 했던 것과 같은 방식이죠.

TransformStream은 단독으로 쓰기보다는 파이프라인의 중간 단계로 연결할 때 진가를 발휘합니다.

파이프라인 구성하기

지금까지 배운 세 가지 스트림을 연결하면 데이터 파이프라인을 만들 수 있습니다.

ReadableStreampipeTo() 메서드는 읽기 스트림을 쓰기 스트림에 직접 연결합니다.

await readableStream.pipeTo(writableStream);

중간에 변환 단계를 넣고 싶다면 pipeThrough()를 사용합니다.

await readableStream.pipeThrough(transformStream).pipeTo(writableStream);

pipeThrough()TransformStream을 통과시킨 뒤 새로운 ReadableStream을 반환합니다. 그래서 여러 개의 pipeThrough()를 체이닝할 수도 있어요.

실제로 동작하는 예제를 하나 만들어 볼게요. 숫자를 생성하는 읽기 스트림, 2배로 만드는 변환 스트림, 결과를 출력하는 쓰기 스트림을 연결합니다.

const source = new ReadableStream({
  start(controller) {
    for (let i = 1; i <= 5; i++) {
      controller.enqueue(i);
    }
    controller.close();
  },
});

const double = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk * 2);
  },
});

const sink = new WritableStream({
  write(chunk) {
    console.log(chunk);
  },
});

await source.pipeThrough(double).pipeTo(sink);
결과
2
4
6
8
10

데이터가 source → double → sink 순서로 흘러가면서 각 단계에서 처리됩니다. 리눅스의 파이프(|)로 명령어를 연결하는 것과 비슷한 개념이에요.

마치며

Streams API는 데이터를 조각 단위로 처리하는 웹 표준 인터페이스입니다. ReadableStream으로 읽고, WritableStream으로 쓰고, TransformStream으로 변환하며, pipeTo()pipeThrough()로 이들을 연결해 파이프라인을 구성할 수 있습니다.

LLM 서비스를 만들거나 대용량 파일을 다루는 등 스트리밍이 필요한 상황에서 이 글에서 다룬 패턴이 도움이 되면 좋겠습니다. 특히 fetch() 응답의 bodygetReader()로 읽는 패턴은 실무에서 정말 자주 쓰이니까 꼭 기억해두세요.

더 자세한 내용은 Streams API - MDN Web Docs를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord