웹 개발
메모리 사용량이 늘어나는 원인을 체계적으로 찾기
서버의 메모리가 자꾸 늘어날 때, 어느 프로세스가 문제인지 빠르게 진단하고 메모리 누수를 추적하는 방법.
운영 서버에서 메모리 사용량이 점점 늘어난다. 어제는 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)
운영 환경의 로그에 메모리 사용량을 정기적으로 기록하면, 나중에 추세를 분석할 수 있다.
빠른 진단 순서
free -h또는watch free- 실시간 메모리 추이 보기top또는ps aux- 어느 프로세스가 문제인지- 앱 로그 - 에러나 경고가 있나
- 프로세스의 VmRSS - 실제 물리 메모리 (가상이 아닌)
- 캐시 크기 확인 - 제한이 있는가
- 힙 덤프 (Node.js) - 뭐가 메모리를 차지하나
이 순서대로 가면, 대부분의 메모리 누수를 30분 안에 찾을 수 있다.