- Published on
React 메모리 누수? useEffect 클린업 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
React 앱에서 “메모리 누수” 경고를 마주칠 때가 있습니다. 대표적으로 Can't perform a React state update on an unmounted component 같은 메시지, 혹은 탭을 오래 켜두면 점점 느려지는 현상, 이벤트가 중복으로 실행되는 현상 등이죠.
React 자체가 메모리를 “새는” 경우는 드뭅니다. 실무에서 문제의 대부분은 useEffect로 등록한 부작용(side effect)이 언마운트 이후에도 계속 실행되거나, 구독/타이머/네트워크 요청이 중복되어 리소스가 쌓이는 패턴에서 발생합니다.
이 글은 useEffect 클린업을 점검할 때 바로 써먹을 수 있는 9가지 체크리스트를 원인별로 정리합니다.
성능 이슈 전반을 함께 점검하고 싶다면 Safari iOS 스크롤 끊김 해결 - 레이어·합성 최적화도 같이 보시면 “느려짐”의 다른 축(렌더링/컴포지팅)까지 연결해서 볼 수 있습니다.
먼저: React에서 말하는 메모리 누수의 전형적인 형태
대부분 아래 중 하나입니다.
- 언마운트된 컴포넌트에 대해
setState가 호출됨(비동기 후속 처리) - 이벤트 리스너가 해제되지 않아 핸들러가 중복 등록됨
setInterval/setTimeout이 해제되지 않아 계속 실행됨- 웹소켓/스트림/구독이 끊기지 않아 백그라운드에서 계속 데이터가 들어옴
AbortController없이 fetch를 날려 응답이 늦게 돌아오며 상태 업데이트를 유발- 외부 라이브러리 인스턴스(차트, 지도 등)가
destroy없이 남음
이제부터는 “무엇을 확인하고 어떻게 고칠지”를 9개로 쪼개서 봅니다.
1) 이벤트 리스너 제거 누락: addEventListener는 항상 removeEventListener
가장 흔한 누수 패턴입니다. 특히 핸들러 함수 레퍼런스가 매 렌더마다 바뀌면 제거가 실패합니다.
잘못된 예
useEffect(() => {
window.addEventListener('resize', () => {
// ...
});
}, []);
위 코드는 익명 함수라서 removeEventListener로 제거할 방법이 없습니다.
올바른 예
useEffect(() => {
const onResize = () => {
// ...
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
점검 포인트
- DOM 이벤트뿐 아니라
document,window,matchMedia,visibilitychange같은 것들도 동일 - 의존성 배열이 바뀌며 effect가 재실행될 때, 기존 리스너가 누적되지 않는지 확인
2) 타이머 정리 누락: setInterval과 setTimeout은 반드시 해제
타이머는 언마운트 이후에도 계속 실행되며 CPU와 메모리를 갉아먹을 수 있습니다.
useEffect(() => {
const id = window.setInterval(() => {
// 주기 작업
}, 1000);
return () => window.clearInterval(id);
}, []);
setTimeout도 동일합니다.
useEffect(() => {
const id = window.setTimeout(() => {
// 지연 작업
}, 500);
return () => window.clearTimeout(id);
}, []);
점검 포인트
- “재시도 로직”이 타이머로 구현되어 있을 때 누적되기 쉽습니다
- Strict Mode 개발 환경에서 effect가 2번 실행되는 특성 때문에, 클린업이 없으면 증상이 더 빨리 드러납니다
3) fetch 취소 누락: AbortController로 요청을 중단
네트워크 요청이 늦게 끝나면, 컴포넌트가 이미 언마운트된 뒤에 응답이 돌아와 setState가 호출될 수 있습니다.
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch('/api/items', { signal: controller.signal });
const data = await res.json();
// setItems(data)
} catch (e: any) {
if (e?.name === 'AbortError') return;
throw e;
}
})();
return () => controller.abort();
}, []);
점검 포인트
axios도 취소 토큰(버전에 따라 API 다름) 또는AbortController지원 여부 확인- “요청을 취소”하는 것과 “응답을 무시”하는 것은 다릅니다. 가능하면 취소가 더 안전합니다
4) “응답 무시” 가드: 취소가 불가능한 비동기에는 플래그를 둔다
모든 비동기 작업이 취소 가능한 건 아닙니다. 예를 들어 어떤 SDK 콜백, 이미지 디코딩, 서드파티 API 등은 취소가 애매합니다. 그럴 때는 언마운트 이후 결과를 무시하는 가드가 필요합니다.
useEffect(() => {
let active = true;
(async () => {
const data = await someAsync();
if (!active) return;
// setState(data)
})();
return () => {
active = false;
};
}, []);
점검 포인트
active는 effect 스코프 내부 변수여야 합니다(컴포넌트 외부 전역으로 빼면 안 됨)- 여러 비동기가 동시에 돌아가면 “마지막 응답만 반영” 같은 정책도 함께 고려
5) WebSocket, SSE, 스트림 구독 해제: close 또는 unsubscribe
실시간 연결은 누수가 나면 증상이 큽니다. 네트워크, 메모리, 이벤트 루프 모두에 부담이 생깁니다.
WebSocket 예
useEffect(() => {
const ws = new WebSocket('wss://example.com/socket');
ws.onmessage = (ev) => {
// setState(JSON.parse(ev.data))
};
return () => {
ws.close();
};
}, []);
RxJS 같은 구독 모델 예
useEffect(() => {
const sub = stream$.subscribe(value => {
// setState(value)
});
return () => sub.unsubscribe();
}, [stream$]);
점검 포인트
- reconnect 로직이 있을 때, 이전 소켓이 닫히지 않고 남아 있는지 확인
- 구독형 라이브러리는 대부분
unsubscribe또는dispose가 있습니다
6) requestAnimationFrame 정리: 애니메이션 루프는 반드시 cancelAnimationFrame
스크롤/애니메이션 최적화에서 requestAnimationFrame을 자주 쓰는데, 루프를 끊지 않으면 영구 실행됩니다.
useEffect(() => {
let rafId = 0;
const tick = () => {
// 프레임 단위 작업
rafId = window.requestAnimationFrame(tick);
};
rafId = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(rafId);
};
}, []);
점검 포인트
- 루프 내부에서 상태 업데이트를 하면 렌더 폭주로 이어질 수 있습니다
- iOS Safari에서 체감 성능 문제로 이어질 때가 많습니다(관련해서 Safari iOS scroll jank 해결 - 레이어 최적화도 참고)
7) IntersectionObserver / ResizeObserver 해제: disconnect가 핵심
관찰자(Observer)는 등록만 하고 해제하지 않으면 계속 콜백이 살아 있습니다.
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(entries => {
// ...
});
observer.observe(el);
return () => {
observer.disconnect();
};
}, [ref]);
점검 포인트
- 특정 엘리먼트만 중단하려면
unobserve를 쓰고, 전체 종료는disconnect - 의존성 변경으로 effect가 재실행될 때, 이전 observer가 남지 않도록
8) 외부 라이브러리 인스턴스 파기: destroy / dispose / remove
차트, 지도, 에디터 같은 UI 라이브러리는 DOM에 이벤트와 내부 캐시를 붙입니다. React가 DOM을 제거해도 라이브러리 내부 참조가 남으면 누수가 됩니다.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const chart = createChart(el, { /* 옵션 */ });
return () => {
chart.destroy();
};
}, []);
점검 포인트
- 라이브러리 문서에서 “cleanup” 키워드로
destroy/dispose/remove메서드를 찾기 - 이벤트 바인딩을 직접 했다면 1번 체크리스트와 함께 적용
9) 의존성 배열 점검: 클린업이 있어도 “중복 실행”이 누적될 수 있다
클린업을 작성했는데도 누수처럼 보이는 경우가 있습니다. 원인은 useEffect 의존성 배열이 불안정해서 effect가 너무 자주 재실행되기 때문입니다.
문제 패턴: 매 렌더마다 새 객체를 의존성에 넣음
useEffect(() => {
const id = window.setInterval(() => {
// ...
}, 1000);
return () => window.clearInterval(id);
}, [{ a: 1 }]);
{ a: 1 }은 매 렌더마다 새 레퍼런스라 effect가 계속 재실행됩니다.
해결: useMemo 또는 의존성 구조 조정
const options = useMemo(() => ({ a: 1 }), []);
useEffect(() => {
const id = window.setInterval(() => {
// ...
}, 1000);
return () => window.clearInterval(id);
}, [options]);
점검 포인트
- effect 내부에서 사용하는 함수가 외부에서 매번 새로 만들어지면
useCallback고려 - “정말로 재실행이 필요하냐”를 먼저 따져보고, 필요 없다면 의존성을 줄이기
- 개발 환경 Strict Mode에서 effect가 한 번 더 실행되더라도, 클린업이 정확하면 문제 없어야 합니다
디버깅 체크: 누수 의심 시 빠르게 확인하는 방법
1) React 경고 로그를 원인으로 연결하기
- 언마운트 후
setState경고가 보이면 3번, 4번을 최우선으로 의심 - 이벤트가 중복 실행되면 1번, 9번을 의심
2) Chrome DevTools로 핸들러/타이머가 쌓이는지 보기
- Performance 프로파일에서 타이머 콜백이 계속 찍히는지
- Memory 탭에서 스냅샷 비교로 특정 객체가 해제되지 않는지
3) “느려짐”이 렌더링 문제인지 구분하기
메모리 누수처럼 보이지만 실제로는 레이아웃 시프트나 렌더 비용이 누적되는 경우도 있습니다. UI가 흔들리거나 스크롤이 버벅이면 CLS 폭증 원인? 폰트 로딩·이미지 크기 고정 실전처럼 렌더링/레이아웃 관점에서 원인을 분리해보는 게 좋습니다.
실전 템플릿: “클린업 우선” useEffect 골격
아래 템플릿을 기본으로 두면, 누수의 80%는 예방됩니다.
useEffect(() => {
let active = true;
const controller = new AbortController();
const onEvent = () => {
if (!active) return;
// ...
};
window.addEventListener('resize', onEvent);
(async () => {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const data = await res.json();
if (!active) return;
// setState(data)
} catch (e: any) {
if (e?.name === 'AbortError') return;
// 필요 시 로깅
}
})();
return () => {
active = false;
controller.abort();
window.removeEventListener('resize', onEvent);
};
}, []);
마무리: 9가지 체크리스트 요약
- 이벤트 리스너는
removeEventListener - 타이머는
clearInterval/clearTimeout - fetch는
AbortController로 취소 - 취소 불가 비동기는
active가드로 무시 - WebSocket/SSE/구독은
close/unsubscribe - 애니메이션 루프는
cancelAnimationFrame - Observer는
disconnect/unobserve - 외부 라이브러리는
destroy/dispose - 의존성 배열이 불안정하면 effect가 과도 재실행(클린업 있어도 비용 누적)
이 9가지를 기준으로 코드를 훑으면, “메모리 누수처럼 보이는 현상”의 원인을 대부분 빠르게 좁힐 수 있습니다. 특히 비동기 작업과 구독 모델이 섞이는 화면(대시보드, 실시간 리스트, 무한 스크롤)에서는 클린업을 기능 구현의 일부로 취급하는 습관이 장기적으로 가장 큰 성능 개선입니다.