avatar
Node.js의 메모리 제한과 누수 추적 가이드
avatar

yceffort

·11

V8 가비지 콜렉션

힙은 메모리 할당이 필요한 곳이고, 이는 여러 generational regions로 나뉜다. 이 region들은 단순히 generations이라고 불리우고, 이 객체들은 라이프 사이클 동안 같은 세대 (generation)을 공유한다.

여기에는 young generationold generation이 있다. 그리고 young generationyoung objects는 또다시 nursery(유아)와 intermediate(중간) 세대로 나뉜다. 이 객체들이 가비지 컬렉션에서 살아남게 되면, older generation에 합류하게 된다.

generation

generation 가설의 기본 원리는 대부분의 객체가 older로 넘어가기 전에 죽는다. (가비지 콜렉팅 당한다)는 것이다. V8 가비지 컬렉터는 이러한 기본적인 가정을 기반으로 설계 되어 있으며, 여기에서 살아남은 객체만 승격하게 된다. 객체는 살아남으면서 다음 영역으로 복사되고, 그리고 결국엔 old generation이 되는 것이다.

node에서 메모리가 소비되는 영역은 크게 세군데로 볼 수 있다.

  • code
  • call stack: 숫자, 문자열, boolean 과 같은 primitive values 또는 함수
  • heap memory

우리는 여기에서 힙 메모리를 중점적으로 볼 것이다.

가비지 콜렉터에 대해 간단히 알아봤으니, 힙에 메모리를 할당해보자.

function allocateMemory(size) {
  // Simulate allocation of bytes
  const numbers = size / 8
  const arr = []
  arr.length = numbers
  for (let i = 0; i < numbers; i++) {
    arr[i] = i
  }
  return arr
}

지역 변수는 함수 호출이 call stack에서 끝나는 즉시 young generation에 있다가 사라지게 된다. 숫자와 같은 기본형 변수들은 힙에 도달하지 못하고 대신 호출 스택에서 할당된다. arr의 경우 힙에 들어가서 가비지 콜렉션에서 살아남을 수 있다.

힙 메모리에 제한이 있을까?

이제 노드 프로세스를 최대 용량으로 밀어넣고, 힙 메모리가 언제쯤 고갈되는지 살펴보자.

const memoryLeakAllocations = []

const field = 'heapUsed'
const allocationStep = 10000 * 1024 // 10MB

const TIME_INTERVAL_IN_MSEC = 40

setInterval(() => {
  const allocation = allocateMemory(allocationStep)

  memoryLeakAllocations.push(allocation)

  const mu = process.memoryUsage()
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024
  const gbRounded = Math.round(gbNow * 100) / 100

  console.log(`Heap allocated ${gbRounded} GB`)
}, TIME_INTERVAL_IN_MSEC)

위 코드는 40ms 간격으로 10메가바이트를 계속 할당하므로, 가비지 콜렉팅에 필요한 시간이 남아있는 객체들을 old generation으로 빠르게 승격시킬 수 있다. process.memoryUsage는 현재 힙 사용률에 대한 지표를 수집할 수 있는 도구다. 힙 할당량이 커지면, heapUsed 필드에서 현재 힙 사이즈를 추적한다.

결과는 실행환경에 따라 다르다. 16gb 메모리가 있는 내 맥에서는 다음과 같은 결과가 나왔다.

...
Heap allocated 3.95 GB
Heap allocated 3.96 GB
Heap allocated 3.97 GB
Heap allocated 3.98 GB
Heap allocated 3.99 GB
Heap allocated 4 GB

<--- Last few GCs --->

[88809:0x130008000]    23137 ms: Scavenge (reduce) 4085.6 (4094.2) -> 4085.6 (4094.2) MB, 1.6 / 0.0 ms  (average mu = 0.855, current mu = 0.691) allocation failure
[88809:0x130008000]    23449 ms: Mark-sweep (reduce) 4095.4 (4104.0) -> 4095.3 (4104.0) MB, 274.1 / 0.0 ms  (+ 138.5 ms in 153 steps since start of marking, biggest step 6.2 ms, walltime since start of marking -558038699 ms) (average mu = 0.740, current m

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

여기에서 가비지 콜렉터는 heap out of memory 예외를 던지기 전에 마지막 수단으로 메모리 압축을 시도하는 것을 볼 수 있다. 이 프로세스는 4.1gb까지 도달했고, 23.1초 정도가 소요 되었다.

메모리 할당량 늘리기

--max-old-space-size 파라미터를 사용하면 크기를 늘릴 수 있다.

node index.js --max-old-space-size=8000

위 커맨드에서는 최대 제한을 8gb로 설정했다. 이 크기를 설정할 때는 조심해야 한다. RAM에 물리적으로 사용가능한 공간을 설정해두는 것이 좋다. 물리적 메모리가 부족하면, 프로세스는 가상 메모리를 통해 디스크 공간을 확보하기 시작한다. 이 제한을 너무 높게 설정하면 PC가 손상될 수 있다.

...
Heap allocated 7.8 GB
Heap allocated 7.8 GB
Heap allocated 7.81 GB

<--- Last few GCs --->

[89239:0x148008000]    51777 ms: Mark-sweep (reduce) 7992.0 (8006.7) -> 7991.8 (8006.7) MB, 2770.5 / 0.0 ms  (+ 106.4 ms in 97 steps since start of marking, biggest step 8.0 ms, walltime since start of marking -558036240 ms) (average mu = 0.302, current m[89239:0x148008000]    54751 ms: Mark-sweep (reduce) 8001.7 (8016.5) -> 8001.6 (8016.5) MB, 2968.3 / 0.0 ms  (average mu = 0.171, current mu = 0.002) allocation failure scavenge might not succeed


<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

프로덕션에서는 메모리가 부족해지는 데에는 1분도 채 걸리지 않을 수 있다. 이것이 메모리 소비량을 계속해서 모니터링하고 파악해야 하는 이유 중 하나다. 메모리 소비량은 시간이 지남에 따라 점차 느리게 증가할 수 있고, 문제가 있다는 것을 알 때 까지 며칠이 더 걸릴 수 잇다. 프로세스가 계속 충돌하고, 메모리 부족 예외가 로그에 표시되면 코드에서 메모리 누수가 발생한 것일 수 있다.

또한 프로세스는 더 많은 데이터로 작업 하기 때문에 더많은 메모리를 소비할 수 있다. 리소스 사용량이 계속 증가하면 이를 마이크로서비스로 분리해야 할 수도 있다. 마이크로 서비스로 분리하면 메모리 부담을 줄이고, 노드를 수평으로 확장할 수 있다.

nodejs의 메모리 누수를 추적하는 방법

process.memoryUsage 함수내 heapUsed 변수는 유용하다. 메모리 누수를 디버깅하는 한가지 방법은 메모리 지표를 다른 도구에 넣어두는 것이다. 그러나 이 구현은 정교하지 않아서 분석을 할 때는 수동으로 해야 한다.

const path = require('path')
const fs = require('fs')
const os = require('os')

const start = Date.now()
const LOG_FILE = path.join(__dirname, 'memory-usage.csv')

fs.writeFile(LOG_FILE, 'Time Alive (secs),Memory GB' + os.EOL, () => {}) // fire-and-forget

힙 할당 지표를 메모리에 저장하지 않기 위해 데이터를 쉽게 사용할 수 있도록 csv 파일에 쓰도록 처리한다. 만약 점진적으로 메모리 지표를 가져오기 위해서는 위 테스트 코드 console.log 상단에 아래 코드를 붙여 두면 된다.

const elapsedTimeInSecs = (Date.now() - start) / 1000
const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100

fs.appendFile(LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {}) // fire-and-forget

이 코드를 사용하면 시간이 지남에 따라, 힙 사용이 증가한다면 메모리 누수를 디버깅할 수 있다.

index.js

function allocateMemory(size) {
  // Simulate allocation of bytes
  const numbers = size / 8
  const arr = []
  arr.length = numbers
  for (let i = 0; i < numbers; i++) {
    arr[i] = i
  }
  return arr
}

const path = require('path')
const fs = require('fs')
const os = require('os')

const memoryLeakAllocations = []

const field = 'heapUsed'
const allocationStep = 10000 * 1024 // 10MB

const TIME_INTERVAL_IN_MSEC = 40

setInterval(() => {
  const allocation = allocateMemory(allocationStep)

  memoryLeakAllocations.push(allocation)

  const mu = process.memoryUsage()
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024
  const gbRounded = Math.round(gbNow * 100) / 100

  const elapsedTimeInSecs = (Date.now() - start) / 1000
  const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100

  fs.appendFile(LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {})
  console.log(`Heap allocated ${gbRounded} GB`)
}, TIME_INTERVAL_IN_MSEC)

./images/memory-usage.png

메모리 누수 감지 코드를 재사용할 수 있게 만드는 방법 중 하나는, 이 누수 감지 코드가 메인 루프 내부에 존재할 필요가 없으므로 이 코드를 자체 간격으로 실행될 수 있도록 래핑하는 것이다.

setInterval(() => {
  const mu = process.memoryUsage()
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024
  const gbRounded = Math.round(gbNow * 100) / 100

  const elapsedTimeInSecs = (Date.now() - start) / 1000
  const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100

  fs.appendFile(LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {}) // fire-and-forget
}, TIME_INTERVAL_IN_MSEC)

이는 운영용 코드로는 쓸 수 없지만, 적어도 로컬 에서 메모리 누수를 디버깅하는 방법을 보여주었다.실제 구현에서는 서버 디스크 공간이 부족하지 않도록 하는 설정, 비주얼, 알림, 로그 rotate 등이 필요하다.

Chrome DevTools로 힙 스냅샷 분석하기

--inspect 플래그를 사용하면 Chrome DevTools에서 힙 스냅샷을 직접 분석할 수 있다. 이 방법이 메모리 누수를 추적하는 가장 강력한 방법이다.

node --inspect index.js

이후 Chrome에서 chrome://inspect를 열고 해당 Node.js 프로세스에 연결하면 된다. Memory 탭에서 힙 스냅샷을 찍고, 시간 간격을 두고 두 번째 스냅샷을 찍은 뒤 비교하면 어떤 객체가 해제되지 않고 누적되고 있는지 확인할 수 있다.

v8.getHeapStatistics()로 상세 힙 정보 확인

process.memoryUsage()보다 더 상세한 V8 힙 정보가 필요하다면 v8 모듈을 사용할 수 있다.

const v8 = require('v8')

const heapStats = v8.getHeapStatistics()
console.log({
  total_heap_size: `${(heapStats.total_heap_size / 1024 / 1024).toFixed(2)} MB`,
  used_heap_size: `${(heapStats.used_heap_size / 1024 / 1024).toFixed(2)} MB`,
  heap_size_limit: `${(heapStats.heap_size_limit / 1024 / 1024).toFixed(2)} MB`,
  malloced_memory: `${(heapStats.malloced_memory / 1024 / 1024).toFixed(2)} MB`,
  external_memory: `${(heapStats.external_memory / 1024 / 1024).toFixed(2)} MB`,
})

heap_size_limit을 확인하면 현재 프로세스의 최대 힙 크기를 알 수 있다. 참고로 Node.js의 기본 힙 크기는 버전과 시스템 메모리에 따라 다르다. Node.js 12 이후부터는 시스템 가용 메모리에 따라 동적으로 결정되며, 보통 1.5GB ~ 4GB 정도로 설정된다.

프로덕션 코드에서 메모리 누수 추적하기

위 코드를 프로덕션에서 그대로 쓰는 것은 무리일 것이다. 프로덕션에서는 PM2와 같은 데몬 프로세스를 활용하여 메모리 초과 시 자동으로 재시작하도록 설정할 수 있다.

pm2 start index.js --max-memory-restart 8G

Node.js 내장 기능인 Diagnostic Report도 유용하다. 프로세스 상태, 힙 정보, 네이티브 스택 등을 JSON 리포트로 출력한다.

# OOM 발생 시 자동으로 리포트 생성
node --report-on-fatalerror index.js

# 시그널로 수동 트리거
node --report-on-signal index.js
# 다른 터미널에서: kill -USR2 <pid>

리포트에는 javascriptHeap 섹션이 포함되어 있어 OOM 시점의 힙 상태를 사후 분석할 수 있다.

요약

  • V8은 세대별 가비지 컬렉션을 사용하며, 대부분의 객체는 young generation에서 수거된다.
  • 기본 힙 크기는 시스템 메모리에 따라 동적으로 결정되며, --max-old-space-size로 조정할 수 있다.
  • 메모리 누수 디버깅에는 --inspect를 통한 Chrome DevTools 힙 스냅샷이 가장 효과적이다.
  • 프로덕션에서는 PM2 자동 재시작, Diagnostic Report, APM 도구 등을 조합하여 모니터링한다.