← 전체 글로 돌아가기

웹 개발

메모리 사용량이 늘어나는 원인을 체계적으로 찾기

서버의 메모리가 자꾸 늘어날 때, 어느 프로세스가 문제인지 빠르게 진단하고 메모리 누수를 추적하는 방법.

운영 서버에서 메모리 사용량이 점점 늘어난다. 어제는 30%였는데 오늘은 50%, 이틀 뒤엔 70%다. 이런 패턴은 메모리 누수를 시사한다.

하지만 "메모리 누수"라고 해서 뭘 해야 할지 모르면, 결국 일단 재시작하고 본다. 이건 임시 방편일 뿐, 근본 원인은 남는다.

1단계: 실시간 메모리 사용 모니터링

# 가장 간단한 방법
free -h

# 시간에 따른 변화 보기
while true; do clear; free -h; date; sleep 10; done

# 또는 watch 명령
watch -n 1 'free -h'

메모리가 정말 계속 늘어나는가, 아니면 한 번에 올라가고 유지되는가? 이것만으로도 패턴을 알 수 있다.

계속 늘어나면 메모리 누수 가능성이 높다. 한 번 올라가고 유지되면, 초기 로딩이나 캐시 때문일 수도 있다.

2단계: 프로세스별 메모리 사용량

# 가장 많이 쓰는 프로세스
ps aux --sort=-%mem | head -10

# 또는 top 명령 (interactive)
top
# 'd' 누르고 2 (2초마다 새로고침)
# 'M' 누르고 메모리 기준 정렬

# PID로 깊게 보기
ps -p <PID> -o pid,ppid,cmd,%mem,rss

어느 앱이 얼마나 메모리를 쓰는가? 예상과 다른가?

3단계: 특정 프로세스의 메모리 추적

Node.js 앱이라고 가정하면:

# Node.js 힙 메모리 확인
node --max-old-space-size=4096 app.js

# 실행 중인 프로세스의 힙 크기
cat /proc/<PID>/status | grep VmPeak
cat /proc/<PID>/status | grep VmRSS

VmPeak는 최대 사용했던 메모리, VmRSS는 현재 물리 메모리 사용량이다.

4단계: 힙 덤프 뜨기 (Node.js)

메모리 누수를 의심하면, 힙 덤프로 뭐가 메모리를 차지하는지 본다:

# 실행 중인 Node.js 프로세스의 힙 덤프
node --inspect=9229 app.js

# Chrome DevTools에서
# chrome://inspect -> 프로세스 클릭 -> Memory 탭
# Heap snapshot 찍기

또는 프로그래매틱하게:

const heapdump = require('heapdump')

// 매일 자정에 힙 덤프
setInterval(() => {
  const filename = `heaps/heap-${new Date().toISOString()}.heapsnapshot`
  heapdump.writeSnapshot(filename)
}, 24 * 60 * 60 * 1000)

힙 덤프를 분석하면, 뭐가 메모리를 점유하는지 알 수 있다. 예를 들어 캐시 크기가 제한 없이 늘어났다거나, closure 때문에 대량의 객체가 GC되지 않았다거나.

5단계: 캐시 크기 확인

// 의심되는 캐시 모니터링
const cache = new Map()

// 정기적으로 크기 출력
setInterval(() => {
  console.log(`Cache size: ${cache.size} items`)
}, 60000)  // 1분마다

캐시가 무한정 늘어나는가? 제한이 있는가?

// 좋은 패턴: LRU 캐시
const LRU = require('lru-cache')
const cache = new LRU({
  max: 500,  // 최대 500개 항목
  maxAge: 1000 * 60 * 60  // 1시간
})

6단계: 데이터베이스 연결 풀

// Prisma
const prisma = new PrismaClient({
  log: ['info'],
})

// 연결 풀의 상태 보기
const status = await prisma._engine.constructor._instanceStatus?.()

// 또는 직접 카운팅

DB 연결이 닫히지 않고 계속 열리면, 메모리 누수처럼 보인다.

7단계: 외부 API 응답 누적

// 나쁜 패턴: 모든 응답을 메모리에 저장
const responses = []

async function fetchData() {
  const data = await fetch('...')
  responses.push(data)  // 계속 누적됨!
}

// 좋은 패턴: 스트림으로 처리
const stream = await fetch('...')
stream.pipe(processStream)

네트워크 요청의 응답을 전부 메모리에 저장하면, 요청이 늘어날수록 메모리도 늘어난다.

8단계: 타이머와 이벤트 리스너

// 나쁜 패턴: 이벤트 리스너를 제거하지 않음
emitter.on('data', handler)

// 요청마다 리스너가 계속 추가됨
router.post('/api/process', (req, res) => {
  emitter.on('data', handler)  // 매번 추가!
})

// 좋은 패턴: 한 번만 등록하거나 제거
emitter.once('data', handler)
// 또는
emitter.off('data', handler)

setInterval, setTimeout이 정리되지 않아도 마찬가지다.

9단계: 메모리 누수 패턴 정리

  • 무한정 늘어나는 캐시: max 크기 제한 설정
  • 닫히지 않는 연결: 연결 풀 제한 설정
  • 제거되지 않는 리스너: once() 또는 off() 사용
  • closure에 붙잡힌 큰 객체: 스코프 정리

최소한의 모니터링 코드

setInterval(() => {
  const mem = process.memoryUsage()
  console.log(`Memory: ${(mem.heapUsed / 1024 / 1024).toFixed(2)}MB / ${(mem.heapTotal / 1024 / 1024).toFixed(2)}MB`)
}, 60000)

운영 환경의 로그에 메모리 사용량을 정기적으로 기록하면, 나중에 추세를 분석할 수 있다.

빠른 진단 순서

  1. free -h 또는 watch free - 실시간 메모리 추이 보기
  2. top 또는 ps aux - 어느 프로세스가 문제인지
  3. 앱 로그 - 에러나 경고가 있나
  4. 프로세스의 VmRSS - 실제 물리 메모리 (가상이 아닌)
  5. 캐시 크기 확인 - 제한이 있는가
  6. 힙 덤프 (Node.js) - 뭐가 메모리를 차지하나

이 순서대로 가면, 대부분의 메모리 누수를 30분 안에 찾을 수 있다.