Node.js 스트림 — 대용량 데이터를 메모리에 올리지 않고 처리하는 법 (7편)
들어가며
6편에서 V8의 메모리 구조와 GC를 다뤘습니다. 객체가 New Space에서 태어나고, 참조가 끊기면 Scavenge가 정리하고, 오래 살아남으면 Old Space로 승격된다는 것을 확인했습니다.
그런데 한 가지 위험한 상황이 있습니다. 파일 하나가 V8 힙 전체를 채워버리는 경우입니다.
// 1GB CSV 파일을 통째로 메모리에 올림
const data = fs.readFileSync('huge.csv'); // → heapUsed 1GB 이상
// Old Space 기본 한계 ~1.4GB → 이 한 줄로 힙의 70%를 점유
6편에서 --max-old-space-size의 기본값이 약 1.4GB라고 했습니다. 1GB 파일을 통째로 읽으면 힙의 상당 부분을 한 객체가 차지하고, 나머지 요청 처리에 필요한 공간이 부족해집니다. 파일이 더 크면 JavaScript heap out of memory로 프로세스가 죽습니다.
스트림은 이 문제를 해결합니다. 전체를 메모리에 올리지 않고, 조각(chunk) 단위로 읽고 처리하고 흘려보내는 것이 스트림의 핵심입니다.
스트림이란 — 데이터의 흐름
스트림은 추상적인 개념이 아닙니다. Node.js에서 이미 매일 쓰고 있습니다.
// HTTP 요청/응답 — 둘 다 스트림
app.get('/download', (req, res) => {
// req는 Readable 스트림 (클라이언트가 보낸 데이터를 읽음)
// res는 Writable 스트림 (클라이언트에게 데이터를 씀)
res.write('첫 번째 청크\n');
res.write('두 번째 청크\n');
res.end('마지막 청크');
});
// 콘솔 출력 — stdout도 스트림
process.stdout.write('이것도 스트림입니다\n');
// console.log()는 내부적으로 process.stdout.write()를 호출
5편에서 HTTP 요청이 Poll 페이즈에서 도착한다고 했습니다. 정확히는, TCP 소켓에 데이터가 도착할 때마다 Poll이 감지해서 콜백을 실행합니다. 대용량 데이터는 한 번에 오지 않고 여러 TCP 패킷으로 나뉘어 옵니다. 이 패킷 단위의 데이터를 순서대로 처리하는 것이 스트림입니다.
4가지 스트림 타입
| 타입 | 역할 | 실제 예시 |
|---|---|---|
| Readable | 데이터를 읽어서 내보냄 | fs.createReadStream(), HTTP 요청(req), process.stdin |
| Writable | 데이터를 받아서 씀 | fs.createWriteStream(), HTTP 응답(res), process.stdout |
| Duplex | 읽기 + 쓰기 동시 | TCP 소켓 (net.Socket), WebSocket |
| Transform | 읽고 → 변환해서 → 내보냄 | zlib.createGzip(), crypto.createCipheriv() |
데이터 흐름:
Readable ──→ Transform ──→ Transform ──→ Writable
(읽기) (변환 1) (변환 2) (쓰기)
예: 파일 읽기 → gzip 압축 → 암호화 → 파일 쓰기
ReadStream → Gzip → Cipher → WriteStream
Readable — 데이터 소스
Readable 스트림은 내부에 버퍼(buffer)를 가지고 있습니다. 소스에서 데이터를 읽어 버퍼에 채우고, 소비자가 가져갈 때까지 보관합니다.
const readable = fs.createReadStream('huge.csv', {
highWaterMark: 64 * 1024, // 내부 버퍼 크기: 64KB (기본값)
encoding: 'utf8',
});
readable.on('data', (chunk) => {
// chunk: 최대 64KB 단위로 데이터가 들어옴
console.log(`받은 크기: ${chunk.length}`);
});
readable.on('end', () => {
console.log('파일 끝');
});
highWaterMark는 "이 정도 채우면 잠깐 멈춰"라는 수위 표시입니다. 6편에서 다룬 V8 힙 관점에서 보면, 한 번에 메모리에 올라가는 양이 64KB로 제한됩니다. 1GB 파일이든 10GB 파일이든 메모리 사용량은 일정합니다.
Writable — 데이터 목적지
Writable도 내부 버퍼가 있습니다. write()로 데이터를 보내면 버퍼에 쌓이고, OS에 비동기로 기록됩니다.
const writable = fs.createWriteStream('output.csv');
writable.write('첫 번째 줄\n'); // 버퍼에 추가
writable.write('두 번째 줄\n'); // 버퍼에 추가
writable.end('마지막 줄'); // 버퍼 비우고 스트림 종료
write()는 boolean을 반환합니다. true면 버퍼에 여유가 있으니 계속 써도 됨. false면 버퍼가 highWaterMark에 도달했으니 잠깐 멈추라는 신호입니다. 이것이 백프레셔의 시작점입니다.
Transform — 변환 파이프라인
Transform은 Duplex의 특수한 형태입니다. 입력을 받아서 변환한 뒤 출력합니다.
const { Transform } = require('stream');
const upperCase = new Transform({
transform(chunk, encoding, callback) {
// chunk를 변환해서 push
this.push(chunk.toString().toUpperCase());
callback(); // 다음 chunk 요청
}
});
// 파일을 읽어서 → 대문자로 변환해서 → 콘솔에 출력
fs.createReadStream('input.txt')
.pipe(upperCase)
.pipe(process.stdout);
Duplex — 양방향 통신
TCP 소켓이 대표적인 Duplex 스트림입니다. 3편에서 다룬 것처럼, 네트워크 소켓은 데이터를 보내면서 동시에 받을 수 있습니다.
const net = require('net');
const server = net.createServer((socket) => {
// socket은 Duplex — 읽기와 쓰기가 독립적
socket.on('data', (data) => { // Readable 쪽: 클라이언트 데이터 수신
socket.write('Echo: ' + data); // Writable 쪽: 클라이언트에게 응답
});
});
pipe — 스트림 연결의 핵심
pipe()는 Readable의 출력을 Writable의 입력에 자동으로 연결합니다.
// pipe 없이 — 수동 연결
readable.on('data', (chunk) => {
writable.write(chunk);
});
readable.on('end', () => {
writable.end();
});
// pipe로 — 한 줄
readable.pipe(writable);
두 코드가 같아 보이지만, 결정적인 차이가 있습니다. pipe는 백프레셔를 자동으로 처리합니다.
pipe가 내부에서 하는 일
readable.pipe(writable) 내부 동작:
1. readable에서 chunk를 읽음
2. writable.write(chunk) 호출
3. write()가 true를 반환하면 → 계속 읽기
4. write()가 false를 반환하면 → readable.pause() (읽기 중단)
5. writable이 버퍼를 비우면 'drain' 이벤트 발생
6. 'drain' 이벤트 → readable.resume() (읽기 재개)
7. readable이 끝나면 writable.end() 호출
이 과정을 수동으로 구현하면:
readable.on('data', (chunk) => {
const canContinue = writable.write(chunk);
if (!canContinue) {
readable.pause(); // 쓰기가 밀리면 읽기를 멈춤
}
});
writable.on('drain', () => {
readable.resume(); // 쓰기 버퍼가 비워지면 읽기 재개
});
readable.on('end', () => {
writable.end();
});
pipe() 한 줄이 이 모든 것을 처리합니다.
백프레셔 — 빠른 생산자와 느린 소비자
백프레셔(backpressure)는 소비자가 생산자보다 느릴 때 생산 속도를 조절하는 메커니즘입니다.
백프레셔가 없으면:
SSD (읽기: 3GB/s) ────→ 네트워크 (전송: 100MB/s)
↑ ↑
엄청 빠르게 읽음 전송이 느림
↓
버퍼에 데이터 쌓임 → 메모리 폭발 💥
백프레셔가 있으면:
SSD (읽기) ←── pause() ─── 네트워크 (전송)
│ │
│ "버퍼 찼어, 잠깐 멈춰" │
│ │
└──── resume() ←── drain ──────┘
"비워졌어, 다시 읽어"
6편에서 다룬 메모리 관점에서 보면, 백프레셔는 V8 힙이 한 번에 감당할 수 없는 양의 데이터가 쌓이는 것을 방지합니다. highWaterMark(기본 64KB)가 수위를 제한하고, pause/resume이 흐름을 조절합니다.
백프레셔를 무시하면 생기는 일
// ❌ 백프레셔 무시 — write()의 반환값을 확인하지 않음
const readable = fs.createReadStream('huge-file.csv');
const writable = fs.createWriteStream('output.csv');
readable.on('data', (chunk) => {
writable.write(chunk); // 반환값 무시 → 버퍼 무한 증가 가능
});
// ✅ pipe 사용 — 백프레셔 자동 처리
fs.createReadStream('huge-file.csv')
.pipe(fs.createWriteStream('output.csv'));
직접 data 이벤트를 다룰 때는 반드시 write()의 반환값을 확인하거나, pipe를 사용해야 합니다.
readFile vs createReadStream — 메모리 비교
// 방법 1: readFile — 전체를 메모리에 올림
const data = await fs.promises.readFile('1gb-file.csv');
// heapUsed: ~1GB 증가. GC가 이 객체를 수거하기 전까지 메모리 점유.
// 방법 2: createReadStream — 청크 단위 처리
const stream = fs.createReadStream('1gb-file.csv');
stream.on('data', (chunk) => {
// chunk: 최대 64KB. 처리 후 참조가 끊기면 다음 Scavenge에서 수거.
});
// heapUsed: 수십~수백KB 수준으로 유지
| readFile | createReadStream | |
|---|---|---|
| 메모리 사용 | 파일 크기만큼 | highWaterMark만큼 (기본 64KB) |
| 1GB 파일 | heapUsed ~1GB | heapUsed 수십~수백KB |
| GC 부담 | Large Object Space 직행, Major GC 유발 | New Space에서 Scavenge로 빠르게 정리 |
| 첫 바이트 처리 | 전체 읽기 완료 후 | 첫 청크 도착 즉시 |
| 적합한 경우 | 작은 설정 파일, JSON 파싱이 필요한 경우 | 대용량 파일, 네트워크 전송, 로그 처리 |
스트림과 이벤트 루프
3~5편에서 추적한 이벤트 루프와 스트림이 어떻게 연결되는지 정리합니다.
fs.createReadStream('huge.csv').pipe(res);
이 한 줄이 이벤트 루프에서 하는 일:
1. createReadStream() 호출
→ libuv 스레드 풀에 파일 읽기 위임 (3편: fs.* → 스레드 풀 경로)
2. 워커 스레드가 64KB 읽기 완료
→ Poll 페이즈에서 콜백 실행 → 'data' 이벤트 발생
3. pipe 내부: res.write(chunk) 호출
→ OS 비동기 API로 TCP 전송 위임 (3편: 네트워크 → OS 비동기 경로)
4. res의 버퍼가 차면 → readable.pause()
→ 스레드 풀에 다음 읽기 요청을 보내지 않음
5. TCP 전송 완료 → Poll에서 drain 이벤트
→ readable.resume() → 스레드 풀에 다음 64KB 읽기 요청
6. 파일 끝 → 'end' 이벤트 → res.end()
이벤트 루프가 한 바퀴 돌 때마다 한 청크씩 처리됩니다. 1GB 파일을 64KB 청크로 읽으면 약 16,000번의 루프가 도는 셈입니다. 각 루프에서 메모리에 올라가는 건 64KB뿐이므로, 다른 HTTP 요청도 정상적으로 처리할 수 있습니다.
pipeline — pipe의 에러 안전한 대안
pipe()에는 한 가지 약점이 있습니다. 중간 스트림에서 에러가 발생하면, 이전 스트림이 자동으로 정리(destroy)되지 않아 메모리 누수가 발생할 수 있습니다.
const { pipeline } = require('stream/promises');
// pipe — 에러 시 스트림 수동 정리 필요
readStream
.pipe(gzip)
.pipe(writeStream);
// gzip에서 에러 → readStream은 계속 열려있음 (누수)
// pipeline — 에러 시 모든 스트림 자동 정리
await pipeline(
fs.createReadStream('input.csv'),
zlib.createGzip(),
fs.createWriteStream('output.csv.gz')
);
// 어느 단계에서 에러가 나든, 모든 스트림이 destroy됨
Node.js 15부터 stream/promises의 pipeline은 Promise를 반환하므로 async/await과 함께 쓸 수 있습니다.
실전 패턴 — 언제 스트림을 쓰는가
| 상황 | readFile / 통째로 | Stream |
|---|---|---|
| 설정 파일 (수 KB) | ✅ | 과도함 |
| JSON 파싱이 필요한 API 응답 | ✅ | 가능하지만 복잡 |
| CSV/Excel 대량 처리 | 위험 | ✅ |
| 파일 다운로드 API | 위험 | ✅ |
| 로그 파일 분석 | 위험 | ✅ |
| 이미지/영상 업로드 | 위험 | ✅ |
| DB 대량 export | 위험 | ✅ |
기준은 간단합니다. 데이터 크기를 예측할 수 없거나, 파일/응답이 수 MB를 넘을 수 있으면 스트림을 사용합니다.
NestJS에서의 스트림 활용
// NestJS — 파일 다운로드 엔드포인트
@Get('export')
async exportPosts(@Res() res: Response) {
const cursor = this.blogPostModel.find().cursor();
// Mongoose cursor는 Readable 스트림
// DB에서 한 건씩 읽어서 메모리에 전부 올리지 않음
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=posts.csv');
cursor
.pipe(transformToCSV) // Transform: Document → CSV 행 변환
.pipe(res); // Writable: HTTP 응답으로 전송
}
5편에서 이 서버의 모든 작업이 I/O-bound라고 했습니다. 스트림도 마찬가지입니다. 파일 읽기는 스레드 풀, 네트워크 전송은 OS 비동기 API에 위임되고, 메인 스레드는 청크 사이의 변환 로직만 실행합니다.
정리
| 개념 | 핵심 |
|---|---|
| 스트림 | 데이터를 통째로 올리지 않고, 청크 단위로 흘려보내는 처리 방식 |
| 4가지 타입 | Readable(읽기), Writable(쓰기), Duplex(양방향), Transform(변환) |
| highWaterMark | 내부 버퍼의 수위 제한. 기본 64KB. 메모리 사용량을 일정하게 유지 |
| 백프레셔 | 소비자가 느리면 생산자를 pause. 버퍼가 비면 resume. 메모리 폭발 방지 |
| pipe | Readable → Writable 자동 연결 + 백프레셔 자동 처리 |
| pipeline | pipe의 에러 안전한 버전. 에러 시 모든 스트림 자동 정리 |
6편에서 V8의 메모리 한계를 다뤘습니다. 스트림은 그 한계 안에서 무한한 크기의 데이터를 처리할 수 있게 해주는 설계입니다. 1GB 파일이든 100GB 파일이든, 메모리에는 highWaterMark만큼만 올라갑니다. GC 입장에서도, 64KB 청크는 New Space에서 Scavenge로 빠르게 정리되므로 Old Space를 압박하지 않습니다.
관련 글
프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)
Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.
Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)
1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.
이벤트 루프의 6개 페이즈 — 콜백은 어떤 순서로 실행되는가 (4편)
이벤트 루프의 6개 페이즈를 하나씩 살펴봅니다. 타이머 힙의 동작 방식, Poll이 메인 무대인 이유, setTimeout(0)과 setImmediate의 순서가 달라지는 원리, 그리고 process.nextTick과 Promise가 매 페이즈 사이에 끼어드는 구조까지 정리합니다.