사용자의 브라우저 탭이 30분 후에 멈춥니다. UI가 버벅거립니다. 그러더니 브라우저가 out-of-memory 오류와 함께 충돌합니다.
컴포넌트 코드를 한 줄씩 검토해도 아무런 문제가 보이지 않습니다. 이것이 React 메모리 누수의 미칠 듯한 부분입니다. 버그는 JSX 구문이나 hook 로직에 있는 것이 아닙니다. 컴포넌트와 브라우저의 가비지 컬렉터(garbage collector) 사이의 간극에 존재합니다. 길을 잃은 이벤트 리스너나 오래 지속되는 클로저(closure)가 컬렉터가 메모리를 회수하는 것을 방해합니다. 컴포넌트를 언마운트(unmount)하더라도, 단 하나의 남아있는 참조(reference)가 힙(heap) 내의 전체 트리를 계속 살아있게 만듭니다. 코드를 읽는 것만으로는 이러한 누수를 찾을 수 없습니다. 문제는 표면 아래에 숨겨져 있어 눈에 보이지 않고 단위 테스트에서도 조용히 지나칩니다.
왜 누수는 눈앞에서 숨어 있는가
V8과 같은 JavaScript 엔진은 메모리를 자동으로 관리합니다. 루트(root)에서 객체로 이어지는 참조 경로가 더 이상 존재하지 않으면, 엔진은 해당 객체를 가비지로 표시하고 공간을 회수합니다. 이 프로세스는 의도치 않게 숨겨진 참조가 예상보다 오래 살아남기 전까지는 잘 작동합니다.
React에서 위험은 주로 컴포넌트와 DOM 사이의 경계에서 나타납니다. 모달 내부에서 window에 resize 리스너를 부착하거나, 대시보드 위젯에서 WebSocket을 구독할 수 있습니다. 사용자가 모달을 닫거나 페이지를 이동하면 컴포넌트는 언마운트됩니다. 만약 구독이 살아남는다면, 엔진은 전역 window 객체에서 핸들러로, 그리고 핸들러에서 다시 컴포넌트의 클로저로 이어지는 유효한 참조를 보게 됩니다. 컴포넌트, props, state, 그리고 DOM 노드의 전체 서브트리가 메모리에 고정(pinned)된 상태로 남습니다. 수백 번의 상호작용을 거치면서 이러한 고정된 객체들이 축적됩니다. 메모리 사용량은 완전히 떨어지지 않고 톱니바퀴 모양(sawtooth pattern)으로 계속 상승합니다.
엔터프라이즈 대시보드 문제
이는 사용자가 한 페이지에 몇 시간 동안 머무는 엔터프라이즈 대시보드에서 가장 빈번하게 발생합니다. 모니터링 패널, 분석 뷰 또는 티켓팅 시스템을 생각해 보세요. 사용자가 상세 모달을 열고, 대규모 데이터셋을 필터링하거나, 싱글 페이지 앱(SPA) 내에서 탭을 전환합니다. 개별적인 상호작용은 모두 괜찮아 보입니다. 하지만 시간이 지나면서 고립된(orphaned) 노드와 분리된(detached) 리스너들이 쌓입니다. 애플리케이션이 느려지는 이유는 단일 렌더링 비용이 높아서가 아니라, 힙이 너무 커져서 빈번하고 비용이 많이 드는 가비지 컬렉션 일시 중지(GC pause)가 발생하기 때문입니다.
useEffect 훅에 대해 추측하는 것을 멈추세요. 누수가 있는지 확인하는 유일한 방법은 힙을 직접 측정하는 것입니다. Chrome DevTools가 그 가시성을 제공합니다.
Chrome DevTools로 누수 추적하기
재현 가능한 시퀀스와 몇 분간의 집중이 필요합니다. Chrome에서 애플리케이션을 열고, DevTools를 실행한 뒤 Memory 탭으로 이동합니다.
베이스라인 기록. Heap snapshot을 선택하고 Take snapshot을 클릭합니다. 이는 현재 JavaScript 힙에 살아있는 모든 객체를 캡처하여 시작 메모리를 보여줍니다. 초기 로드 중이 아니라 페이지가 초기 유휴(idle) 상태로 안정된 후에 수행하여, 사용자 작업으로 인한 증가분만 측정하세요.
동작 유발. 누수를 일으킨다고 의심되는 정확한 UI 상호작용을 수행합니다. 모달을 열고 닫거나, 복잡한 차트를 토글하거나, 경로(route)를 변경한 뒤 다시 돌아옵니다. 완료되면 앱을 원래의 시각적 상태로 되돌립니다. 이 단계가 매우 중요합니다. UI가 베이스라인 때와 동일하게 보여야 합니다. UI가 비어 있음에도 불구하고 힙이 늘어났다면, 누수의 강력한 증거가 됩니다.
가비지 컬렉션 강제 실행. Memory 탭의 휴지통 아이콘을 클릭합니다. 이는 전체 GC 사이클을 트리거하여 다음 컬렉션까지 정당하게 살아남은 임시 객체들을 정리합니다. 남은 것은 실제 누수, 즉 수집되었어야 하지만 실수로 인한 참조로 인해 계속 살아있는 객체들입니다.
두 번째 스냅샷 찍기. Take snapshot을 다시 클릭합니다. 이제 동일한 UI 조건에서 찍은 힙의 사진 두 장을 갖게 됩니다.
결과 비교. 뷰를 Summary에서 Comparison으로 변경합니다. 베이스라인을 첫 번째 스냅샷으로, 비교할 스냅샷을 두 번째 스냅샷으로 설정합니다. Comparison 뷰는 모든 객체 카테고리를 나열하고 두 캡처 사이의 객체 수 순 변화량인 델타(delta)를 보여줍니다.
Delta로 정렬. 수치가 크게 증가한 카테고리를 찾습니다. 특히 Detached HTMLElement와 React fiber 노드에 집중하세요. Detached HTML 요소는 활성 문서 트리(document tree)에 더 이상 연결되어 있지 않지만, 일부 JavaScript 참조가 여전히 이를 붙잡고 있는 DOM 노드입니다. 이것이 바로 결정적인 증거(smoking guns)입니다. 모달을 닫거나 컴포넌트를 언마운트한 후에는 존재해서는 안 되는 것들입니다.
Retaining Path 읽기
스냅샷에서 분리된(detached) 요소를 선택하면, Chrome은 하단 패널에 retaining path를 표시합니다. 이 경로는 루트에서 선택한 객체까지 이어지는 참조 체인입니다. 이를 주의 깊게 따라가 보세요. 종종 이벤트 리스너, IntersectionObserver, setInterval ID 또는 컴포넌트의 특정 라인을 가리키는 클로저를 발견하게 될 것입니다.
익숙한 이름을 찾아보세요. 만약 코드베이스에 있는 함수 이름과 함께 window에 연결된 리스너가 보인다면, 앵커(anchor)를 찾은 것입니다. 해당 리스너를 보유하고 있는 객체가 컴포넌트 전체를 메모리에 유지시키고 있는 것입니다. 때로는 이 체인이 서드파티 라이브러리를 거쳐 이어지기도 합니다. 이 경우, 라이브러리에서 명시적인 teardown 호출을 요구하는데 cleanup 함수에서 호출하는 것을 잊지는 않았는지 확인하십시오.
근본 원인 해결하기
retaining path를 식별했다면, 해결 방법은 대개 기계적이지만 팀 전체의 원칙 준수가 필요합니다.
cleanup 함수를 사용하세요. window나 document에 리스너를 추가할 때는 항상 useEffect에서 cleanup 함수를 반환해야 합니다. 만약 effect가 resize 이벤트에 구독(subscribe)한다면, 컴포넌트가 언마운트되기 전에 해당 구독을 해제하십시오. cleanup은 React가 컴포넌트를 해제할 때 실행되므로, 외부 연결을 끊을 수 있는 확실한 훅(hook)을 제공합니다.
참조를 안정화하세요. 핸들러를 useCallback으로 감싸세요. 이렇게 하면 addEventListener에 처음에 전달했던 것과 정확히 동일한 함수 참조를 removeEventListener에 전달할 수 있습니다. 만약 window.addEventListener('resize', () => { ... })와 같이 인라인 함수를 등록하고 나중에 다른 인라인 함수로 이를 제거하려고 하면, 참조가 일치하지 않게 됩니다. 그러면 리스너는 계속 연결된 상태로 남게 됩니다. 그 내부의 클로저가 컴포넌트 상태를 계속 유지하게 됩니다. 안정적인 의존성 배열을 가진 useCallback을 사용하면 이러한 참조 불일치를 방지할 수 있습니다.
글로벌 연결을 끊으세요. window나 document와 같은 브라우저의 이벤트 타겟 객체는 페이지의 생명주기 동안 유지된다는 점을 기억하십시오. 이 객체들로부터 컴포넌트로 이어지는 모든 참조는 글로벌 앵커 역할을 합니다. 리스너를 제거하면 해당 앵커가 끊어지며, 다음 가비지 컬렉션(GC) 사이클 중에 V8 엔진이 컴포넌트 상태와 DOM 노드를 정리할 수 있게 됩니다.
창 너비를 추적하는 모달을 예로 들어보겠습니다. cleanup이 없다면 사용자가 모달을 열 때마다 새로운 리스너가 추가됩니다. 기존 리스너들은 해당 리스너가 속했던 컴포넌트 인스턴스는 사라졌더라도, 함수 자체가 익명이었고 참조를 잃어버렸기 때문에 결코 해제되지 않습니다. useCallback으로 감싼 이름이 있는 핸들러와 removeEventListener를 호출하는 cleanup 함수를 사용하면 이 과정을 깔끔하게 마무리할 수 있습니다.
핵심 요약
React의 메모리 누수는 명확한 에러 메시지와 함께 나타나는 경우가 드뭅니다. 대신 탭을 오래 열어둘수록 점점 더 무거워지는 탭의 상태로 자신을 드러냅니다. 어떤 훅이 문제인지 추측하는 데 시간을 낭비하지 마세요. Memory 탭을 열고, 가비지 컬렉션을 강제로 실행한 뒤, 스냅샷을 비교하십시오. 힙 프로파일러(heap profiler)를 통해 정확한 retaining path를 확인하세요. 그런 다음 cleanup 함수를 작성하고, 콜백 참조를 안정화하며, 글로벌 연결을 끊으십시오. 사용자가 수정 사항을 직접적으로 느끼지는 못하겠지만, 하루 종일 대시보드가 여전히 매끄럽게 작동한다는 사실은 체감하게 될 것입니다.
