메모리 누수 사냥기: 그래프 하나를 두고 열두 번 의심하기
‘React 성능·메모리’ 시리즈의 실전편. 원인 하나를 규명하는 데 열두 번 의심하고, 그중 몇 번은 헛다리였다. 그 사냥 기록이다.
관측에 쓴 부하·모니터링 스택(k6 + Prometheus + Grafana)은 동료가 세팅해 준 걸 썼고, 이 글은 그 그래프 위에서 뭘 의심하고, 뭘 고치고, 뭐가 헛다리였나의 기록이다.
판정 기준: 톱니냐 계단이냐
시작은 차량상세 웹이었다. 메모리 누수 = 안 쓰는 메모리를 계속 붙잡고 있는 현상. Grafana의 힙 그래프(nodejs_heap_size_used_bytes) 모양으로 판정한다.
- 톱니 = 정상. 부하로 오르다 GC로 뚝 떨어지고 베이스라인 복귀.
- 계단 = 누수. GC 후에도 베이스라인이 계속 올라감 → 결국 OOM.
계단이 보이면, 이제 “어디서 새나”를 하나씩 소거하는 싸움이다. 아래는 실제로 밟은 접근들이다.
클라이언트/컴포넌트 쪽 의심
- API 호출에 urlParams를 안 쓰던 문제 — 요청 키가 뭉개지면 캐시가 엉뚱하게 쌓인다. 호출 파라미터부터 점검.
- JS 메모리 누수 4대 유형 재점검 — 안 풀린 타이머, 남은 이벤트 리스너/옵저버, 전역에 매달린 참조, 클로저 캡처. 교과서지만 실전에선 여기서 다 난다.
- Zustand 스토어 최적화 — 셀렉터를 좁혀 불필요한 구독/보관을 줄임.
- 공통
Image컴포넌트 —@myorg/common-react-components의Image를 걷어내고/React.memo로 감쌌더니 그래프가 눈에 띄게 달라졌다. 리스트에서 이미지가 재생성되며 잡고 있던 메모리가 컸다. - 무한 스크롤의 리스트 아이템 누적 — 스크롤로 쌓인 아이템이 언마운트돼도 참조가 안 풀리면 계단이 된다.
해결된 케이스: observer를 1개로 (차량상세 웹 완료)
가장 명확하게 잡힌 건 IntersectionObserver 남발이었다. 아이템마다 옵저버를 만들고, contextAPI로 들고 다니던 옵저버가 정리되지 않고 쌓였다. 한 번에 고친 게 아니라 순서대로 정리했다.
- 1단계 — SharedObserverContext 도입: 각자 돌던 옵저버를 공통 하나로 묶어 중복 계산 제거.
- 2단계 — observer target 정리 + observer context 자체 제거.
- 3단계 —
IntersectionHandler로 타입 이전 + 미사용 훅 제거.
무거운 컴포넌트는 dynamic import로 분리했다. → 차량상세 웹은 이 조합으로 해결 완료. limit(800MB)까지 계단으로 치받던 그래프가, 이후엔 300~400MB 대에서 평평하게 눌러앉았다(limit 대비 400MB 이상 여유). 더 확실한 증거는 그다음 기록이다 — 이 레포엔 2025년 7월 이후로 메모리 관련 작업을 한 줄도 더 하지 않았다. “안 건드려도 되는 상태”가 됐다는 게 곧 안정화의 증거다.
클로저 캡처도 같은 결이다. 커뮤니티 쪽에선 큰 객체를 클로저에 캡처한 게 잡혔다.
useCallback/이벤트 핸들러가 큰 객체를 참조로 물고 있으면, 그 콜백이 사는 동안 객체 전체가 안 죽는다. (이건 시리즈의 ‘useCallback과 클로저’ 편과 같은 병이다.)
서버(Node.js) 쪽 의심
클라이언트를 다 훑어도 서버 그래프가 안 잡히면, 무대는 Node.js 프로세스다. SSR·ISR·RSC가 전부 한 프로세스에서 도니까, 요청 스코프를 넘긴 참조는 그 프로세스 힙에 그대로 쌓인다.
- SSR 환경 디버깅 — 서버에서 도는 코드가 브라우저 전용 API를 붙잡거나 pending Promise로 매달리는지.
- server-side Query Client의
gcTime/staleTime— 서버에서 만든 query client 캐시가 안 비워지면 요청마다 자란다. gcTime/staleTime을 손보고 그래프 변화를 관측했다. - Server Component에서의 메모리 누적 / ISR 인메모리 캐시 — 상한 없는 서버 캐시는 SSR의 시한폭탄. (자세한 건 ‘SSR 환경에서의 OOM’ 편에서 다뤘다.)
헛다리도 기록한다
사냥에서 제일 값진 건 아니라고 판명난 가설이다.
- “접속자 수가 줄면 메모리도 줄어야 한다” (
중고차 웹) — 유저 감소와 힙 그래프 감소의 상관성을 봤는데, 결론: 관계 없음. 유저가 빠져도 그래프가 안 내려온다는 건, 누수가 요청량이 아니라 프로세스 수명에 붙어 있다는 신호였다. 헛다리였지만 방향을 좁혀 줬다. - ISR 인메모리 캐시를 아예 끄기(
isrMemoryCacheSize: 0) — 메모리는 잡혔지만 응답이 느려져서 되돌리고 50MB 상한으로. - Node 힙 상한 올리기(
--max-old-space-size) — OOM 빈도는 줄지만 근본 해결이 아니라 시간벌기. 계단이면 양동이만 키운 셈.
정리
- 재현 가능한 그래프 하나(k6 부하 → Grafana 힙)를 기준으로, 가설을 하나씩 소거했다.
- 확실히 잡은 것: 옵저버 1개화 + dynamic import(차량상세 웹 완료), 공통
ImageReact.memo, 클로저 캡처 정리, server-side query client gc 설정. - 헛다리: 유저 수 상관성(관계 없음), 캐시 완전 끄기(지연), 힙 상한 상향(미봉책).
- 아직 진행 중: Server Component/ISR 누적처럼 서버 쪽에 남은 계단.
교훈은 하나다. “안 새는 걸 증명”하는 게 아니라 “새는 지점을 하나씩 지운다.” 그리고 헛다리도 반드시 적어둔다 — 다음 사람이 같은 데서 안 헤매게.
관련 작업
- 조사 노트: 「Sneaky React Memory Leaks / 메모리 누수 접근 1~12」 — 차량상세 웹·중고차 웹·community·금융솔루션 어드민에 걸친 가설·검증 로그(원작자).
- 최초 알림:
사내 알림 채널에서 차량상세 웹·중고차 웹 파드 재시작·메모리 경고(7/31부터 증가) 제기 → 조사 착수. 차량상세 웹observer 정리 체인: SharedObserverContext 도입(공통 옵저버·중복 계산 제거) → observer context 제거 → IntersectionHandler 이전·미사용 훅 제거. 2025-07 이후 이 레포 메모리 작업 없음 = 안정화.- 서버 쪽: (ISR 인메모리 캐시 off·server-side query client 캐시), isrMemoryCacheSize 50MB 상한, 렌더 단계 옵셔널 체이닝.
- 부하·모니터링 스택(k6·Prometheus·Grafana
Memory Usage/nextjs-memory대시보드)과 Node 힙/GC 튜닝은 동료가 구축. 관측은 그 위에서, 원인 규명·수정이 내 몫.