Back

[TIL] libuv와 OS 메모리 - OOM의 진짜 원인?

2026. 03. 19.Yeji Kim
CSPlatform

이전 두 글에서 V8 Heap(JS 객체)과 Buffer/Stream(off-heap 바이너리)을 다뤘다. 하지만 Node.js 프로세스가 OOMKilled 되는 원인은 이 두 영역만으로 설명되지 않는 경우가 많다. heapUsed도 정상이고, external도 크지 않은데 프로세스가 죽는다. 왜?

답은 더 아래 계층에 있다. Node.js는 JavaScript만으로 동작하지 않는다. libuv라는 C 라이브러리가 실제 I/O를 처리하고, 그 아래에는 OS의 메모리 관리 시스템이 있다. 이 글에서는 JS → V8 → Node → libuv → OS의 전체 흐름과, OS가 메모리를 어떻게 다루는지를 살펴보자.


Node.js의 아키텍처

Node.js를 "JavaScript 런타임"이라고 부르지만, 실제 구조는 여러 계층의 조합이다.

text
┌──────────────────────────────────────────────┐
│  JavaScript 코드 (우리가 쓰는 코드)           │
├──────────────────────────────────────────────┤
│  Node.js API (fs, net, http, stream, ...)    │
│  → JS + C++ 바인딩                           │
├──────────────────────────────────────────────┤
│  V8 Engine          │  Node.js C++ bindings  │
│  (JS 실행, Heap,    │  (node_buffer.cc,      │
│   GC)               │   node_file.cc, ...)   │
├─────────────────────┼────────────────────────┤
│                     │  libuv                  │
│                     │  (이벤트 루프, 비동기    │
│                     │   I/O, 스레드 풀)       │
├──────────────────────────────────────────────┤
│  OS Kernel (syscall, epoll/kqueue, TCP/IP)   │
└──────────────────────────────────────────────┘

각 계층이 메모리를 각각 따로 사용한다.

계층메모리 종류추적 방법
JS 코드V8 Heap (heapUsed)process.memoryUsage(), --trace-gc
Buffer/ArrayBufferoff-heap (external)process.memoryUsage().external
libuvC malloc (스레드 풀, 핸들 등)OS 도구 (pmap, top)
OS가상 메모리, 페이지 캐시/proc/[pid]/status, ps
heapUsed가 정상인데 프로세스가 죽는 이유

process.memoryUsage()는 V8 Heap과 external만 보여준다. libuv가 내부적으로 할당한 메모리, 스레드 스택, OS 페이지 캐시 등은 보이지 않는다. 하지만 이것들도 전부 프로세스의 RSS에 포함되고, cgroup 메모리 제한에 걸린다. heapUsed만 보면 안 보이는 메모리가 프로세스를 죽일 수 있다.


libuv - Node.js의 I/O 엔진

libuv는 Node.js를 위해 만들어진 비동기 I/O 라이브러리다. 현재는 독립 프로젝트로, Julia, Neovim 등 다른 프로젝트에서도 사용한다. libuv 공식 문서에 설계 개요가 있다.

libuv가 하는 일:

  • 이벤트 루프 관리
  • 비동기 I/O: 파일, 네트워크, DNS, 파이프
  • 스레드 풀: 파일 시스템 작업 등 블로킹 I/O를 비동기로 전환
  • 타이머, 시그널, 프로세스 관리

이벤트 루프

Node.js의 "싱글 스레드"라 불리는 것의 실체가 libuv의 이벤트 루프다. Node.js 공식 가이드에 상세히 설명되어 있다.

text
libuv 이벤트 루프의 각 phase:
 
   ┌───────────────────────────┐
┌→ │        timers              │  ← setTimeout, setInterval 콜백
│  └────────────┬──────────────┘
│  ┌────────────┴──────────────┐
│  │     pending callbacks     │  ← 이전 루프에서 지연된 I/O 콜백
│  └────────────┬──────────────┘
│  ┌────────────┴──────────────┐
│  │       idle, prepare       │  ← 내부용
│  └────────────┬──────────────┘
│  ┌────────────┴──────────────┐
│  │          poll              │  ← I/O 이벤트 대기 (핵심!)
│  │  (epoll/kqueue/IOCP)      │     여기서 네트워크, 파일 I/O 완료를 감지
│  └────────────┬──────────────┘
│  ┌────────────┴──────────────┐
│  │          check             │  ← setImmediate 콜백
│  └────────────┬──────────────┘
│  ┌────────────┴──────────────┐
│  │     close callbacks       │  ← socket.on('close') 등
│  └────────────┬──────────────┘
└───────────────┘

libuv의 메모리 사용

libuv는 자체적으로 C malloc을 사용해 메모리를 할당한다. 이 메모리는 V8의 heapUsedexternal에 잡히지 않는다.

text
libuv가 할당하는 메모리 (V8에 보이지 않음):
 
1. 핸들(Handle)
   - uv_tcp_t, uv_udp_t, uv_fs_t, uv_timer_t 등
   - TCP 커넥션 하나당 uv_tcp_t 구조체 하나 (수백 바이트)
   - 10만 커넥션이면 ~수십MB
 
2. 스레드 풀 스택
   - 기본 4개 스레드 (UV_THREADPOOL_SIZE로 조절 가능, 최대 1024)
   - 스레드 하나당 스택 ~2MB (OS 기본값)
   - 기본 설정: 4 × 2MB = ~8MB
 
3. I/O 버퍼
   - 네트워크 데이터 수신 시 libuv가 할당하는 read buffer
   - alloc_cb 콜백으로 크기를 제어할 수 있음
js
// 스레드 풀 크기 확인 및 조절
console.log(process.env.UV_THREADPOOL_SIZE); // 기본: undefined (4개)
 
// 더 많은 병렬 파일 I/O가 필요하면:
// UV_THREADPOOL_SIZE=16 node server.js
// → 16개 스레드 × ~2MB 스택 = ~32MB 추가 메모리
스레드 풀과 메모리

UV_THREADPOOL_SIZE를 무작정 키우면 스레드 스택 메모리가 늘어난다. 128로 설정하면 스택만 거의 256MB까지다. 또한 CPU 코어 수보다 스레드가 많으면 컨텍스트 스위칭 오버헤드도 증가한다. 대부분의 경우 기본값 4이면 충분하고, DNS/파일 I/O가 많은 경우에만 8~16 정도로 올리는 것이 적절하다. libuv 스레드 풀 문서를 참고하자.

libuv 소스에서 보는 I/O

네트워크 데이터가 왜 Buffer로 오는지, libuv 소스 src/unix/stream.c를 보면 이해된다.

c
// libuv src/unix/stream.c (개념적 흐름)
static void uv__read(uv_stream_t* stream) {
  // 1. Node.js에 "버퍼를 할당해줘" 요청 (alloc_cb 호출)
  //    → Node.js는 여기서 Buffer를 할당해서 반환
  buf = stream->alloc_cb(stream, suggested_size);
 
  // 2. OS로부터 데이터를 읽어서 그 버퍼에 채움
  nread = read(stream->io_watcher.fd, buf.base, buf.len);
 
  // 3. Node.js에 "데이터 왔어" 콜백 (read_cb 호출)
  //    → Node.js는 이걸 'data' 이벤트로 JS에 전달
  stream->read_cb(stream, nread, &buf);
}
text
네트워크 데이터가 JS까지 올라오는 경로:
 
OS kernel (TCP 수신 버퍼)
  ↓ read() syscall
libuv (alloc_cb로 Buffer 할당)
  ↓ read_cb
Node.js C++ 바인딩
  ↓ 'data' event
JS 코드의 socket.on('data', callback)

이 과정에서 생기는 메모리:

  • OS 커널의 TCP 수신 버퍼 (net.core.rmem_default)
  • libuv가 요청한 Buffer (Node.js가 alloc_cb에서 할당)
  • JS 콜백에서 사용하는 변수들

세 계층 모두 메모리를 쓰지만, process.memoryUsage()에는 두 번째(Buffer)만 external로 잡힌다.


RSS vs VSZ vs Heap

프로세스의 메모리를 이야기할 때 RSS, VSZ, Heap은 각각 다른 것을 가리킨다. 이 차이를 정확히 아는 것이 OOM 분석의 기본이다.

Virtual Memory (VSZ)

프로세스가 접근할 수 있는 가상 주소 공간의 총 크기다. 실제 물리 메모리와는 다르다.

text
64비트 프로세스의 가상 주소 공간:
 
0x000000000000 ┌────────────────────┐
               │  코드 세그먼트       │  ← 실행 파일, 공유 라이브러리
               ├────────────────────┤
               │  데이터 세그먼트     │  ← 전역 변수
               ├────────────────────┤
               │  Heap (V8 + malloc) │  ← 동적 할당 (위로 성장)
               │       ↓            │
               │     (빈 공간)       │
               │       ↑            │
               │  Stack             │  ← 함수 호출 스택 (아래로 성장)
               ├────────────────────┤
               │  mmap 영역          │  ← 메모리 맵핑 파일, 공유 메모리
               ├────────────────────┤
               │  커널 영역           │
0xFFFFFFFFFFFF └────────────────────┘
 
VSZ = 이 전체 가상 공간의 크기
→ 실제로 물리 메모리에 올라가지 않은 부분도 포함
→ 그래서 VSZ가 매우 커도 실제 메모리 사용은 적을 수 있다

Resident Set Size (RSS)

실제로 물리 메모리(RAM)에 올라가 있는 페이지의 총 크기다. OS가 프로세스를 죽일지 말지 판단하는 기준이 된다.

text
가상 페이지 → 물리 페이지 매핑:
 
가상 주소 공간          물리 메모리 (RAM)
┌──────┐              ┌──────┐
│ 페이지1│ ────→       │ 프레임A│  ← 매핑됨 (RSS에 포함)
├──────┤              ├──────┤
│ 페이지2│ (미접근)     │ 프레임B│  ← 다른 프로세스 사용 중
├──────┤              ├──────┤
│ 페이지3│ ────→       │ 프레임C│  ← 매핑됨 (RSS에 포함)
├──────┤              └──────┘
│ 페이지4│ → swap      디스크
├──────┤              ┌──────┐
│ 페이지5│ (미접근)     │ swap │  ← 물리 메모리에 없음
└──────┘              └──────┘
 
RSS = 페이지1 + 페이지3 = 실제 RAM에 올라간 것만
VSZ = 페이지1~5 전체 = 가상 공간 전체

정리

VSZRSSheapUsed
무엇가상 주소 공간 전체물리 메모리에 올라간 부분V8 Heap 중 사용 중인 부분
포함 범위코드 + 데이터 + Heap + 스택 + mmap + 미할당실제 RAM에 매핑된 페이지만JS 객체만
OOM 기준아님이것 (cgroup 기준)V8 내부 OOM만
확인 방법ps aux VSZ 열ps aux RSS 열process.memoryUsage().heapUsed
bash
# Node.js 프로세스의 VSZ와 RSS 확인
ps aux | grep node
# USER  PID  %CPU %MEM   VSZ    RSS  TTY STAT TIME COMMAND
# yeji  1234  0.5  1.2  890000  45000 ...  node server.js
#                       ↑ VSZ    ↑ RSS (KB 단위)

페이지 폴트와 메모리의 실제 동작

OS는 가상 메모리를 페이지(보통 4KB) 단위로 관리한다. 프로세스가 malloc으로 메모리를 요청해도, OS는 즉시 물리 메모리를 할당하지 않는다. 실제로 그 메모리에 접근(쓰기)할 때 비로소 물리 메모리를 할당한다. 이것이 demand paging이다.

text
Demand Paging (지연 할당):
 
1. malloc(100MB) 호출
   → OS: "가상 주소 100MB 예약해줄게" (VSZ += 100MB)
   → 물리 메모리는 아직 0 할당 (RSS 변동 없음)
 
2. 첫 번째 페이지에 쓰기: buffer[0] = 1
   → Page Fault 발생! (Minor)
   → OS: "아, 진짜 쓰네. 물리 페이지 하나 할당할게" (RSS += 4KB)
 
3. 두 번째 페이지에 쓰기: buffer[4096] = 1
   → 또 Page Fault
   → OS: 물리 페이지 하나 더 할당 (RSS += 4KB)
 
→ 100MB를 malloc해도, 실제로 1MB만 접근하면 RSS는 ~1MB만 증가한다

이것이 Buffer.alloc()Buffer.allocUnsafe()의 메모리 동작이 다른 또 다른 이유다.

js
// Buffer.alloc(100MB) → 0으로 초기화 → 모든 페이지에 쓰기 발생
// → 즉시 RSS가 ~100MB 증가
 
// Buffer.allocUnsafe(100MB) → 초기화 안 함 → 아직 접근 안 한 페이지는 물리 할당 안 됨
// → RSS는 실제로 접근한 부분만큼만 증가
// (단, 작은 크기에서는 pool을 사용하므로 이 차이가 드러나지 않을 수 있음)

Minor vs Major Page Fault

Minor Page FaultMajor Page Fault
원인가상 페이지에 처음 접근 (물리 매핑 없음)필요한 페이지가 디스크(swap)에 있음
비용가볍다 (커널 내에서 페이지 테이블 업데이트)무겁다 (디스크 I/O 발생)
성능 영향거의 없음심각 (ms 단위 지연)
bash
# 프로세스의 page fault 통계 확인 (Linux)
cat /proc/[PID]/stat | awk '{print "minor:", $10, "major:", $12}'
 
# 또는 time 명령으로
/usr/bin/time -v node server.js 2>&1 | grep "page faults"
# Minor (reclaiming) page faults: 15234
# Major (requiring I/O) page faults: 0    ← 0이 정상
Major Page Fault가 발생한다면

Major page fault는 swap이 발생하고 있다는 신호다. Node.js에서 swap이 발생하면 GC가 swap된 페이지를 스캔하면서 디스크 I/O가 폭발하고, 응답 시간이 수백ms~수초로 치솟는다. 프로덕션 Node.js 서버에서 swap은 사실상 장애다. K8s 환경에서는 swap을 비활성화하는 것이 권장되며, 메모리가 부족하면 swap 대신 OOMKill이 발생하도록 하는 것이 오히려 낫다.


cgroup과 OOM Killer

컨테이너(Docker, K8s) 환경에서 Node.js를 실행하면, OS 수준의 메모리 제한인 cgroup(control group)이 적용된다. OOMKilled의 진짜 원인을 이해하려면 cgroup을 알아야 한다.

cgroup 메모리 제한

yaml
# K8s Pod 설정 예시
resources:
  limits:
    memory: "512Mi"   # ← 이게 cgroup 메모리 제한이 된다
  requests:
    memory: "256Mi"

이 설정이 되면 커널이 이 컨테이너의 cgroup에 512MB 제한을 건다. 컨테이너 안의 프로세스가 합산으로 512MB를 넘으면 OOM Killer가 동작한다.

text
cgroup 메모리 제한의 범위:
 
cgroup memory limit (512MB)
┌─────────────────────────────────────────────────┐
│                                                  │
│  Node.js 프로세스 RSS                             │
│  ├── V8 Heap (heapUsed + 빈 공간)                │
│  ├── Buffer / ArrayBuffer (external)             │
│  ├── libuv (핸들, 스레드 스택)                    │
│  ├── JIT 코드 (Code Space)                       │
│  ├── 공유 라이브러리 (.so 파일 매핑)              │
│  └── 기타 (스택, 환경 변수 등)                    │
│                                                  │
│  + 커널 메모리 (page cache, slab 등) ← 이것도!    │
│                                                  │
└─────────────────────────────────────────────────┘

   이 전체 합이 512MB를 넘으면 → OOM Killer 발동
메모리가 남았는데 OOMKilled 되는 이유

가장 흔한 원인 세 가지:

1. Page Cache 포함: Linux는 파일 I/O 시 page cache를 적극적으로 사용한다. 파일을 많이 읽으면 page cache가 cgroup 메모리에 포함되어 제한에 걸릴 수 있다. heapUsed가 200MB인데 512MB 제한에서 죽는다면, page cache가 나머지를 차지하고 있을 수 있다.

2. V8 Heap이 제한보다 큼: --max-old-space-size를 cgroup 제한보다 크게 잡으면, V8이 Heap을 확장하다가 cgroup 제한에 걸려서 OOMKilled 된다. V8의 에러 메시지 대신 커널의 SIGKILL(exit code 137)이 날아온다.

3. 메모리 단편화: RSS에 잡히는 물리 메모리와 실제 "사용 중인" 메모리는 다를 수 있다. 단편화된 메모리는 free list에 있어도 물리 페이지를 차지한다.

OOM Killer의 동작

Linux OOM Killer는 메모리 제한에 도달하면 oom_score가 가장 높은 프로세스를 SIGKILL로 강제 종료한다. 컨테이너 환경에서는 cgroup 내에서만 판단한다.

bash
# 프로세스의 oom_score 확인 (Linux)
cat /proc/[PID]/oom_score        # 0~1000, 높을수록 먼저 죽음
cat /proc/[PID]/oom_score_adj    # -1000~1000, 관리자가 조정 가능
 
# cgroup 메모리 사용량 확인 (cgroup v2)
cat /sys/fs/cgroup/memory.current     # 현재 사용량 (bytes)
cat /sys/fs/cgroup/memory.max         # 제한 (bytes)
 
# K8s에서 확인
kubectl top pod [pod-name]
kubectl describe pod [pod-name] | grep -A5 "Last State"
# OOMKilled이면 exit code 137 (128 + 9 = SIGKILL)

--max-old-space-size와 cgroup의 관계

V8의 --max-old-space-size와 cgroup 메모리 제한은 독립적으로 동작한다. 둘 다 맞춰야 한다.

text
잘못된 설정:
  cgroup limit:          512MB
  --max-old-space-size:  1024MB   ← V8은 1GB까지 Heap을 키울 수 있다고 생각
 
  → V8이 Heap을 600MB로 키우려는 순간
  → RSS가 cgroup 512MB를 넘김
  → OS가 SIGKILL (exit 137)
  → V8의 "JavaScript heap out of memory" 메시지도 안 뜸!
 
올바른 설정:
  cgroup limit:          512MB
  --max-old-space-size:  384MB    ← 나머지 ~128MB는 Buffer, libuv, 스택 등에 여유
js
// 프로그래밍 방식으로 cgroup 제한을 읽어서 자동 설정하는 패턴
const os = require('os');
const fs = require('fs');
 
function getMemoryLimit() {
  try {
    // cgroup v2
    const limit = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
    if (limit !== 'max') return parseInt(limit);
  } catch {}
 
  try {
    // cgroup v1
    const limit = fs.readFileSync(
      '/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf8'
    ).trim();
    const parsed = parseInt(limit);
    // 매우 큰 값이면 제한 없음
    if (parsed < os.totalmem()) return parsed;
  } catch {}
 
  return os.totalmem(); // cgroup 없으면 시스템 전체 메모리
}
 
const limitMB = Math.floor(getMemoryLimit() / 1024 / 1024);
console.log(`Memory limit: ${limitMB}MB`);
// → 이 값의 약 75%를 --max-old-space-size로 설정하는 것이 안전
cgroup 자동 감지

Node.js(V8)는 cgroup 메모리 제한을 감지하여 Heap 상한을 조정하는 기능이 있다 (관련 이슈 #27508). 하지만 cgroup v2 환경에서 감지가 실패하는 버그가 보고된 바 있어, 모든 환경에서 완벽하게 동작하지는 않는다. 프로덕션에서는 --max-old-space-size를 명시적으로 설정하는 것이 권장된다.


메모리 모니터링 실전

각 계층의 메모리를 종합적으로 모니터링하는 방법을 정리한다.

Node.js 레벨

js
// 종합 메모리 모니터링
const v8 = require('v8');
const os = require('os');
 
function memoryReport() {
  const proc = process.memoryUsage();
  const heap = v8.getHeapStatistics();
 
  return {
    // OS 레벨
    rss_mb: (proc.rss / 1024 / 1024).toFixed(1),
    system_total_mb: (os.totalmem() / 1024 / 1024).toFixed(0),
    system_free_mb: (os.freemem() / 1024 / 1024).toFixed(0),
 
    // V8 Heap
    heap_used_mb: (proc.heapUsed / 1024 / 1024).toFixed(1),
    heap_total_mb: (proc.heapTotal / 1024 / 1024).toFixed(1),
    heap_limit_mb: (heap.heap_size_limit / 1024 / 1024).toFixed(0),
 
    // Off-heap
    external_mb: (proc.external / 1024 / 1024).toFixed(1),
    array_buffers_mb: (proc.arrayBuffers / 1024 / 1024).toFixed(1),
 
    // 계산값: "설명 안 되는" 메모리
    // RSS - (heapTotal + external) = libuv, 스택, 코드, page cache 등
    unaccounted_mb: (
      (proc.rss - proc.heapTotal - proc.external) / 1024 / 1024
    ).toFixed(1),
  };
}
 
// 주기적 로깅
setInterval(() => {
  console.log(JSON.stringify(memoryReport()));
}, 30_000);

OS 레벨 (Linux)

bash
# 프로세스 상세 메모리 맵
cat /proc/[PID]/status | grep -E "VmRSS|VmSize|VmSwap|RssAnon|RssFile"
# VmSize:   890000 kB   ← VSZ
# VmRSS:     45000 kB   ← RSS
# RssAnon:   30000 kB   ← 익명 메모리 (Heap, 스택 등)
# RssFile:   15000 kB   ← 파일 매핑 (공유 라이브러리, page cache)
# VmSwap:        0 kB   ← swap된 메모리 (0이 정상!)
 
# 메모리 영역별 상세 맵
pmap -x [PID] | tail -1
# total kB   890000   45000   30000
#            VSZ      RSS     Dirty
 
# cgroup 메모리 사용량 (컨테이너 환경)
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.stat | head -5
RssAnon vs RssFile

RssAnon(Anonymous RSS)은 Heap, 스택, mmap(MAP_ANONYMOUS) 등 파일에 기반하지 않는 물리 메모리다. V8 Heap, Buffer, libuv 할당이 여기에 포함된다. RssFile은 공유 라이브러리(.so), 실행 파일, 파일 mmap 등 파일에 기반한 물리 메모리다. 메모리 릭 분석 시 RssAnon이 계속 올라가는지를 먼저 확인하는 것이 효과적이다.


직접 확인해보기

각 계층의 메모리 사용량을 관찰하는 실험을 해보자.

js
// memory-layers.js
// 실행: node --expose-gc memory-layers.js
 
function printAll(label) {
  global.gc && global.gc();
  const mem = process.memoryUsage();
  const unaccounted = mem.rss - mem.heapTotal - mem.external;
  console.log(`\n[${label}]`);
  console.log(`  RSS:         ${(mem.rss / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  Heap Used:   ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  Heap Total:  ${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  External:    ${(mem.external / 1024 / 1024).toFixed(1)}MB`);
  console.log(`  Unaccounted: ${(unaccounted / 1024 / 1024).toFixed(1)}MB`);
  //                            ↑ libuv, 스택, 코드, page cache 등
}
 
printAll('초기 상태');
 
// 1. JS 객체 → heapUsed 증가
const jsObjects = [];
for (let i = 0; i < 100_000; i++) {
  jsObjects.push({ i, data: 'x'.repeat(100) });
}
printAll('JS 객체 10만 개');
 
// 2. Buffer → external 증가, heapUsed 거의 불변
const buffers = [];
for (let i = 0; i < 50; i++) {
  buffers.push(Buffer.alloc(1024 * 1024)); // 50MB
}
printAll('Buffer 50MB');
 
// 3. TCP 서버 → libuv 핸들 할당 (unaccounted 증가)
const net = require('net');
const server = net.createServer((socket) => {
  // 커넥션마다 uv_tcp_t 핸들이 libuv에서 할당됨
  socket.on('data', () => {});
});
server.listen(0, () => {
  // 자기 자신에게 커넥션 100개 생성
  const connections = [];
  for (let i = 0; i < 100; i++) {
    const client = net.connect(server.address().port);
    connections.push(client);
  }
  setTimeout(() => {
    printAll('TCP 커넥션 100개');
    // → unaccounted가 약간 증가한 것을 관찰할 수 있다
    //   (각 커넥션의 uv_tcp_t + 소켓 버퍼)
 
    // 정리
    connections.forEach(c => c.destroy());
    server.close();
  }, 1000);
});

정리

text
JS → V8 → Node.js → libuv → OS
 
각 계층에서 메모리가 할당되고, 각각 다른 방식으로 추적/관리된다:
 
JS 객체     → V8 Heap      → heapUsed       → V8 GC가 관리
Buffer      → off-heap     → external       → GC + C++ destructor
libuv       → C malloc     → (보이지 않음)   → libuv 내부 관리
OS          → 가상 메모리    → RSS/VSZ        → 커널이 관리
cgroup      → 물리 메모리    → memory.current → OOM Killer
개념핵심기억할 것
libuvNode.js의 I/O 엔진, C로 작성자체 메모리는 heapUsed에 안 잡힘
VSZ가상 주소 공간 전체커도 괜찮다, 실제 사용량이 아님
RSS물리 메모리 사용량OOM 판단의 실제 기준
Page Fault가상 → 물리 매핑 시 발생Major page fault(swap)은 장애 신호
cgroup컨테이너 메모리 제한page cache도 포함됨
OOM KillerRSS가 cgroup 제한 넘으면 SIGKILLexit code 137
--max-old-space-sizecgroup의 ~75%로 설정V8 제한 > cgroup이면 SIGKILL

다음 글에서는 이 모든 지식을 활용해서 실제로 메모리 릭을 잡는 방법을 다룬다. Heap Snapshot, Chrome DevTools, Clinic.js 등 디버깅 도구와 실전 포스트모템까지!


레퍼런스

공식 문서

소스코드

OS / 시스템