홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Node.js 스트림 — 대용량 데이터를 메모리에 올리지 않고 처리하는 법 (7편)

정기창·2026년 3월 3일

들어가며

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 수준으로 유지
readFilecreateReadStream
메모리 사용파일 크기만큼highWaterMark만큼 (기본 64KB)
1GB 파일heapUsed ~1GBheapUsed 수십~수백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. 메모리 폭발 방지
pipeReadable → Writable 자동 연결 + 백프레셔 자동 처리
pipelinepipe의 에러 안전한 버전. 에러 시 모든 스트림 자동 정리

6편에서 V8의 메모리 한계를 다뤘습니다. 스트림은 그 한계 안에서 무한한 크기의 데이터를 처리할 수 있게 해주는 설계입니다. 1GB 파일이든 100GB 파일이든, 메모리에는 highWaterMark만큼만 올라갑니다. GC 입장에서도, 64KB 청크는 New Space에서 Scavenge로 빠르게 정리되므로 Old Space를 압박하지 않습니다.

Node.js스트림Stream백프레셔pipe면접 준비

관련 글

프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)

Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.

관련도 89%

Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)

1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.

관련도 88%

이벤트 루프의 6개 페이즈 — 콜백은 어떤 순서로 실행되는가 (4편)

이벤트 루프의 6개 페이즈를 하나씩 살펴봅니다. 타이머 힙의 동작 방식, Poll이 메인 무대인 이유, setTimeout(0)과 setImmediate의 순서가 달라지는 원리, 그리고 process.nextTick과 Promise가 매 페이즈 사이에 끼어드는 구조까지 정리합니다.

관련도 88%