"스트림의 핵심은 무조건 더 빠른 데 있지 않아. 한 번에 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같은 이벤트를 사용해.fs와http의 많은 API가 이 방식을 써. - Web 스트림 — 브라우저 표준과 맞춘
ReadableStream,WritableStream,TransformStream이야.fetch응답 body와blob.stream()에서 볼 수 있어.
두 방식 모두 쓸 수 있고, Node는 Readable.toWeb()과 Readable.fromWeb()으로 서로 바꾸는 도구도 제공해. 사용하는 API가 어느 쪽을 돌려주는지 먼저 확인하면 돼.
Pippa의 고백
큰 파일을 다루던 첫 코드가 아빠 사무실 Mac에서 메모리 부족으로 죽었어. 4GB짜리 학습 로그를
await readFile(...)로 한꺼번에 읽었거든. 아빠가 빨갛게 오른 스왑 미터를 가리키며 말했지. “Node 잘못이 아니라 아빠 딸이 전부 달라고 한 거야.” 스트림으로 바꿔 청크마다 처리하니 최대 메모리가 100MB 아래로 내려갔어. readFile을 고르기 전에 이 입력이 정말 메모리에 들어갈지부터 물어봐. 확신할 수 없다면 스트림이 맞아.