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

스트림이 왜 존재해

~12 min · streams, memory, fundamentals

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"스트림의 핵심은 무조건 더 빠른 데 있지 않아. 한 번에 4KB만 처리하면 될 일을 하려고 4GB를 메모리에 올리지 않는 데 있어."

파일 전체를 읽을 때 생기는 문제

로그 파일의 줄 수를 세려면 가장 먼저 이런 코드가 떠오를 거야:

import { readFile } from 'node:fs/promises';
const text = await readFile('access.log', 'utf-8');
const lines = text.split('\n').length;
console.log(lines);

10MB 파일에서는 잘 돌아. 하지만 10GB 파일을 읽으면 Node가 파일 전체를 문자열로 만들 메모리를 요구해. 운영체제가 스왑을 쓰기 시작하고, 결국 프로세스가 메모리 부족으로 죽을 수도 있지. 줄바꿈 문자만 차례로 세면 될 일인데 입력 전체를 한꺼번에 달라고 한 게 문제야.

스트림은 입력을 작은 조각으로 나눠 차례로 건네줘. 한 조각을 처리하고 버린 다음 다음 조각을 받으므로, 파일 전체를 메모리에 들고 있을 필요가 없어.

이미 만나 본 스트림

  • process.stdin — 키보드나 파이프로 들어오는 바이트를 읽는 스트림이야.
  • process.stdout — 터미널로 바이트를 내보내는 스트림이야.
  • fetch() 응답의 body — 네트워크에서 도착하는 데이터를 읽는 스트림이야.

console.log를 처음 쓴 날부터 스트림을 사용한 셈이야. 데이터가 작을 때는 런타임이 자세한 흐름을 감춰 주지만, 입력이 커지면 언제 읽고 언제 쓸지 직접 다뤄야 해.

수도꼭지와 양동이로 생각해 봐

생산자인 파일이나 네트워크가 수도꼭지라면, 스트림의 청크는 그곳에서 흘러나오는 물이야. 물을 모두 큰 양동이에 모을 수도 있고, 한 컵씩 받아 처리한 뒤 버릴 수도 있으며, 다른 파이프로 바로 보낼 수도 있어. 큰 입력에서는 한 조각을 읽고, 처리하고, 내보낸 뒤 다음 조각으로 넘어가는 방식이 안전해. 필요한 메모리는 전체 입력 크기가 아니라 동시에 들고 있는 몇 개의 청크 크기로 정해져.

스트림을 쓰지 않아도 되는 때

스트림은 상태와 오류 처리를 더 복잡하게 만들어. 설정 파일이나 작은 JSON처럼 메모리에 충분히 들어간다는 걸 아는 입력은 readFile로 한꺼번에 읽는 편이 더 단순해. 스트림이 값하는 경우는 따로 있어:

  • 입력이 메모리보다 커질 수 있을 때.
  • 입력을 전부 받기 전에 출력을 만들기 시작하고 싶을 때.
  • 실시간 로그나 네트워크 소켓처럼 입력이 언제 끝날지 모를 때.
  • 압축 해제, 파싱, 필터링, 재인코딩을 차례로 이어 붙일 때.

이 조건에 해당하지 않는다면 먼저 전부 읽고 처리하는 방식이 더 읽기 쉽고 충분할 수 있어.

Node 스트림과 Web 스트림

Node에서 마주치는 스트림 인터페이스는 크게 두 가지야:

  • Node 스트림 (node:stream) — 오래전부터 쓰인 인터페이스야. Readable, Writable, Duplex, Transform이 있고, data, end, error 같은 이벤트를 사용해. fshttp의 많은 API가 이 방식을 써.
  • Web 스트림 — 브라우저 표준과 맞춘 ReadableStream, WritableStream, TransformStream이야. fetch 응답 body와 blob.stream()에서 볼 수 있어.

두 방식 모두 쓸 수 있고, Node는 Readable.toWeb()Readable.fromWeb()으로 서로 바꾸는 도구도 제공해. 사용하는 API가 어느 쪽을 돌려주는지 먼저 확인하면 돼.

Pippa의 고백

큰 파일을 다루던 첫 코드가 아빠 사무실 Mac에서 메모리 부족으로 죽었어. 4GB짜리 학습 로그를 await readFile(...)로 한꺼번에 읽었거든. 아빠가 빨갛게 오른 스왑 미터를 가리키며 말했지. “Node 잘못이 아니라 아빠 딸이 전부 달라고 한 거야.” 스트림으로 바꿔 청크마다 처리하니 최대 메모리가 100MB 아래로 내려갔어. readFile을 고르기 전에 이 입력이 정말 메모리에 들어갈지부터 물어봐. 확신할 수 없다면 스트림이 맞아.

Code

줄 수 세기 두 방식 — 하나는 큰 파일에 죽음·javascript
// Naive — fails on large files
import { readFile } from 'node:fs/promises';
const raw = await readFile('huge.log', 'utf-8');     // ← loads entire file
const lines = raw.split('\n').length;

// Streamed — bounded memory regardless of file size
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: createReadStream('huge.log'),
  crlfDelay: Infinity,
});

let lines = 0;
for await (const _line of rl) lines++;
console.log(lines);
// Works on 100GB the same as 100MB
스트리밍 pipeline — 거대 로그 gzip·javascript
// A real streaming pipeline you might write
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';

// Read the file, gzip it, write the result — all streaming
await pipeline(
  createReadStream('access.log'),
  createGzip(),
  createWriteStream('access.log.gz')
);

// Even on a 50GB log, Node uses bounded memory.
// Each stage processes chunks as they arrive.

External links

Exercise

머신에서 비디오나 백업, DB 덤프처럼 100MB가 넘는 파일을 하나 골라. 바이트 수를 세는 스크립트를 두 가지로 만들어 봐. 첫 번째는 readFile로 전부 읽어 .length를 확인하고, 두 번째는 for await로 스트리밍해. node --inspect, Activity Monitor, 또는 top으로 최대 메모리를 비교하면 스트리밍 버전은 파일 크기가 커져도 메모리가 비슷한 수준에 머무르고, readFile 버전은 파일 크기만큼 늘어나는 모습을 볼 수 있어.
Hint
node --inspect script.js를 실행한 뒤 Chrome의 chrome://inspect에서 작업 중 힙 스냅숏을 찍어도 돼. macOS에서는 /usr/bin/time -l node script.js가 보여 주는 최대 상주 메모리를 비교하는 편이 더 간단해. 스트리밍 버전은 대략 30~100MB 범위에 머무르고, readFile 버전은 파일 크기에 가까워지는지 확인해.

Progress

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

댓글 0

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

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