메모리 누수 해결을 위한 여정 - 서버사이드 편
제한된 힙 메모리 환경에서 부하 테스트를 돌려 서버사이드 메모리 누수의 직접 원인을 찾아낸 과정 — 결국 한 컴포넌트의 gcTime이 범인이었습니다.
운영 중인 Next.js 애플리케이션에서 정적 리소스를 CDN으로 옮겨 서버 부하를 덜고, 클라이언트측 누수 후보들도 하나씩 정리했습니다. 그래도 운영 환경에서 메모리는 여전히 꾸준히 우상향하고 있었습니다. 이번 글에서는 재현 환경을 만들고, 그 안에서 실제 원인 한 점을 찾아낸 과정을 정리합니다.
부하 테스트 환경 구성
메모리 누수를 의도적으로 재현하기 위해, 힙 메모리를 강제로 줄인 빌드 옵션을 추가했습니다.
{
"scripts": {
"build": "next build",
"start:memory-test": "NODE_OPTIONS='--max-old-space-size=128 --heapsnapshot-signal=SIGUSR2 --inspect' next start"
}
}--max-old-space-size=128— 힙 메모리를 128MB로 제한--heapsnapshot-signal=SIGUSR2—SIGUSR2로 힙 스냅샷 트리거--inspect— 디버깅 모드 활성화
홈 화면이 누수의 시작점으로 의심됐기 때문에, 해당 페이지에 반복 요청을 쏘는 단순한 부하 테스트를 돌렸습니다.
for i in {1..500}; do curl http://localhost:3000/ko; done정상 응답을 일정 횟수 반복하다가 특정 시점부터 터미널에 익숙한 에러가 떴습니다. 운영 서버에서 발생했던 것과 동일한 OOM 패턴이었습니다.


원인 좁히기
재현이 가능해졌으니 이진 탐색식으로 코드를 줄여나갔습니다. 정적인 로직은 그대로 두고, 의심되는 컴포넌트만 하나씩 빼면서 어느 시점에 OOM이 사라지는지 확인했습니다.
그 결과 한 컴포넌트가 들어갈 때만 에러가 재현됐습니다. 바로 홈 화면 진입 시 노출되는 메인 팝업 컴포넌트였습니다.
코드를 들여다보니 TanStack Query로 데이터를 불러오는데, 옵션값이 다음과 같이 설정되어 있었습니다.
{
staleTime: 12 * 60 * 60 * 1000, // 12시간
gcTime: 24 * 60 * 60 * 1000, // 24시간
}옵션을 하나씩 제거하며 테스트했습니다.
staleTime만 제거 → 여전히 OOM 발생gcTime까지 제거 → 정상 동작
즉, 24시간으로 설정된 gcTime이 직접 원인이었습니다. 컴포넌트 단위에서 캐시가 사실상 영구 보관되며 메모리가 누적되고 있었습니다.
적용 결과
Before

After


앞서 전역 QueryClient의 gcTime을 30분 → 5분으로 줄여 두었고, 여기에 더해 컴포넌트 단위에서 길게 잡혀 있던 옵션도 제거했습니다. 두 변경의 합으로 부하 테스트와 운영 환경 모두에서 메모리가 안정적으로 회수되는 것을 확인했습니다.
의문점 — gcTime은 트래픽이 클수록 더 위험하다
gcTime이 동작하는 원리는 다음과 같습니다.
- 컴포넌트가 unmount → 해당 쿼리가
inactive상태로 전환 - 그 시점부터
gcTime카운트다운 시작 - 카운트다운 동안 동일한 요청이 들어오지 않으면 GC가 캐시를 회수
문제는 카운트다운이 끝나기 전에 같은 요청이 들어오면 카운트다운이 다시 리셋된다는 점입니다. po 도메인의 일평균 접속자 수를 확인해 보니 일평균 2000회 전후로 측정됐습니다.

이 정도 트래픽에서도 24시간 gcTime이 직접 원인이 됐다는 건, 트래픽이 더 많은 대규모 서비스에서는 긴 gcTime을 절대 쓸 수 없다는 뜻이기도 합니다. 5분으로 줄인 지금 설정도 트래픽이 더 늘어난다면 다시 검토해야 할 항목입니다.
캐시 옵션은 "성능을 위해 길게 잡는 것"이 아니라 다음 GC 시점에 진짜 회수가 일어날 수 있는 길이여야 합니다.
다만 이 글을 쓰고 나서도 풀리지 않은 질문이 하나 남았습니다. "왜 클라이언트에서는 같은 옵션이 문제를 일으키지 않았을까?" 결과는 맞췄지만 이 글에서 설명한 메커니즘(컴포넌트 unmount → 카운트다운 → 트래픽으로 리셋)이 SSR 환경을 충분히 설명하지 못했기 때문입니다. 다음 글 — gcTime 한 줄이 SSR을 죽인 진짜 이유에서는 TanStack Query 소스 두 파일을 직접 열어 SSR 분기의 실제 동작을 따라가며, 이 글의 설명 일부를 정정합니다.