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

내장 fetch와 Web Streams — 스트리밍 깊게 보기

~12 min · modern-node, fetch, web-streams

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"트랙 5에서는 fetch의 기본을 봤어. 운영 코드에서는 짧은 예제 뒤가 더 중요해. 요청 본문을 흘려보내고, 잘린 조각을 이어 붙이고, Web Stream과 Node 스트림을 연결하며, 언제 낮은 수준의 undici가 필요한지 판단해야 해."

큰 본문은 스트림으로 올려

작은 POST 요청에는 body: JSON.stringify(...)면 충분해. 하지만 큰 영상이나 계속 쌓이는 로그를 보낼 때는 본문 전체를 메모리에 올리지 말고 조각마다 흘려보내야 해.

import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';

const src = createReadStream('./huge.bin');

const res = await fetch('https://uploader.example.com/upload', {
  method: 'PUT',
  body: Readable.toWeb(src),
  duplex: 'half',
  headers: { 'Content-Type': 'application/octet-stream' },
});
console.log('status:', res.status);

Node에서 스트림을 fetch 요청 본문으로 보낼 때는 duplex: 'half'를 적어야 해. 스트리밍 본문을 보낸다는 사실을 명시하는 옵션이라서, 빠뜨리면 Node가 요청을 보내기 전에 오류를 내. 이름에 half가 들어가도 파일 전체를 먼저 버퍼링한다는 뜻은 아니야. ReadableStream이 내놓는 조각이 차례로 요청에 쓰여.

SSE는 오래 이어지는 HTTP 응답이야

Server-Sent Events는 Content-Type: text/event-stream인 HTTP 응답이야. fetch는 그 본문을 Web Stream으로 내줘. 다만 네트워크 조각이 줄 끝에서 정확히 잘린다는 보장은 없으니, 읽다 만 꼬리를 다음 조각과 이어야 해.

const res = await fetch('https://api.example.com/stream', {
  headers: { Accept: 'text/event-stream' },
});
if (!res.ok || !res.body) throw new Error(`SSE failed: ${res.status}`);

const reader = res.body
  .pipeThrough(new TextDecoderStream())
  .getReader();

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

  pending += value;
  const lines = pending.split('\n');
  pending = lines.pop() ?? '';

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      handleEvent(JSON.parse(line.slice(6)));
    }
  }
}

이 코드는 한 줄짜리 data:마다 JSON 하나를 보내는 단순한 스트림에 맞고, 한 줄이 두 조각으로 나뉘어 와도 제대로 이어. 완전한 SSE 규격에는 여러 줄 데이터, 이벤트 이름, ID, 재시도 시간, 주석, CRLF 구분도 있어. 서버가 그 기능을 쓴다면 검증된 완전한 파서를 쓰는 편이 안전해.

세밀한 제어가 필요할 때만 undici를 직접 써

Node의 내장 fetch는 내부에서 undici를 쓰지만, undici에서 Pool을 직접 가져오려면 그 패키지를 직접 의존성으로 추가해야 해. 연결 풀의 크기, 파이프라이닝, 시간 제한, 디스패처를 세밀하게 다뤄야 한다면 그 비용을 낼 만해.
import { Pool } from 'undici';

const pool = new Pool('https://api.example.com', {
  connections: 100,
  pipelining: 10,
  bodyTimeout: 30_000,
});

const { body, statusCode } = await pool.request({
  method: 'GET',
  path: '/items',
});

for await (const chunk of body) {
  // chunk is a Buffer
}
먼저 내장 fetch로 시작해. fetch 표면에 없는 제어가 실제로 필요해졌을 때 undici를 추가하고, 낮은 수준의 API가 무조건 빠를 거라고 짐작하지 말고 실제 워크로드를 재서 결정해.

Response로 스트림을 감쌀 수 있어

Response는 fetch가 돌려주는 값으로만 쓰는 객체가 아니야. 변환한 스트림을 감싸 새 HTTP 응답을 만들 수도 있어서 프록시, 캐시, 서버 처리 함수에 유용해.

const upstream = await fetch('https://api.example.com/big.json');

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

const piped = upstream.body.pipeThrough(upper);
const response = new Response(piped, {
  status: upstream.status,
  headers: upstream.headers,
});

// `response` can be returned by a compatible server handler,
// or consumed with .text(), .json(), and the other Body methods.

Pippa의 고백

cwkPippa의 첫 Claude 연동에는 서드파티 SSE 라이브러리가 있었어. 소스를 읽어 보니 중심은 fetch와 Web Streams, 이벤트 경계를 찾는 코드였지. 아빠가 물었어. "그 의존성이 처리하는 규격 가운데 우리 스트림이 실제로 쓰는 건 뭐야?" 한 줄 JSON만 보내는 단순한 규약이라면 꼬리를 보관하는 작은 판독기로 충분할 수 있어. 반대로 SSE 전체 규격을 받아야 한다면 검증된 파서를 유지하는 편이 모든 예외를 조용히 다시 만드는 것보다 싸.

Code

SSE 위 재사용 가능 async-iterator — 순수 fetch·javascript
// A reusable SSE consumer for any fetch-based stream
export async function* sseEvents(url, init = {}) {
  const res = await fetch(url, {
    ...init,
    headers: { Accept: 'text/event-stream', ...init.headers },
  });
  if (!res.ok || !res.body) throw new Error(`SSE failed: ${res.status}`);

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buf = '';
  while (true) {
    const { value, done } = await reader.read();
    if (done) return;
    buf += value;
    let i;
    while ((i = buf.indexOf('\n\n')) !== -1) {
      const block = buf.slice(0, i);
      buf = buf.slice(i + 2);
      const lines = block.split('\n');
      const event = { type: 'message', data: '' };
      for (const l of lines) {
        if (l.startsWith('data: ')) event.data += l.slice(6);
        else if (l.startsWith('event: ')) event.type = l.slice(7);
      }
      yield event;
    }
  }
}

// Use it
for await (const ev of sseEvents('https://api.example.com/stream')) {
  console.log(ev.type, ev.data);
}
Progress Transform 있는 스트리밍 업로드·javascript
// Streaming upload with progress reporting
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Transform } from 'node:stream';
import { Readable } from 'node:stream';

const path = './huge.bin';
const total = (await stat(path)).size;
let sent = 0;

const progress = new Transform({
  transform(chunk, _enc, cb) {
    sent += chunk.length;
    process.stdout.write(`\r${(sent / total * 100).toFixed(1)}%`);
    cb(null, chunk);
  },
});

const src = createReadStream(path).pipe(progress);
const res = await fetch('https://uploader.example.com/upload', {
  method: 'PUT',
  body: Readable.toWeb(src),
  duplex: 'half',
});
console.log('\nstatus:', res.status);

External links

Exercise

GET /proxy/<encoded-url> 요청을 받는 스트리밍 프록시 서버를 만들어. 원격 URL을 fetch하고, 응답 본문을 줄마다 대문자로 바꿔 클라이언트에 스트리밍해. 원격 응답에는 fetch, 대문자 변환에는 Web Streams의 TransformStream, 응답 순회에는 Web ReadableStream을 사용해. 100MB짜리 원격 파일로 시험하면서 서버 메모리가 약 50MB를 넘지 않는지 확인해.
Hint
서버 처리 함수의 뼈대는 다음과 같아. const upstream = await fetch(decodedUrl); const upper = new TransformStream({ transform(chunk, c) { c.enqueue(new TextEncoder().encode(new TextDecoder().decode(chunk).toUpperCase())); } }); res.writeHead(upstream.status); for await (const chunk of upstream.body.pipeThrough(upper)) res.write(chunk); res.end(); 핵심은 전체 본문을 버퍼에 담지 않고 청크가 원격 서버에서 변환 단계를 거쳐 클라이언트까지 계속 흐르게 하는 거야.

Progress

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

댓글 0

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

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