🌊

Node.js Streams — 대용량 데이터를 메모리 폭발 없이 처리하는 법

Readable, Writable, Transform + backpressure의 동작 원리

// ❌ 10GB 파일을 메모리에 전부 올린다
const data = fs.readFileSync('huge.csv');

// ✅ chunk 단위로 흘려보낸다 — 메모리 ~64KB 유지
fs.createReadStream('huge.csv')
  .pipe(transformStream)
  .pipe(fs.createWriteStream('output.csv'));

4가지 스트림 타입

  • Readable — 데이터를 읽어들이는 소스 (fs.createReadStream, http request)

  • Writable — 데이터를 쓰는 목적지 (fs.createWriteStream, http response)

  • Transform — 데이터를 변환하면서 통과 (zlib.createGzip, csv-parser)

  • Duplex — 읽기+쓰기 모두 (net.Socket, WebSocket)

Backpressure — 소비자가 느리면 생산자를 멈춘다

writable.write(chunk)false를 반환하면 내부 버퍼가 가득 찼다는 뜻이다. 이때 Readable은 읽기를 멈추고(pause), Writable의 'drain' 이벤트를 기다린다.

.pipe()가 이걸 자동으로 해준다. 수동으로 하려면:

readable.on('data', (chunk) => {
  const ok = writable.write(chunk);
  if (!ok) {
    readable.pause();                    // 생산 멈춤
    writable.once('drain', () => {
      readable.resume();                 // 소비자 준비 → 생산 재개
    });
  }
});

backpressure 없이 빠른 Readable + 느린 Writable을 연결하면, 내부 버퍼가 계속 커져서 결국 메모리가 터진다.

pipeline() — 에러 처리까지 자동

const { pipeline } = require('stream/promises');
await pipeline(
  fs.createReadStream('huge.csv'),
  zlib.createGzip(),
  fs.createWriteStream('huge.csv.gz')
);
// 에러 발생 시 모든 스트림을 자동으로 정리(destroy)

.pipe()는 에러 전파를 안 해서 스트림이 누수될 수 있다. pipeline()이 이걸 해결한다.

핵심 포인트

1

Stream은 데이터를 chunk 단위로 처리 — 전체를 메모리에 올리지 않는다

2

pipe()로 Readable → Transform → Writable 체이닝

3

write()가 false 반환 = 버퍼 가득 참 → pause/drain으로 backpressure 처리

4

pipeline()이 pipe()보다 안전 — 에러 시 모든 스트림 자동 destroy

사용 사례

CSV/로그 파일 처리 — GB 단위 파일을 메모리 64KB로 처리 HTTP 프록시 — 요청 body를 버퍼링 없이 그대로 전달