"Readable은 데이터를 만들어 내고, Writable은 받아서 내보내. Node 스트림의 나머지 형태도 이 두 역할을 조합한 거야."
데이터가 들어오는 Readable
Readable 스트림은 데이터를 작은 덩어리로 나눠 차례로 내보내. fs.createReadStream()과 process.stdin, HTTP 서버의 요청 객체가 대표적인 예야. 기본적으로는 바이트를 내보내지만 객체 모드에서는 JavaScript 객체도 한 덩어리로 다룰 수 있어.
현재 코드에서는 비동기 순회로 읽는 방법이 가장 단순해:
import { createReadStream } from 'node:fs';
const stream = createReadStream('input.txt', { encoding: 'utf-8' });
for await (const chunk of stream) {
console.log('got', chunk.length, 'chars');
}
이벤트 방식도 여전히 많이 보여. data는 새 덩어리가 왔을 때, end는 입력이 끝났을 때, error는 읽기에 실패했을 때 발생해. 새 코드는 비동기 순회를 먼저 고려하고, 기존 이벤트 기반 코드와 연결할 때 이 방식을 이해하면 돼.
데이터를 내보내는 Writable
Writable 스트림은 들어온 데이터를 목적지로 보낼 때 사용해. fs.createWriteStream()은 파일로, process.stdout은 터미널로, HTTP 응답 객체는 클라이언트로 데이터를 보내.
import { createWriteStream } from 'node:fs';
const out = createWriteStream('output.txt');
out.write('hello\n');
out.write('world\n');
out.end();
write()가 true를 돌려주면 내부 버퍼에 여유가 있다는 뜻이야. false라면 소비 속도가 쓰기 속도를 따라가지 못해 버퍼가 찼다는 뜻이고, 이 반환값 자체가 쓰는 쪽에 전달되는 역압 신호야. 이때는 drain 이벤트가 올 때까지 쓰기를 멈춰야 메모리가 끝없이 늘어나지 않아.
바이트 대신 객체를 흘려보낼 때
{ objectMode: true }를 주면 JavaScript 값을 하나의 덩어리로 보낼 수 있어. CSV 바이트를 행 객체로 바꾸는 파서, 데이터베이스 행을 차례로 읽는 커서, 이벤트를 객체로 감싼 입력에 유용해. 객체 모드는 바이트 모드보다 부가 비용이 있으므로 정말 객체 단위 처리가 필요할 때 사용하고, 스트림을 만든 뒤 두 모드를 섞지는 마.Readable이 멈춰 있을 때와 흐를 때
Readable은 소비자가 직접 read()를 호출할 때까지 데이터를 버퍼에 두는 상태와, 데이터가 생길 때마다 소비자에게 내보내는 상태가 있어. data 처리 함수를 붙이거나 resume()을 호출하면 후자의 방식으로 바뀌어.
for await...of를 쓰면 이 차이를 직접 관리할 일이 거의 없어. 현재 덩어리의 처리가 끝난 뒤 다음 값을 요청하므로 소비 속도에 맞춰 자연스럽게 멈췄다가 이어져. 두 상태의 구분은 이벤트 API와 수동 흐름 제어를 섞을 때 특히 중요해.
Readable을 직접 만드는 방법
Readable을 상속해 _read()를 구현할 수 있지만, 대부분은 Readable.from(iterableOrAsyncIterable)이 더 간단해. 배열이나 생성기, 비동기 생성기를 이미 갖고 있다면 그대로 스트림으로 바꿀 수 있어. 상속은 운영체제 장치나 특별한 데이터 원본처럼 읽기 동작을 직접 제어해야 할 때만 선택해.
Pippa의 고백
data와 end 이벤트, pause()와 resume()을 한꺼번에 보고 스트림은 원래 복잡한 줄 알았어. 아빠가 같은 입력을 for await (const chunk of readable)로 읽어 보라고 했지. 동작 원리는 같아도 소비하는 코드는 평범한 반복문처럼 보였어. 그 뒤로 큰 입력은 먼저 비동기 순회로 읽고, 정말 필요한 경우에만 낮은 단계의 흐름 제어로 내려가.