
서버 프로세스가 매일 같은 시간대에 죽었다 살아나는 이유가 궁금해서 검색하셨다면, 답부터 말씀드립니다. 대부분은 애플리케이션 내부의 메모리 누수 때문이 아니라 OOM 킬러나 systemd 재시작 정책이 증상만 가리고 있는 경우가 많습니다. 원인을 좁히려면 “누수냐 아니냐”를 먼저 그래프로 확인하고, 그다음 트래픽 조건별로 재현 범위를 줄이고, 마지막에 런타임별 프로파일러로 지점을 찾는 순서가 효율적입니다.
이 글은 특정 프레임워크 광고가 아니라, Node.js·JVM·Python·Go 환경에서 공통으로 쓸 수 있는 진단 순서와 실제 동작하는 코드 예시를 정리한 글입니다. cron으로 매일 재시작을 걸어둔 상태에서 근본 원인을 찾으려는 분을 기준으로 썼습니다.
서버 프로세스가 매일 재시작해야 한다면 가장 먼저 확인할 것
가장 먼저 볼 것은 재시작이 애플리케이션이 직접 죽인 것인지, 아니면 외부에서 강제로 죽인 것인지입니다. Linux에서는 커널의 OOM 킬러가 메모리가 부족해지면 점수가 높은 프로세스를 강제 종료하는데, 이때 dmesg나 /var/log/syslog에 Out of memory: Killed process라는 기록이 남습니다.
systemd로 서비스를 관리하고 있다면 systemctl status <서비스명> 결과에 Main PID exited, status=... (OOM-kill) 같은 문구가 있는지부터 확인하는 것이 좋습니다. 이 로그가 있다면 매일 반복되는 재시작의 1차 원인은 메모리 초과가 맞고, 그다음 질문은 “왜 특정 시간에 초과되는가”로 좁혀집니다. 반대로 이 로그가 없다면 애플리케이션 자체 크래시나 워치독 스크립트, 혹은 cron으로 걸어둔 강제 재시작 때문일 수 있으므로 방향이 완전히 달라집니다.
메모리 증가 그래프부터 그려보세요
진짜 메모리 누수인지, 아니면 캐시나 커넥션 풀이 정상적으로 커진 것인지는 그래프 모양으로 구분됩니다. 트래픽이 줄어드는 새벽 시간에도 RSS(Resident Set Size)가 함께 떨어지지 않고 계속 우상향한다면 누수 쪽에 무게가 실립니다.
Prometheus나 Grafana가 없어도 아래처럼 1분 간격으로 /proc/[pid]/status의 VmRSS를 크론으로 기록하면 최소한의 추세는 확인할 수 있습니다.
*/1 * * * * grep VmRSS /proc/$(pgrep -f "node server.js" | head -1)/status >> /var/log/rss_trend.log 2>&1

하루치 로그를 모은 뒤 값이 계단식으로 뛰는 시점이 있는지 보세요. 특정 배치 작업이나 특정 API 호출 직후에 계단이 생긴다면, 3단계에서 그 지점을 좁혀서 재현하면 됩니다. 반대로 완만하게 선형으로만 증가한다면 커넥션이나 타이머, 이벤트 리스너가 정리되지 않고 계속 쌓이는 패턴을 의심해야 합니다.
재현 조건을 좁히는 순서 — 트래픽, 엔드포인트, 시간대
전체 트래픽을 대상으로 프로파일러를 돌리면 노이즈가 너무 많아서 원인이 잘 안 보입니다. 아래 순서대로 범위를 좁혀가는 편이 훨씬 빠릅니다.
- 엔드포인트 단위로 분리: 접근 로그에서 요청량이 많은 엔드포인트 상위 5개만 따로 부하 테스트 도구(k6, autocannon 등)로 반복 호출하면서 메모리 추이를 봅니다.
- 배치·스케줄러 분리: 크론으로 도는 배치 작업이 있다면 그 시간대와 메모리 계단 상승 시점이 겹치는지 대조합니다.
- 동시성 조건 분리: 동시 요청 수를 늘렸을 때만 문제가 커진다면 커넥션 풀이나 워커 스레드 쪽을 의심합니다.
이 단계에서 “특정 엔드포인트를 반복 호출하면 힙이 안 줄어든다”는 사실만 확인해도, 4단계 프로파일링 시간을 절반 이하로 줄일 수 있습니다.
런타임별로 누수 지점을 찾는 도구와 코드 예시
범위를 좁혔다면 이제 런타임에 맞는 도구로 실제 객체가 어디서 쌓이는지 봅니다. 아래 표는 런타임별로 1차 확인 도구와 상세 분석 도구를 정리한 것입니다.
| 런타임 | 1차 확인 | 상세 분석 | 비고 |
|---|---|---|---|
| Node.js | process.memoryUsage() |
--inspect + Chrome DevTools 힙 스냅샷, heapdump 패키지 |
--trace-gc 플래그로 GC 빈도 확인 가능 |
| JVM(Java, Kotlin) | jstat -gc <pid> 1000 |
jmap -dump:live,format=b,file=heap.hprof <pid> + Eclipse MAT |
-XX:+HeapDumpOnOutOfMemoryError 미리 설정 |
| Python | psutil.Process().memory_info() |
tracemalloc |
tracemalloc.start() 이후 스냅샷 비교 |
| Go | runtime.ReadMemStats |
net/http/pprof + go tool pprof |
고루틴 누수도 같이 확인 가능 |
Node.js에서는 process.memoryUsage()로 간단히 추세를 로그로 남길 수 있습니다.

setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
rss: Math.round(m.rss / 1024 / 1024) + 'MB',
heapUsed: Math.round(m.heapUsed / 1024 / 1024) + 'MB',
external: Math.round(m.external / 1024 / 1024) + 'MB',
}));
}, 60000);
// 출력 예시
// {"rss":"210MB","heapUsed":"98MB","external":"12MB"}
// {"rss":"215MB","heapUsed":"99MB","external":"12MB"}
// {"rss":"260MB","heapUsed":"142MB","external":"12MB"} ← heapUsed가 같이 뛴 시점이 원인 후보
heapUsed와 rss가 같이 뛰는 시점이 있다면 JS 객체가 늘어나는 것이고, heapUsed는 그대로인데 rss만 뛴다면 네이티브 애드온이나 버퍼 쪽을 의심해야 합니다. Python에서는 tracemalloc으로 어느 코드 줄이 메모리를 물고 있는지 바로 확인할 수 있습니다.
import tracemalloc
tracemalloc.start()
# ... 요청 처리 로직 실행 ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:5]:
print(stat)
# 출력 예시
# app/cache.py:42: size=45.2 MiB, count=120032, average=394 B
이렇게 파일명과 줄 번호가 바로 나오기 때문에, 캐시에 넣은 객체를 지우지 않고 계속 append만 하는 코드 같은 패턴을 빠르게 찾아낼 수 있습니다.
힙은 안 늘어나는데 왜 RSS는 계속 커지나요?
프로파일러로 힙을 찍어봐도 이상이 없는데 프로세스 전체 메모리(RSS)만 계속 커지는 경우가 실무에서 꽤 흔합니다. 이럴 때는 JS 힙이나 애플리케이션 힙이 아니라, 그 아래 있는 glibc의 malloc 동작을 의심해볼 차례입니다.
glibc의 기본 메모리 할당자는 스레드마다 별도의 arena를 두고, 한 번 커널로부터 받아온 메모리 블록을 애플리케이션이 다 쓰지 않아도 곧바로 OS에 반환하지 않는 특성이 있습니다. 멀티스레드로 짧은 수명의 객체를 자주 할당·해제하는 워커 구조(Java 스레드풀, Node.js의 libuv 워커 등)에서 이 현상이 두드러집니다. 이 경우 환경변수 MALLOC_ARENA_MAX=2나 MALLOC_TRIM_THRESHOLD_를 조정해 arena 수를 줄이면 RSS 증가 폭이 줄어드는 경우가 있는데, 애플리케이션 코드를 바꾸는 게 아니라 런타임 옵션 조정이라는 점에서 4단계 프로파일링과는 별개의 트랙으로 다뤄야 합니다.
지금 서버에서 바로 시작할 수 있는 점검 순서
정리하면 순서는 “OOM 로그 확인 → RSS 추세 그래프화 → 엔드포인트·배치·동시성 조건으로 재현 범위 좁히기 → 런타임별 프로파일러로 지점 특정 → 힙은 정상인데 RSS만 늘면 glibc arena 설정 점검”입니다. 다만 이 순서는 만능은 아닙니다. 트래픽 자체가 예측 불가능하게 튀는 서비스라면 재현 조건을 좁히는 3단계가 거의 불가능하고, 이런 경우엔 차라리 메모리 임계치에 도달하면 안전하게 재시작하는 헬스체크 정책을 먼저 세우는 편이 현실적입니다.
오늘 당장 할 수 있는 것은 앞서 소개한 /proc/[pid]/status 크론 로그를 걸어두고 내일 재시작 시점까지 데이터를 모으는 일입니다. 그 로그 하나만으로도 누수인지 정상 증가인지는 대부분 구분이 됩니다.
