useEffect cleanup 함수 완전 정복 — 메모리 누수·구독·타이머 정리 패턴

목차

Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks
in a useEffect cleanup function.

useEffect cleanup 함수를 빼먹은 컴포넌트는 React가 직접 메모리 누수라고 콘솔에 적어준다. 위 경고는 React 17까지 기본 활성화되어 있었고, React 18에서 일부 시나리오에 한해 제거되었다 (React GitHub discussion #82, 2022-04). 경고가 사라졌다고 누수가 없어진 건 아니다. 페이지 전환을 반복할수록 Chrome DevTools Memory 탭의 JS Heap이 계단식으로 올라가고, 종료되지 않은 setInterval이 백그라운드에서 setState를 시도한다.

예를 들어, 이 글은 cleanup 함수가 왜 필요한지, 어떤 자원이 정리 대상인지, React 18 Strict Mode가 effect를 두 번 호출하는 동작을 어떻게 해석해야 하는지를 패턴 단위로 분석한다. 끝에는 cleanup을 강제로 끼워 넣을 때와 생략해도 되는 경계 조건을 가르는 실용적 기준을 제시한다.

문제 정의 — cleanup 누락이 만드는 세 가지 증상

cleanup이 빠진 effect는 대체로 세 가지 증상을 만든다. 셋 다 처음에는 사소해 보이는 게 함정이다.

첫째, Detached DOM 노드 누적이다. 컴포넌트가 언마운트되어도 외부 라이브러리가 잡고 있는 DOM 참조가 끊기지 않으면 가비지 컬렉터가 회수하지 못한다. ECharts, Chart.js, Leaflet 같은 라이브러리는 init 시점에 자체적으로 SVG/Canvas 노드를 만들고 내부 인스턴스로 들고 있는다. React가 DOM 트리에서 노드를 떼어내도 라이브러리 인스턴스가 살아 있는 한 노드는 메모리에 남는다. Chrome DevTools의 Performance Monitor에서 JS Heap Size 그래프를 켜놓고 페이지 전환을 반복하면 계단식 증가가 그대로 보인다.

예를 들어, 둘째, 좀비 타이머다. setInterval과 setTimeout은 컴포넌트와 무관한 전역 큐에 등록된다. 등록한 컴포넌트가 사라져도 콜백은 살아남아 주기적으로 실행된다. 콜백이 클로저로 잡고 있는 setState가 호출되면 위에 인용한 그 경고가 뜬다. 한 사용자가 SPA에서 10개의 페이지를 옮겨다녔다면 좀비 타이머도 그만큼 누적된 상태가 된다.

게다가, 셋째, 끝나지 않는 구독이다. WebSocket, EventSource, Firebase onSnapshot, RxJS subscribe 같은 호출은 명시적으로 끊지 않으면 평생 메시지를 수신한다. 클라이언트 메모리만 새는 게 아니라 서버 측 동시 연결 수가 부풀어 오른다. 트래픽이 큰 서비스라면 이게 더 무서운 부작용이다.

반면, (개인적으로 가장 발견이 늦는 건 두 번째다. 메모리는 새는 게 그래프에 찍히지만, 좀비 타이머는 사용자 브라우저에서만 조용히 돈다. 서버 로그만 보는 개발자에게는 보이지 않는다.)

기존 접근의 한계 — useEffect 라이프사이클 오해

cleanup이 필요한 이유를 이해하려면 effect의 실행 시점을 먼저 정리해야 한다. React 공식 문서(2024년 개정)는 effect를 "리액티브 시스템과 외부 시스템을 동기화하는 메커니즘"으로 정의한다. 동기화는 시작과 끝이 짝지어져야 하므로, 모든 effect는 잠재적으로 cleanup을 가질 수 있다고 가정하는 게 안전하다.

의존성 배열이 비어도 effect는 다시 실행될 수 있다

물론, 흔한 오해 중 하나는 의존성 배열을 비우면([]) "마운트 시 한 번만 실행된다"는 생각이다. React 18 Strict Mode에서는 개발 환경에 한해 마운트 직후 한 번 더 언마운트와 마운트를 시뮬레이션한다. effect가 두 번 호출되는 셈이다. cleanup이 없으면 두 번째 마운트에서 같은 자원을 두 번 잡는다. WebSocket이라면 연결이 두 개, setInterval이라면 타이머가 두 개로 늘어난다.

특히, 의존성이 있는 effect는 더 자주 실행된다. props가 바뀔 때마다 effect가 다시 돌고, 이전 effect의 cleanup이 새 effect 실행 전에 호출된다. cleanup이 없다면 이전 자원은 영영 정리되지 않는다. 대시보드처럼 필터 옵션을 자주 바꾸는 화면에서 누수가 폭발적으로 증가하는 이유다.

"마운트 시 한 번" 패턴이 만드는 누적 효과

cleanup 없이 빈 의존성 배열만 쓰는 패턴은 위험하다. WebSocket을 마운트 시점에 열고 닫지 않으면, 사용자가 같은 페이지를 닫았다 다시 열 때마다 새 WebSocket이 추가로 열린다. 브라우저는 한 도메인당 동시 WebSocket 수를 스펙상 제한하지 않는다(RFC 6455, Section 4.1 참고). 누수가 누적되어도 클라이언트 측 명시적 실패가 발생하지 않으므로, 서버가 동시 연결 제한에 먼저 부딪히기 전까지 문제가 드러나지 않는다.

이 종류의 버그는 단위 테스트로도 잡기 어렵다. 컴포넌트 한 번 마운트로는 누수가 보이지 않기 때문이다. React Testing Library의 rerender 또는 unmount를 의도적으로 반복 호출해야 잡힌다. 정적 분석으로는 더 어려운데, "cleanup이 없는 effect"가 본질적으로 잘못된 패턴이라고 단정할 수 없어서다. 짧은 setTimeout처럼 cleanup이 굳이 필요 없는 경우도 있다.

제안 — cleanup 대상 네 가지 분류

cleanup 대상은 크게 네 부류로 나뉜다. 부류별로 정리 호출이 다르고, 실수 패턴도 다르다.

분류 대표 자원 정리 호출 자주 빠지는 부분
타이머 setInterval, setTimeout clearInterval, clearTimeout id를 클로저 밖에 둠
구독 WebSocket, EventSource, Observable close, removeEventListener, unsubscribe 핸들러 참조 불일치
비동기 요청 fetch, axios AbortController.abort AbortError catch 누락
외부 라이브러리 ECharts, Leaflet, IntersectionObserver dispose, destroy, disconnect ref만 null로 만듦

타이머: id를 같은 클로저에서 잡아라

즉, setInterval은 가장 단순하지만 가장 자주 빠진다. 단순한 만큼 "이 정도는 괜찮겠지" 하고 넘어가는 심리적 함정이 있다. 핵심 규칙은 clearInterval이 호출될 때 같은 id가 클로저로 캡처되어야 한다는 것이다. setInterval 반환값을 컴포넌트 스코프 변수에 저장하면 매 렌더마다 새 id가 생기면서 이전 id 참조가 끊긴다.

useEffect(() => {
  // id는 effect 클로저 안에서만 유효
  const id = setInterval(() => fetchLatestPrice().then(setPrice), 1000);
  return () => clearInterval(id);
}, []);

타이머의 콜백 안에서 setState를 호출한다면 cleanup이 두 배로 중요해진다. 좀비 타이머는 언마운트 이후에도 setState를 시도하기 때문이다. setState 호출이 무해한 no-op으로 끝나는 경우도 있지만, 콜백 안에서 외부 API를 더 호출한다면 누수와 비용이 함께 증가한다.

구독: 라이브러리마다 해제 API가 다르다

그러나, WebSocket은 ws.close(), EventSource는 es.close(), addEventListener로 등록한 핸들러는 removeEventListener(eventName, sameReference), RxJS Observable은 subscribe가 반환하는 Subscription의 unsubscribe(). API 모양이 다 달라 단일 패턴으로 잡기 어렵다.

특히 addEventListener는 함정이 많다. 떼려면 정확히 같은 참조의 함수를 넘겨야 한다. 인라인 화살표 함수로 등록하면 떼지 못한다. 이 규칙은 React 외에서도 동일하게 적용되는 DOM API 기본이다(MDN EventTarget.removeEventListener, 2024년 기준 문서). useEffect 안에서 핸들러를 명시적으로 변수에 저장해두는 습관이 안전하다.

useEffect(() => {
  const handler = (e: MessageEvent) => setData(JSON.parse(e.data));
  const ws = new WebSocket('wss://api.example.com/stream');
  ws.addEventListener('message', handler);
  return () => {
    ws.removeEventListener('message', handler);
    ws.close();
  };
}, []);

비동기 요청: AbortController로 응답 자체를 끊는다

fetch가 응답을 기다리는 동안 컴포넌트가 언마운트되면, 응답 도착 시점에 setState가 호출되어 경고가 뜬다. AbortController로 요청 자체를 취소하면 응답 이벤트가 발생하지 않는다.

또한, 주의할 점은 AbortError를 catch에서 명시적으로 걸러내야 한다는 것이다. 그렇지 않으면 정상적인 cleanup이 에러 로그로 남는다. axios는 v0.22.0부터 AbortController를 네이티브 지원한다(axios CHANGELOG v0.22.0, 2021-10-01). 그 이전 버전을 쓰는 프로젝트라면 deprecated된 axios.CancelToken이라는 별도 API를 써야 한다.

외부 라이브러리: ref를 null로 만드는 것과 destroy 호출은 다르다

차트 라이브러리는 내부적으로 SVG나 Canvas DOM을 직접 관리한다. React 컴포넌트가 언마운트되면서 컨테이너 노드가 사라져도, 라이브러리 인스턴스가 들고 있는 내부 참조와 이벤트 리스너는 남는다. ref를 null로 만드는 건 React 내부 참조만 정리할 뿐, 라이브러리 인스턴스에는 아무 영향도 주지 않는다.

반면, ECharts는 chart.dispose(), Chart.js는 chart.destroy(), Leaflet은 map.remove(). ResizeObserver와 IntersectionObserver는 observer.disconnect() 하나로 모든 등록을 해제한다. 라이브러리 README 첫 페이지에 정리 API가 보이지 않으면 "destroy", "dispose", "cleanup" 키워드로 문서 전체를 검색하는 게 빠르다.

검증 — React 18 Strict Mode가 잡아주는 것과 못 잡는 것

React 18에서 가장 혼란스러운 변화는 Strict Mode에서 effect가 두 번 실행된다는 점이다. 정확히는 마운트 → cleanup → 마운트 순서로 호출된다. React 공식 문서(2024년 기준)는 이 동작을 "effect가 cleanup과 짝지어 잘 동작하는지 검증하기 위한 의도된 동작"이라고 설명한다.

개발 환경에서만 두 번 실행된다

production 빌드에서는 한 번만 실행된다. 개발 환경에서 두 번 실행되는 이유는 cleanup 누락을 빨리 발견하도록 강제하기 위해서다. 두 번 실행해서 화면이 깨진다면 cleanup이 부족하다는 신호다. 이 변경이 React 18 announcement(2022-03-29)에서 발표됐을 때 가장 많이 받은 질문이 "왜 두 번 실행되냐"였고, React 팀의 답은 일관되게 "cleanup을 제대로 작성하면 두 번 실행되어도 문제가 없다"였다.

useRef로 "한 번만 실행" 우회는 안티패턴이다

const didMount = useRef(false);
useEffect(() => {
  if (didMount.current) return;
  didMount.current = true;
  // 한 번만 실행되어야 하는 로직
}, []);

이 패턴은 Strict Mode를 우회하지만 진짜 문제(cleanup 누락)를 가린다. 결제 API 호출처럼 진짜로 한 번만 실행되어야 하는 로직이라면 useEffect가 아니라 이벤트 핸들러로 옮기는 게 맞다. 공식 문서의 "You Might Not Need an Effect" 섹션(2024년 기준)이 이 주제를 길게 다룬다. 핵심 가이드라인은 "사용자 액션에 반응하는 로직은 이벤트 핸들러에, 외부 시스템 동기화는 effect에"다.

Strict Mode가 못 잡는 누락

Strict Mode는 effect의 cleanup 짝맞춤은 검증하지만, 의존성 배열 실수까지는 잡지 못한다. props로 받은 콜백을 의존성에 빼먹어 stale closure가 만들어지는 케이스, ref 변경 감지를 잘못 시도하는 케이스 등은 별도의 정적 분석 도구가 필요하다. ESLint의 react-hooks/exhaustive-deps 룰을 켜고 경고를 진지하게 받아들이는 게 표준 권고다(eslint-plugin-react-hooks v4 이상에서 기본 포함).

한계점 — cleanup 함수가 풀지 못하는 문제

특히, cleanup 함수는 강력하지만 모든 누수를 막아주지는 않는다. 몇 가지 구조적 한계가 있다.

cleanup은 비동기를 기다려주지 않는다

cleanup 함수의 반환값은 무시된다. async cleanup을 작성해도 React는 Promise를 기다려주지 않는다. WebSocket을 graceful하게 닫고 마지막 메시지를 처리한 뒤 종료하는 식의 로직은 cleanup 안에 그대로 들어갈 수 없다. 이런 경우 보통 ref에 상태를 저장하고 다음 effect 시작 시점에 처리하거나, useSyncExternalStore 같은 외부 상태 동기화 API로 옮기는 식으로 우회한다.

cleanup 호출 순서는 자식부터다

부모와 자식 컴포넌트가 같은 외부 자원을 공유할 때, cleanup이 자식 → 부모 순서로 호출된다는 점을 모르면 race condition이 생긴다. 자식이 먼저 unsubscribe하고 부모가 다음 메시지를 기다리는 식의 의존이 있다면 깨진다. 이 동작은 React 17과 18 모두 동일하다. 부모-자식 간 자원 공유는 보통 Context나 외부 store로 끌어올리는 게 안전하다.

Strict Mode가 production 동작과 100% 일치하지는 않는다

Strict Mode는 effect를 두 번 호출해 cleanup 누락을 드러내지만, 그 동작이 production에서 발생하는 모든 시나리오를 시뮬레이션하지는 않는다. 실제 production에서는 Suspense 경계, React Server Components, Concurrent rendering 중단 등 더 복잡한 상황에서 effect가 재실행될 수 있다. cleanup이 완벽해도 다른 동시성 이슈가 남을 수 있다는 뜻이다.

판단 기준 — 언제 쓰고 언제 안 써도 되는가

지금까지 정리한 내용을 실용적 기준으로 좁히면 이렇다.

반드시 cleanup이 필요한 자원. WebSocket, EventSource, 외부 라이브러리 인스턴스(차트·맵·비디오 플레이어), setInterval, addEventListener(특히 window/document에 붙인 것), ResizeObserver/IntersectionObserver, 응답 시간이 긴 fetch. 이 자원들은 컴포넌트와 수명이 다르므로 명시적 해제가 필수다. cleanup이 빠진 코드는 PR 단계에서 막아야 한다.

그런데, cleanup이 없어도 큰 문제 없는 자원. 수십 ms 안에 끝나는 짧은 setTimeout(setState 없음), 순수 디버깅용 console.log effect, 한 번만 실행되는 idempotent GET 요청. 이런 경우는 cleanup을 추가해도 가독성만 떨어진다. 다만 같은 컴포넌트 안에 cleanup이 필요한 effect와 섞여 있다면 일관성 차원에서 모든 effect에 cleanup을 다는 편이 낫다.

물론, 판단이 갈리는 회색 지대. 한 번만 호출되는 GET 요청은 cleanup 없이 두는 코드가 많다. Strict Mode에서 두 번 호출되는 게 서버에 부담이라면 AbortController를 추가하고, 무해한 idempotent 호출이라면 그대로 둬도 된다. POST/DELETE처럼 부작용이 있는 요청은 effect에서 자동으로 호출하지 말고 이벤트 핸들러로 옮기는 게 정답이다.

그래서, 당장 적용할 수 있는 액션 세 가지를 좁히면 이렇게 된다. 첫째, 콘솔에서 "Can't perform a React state update on an unmounted component" 경고를 grep하라. 한 건이라도 잡히면 그 컴포넌트의 effect에 cleanup이 빠진 것이다. 둘째, Chrome DevTools Memory 탭에서 페이지 전환을 10회 반복한 뒤 두 개의 힙 스냅샷을 비교해 Detached DOM 노드 수를 확인하라. 같은 노드가 누적되어 있다면 외부 라이브러리 cleanup이 누락된 것이다. 셋째, ESLint react-hooks/exhaustive-deps 룰을 켜고 의존성 배열 경고를 진지하게 처리하라. 의존성 누락이 stale closure를 만들고, stale closure가 cleanup의 잘못된 동작을 유발하는 게 흔한 경로다.

관련 글