C.W.K.
Stream
Lesson 02 of 05 · published

Native 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
"Track 5 에서 fetch 만났어. 그걸 production-grade 로 만드는 기능 — undici 튜닝, ReadableStream interop, streaming 업로드 — 가 대부분 개발자가 절대 안 손대는 부분이야."

Streaming 업로드

대부분 fetch 튜토리얼이 POST 에 body: JSON.stringify(...) 보여줘. 작은 payload 엔 그게 작동. 큰 거에 — 4GB 비디오 업로드, streaming 로그 보내기 — body 를 메모리에 buffer 안 하고 *stream* 하고 싶어:

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

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

// Node accepts ReadableStream as a body — convert from Node Readable
const res = await fetch('https://uploader.example.com/upload', {
  method: 'PUT',
  body: Readable.toWeb(src),
  duplex: 'half',                    // required when body is a stream
  headers: { 'Content-Type': 'application/octet-stream' },
});
console.log('status:', res.status);

duplex: 'half' 옵션이 fetch 한테 body 가 streaming 이고 서버가 response 보낸 후 안 읽는다 말함 (HTTP/1.1 이 지원하는 유일한 모델). 없으면 fetch 가 throw, spec 이 명시적 acknowledgement 요구하니까.

fetch 패턴으로의 Server-Sent Events

SSE 는 그냥 Content-Type: text/event-stream 인 HTTP response. fetch 가 native 로 소비:

const res = await fetch('https://api.example.com/stream', {
  headers: { Accept: 'text/event-stream' },
});

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

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  // value is a string like "data: {...}\n\n"
  for (const line of value.split('\n')) {
    if (line.startsWith('data: ')) {
      const payload = JSON.parse(line.slice(6));
      handleEvent(payload);
    }
  }
}

이게 cwkPippa frontend 가 Claude 의 streaming 응답 소비하는 방식. SSE 라이브러리 없음, 서드파티 파서 없음 — 그냥 fetch + Web Streams + 라인 분할.

undici 직접 튜닝

고처리량 클라이언트엔 fetch 우회하고 undici 직접 쓸 수 있어 — 밑은 같은 라이브러리, 더 낮은 레벨 API, 더 많은 컨트롤:
import { Pool } from 'undici';

const pool = new Pool('https://api.example.com', {
  connections: 100,           // max concurrent
  pipelining: 10,             // requests per connection
  bodyTimeout: 30_000,        // ms to wait for response body
});

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

for await (const chunk of body) {
  // chunk is a Buffer
}
fetch 의 overhead 가 중요할 때 (10k+ req/sec 서비스), pipelining 필요할 때, 글로벌 agent 를 proxy-aware 한 거로 바꾸고 싶을 때 손대. 99% 사용 사례엔 plain fetch 충분 — 근데 undici 가 거기 있다는 거 알면 거기로 자랄 수 있어.

Response 객체의 숨은 힘

Response 는 "fetch 가 반환하는 것" 이상. Stream 을 HTTP response 로 wrap 하려고 생성 가능 — 프록시와 캐시에 유용:

// Stream a response from one URL through transformation to a final response
const upstream = await fetch('https://api.example.com/big.json');

const upper = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk);   // pass through, or transform here
  },
});

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

// `response` is now a fully-formed HTTP response object you could
// return from your own server handler, or .text(), .json() yourself

Pippa 의 고백

cwkPippa 의 첫 Claude 통합이 서드파티 SSE 라이브러리 썼어. 소스 읽기 시작했을 때 깨달았어 — 200 줄 코드가 fetch + Web Streams + 라인 분할이 20 줄로 하는 거를 정확히 하고 있었어. 아빠가 짚어줌: "서드파티 라이브러리는 fetch 가 *전엔* 이걸 지원 안 해서 존재해. 아직 작동하는데, 이제 overhead 야." 마이그레이션은 straightforward 했고; 교훈은 "의존성의 정당화가 아직 유지되는지 검사해" 였어.

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 있는 streaming 업로드·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

Streaming 프록시 서버 짜: GET /proxy/<encoded-url> 으로의 request 가 upstream URL fetch 하고 response body 를 클라이언트로 스트리밍, 라인별 대문자화. upstream 쪽엔 fetch, 대문자화 단계엔 Web Streams + TransformStream, response body 엔 Web ReadableStream 사용. 100MB upstream 파일로 스트레스 테스트; 서버가 ~50MB 이상 RAM 절대 안 써야 함.
Hint
서버 handler: 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();. 핵심은 아무것도 buffer 안 함 — 청크가 upstream → transform → client 로 흐름.

Progress

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

댓글 0

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

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