Your app runs fine for ten minutes. Then the scroll gets sticky. After half an hour the tab hits a gigabyte. Eventually the page dies with an out-of-memory error and you have no stack trace to show for it.
이것은 렌더링 성능 문제가 아닙니다. React DevTools Profiler는 조용해 보일 것입니다. 문제는 컴포넌트가 얼마나 자주 다시 그려지는지가 아니기 때문입니다. 문제는 컴포넌트가 unmount된 후에도 무엇이 살아남느냐 하는 것입니다. JavaScript heap 어딘가에 남아 있는 잘못된 참조(stray reference)가 DOM 노드, closure, state로 이루어진 전체 트리를 붙잡고 있습니다. 브라우저는 이 중 어떤 것도 회수할 수 없으므로, 프로세스가 붕괴될 때까지 메모리가 계속 상승합니다.
소스 코드를 읽는 것만으로는 누수(leak)를 찾아낼 수 없습니다. 버그는 당신이 unmount되었다고 생각하는 것과 가비지 컬렉터(garbage collector)가 실제로 보는 것 사이의 간극에 존재합니다. V8은 retaining path가 0인 객체만 해제합니다. 만약 rogue event listener, 해제되지 않은 observer, 또는 오래 지속되는 closure가 fiber나 DOM 노드에 대한 포인터를 단 하나라도 붙잡고 있다면, 컴포넌트 서브트리 전체가 살아남습니다. 모달을 unmount하더라도, window에 등록된 listener가 해당 모달 내부에서 정의된 handler를 여전히 가리키고 있다면, 분리된(detached) 노드들은 메모리에 그대로 남게 됩니다.
누수가 어디에서 발생하는지 증명하려면 에디터가 아니라 heap을 살펴봐야 합니다.
Heap이 진실을 말해주는 이유
Chrome DevTools는 가비지 컬렉터가 보는 내용을 직접 들여다볼 수 있는 창을 제공합니다. Memory 탭에서는 heap snapshot을 기록할 수 있습니다. 이는 현재 JavaScript 메모리에 유지되고 있는 모든 객체, DOM 노드, closure의 전체 목록입니다. 누수가 의심되는 시점 전후의 두 snapshot을 비교함으로써, 어떤 객체가 해제되지 않았는지 정확히 찾아낼 수 있습니다.
이것은 추상적인 이론이 아닙니다. 누수된 단 하나의 React 컴포넌트가 수천 개의 detached HTMLElement 객체를 유지할 수 있습니다. 이 객체들은 더 이상 눈에 보이는 document에 연결되어 있지 않지만, JavaScript 참조 때문에 수집(collected)되지 못합니다. 이들은 comparison view에서 Detached HTMLElement라는 생성자 이름으로 나타납니다. 이 객체들이 급격히 늘어나는 것을 발견했다면, 바로 누수를 찾은 것입니다.
Chrome DevTools 워크플로우
깨끗한 상태에서 시작하세요. 관련 없는 브라우저 탭을 닫고, 관련 없는 확장 프로그램을 비활성화한 뒤, 애플리케이션이 안정적인 상태(steady state)에 도달하도록 둡니다. Chrome DevTools를 열고 Memory 탭으로 전환한 뒤 Heap snapshot을 선택합니다. Take snapshot을 클릭합니다. 이 baseline은 초기 메모리 사용량(memory footprint)을 캡처합니다.
이제 누수가 의심되는 정확한 사용자 동작을 수행합니다. 무거운 모달을 열었다 닫거나, 위젯을 mount/unmount 하거나, 특정 route로 이동했다가 다시 돌아옵니다. UI가 원래의 시각적 상태로 돌아오면, Memory 탭의 휴지통 아이콘을 클릭합니다. 이는 전역 가비지 컬렉션을 강제로 실행합니다. 렌더링 사이클에서 발생한 임시 객체들은 사라져야 합니다. 그 후에도 남아 있는 것이 있다면 그것이 바로 실제 누수 후보입니다.
다시 Take snapshot을 클릭합니다. 이제 메모리의 두 장의 사진을 갖게 되었습니다. 뷰를 Summary에서 Comparison으로 변경합니다. comparison scope를 첫 번째 snapshot으로 설정합니다. 그러면 툴은 런타임의 노이즈를 제거하고 두 캡처 사이에서 변경된 내용만 보여줍니다.
Delta 순으로 정렬합니다. 객체 수가 증가한 항목을 찾으세요. Detached HTMLElement, Array, Function, 또는 본인의 코드베이스에 정의된 이름 있는 클래스 인스턴스와 같은 생성자에 특별히 주의를 기울이십시오. Delta 값이 상승했다는 것은 동작 중에 객체가 생성되었으나 그 이후에 수집되지 않았음을 의미합니다.
Retaining Path 추적하기
누수된 요소를 발견하면 해당 요소를 선택합니다. 하단 패널에 retaining path가 표시됩니다. 이는 이 객체가 왜 여전히 살아있는지를 설명하는 참조 체인(chain of references)입니다. 이 체인은 분리된 div에서 시작하여 React 내부 속성을 거쳐 closure로 이어지고, 최종적으로 컴포넌트 내부에 등록된 event listener에 도달할 수 있습니다. 체인의 마지막 연결 고리가 바로 당신이 찾아야 할 코드의 라인 번호입니다.
여기서 진단에서 근본 원인 파악 단계로 넘어갑니다. 만약 retaining path가 window.addEventListener에서 끝난다면, 전역 listener가 컴포넌트를 붙잡고 있다는 것을 알 수 있습니다. 만약 IntersectionObserver 인스턴스에서 끝난다면, 가비지 컬렉션되었어야 할 노드를 observer가 여전히 감시하고 있다는 뜻입니다.
React에서의 흔한 원인들
React에서의 메모리 누수는 보통 세 가지 패턴으로 나타납니다.
고립된 전역 listener. useEffect가 스크롤 위치, 키 입력 또는 resize 이벤트를 추적하기 위해 window나 document에 훅(hook)을 겁니다. 만약 effect가 removeEventListener를 호출하는 cleanup function을 반환하지 않으면, 해당 listener는 페이지가 유지되는 동안 계속 살아남습니다. listener가 closure이기 때문에, React가 컴포넌트를 unmount한 후에도 한참 동안 컴포넌트 스코프 전체를 살아있게 만듭니다.
해제되지 않은 옵저버. IntersectionObserver와 ResizeObserver는 강력하지만, React의 제어 범위를 벗어난 네이티브 참조를 생성합니다. 컴포넌트 내부에서 옵저버를 인스턴스화하고 정리(cleanup) 단계에서 disconnect() 호출을 잊어버리면, 옵저버는 대상 DOM 노드를 붙잡고 있고, DOM 노드는 React fiber, props, state를 붙잡게 됩니다.
클로저 트랩. 컴포넌트 내부에서 함수를 정의하고 이를 서드파티 라이브러리, 글로벌 캐시, 또는 setTimeout에 전달하면, 해당 함수는 자신의 렉시컬 스코프(lexical scope)에 있는 모든 변수를 클로저로 캡처합니다. 외부 소유자가 해당 함수를 계속 유지한다면, 컴포넌트의 전체 스코프도 함께 유지됩니다.
실제로 효과가 있는 정리(Cleanup) 패턴
누수를 해결한다는 것은 스냅샷에서 발견한 모든 유지 경로(retaining path)를 끊어내는 것을 의미합니다.
useEffect에서 항상 정리 함수를 반환하세요. 이펙트 내에서 리스너를 추가했다면, 그곳에서 제거해야 합니다.
DOM이나 window에 부착하는 모든 핸들러에는 useCallback을 사용하세요. 사용하지 않으면 렌더링될 때마다 새로운 함수 참조가 생성됩니다. 하나의 참조로 addEventListener를 호출하고 나중에 다른 참조로 removeEventListener를 호출하면, 제거 작업은 아무런 경고 없이 실패합니다. 원래의 리스너는 window에 영원히 남게 됩니다. useCallback은 참조를 안정적으로 유지하여 추가(add)와 제거(remove)가 정확히 일치하도록 돕습니다.
옵저버도 동일한 원칙을 적용하여 처리하세요. 옵저버 인스턴스를 ref나 이펙트 내부의 지역 변수에 저장하세요. 정리 함수에서 observer.disconnect()를 호출하십시오. 컴포넌트가 언마운트된다고 해서 옵저버가 자동으로 종료될 것이라고 가정해서는 안 됩니다. 그렇지 않습니다.
컴포넌트가 글로벌 네임스페이스나 싱글톤 서비스에 무언가를 게시한다면, 언마운트 시 해당 참조를 삭제하세요. V8 엔진은 객체에 완전히 도달할 수 없을 때만 메모리를 회수할 수 있습니다. window에 훅을 남겨두거나 모듈 레벨의 Map에 항목을 남겨두면, 힙(heap)이 계속 커지게 만드는 보이지 않는 다리가 형성됩니다.
핵심 요약
메모리 누수는 앱을 즉시 충돌시키지 않습니다. 긴 사용자 세션 동안 분리된 노드가 하나씩 쌓일 뿐입니다. 해결책은 라이브러리 업그레이드나 컴파일러 플래그가 아닙니다. 힙 스냅샷을 통해 정리 로직을 검증하는 습관을 갖는 것입니다.
기준점(baseline)을 잡고, 의심되는 흐름을 실행한 뒤, 가비지 컬렉션을 강제로 수행하고 비교해 보세요. 차이(delta)가 증가한다면 유지 경로를 조사하여 존재해서는 안 될 리스너나 옵저버를 찾아 참조를 끊으십시오. 테스트를 다시 실행하세요. 차이가 일정하게 유지된다면 실제로 문제를 해결한 것입니다. 이렇게 하면 애플리케이션의 응답성을 유지할 수 있으며, 사용자가 브라우저 탭이 멈춰 작업 내용을 잃는 일도 방지할 수 있습니다.
