Back

[TIL] Node.js Buffer와 Stream - V8 Heap 밖의 메모리

2026. 03. 18.Yeji Kim
CS

이전 글에서 V8 Heap의 구조와 GC를 다뤘다. 하지만 Node.js 프로세스의 메모리가 전부 V8 Heap 안에 있는 것은 아니다. process.memoryUsage()를 찍어보면 heapUsed 외에 external, arrayBuffers라는 필드가 보인다. 이 숫자들은 V8 Heap 바깥에 할당된 메모리를 가리킨다.

js
console.log(process.memoryUsage());
// {
//   rss: 30_000_000,        ← OS가 이 프로세스에 할당한 전체 물리 메모리
//   heapTotal: 6_000_000,   ← V8이 확보한 Heap 크기
//   heapUsed: 4_000_000,    ← 그 중 실제 사용 중인 크기
//   external: 1_000_000,    ← V8 Heap 밖, C++ 객체가 관리하는 메모리
//   arrayBuffers: 500_000,  ← external 중 ArrayBuffer/Buffer가 차지하는 부분
// }

Buffer와 Stream은 이 "Heap 밖"의 핵심 주인공이다. 왜 V8 Heap 밖에 있어야 하는지, 내부적으로 어떻게 동작하는지, 그리고 이것이 메모리 관리에 어떤 의미를 갖는지를 파본다.


Buffer는 왜 V8 Heap 밖에 있을까

Buffer는 바이너리 데이터를 다루기 위한 Node.js의 핵심 자료구조다. 네트워크 패킷, 파일 I/O, 이미지 처리 등 "바이트 덩어리"를 다룰 때 쓴다. 그런데 이 데이터를 왜 V8 Heap에 넣지 않을까?

V8 Heap은 JavaScript 객체를 위한 공간이다. GC가 객체를 추적하고, 이동시키고, 포인터를 업데이트한다. 그런데 10MB짜리 이미지 바이너리를 V8 Heap에 넣으면 어떻게 될까? GC가 이 10MB를 매번 스캔하고, Compact 시 복사해야 한다. 바이너리 데이터 안에는 JS 객체 포인터가 없으므로 GC가 이걸 스캔하는 건 완전히 무의미한 작업이다.

이것이 Buffer의 데이터를 V8 Heap 밖에 두는 근본적인 이유다.

내부 구조

택배 상자에 비유하면, V8 Heap 안에는 송장(수신자, 크기, 분류 코드)만 붙어있고, 실제 내용물(바이너리 데이터)은 별도 창고에 보관되어 있는 구조다. GC는 송장만 관리하면 되니까 가볍고, 무거운 내용물은 GC가 건드릴 필요가 없다.

Buffer의 실체는 JavaScript의 Uint8Array(TypedArray의 한 종류)이고, 그 뒤에 ArrayBuffer가 있다. ArrayBuffer는 ECMAScript 표준에서 "고정 길이의 원시 바이너리 데이터 버퍼"로 정의되어 있다.

text
Buffer의 메모리 구조:
 
V8 Heap 안                     V8 Heap 밖 (off-heap)
┌──────────────────────┐      ┌──────────────────────────┐
│  Buffer (JS 객체)     │      │  backing store           │
│  ┌──────────────────┐│      │  (실제 바이너리 데이터)     │
│  │ Uint8Array       ││      │                          │
│  │  .length = 1024  ││ ───→ │  [0x48][0x65][0x6C]...   │
│  │  .byteOffset = 0 ││      │  (1024 bytes)            │
│  │  [[ArrayBuffer]] ─┼┼──→  │                          │
│  └──────────────────┘│      └──────────────────────────┘
│                      │
│  GC가 관리하는 영역    │      malloc/OS가 관리하는 영역
└──────────────────────┘

V8 Heap 안에는 Buffer 객체의 메타데이터(길이, 오프셋, ArrayBuffer 포인터)만 있고, 실제 바이너리 데이터는 V8 Heap 에 있다. Node.js 소스의 src/node_buffer.cc에서 이 할당 로직을 볼 수 있다.

external memory와 GC의 관계

V8 Heap 밖에 있다고 GC가 완전히 무관한 것은 아니다. V8은 AdjustAmountOfExternalAllocatedMemory()를 통해 외부 메모리 크기를 추적한다. 외부 메모리가 급격히 늘어나면 V8이 Major GC를 트리거해서, 더 이상 참조되지 않는 ArrayBuffer의 backing store를 해제한다. 이전 글에서 다룬 "외부 메모리 압박" 트리거가 바로 이것이다.


Buffer 생성 방법의 차이

Buffer를 만드는 세 가지 방법이 있고, 메모리 동작이 각각 다르다. Node.js Buffer 공식 문서에 명시되어 있다.

호텔 방에 비유하면 이해하기 쉽다.

  • Buffer.alloc - 깨끗하게 청소된 방을 받는다. 이전 손님의 흔적이 전혀 없다.
  • Buffer.allocUnsafe - 청소 안 된 방을 바로 받는다. 빠르지만, 이전 손님이 남긴 물건(데이터)이 있을 수 있다.
  • Buffer.from - 내 짐을 가져다가 새 방에 복사해서 넣어준다.

Buffer.alloc(size)

js
// 1024바이트를 할당하고, 0으로 초기화한다
const buf = Buffer.alloc(1024);
// buf[0] === 0, buf[1] === 0, ... buf[1023] === 0
 
// 내부적으로:
// 1. ArrayBuffer를 할당 (calloc 또는 malloc + memset(0))
// 2. Uint8Array로 감싸서 반환
// → 안전하다: 이전 메모리의 잔여 데이터가 없다

안전한 기본 선택이다. 할당된 메모리가 0으로 채워지므로, 이전에 그 메모리 주소에 있던 데이터(비밀번호, 토큰 등)가 노출될 위험이 없다.

Buffer.allocUnsafe(size)

js
// 1024바이트를 할당하지만, 초기화하지 않는다
const buf = Buffer.allocUnsafe(1024);
// buf[0] === ???  ← 이전 메모리의 잔여 데이터가 남아있을 수 있다!
 
// 내부적으로:
// 1. Node.js 내부 Buffer pool에서 공간을 할당 (가능하면)
// 2. 또는 새 ArrayBuffer를 malloc으로 할당
// → 빠르다: 0 초기화를 건너뛰므로
// → 위험하다: 이전 데이터가 노출될 수 있으므로
allocUnsafe의 보안 위험

allocUnsafe로 만든 Buffer를 초기화 없이 네트워크로 보내면, 이전에 그 메모리에 있던 데이터(다른 요청의 데이터, 비밀번호, 세션 토큰 등)가 유출될 수 있다. Node.js 공식 문서에서도 "must eventually be overwritten"이라고 경고한다. 직접 전체를 채울 것이 확실할 때만 사용해야 한다.

Buffer.from(data)

js
// 문자열로부터 Buffer 생성
const buf1 = Buffer.from("hello", "utf-8");
// → "hello"를 UTF-8로 인코딩한 바이트 배열: [0x68, 0x65, 0x6c, 0x6c, 0x6f]
 
// 배열로부터 Buffer 생성
const buf2 = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
// → "Hello"
 
// 다른 Buffer 복사
const buf3 = Buffer.from(buf1);
// → buf1의 데이터를 복사한 새 Buffer (독립적인 메모리)

Buffer.from()은 입력 데이터를 복사해서 새 Buffer를 만든다. 원본과 독립적인 메모리를 갖는다.

내부 Buffer Pool

Buffer.allocUnsafe()Buffer.from()은 작은 크기(기본 8KB 이하)일 때 내부 Buffer pool을 사용한다. 매번 시스템 malloc을 호출하는 대신, 미리 할당해둔 큰 버퍼(8KB)에서 잘라 쓰는 방식이다.

A4 용지에 비유하면, 작은 메모를 쓸 때마다 새 종이를 꺼내는 대신, 한 장의 A4 위에 여러 메모를 나눠 쓰는 것이다. 종이를 아끼고 정리가 쉽지만, 메모 하나를 오래 간직하면 그 메모가 적힌 A4 전체를 버릴 수 없다는 단점이 있다.

text
Buffer Pool (Buffer.poolSize = 8192, 즉 8KB):
 
┌──────────────────────────────────────────────┐
│  pre-allocated 8KB ArrayBuffer              │
│ [buf1: 100B][buf2: 50B][buf3: 200B][  빈  ] │
│                                    ↑         │
│                              poolOffset      │
└──────────────────────────────────────────────┘
 
Buffer.allocUnsafe(30) 호출 시:
→ 8KB pool에서 poolOffset부터 30바이트를 잘라서 반환
→ poolOffset += 30
→ pool에 공간이 부족하면 새 8KB pool을 할당
js
// pool 크기 확인
console.log(Buffer.poolSize); // 8192 (8KB)
 
// 작은 Buffer들은 같은 ArrayBuffer를 공유한다
const a = Buffer.allocUnsafe(10);
const b = Buffer.allocUnsafe(20);
console.log(a.buffer === b.buffer); // true (같은 pool!)
console.log(a.byteOffset);          // 예: 0
console.log(b.byteOffset);          // 예: 16 (8바이트 정렬)
 
// 큰 Buffer는 pool을 쓰지 않고 독립적으로 할당된다
const big = Buffer.allocUnsafe(10000);
console.log(big.buffer === a.buffer); // false (별도 할당)

이건 메모리 릭과도 연결된다. 8KB pool에서 10바이트짜리 Buffer를 잘라 쓴 뒤, 그 10바이트 Buffer만 참조하고 있으면? 8KB 전체가 GC 되지 않는다. 같은 ArrayBuffer를 공유하고 있기 때문이다. 작은 Buffer를 오래 들고 있으면 pool 전체가 해제되지 않을 수 있다.

이 동작은 Node.js 소스의 lib/buffer.js에서 allocate() 함수를 보면 확인할 수 있다.


process.memoryUsage() 해부

process.memoryUsage()가 반환하는 각 필드가 정확히 어디를 가리키는지 정리한다.

건물에 비유하면: rss는 건물 전체 면적이다. 그 안에 heapTotal(V8 사무실 전체)이 있고, 그중 실제 쓰는 책상이 heapUsed다. external은 건물 밖에 따로 빌린 외부 창고(Buffer 데이터 등)다. rss는 이 모든 것 + 복도, 엘리베이터, 기계실(스택, libuv, 코드 세그먼트 등)을 합친 것이다.

js
const mem = process.memoryUsage();
text
OS가 프로세스에 할당한 물리 메모리 (RSS)
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  V8 Heap (heapTotal)                                    │
│  ┌───────────────────────────────────┐                  │
│  │ 사용 중 (heapUsed)                 │                  │
│  │ ┌───────────────┐                 │                  │
│  │ │ JS 객체        │                 │                  │
│  │ │ 클로저         │  빈 공간         │                  │
│  │ │ Hidden Class  │  (GC가 확보)     │                  │
│  │ └───────────────┘                 │                  │
│  └───────────────────────────────────┘                  │
│                                                         │
│  External (external)                                    │
│  ┌───────────────────────────────────┐                  │
│  │ Buffer backing store              │                  │
│  │ ArrayBuffer 데이터                 │  ← arrayBuffers  │
│  │ C++ 객체가 할당한 외부 메모리       │                  │
│  └───────────────────────────────────┘                  │
│                                                         │
│  기타 (코드 세그먼트, 스택, libuv, 공유 라이브러리 등)    │
│                                                         │
└─────────────────────────────────────────────────────────┘
필드의미관리 주체
rssResident Set Size. OS가 이 프로세스에 할당한 물리 메모리 전체OS
heapTotalV8이 확보한 Heap 크기 (빈 공간 포함)V8 GC
heapUsedHeap 중 실제 JS 객체가 차지하는 크기V8 GC
externalV8 Heap 밖에서 C++ 객체가 관리하는 메모리C++ / Node.js
arrayBuffersexternal 중 ArrayBuffer와 SharedArrayBuffer가 차지하는 부분C++ / Node.js
rss와 heapUsed의 차이가 크다면?

rssheapTotal + external보다 훨씬 크다면, V8이나 Buffer가 아닌 다른 곳에서 메모리를 쓰고 있다는 뜻이다. 예를 들어: native addon(C++ 모듈), libuv 내부 버퍼, 스레드 스택, 메모리 매핑된 파일 등이 원인일 수 있다. 이런 메모리는 V8 --trace-gc로 보이지 않으므로 OS 레벨 도구(다음 글에서 다룸)로 분석해야 한다.

직접 확인해보자.

js
// buffer-memory.js
// 실행: node buffer-memory.js
 
function printMem(label) {
  const mem = process.memoryUsage();
  console.log(`[${label}]`);
  console.log(`  rss:          ${(mem.rss / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  heapUsed:     ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  external:     ${(mem.external / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  arrayBuffers: ${(mem.arrayBuffers / 1024 / 1024).toFixed(1)}MB`);
  console.log();
}
 
printMem('초기 상태');
 
// Buffer를 할당하면 heapUsed가 아닌 external/arrayBuffers가 증가한다
const buffers = [];
for (let i = 0; i < 100; i++) {
  buffers.push(Buffer.alloc(1024 * 1024)); // 1MB × 100 = 100MB
}
printMem('Buffer 100MB 할당 후');
// → external이 ~100MB 증가, heapUsed는 거의 변하지 않음
 
// 반면 JS 객체를 할당하면 heapUsed가 증가한다
const objects = [];
for (let i = 0; i < 100_000; i++) {
  objects.push({ index: i, data: 'x'.repeat(100) });
}
printMem('JS 객체 10만 개 할당 후');
// → heapUsed가 증가, external은 변하지 않음

Stream - 메모리 효율의 핵심

대용량 데이터를 처리할 때, 전체를 메모리에 올리는 것과 조금씩 흘려보내는 것은 메모리 사용량이 근본적으로 다르다.

수영장의 물을 옆 수영장으로 옮긴다고 생각해보자. Buffer 방식은 거대한 물탱크에 물을 전부 퍼 담은 뒤 한 번에 옮기는 것이다. 물탱크(메모리)가 수영장만큼 커야 한다. Stream 방식은 호스로 물을 조금씩 흘려보내는 것이다. 호스가 한 번에 보내는 양(highWaterMark)만큼의 메모리만 있으면 된다.

js
const fs = require('fs');
 
// ❌ 나쁜 예: 파일 전체를 메모리에 올린다
// 1GB 파일이면 1GB+ 메모리 필요
fs.readFile('huge-file.csv', (err, data) => {
  // data = 1GB Buffer
  // 이 시점에 process.memoryUsage().external이 1GB 이상
  processData(data);
});
 
// ✅ 좋은 예: Stream으로 조금씩 읽는다
// 한 번에 64KB(기본 highWaterMark)만 메모리에 올림
const stream = fs.createReadStream('huge-file.csv');
stream.on('data', (chunk) => {
  // chunk = 64KB Buffer
  // 이 chunk를 처리하고 나면, 다음 chunk가 올 때 이전 chunk는 GC 대상
  processChunk(chunk);
});

highWaterMark

highWaterMark는 Stream의 내부 버퍼가 얼마나 쌓이면 "그만 읽어라"고 신호를 보낼지를 결정하는 임계값이다. Node.js 공식 문서에서 "buffering" 섹션에 상세히 설명되어 있다.

이름 그대로 "수위 표시선"이다. 댐의 수위가 일정 높이(high water mark)를 넘으면 수문을 닫아서 물이 더 들어오지 못하게 한다. 수위가 낮아지면 다시 수문을 연다. Stream의 내부 버퍼도 똑같은 원리로 동작한다.

js
// highWaterMark의 기본값은 Stream 종류에 따라 다르다
const readable = new Readable({ /* ... */ });
// → 일반 Readable/Writable: 기본 16KB (16384 bytes)
// → objectMode인 경우: 기본 16개 객체
 
// fs.createReadStream은 예외적으로 64KB가 기본값이다
const fileStream = fs.createReadStream('file.txt');
// → 기본 64KB (65536 bytes) — 파일 I/O에 최적화된 값
 
// 커스텀 highWaterMark
const stream = fs.createReadStream('file.txt', {
  highWaterMark: 1024 * 1024, // 1MB로 키움
  // → 한 번에 더 많이 읽어서 시스템 콜 횟수를 줄인다
  // → 대신 그만큼 메모리를 더 쓴다
});
text
Stream 내부 버퍼링:
 
소스 (파일/네트워크)
  ↓ read
[내부 버퍼: chunk1 | chunk2 | chunk3 ]  ← 여기가 highWaterMark 체크 대상
  ↓ 'data' event 또는 read()
소비자 (우리 코드)
 
내부 버퍼 크기 >= highWaterMark → 소스에서 읽기를 일시 중지
소비자가 데이터를 가져가서 버퍼가 줄어들면 → 다시 읽기 시작

Backpressure - Stream이 메모리를 지키는 방법

Stream의 진짜 힘은 backpressure(배압)에 있다. 데이터를 읽는 속도가 쓰는 속도보다 빠를 때, 읽기를 늦춰서 메모리 폭주를 막는 메커니즘이다. Node.js 공식 backpressure 가이드에 상세히 기술되어 있다.

컨베이어 벨트를 생각하면 된다. 벨트 끝에서 상자를 포장하는 사람이 느리면, 벨트 위에 상자가 쌓인다. 결국 벨트가 넘친다(메모리 초과). Backpressure는 포장하는 사람이 "잠깐 멈춰!"라고 외치면, 벨트가 멈추는 것이다. 포장이 끝나면 "다시 보내!"라고 해서 벨트가 다시 움직인다.

backpressure가 없을 때 (위험)

js
const fs = require('fs');
 
const readable = fs.createReadStream('huge-file.dat');  // SSD에서 빠르게 읽음
const writable = fs.createWriteStream('output.dat');     // 느린 디스크에 씀
 
// ❌ 이렇게 하면 backpressure가 작동하지 않는다
readable.on('data', (chunk) => {
  writable.write(chunk);
  // write()가 false를 반환해도 무시하고 계속 읽는다
  // → writable의 내부 버퍼에 데이터가 무한히 쌓인다
  // → 메모리 폭주 → OOM
});

backpressure가 있을 때 (안전)

js
// ✅ pipe()를 쓰면 backpressure가 자동으로 처리된다
readable.pipe(writable);
 
// pipe() 내부에서 일어나는 일 (개념적):
// readable.on('data', (chunk) => {
//   const canContinue = writable.write(chunk);
//   if (!canContinue) {
//     readable.pause();          // "잠깐 멈춰!"
//   }
// });
// writable.on('drain', () => {
//   readable.resume();           // "다시 보내!"
// });
text
Backpressure 흐름:
 
readable (빠른 소스)          writable (느린 대상)
     │                            │
     │──── chunk1 ────→           │
     │──── chunk2 ────→           │
     │──── chunk3 ────→  [내부 버퍼가 highWaterMark 도달]
     │                            │
     │  ←── write() returns false ─┤  "내 버퍼 꽉 찼어!"
     │                            │
     │  readable.pause()          │
     │  (읽기 중지)                │  [버퍼의 데이터를 디스크에 flush]
     │                            │
     │  ←── 'drain' event ────────┤  "버퍼 비었어, 다시 보내!"
     │                            │
     │  readable.resume()         │
     │──── chunk4 ────→           │
pipe() vs pipeline()

pipe()는 에러 처리가 수동이라 실전에서는 stream.pipeline()을 쓰는 것이 권장된다. pipeline()은 에러 발생 시 모든 stream을 자동으로 정리(destroy)하고, 콜백이나 Promise로 완료를 알려준다.

js
const { pipeline } = require('stream/promises');
 
await pipeline(
  fs.createReadStream('input.dat'),
  transformStream,
  fs.createWriteStream('output.dat')
);
// → 에러 시 모든 stream 자동 정리, 메모리 릭 방지

Stream의 내부 버퍼링 메커니즘

Node.js의 Stream은 lib/internal/streams/ 디렉토리에 구현되어 있다. 핵심 파일을 살펴보자.

Stream에는 두 가지 모드가 있다. Flowing mode는 수도꼭지를 틀어놓은 것이다 - 물(데이터)이 자동으로 흘러나온다. Paused mode는 펌프를 눌러야 물이 나오는 것이다 - read()를 호출할 때만 데이터가 나온다. .on('data') 이벤트를 등록하면 자동으로 flowing mode로 전환된다.

Readable Stream

lib/internal/streams/readable.js에서 내부 버퍼는 BufferList라는 연결 리스트로 관리된다.

text
Readable 내부 버퍼 (BufferList):
 
head → [chunk1] → [chunk2] → [chunk3] → null
         64KB       64KB       32KB

                          tail (마지막 청크)
 
총 크기: 160KB
highWaterMark: 64KB (기본값)
 
160KB > 64KB → readable.push() 호출 시 false 반환
→ "내부 버퍼가 충분히 쌓였으니 더 읽지 마"
js
// Readable의 내부 동작 (개념적)
class ReadableState {
  constructor(options) {
    this.highWaterMark = options.highWaterMark || 16 * 1024; // 기본 16KB
    this.buffer = new BufferList();  // 청크들의 연결 리스트
    this.length = 0;                 // 버퍼에 쌓인 총 바이트 수
    this.flowing = null;             // null | true | false
    // flowing === true  → 'data' 이벤트로 자동 전달 (flowing mode)
    // flowing === false → read()를 직접 호출해야 함 (paused mode)
    // flowing === null  → 아직 소비자가 없음
  }
}
 
// push()가 false를 반환하는 조건
Readable.prototype.push = function(chunk) {
  this._readableState.buffer.push(chunk);
  this._readableState.length += chunk.length;
 
  // 내부 버퍼가 highWaterMark 이상이면 false 반환
  return this._readableState.length < this._readableState.highWaterMark;
};

Writable Stream

lib/internal/streams/writable.js에서도 비슷한 버퍼링이 일어난다.

js
// Writable의 backpressure 메커니즘 (개념적)
Writable.prototype.write = function(chunk) {
  this._writableState.length += chunk.length;
 
  // 내부 버퍼가 highWaterMark 이상이면 false 반환
  // → "나 지금 바빠, 더 보내지 마" 신호
  const ret = this._writableState.length < this._writableState.highWaterMark;
 
  if (!ret) {
    this._writableState.needDrain = true;
    // 나중에 버퍼가 비면 'drain' 이벤트 발생
  }
 
  // 실제 쓰기(_write)를 큐에 넣고 비동기로 실행
  this._writableState.buffered.push({ chunk, callback });
 
  return ret; // false면 backpressure!
};

Transform Stream

Transform은 Readable + Writable을 합친 것이다. 입력을 받아 변환한 뒤 출력한다.

정수기를 생각하면 된다. 수돗물(입력)이 들어오면, 필터를 거쳐서(변환), 깨끗한 물(출력)이 나온다. 수돗물 전체를 통에 모았다가 한 번에 정수하는 게 아니라, 흘러 들어오는 대로 조금씩 정수한다. Transform Stream도 마찬가지다.

js
const { Transform } = require('stream');
 
// CSV 라인을 JSON으로 변환하는 Transform
const csvToJson = new Transform({
  transform(chunk, encoding, callback) {
    // chunk: 입력 데이터 (Buffer)
    const line = chunk.toString();
    const [name, age] = line.split(',');
 
    // 변환된 데이터를 출력으로 push
    this.push(JSON.stringify({ name, age: Number(age) }) + '\n');
    callback(); // 다음 chunk를 받을 준비 완료
  },
});
 
// 파이프라인: 파일 → 변환 → 파일
// 메모리 사용량 = highWaterMark × 2 (입력 버퍼 + 출력 버퍼) 수준
Transform의 메모리 특성

Transform은 입력 버퍼(Writable 측)와 출력 버퍼(Readable 측)를 각각 갖고 있다. 양쪽 모두 highWaterMark가 적용된다. 변환 로직이 느리면 입력 버퍼가 차고 backpressure가 걸린다. 변환 결과가 소비되지 않으면 출력 버퍼가 차고 transform() 호출이 멈춘다. 양방향으로 backpressure가 작동하는 것이다.


Buffer와 Stream의 메모리 릭 패턴

Buffer와 Stream은 V8 GC 바깥에서 동작하기 때문에, JS 객체와는 다른 방식으로 릭이 발생한다. heapUsed는 정상인데 rssexternal이 계속 올라가는 경우, 이 패턴들을 의심해봐야 한다.

패턴 1: Stream을 닫지 않음

js
// ❌ 에러 시 stream을 닫지 않으면 리소스 릭
const stream = fs.createReadStream('file.dat');
stream.on('data', (chunk) => {
  if (isInvalid(chunk)) {
    return; // 에러 처리만 하고 stream을 안 닫음
    // → stream이 계속 열려있고, 파일 디스크립터도 누수
    // → 내부 버퍼도 GC 되지 않음
  }
});
 
// ✅ stream.destroy()로 명시적으로 정리
stream.on('data', (chunk) => {
  if (isInvalid(chunk)) {
    stream.destroy(new Error('invalid chunk'));
    return;
  }
});
stream.on('error', (err) => {
  console.error('Stream error:', err.message);
});

패턴 2: Buffer pool 참조 유지

js
// ❌ 작은 Buffer를 오래 들고 있으면 8KB pool 전체가 살아남는다
const cache = new Map();
 
function processPacket(data) {
  // data는 네트워크에서 온 Buffer (allocUnsafe로 할당, pool 사용)
  const header = data.slice(0, 4);
  // header.buffer === data.buffer (같은 8KB pool의 ArrayBuffer!)
  cache.set(header.toString('hex'), header);
  // → 4바이트 header를 캐시에 저장했지만,
  //   header가 참조하는 8KB ArrayBuffer 전체가 GC되지 않음
}
 
// ✅ Buffer.from()으로 복사해서 pool과의 참조를 끊는다
function processPacketSafe(data) {
  const header = Buffer.from(data.slice(0, 4));
  // header.buffer !== data.buffer (독립적인 ArrayBuffer)
  cache.set(header.toString('hex'), header);
  // → 4바이트만 차지하는 독립적인 Buffer
  // → 원본 data의 pool은 자유롭게 GC 가능
}

패턴 3: backpressure 무시

js
// ❌ write()의 반환값을 무시
readable.on('data', (chunk) => {
  writable.write(chunk); // 반환값 무시!
  // writable이 느리면 내부 버퍼가 무한히 커진다
});
 
// ✅ pipe() 또는 pipeline() 사용
const { pipeline } = require('stream/promises');
await pipeline(readable, writable);
// → backpressure 자동 처리

직접 확인해보기

Stream의 메모리 효율을 직접 비교해보자.

js
// stream-vs-buffer.js
// 먼저 테스트용 큰 파일을 생성한다
// node -e "require('fs').writeFileSync('test.dat', Buffer.alloc(200*1024*1024))"
// → 200MB 파일 생성
 
const fs = require('fs');
 
function printMem(label) {
  global.gc && global.gc(); // --expose-gc 사용 시
  const mem = process.memoryUsage();
  console.log(`[${label}] rss: ${(mem.rss/1024/1024).toFixed(0)}MB, ` +
    `heap: ${(mem.heapUsed/1024/1024).toFixed(0)}MB, ` +
    `external: ${(mem.external/1024/1024).toFixed(0)}MB`);
}
 
// 방법 1: readFile — 전체를 메모리에 올림
async function withReadFile() {
  printMem('readFile 전');
  const data = fs.readFileSync('test.dat');
  printMem('readFile 후');  // external이 ~200MB 증가
  return data.length;
}
 
// 방법 2: Stream — 조금씩 처리
async function withStream() {
  printMem('stream 전');
  return new Promise((resolve) => {
    let bytes = 0;
    const stream = fs.createReadStream('test.dat');
    stream.on('data', (chunk) => {
      bytes += chunk.length;
      // chunk는 64KB 단위로 들어온다
      // 이전 chunk는 참조를 잃어 GC 대상이 된다
    });
    stream.on('end', () => {
      printMem('stream 후');  // external이 거의 증가하지 않음
      resolve(bytes);
    });
  });
}
 
// 실행: node --expose-gc stream-vs-buffer.js

정리

text
V8 Heap 안                    V8 Heap 밖
┌────────────────┐           ┌──────────────────────┐
│ JS 객체         │           │ Buffer backing store  │
│ 클로저          │           │ ArrayBuffer 데이터     │
│ Hidden Class   │           │ libuv 내부 버퍼        │
│ Buffer 메타데이터│ ────→     │ (실제 바이너리)        │
└────────────────┘           └──────────────────────┘
  V8 GC가 관리                 C++/OS가 관리
  heapUsed로 측정              external로 측정
개념핵심기억할 것
BufferV8 Heap 밖(off-heap)에 데이터 저장external, arrayBuffers로 추적
Buffer.alloc0 초기화, 안전기본 선택
Buffer.allocUnsafe초기화 안 함, 빠르지만 위험반드시 전체를 덮어쓸 때만
Buffer pool8KB 미리 할당, 작은 Buffer 공유작은 Buffer 오래 잡으면 pool 전체 릭
Stream조금씩 흘려보내기대용량 처리의 핵심
highWaterMark내부 버퍼 임계값기본 16KB (fs.createReadStream은 64KB)
Backpressure느린 쪽이 빠른 쪽을 제어pipe() / pipeline() 쓰면 자동

다음 글에서는 더 아래 계층으로 내려간다. libuv가 실제 I/O를 어떻게 처리하는지, RSS와 Heap의 차이는 왜 생기는지, 그리고 "메모리가 남았는데 OOMKilled 되는" 상황의 원인을 OS 레벨에서 분석한다.


레퍼런스

Node.js 공식 문서

Node.js 소스코드

웹 표준