🔍 검색 키워드: react useEffect cleanup, useEffect 정리 함수, setInterval 중복 실행, addEventListener 중복 등록, StrictMode 두 번 실행, useEffect 메모리 누수
요약: useEffect가 타이머·구독·이벤트 리스너처럼 계속 살아 있는 것을 만들어 놓고 정리(cleanup) 함수를 반환하지 않으면, 의존성이 바뀌어 effect가 다시 실행될 때마다 이전 것이 그대로 남아 중복으로 쌓인다. setup이 만든 것을 그대로 되돌리는 함수를 return하면 해결된다. React 공식 문서 기준으로 cleanup은 의존성이 바뀐 커밋마다 setup보다 먼저, 그리고 언마운트 후 한 번 더 실행된다.
useEffect의 cleanup은 언제 실행되나요?
React 공식 useEffect 문서는 실행 순서를 다음과 같이 못 박는다.
- 컴포넌트가 화면에 추가될 때(mount) setup 코드가 실행된다.
- 의존성이 바뀐 커밋마다, 먼저 이전 props·state로 cleanup이 실행되고 그다음 새 값으로 setup이 실행된다.
- 컴포넌트가 화면에서 제거된 뒤(unmount) cleanup이 마지막으로 한 번 더 실행된다.
문서는 cleanup이 "setup 함수가 하던 일을 멈추거나 되돌려야 한다"고 설명한다. 즉 cleanup은 선택 사항이 아니라 setup의 거울상이다. setup에서 무언가를 만들었다면 cleanup에서 그걸 없애야 한다.
테스트 환경
아래 숫자는 이 환경에서 직접 실행한 결과다. 다만 React 런타임을 띄워 컴포넌트를 렌더한 것이 아니라, effect가 반복 실행될 때 타이머와 리스너가 어떻게 쌓이는지를 Node에서 같은 구조로 재현한 것이다. React의 동작 순서 자체는 공식 문서를 근거로 한다.
- macOS 27.0.1 (Darwin 27.0.0, arm64)
node --version출력: v20.20.2
cleanup이 없으면 실제로 무엇이 쌓이나요?
1. 타이머: 되돌리지 않으면 그대로 중첩된다
effect가 세 번 실행되는 상황을 그대로 만들어 보자.
let ticks = 0;
const timers = [];
function effectNoCleanup() { // cleanup 없는 useEffect
timers.push(setInterval(() => { ticks++; }, 20));
}
for (let i = 0; i < 3; i++) effectNoCleanup(); // 의존성이 바뀌어 3번 재실행
// cleanup 있는 버전
let ticks2 = 0, cur = null;
const effectWithCleanup = () => {
if (cur) clearInterval(cur); // 이전 effect의 cleanup
cur = setInterval(() => { ticks2++; }, 20);
};
for (let i = 0; i < 3; i++) effectWithCleanup();직접 실행 결과 (각각 약 100ms 동안 관찰)
cleanup 없음 - 약 100ms 동안 tick: 12 회 (타이머 3 개 동시 작동)
cleanup 있음 - 약 100ms 동안 tick: 4 회 (타이머 1개)같은 시간에 콜백이 세 배로 돈다. 화면에서는 "숫자가 갑자기 빨리 올라간다", "API가 몇 배로 호출된다"로 나타난다. 의존성이 자주 바뀌는 컴포넌트라면 재실행 횟수만큼 배수로 늘어난다.
2. 이벤트 리스너: 여기는 사정이 조금 다르다
"cleanup이 없으면 리스너가 무조건 쌓인다"는 설명을 자주 보는데, 절반만 맞다. DOM의 addEventListener는 같은 타입·같은 콜백 참조·같은 capture 옵션이면 중복 등록을 무시한다. 직접 확인해 보자.
const t2 = new EventTarget();
let c2 = 0;
const h2 = () => { c2++; };
t2.addEventListener("ping", h2);
t2.addEventListener("ping", h2); // 같은 참조를 두 번
t2.dispatchEvent(new Event("ping"));
const t3 = new EventTarget();
let c3 = 0;
for (let i = 0; i < 3; i++)
t3.addEventListener("ping", () => { c3++; }); // 매번 새 화살표 함수
t3.dispatchEvent(new Event("ping"));직접 실행 결과
동일 참조 2번 등록 -> 호출: 1 회
매번 새 참조 3번 등록 -> 호출: 3 회핵심은 이것이다. 컴포넌트 안에서 매 렌더마다 새로 만들어지는 인라인 핸들러를 등록하면 참조가 매번 다르므로 중복 등록이 그대로 쌓인다. 반대로 컴포넌트 밖에 선언한 함수나 useCallback으로 참조를 고정한 핸들러는 같은 참조라 중복 등록이 무시된다. 그래서 "어떤 코드는 리스너가 쌓이고 어떤 코드는 멀쩡해 보이는" 현상이 생긴다. 물론 어느 쪽이든 언마운트 시 리스너를 떼지 않으면 그 자체로 누수이므로 cleanup은 필요하다.
effect는 왜 자꾸 다시 실행되나요?
cleanup 문제가 드러나려면 effect가 반복 실행돼야 하는데, 그 원인은 보통 의존성 배열이다. React 공식 문서는 "React는 각 의존성을 이전 값과 Object.is로 비교한다"고 명시한다. Object.is가 객체와 함수를 어떻게 비교하는지 직접 확인해 보면 이렇다.
$ node -e '...'
Object.is({a:1},{a:1}) = false
Object.is([1],[1]) = false
Object.is(f,f) = true
Object.is(()=>{},()=>{}) = false내용이 같아도 렌더마다 새로 만든 객체·배열·함수는 전부 다른 값으로 취급된다. 공식 문서도 "컴포넌트 안에서 정의된 객체나 함수가 의존성에 들어가면 effect가 필요 이상으로 자주 재실행될 위험이 있다"고 경고하며, 해결책으로 그 객체를 effect 안에서 만들라고 안내한다.
// 문제: options가 매 렌더 새로 만들어진다
const options = { serverUrl, roomId };
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // 매 커밋마다 다른 값
// 해결: 객체를 effect 안으로 옮기고 원시값만 의존성에 둔다
useEffect(() => {
const options = { serverUrl, roomId };
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);어떻게 해결하나요? 상황별 cleanup 패턴
// 1. 타이머
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// 2. 이벤트 리스너 - 등록과 해제에 같은 참조를 써야 한다
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// 3. 외부 스토어 구독
useEffect(() => {
const unsubscribe = store.subscribe(handleChange);
return unsubscribe;
}, [store]);
// 4. 네트워크 요청 - 늦게 도착한 응답이 새 상태를 덮어쓰지 않게
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => { if (e.name !== "AbortError") setError(e); });
return () => controller.abort();
}, [url]);
// 5. WebSocket
useEffect(() => {
const ws = new WebSocket(url);
return () => ws.close();
}, [url]);2번에서 removeEventListener에 넘기는 함수가 등록할 때와 다른 참조면 해제가 되지 않는다. 등록·해제 모두 effect 안에서 선언한 같은 onResize를 쓰는 것이 중요하다.
개발 모드에서만 두 번 실행되는 건 왜 그런가요?
React 공식 StrictMode 문서는 "Strict Mode가 켜져 있으면 React는 모든 Effect에 대해 개발 환경에서 setup과 cleanup을 한 번 더 실행한다"고 설명한다. 그리고 "이 검사들은 개발 환경에서만 실행되며 프로덕션 빌드에는 영향을 주지 않는다"고 덧붙인다.
즉 콘솔에 로그가 두 번 찍히는 것은 버그가 아니라, cleanup이 setup을 제대로 되돌리는지 확인하는 의도된 스트레스 테스트다. 문서의 표현을 빌리면 setup 대신 setup → cleanup → setup을 돌려서 빠진 cleanup을 눈에 띄게 만드는 것이다. 두 번 실행이 문제를 일으킨다면 StrictMode를 끌 것이 아니라 cleanup을 제대로 구현해야 한다. 프로덕션에서도 사용자가 페이지를 왔다 갔다 하면 같은 상황이 벌어진다.
주의할 점과 한계
- cleanup에서 상태를 다시 세팅하는 식으로 "정리"하려 들면 안 된다. cleanup은 setup이 만든 외부 자원을 되돌리는 자리다.
- 구독 해제 함수를 그대로
return할 때, 그 함수가 cleanup 인자를 받는 형태라면 의도치 않은 인자가 전달될 수 있다. 그런 경우는return () => unsubscribe();처럼 감싸는 편이 안전하다. - 이 글의 수치는 Node 20.20.2에서 effect 반복 실행 구조를 재현한 것이다. React 렌더러를 띄워 측정한 값이 아니며, 실행 순서와 StrictMode 동작은 React 공식 문서를 근거로 했다.
- React 18 이전 버전은 StrictMode의 이중 setup+cleanup 동작이 없다. 버전에 따라 콘솔에 보이는 횟수가 다를 수 있다.
정리표
| setup에서 만든 것 | cleanup에서 할 일 | 빠뜨리면 생기는 증상 |
| setInterval / setTimeout | clearInterval / clearTimeout | 타이머가 중첩되어 콜백이 배수로 실행 |
| addEventListener | 같은 참조로 removeEventListener | 인라인 핸들러면 중복 등록, 언마운트 후에도 잔존 |
| store.subscribe | 반환된 unsubscribe 호출 | 사라진 컴포넌트가 계속 상태 갱신 시도 |
| fetch / axios 요청 | AbortController로 abort | 늦게 온 응답이 최신 상태를 덮어씀 |
| new WebSocket | ws.close() | 연결이 계속 쌓여 서버 커넥션 고갈 |
| 외부 라이브러리 인스턴스 | destroy() 등 해제 API 호출 | DOM 노드와 리스너가 메모리에 잔존 |
자주 묻는 질문(FAQ)
cleanup 함수는 반드시 반환해야 하나요?
setup이 아무것도 만들지 않았다면 필요 없다. 예를 들어 값을 계산해서 state에 넣기만 하는 effect에는 되돌릴 것이 없다. 반대로 타이머, 구독, 이벤트 리스너, 소켓처럼 effect가 끝난 뒤에도 계속 살아 있는 것을 만들었다면 반드시 반환해야 한다.
개발 모드에서 effect가 두 번 실행되는데 StrictMode를 꺼야 하나요?
끄지 않는 편이 좋다. React 공식 문서에 따르면 이 동작은 개발 환경에서만 실행되고 프로덕션 빌드에는 영향을 주지 않으며, 빠진 cleanup을 드러내기 위한 의도된 검사다. 두 번 실행되어 문제가 생긴다면 그 문제는 프로덕션에서도 재마운트 시 똑같이 발생한다.
이벤트 리스너는 중복 등록이 안 된다고 들었는데 왜 쌓이나요?
같은 함수 참조일 때만 중복이 무시되기 때문이다. 직접 확인한 결과 동일 참조를 두 번 등록하면 핸들러가 1회만 호출됐지만, 매번 새로 만든 화살표 함수를 세 번 등록하니 3회 호출됐다. 컴포넌트 안에서 인라인으로 정의한 핸들러는 렌더마다 새 참조라서 쌓인다.
cleanup을 넣었는데도 중복 실행이 줄지 않습니다. 무엇을 봐야 하나요?
removeEventListener에 넘기는 함수가 등록 때와 같은 참조인지 먼저 확인한다. 그다음 의존성 배열을 본다. 렌더마다 새로 만들어지는 객체나 함수가 의존성에 들어 있으면 effect가 매 커밋마다 재실행되므로, 정리와 재생성이 끝없이 반복되며 네트워크 요청 같은 부작용이 계속 발생한다.
관련 글: React useEffect 무한루프 — 의존성 배열 검증과 최적화 전략 · React useEffect 무한루프 문제 해결하기
공식 문서: React — useEffect · React — StrictMode
최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기