React useEffect cleanup이 필요한 이유와 자주 하는 실수 정리
React에서 useEffect를 처음 배울 때는 보통 데이터 요청, 이벤트 등록, 타이머 설정처럼 “무언가 실행하는 코드”에 집중하게 됩니다. 그런데 실제 프로젝트를 하다 보면, effect를 실행하는 것만큼 중요한 것이 바로 정리(cleanup)라는 점을 자주 체감하게 됩니다.
처음에는 화면이 잘 보이기 때문에 문제가 없어 보일 수 있습니다. 하지만 컴포넌트가 다시 렌더링되거나 언마운트되는 과정에서 정리가 제대로 되지 않으면, 중복 이벤트 등록, 메모리 누수처럼 보이는 현상, 예상하지 못한 API 호출, 오래된 상태값 참조 같은 문제가 생길 수 있습니다.
이 글에서는 React에서 useEffect cleanup이 왜 필요한지, 어떤 상황에서 꼭 써야 하는지, 그리고 실무에서 자주 하는 실수를 중심으로 정리해보겠습니다.
useEffect cleanup은 무엇일까
useEffect는 컴포넌트 렌더링 이후 특정 작업을 실행할 때 사용합니다. 예를 들어 이벤트 리스너 등록, 타이머 실행, API 요청, 외부 라이브러리 연결 같은 작업이 여기에 들어갑니다.
그런데 effect 안에서 시작한 작업은, 컴포넌트가 사라지거나 effect가 다시 실행될 때 직접 정리해줘야 하는 경우가 많습니다. 이때 사용하는 것이 cleanup 함수입니다.
useEffect(() => {
console.log('effect 실행');
return () => {
console.log('cleanup 실행');
};
}, []);
즉, cleanup은 effect가 남긴 부작용을 치우는 역할이라고 보면 됩니다.
cleanup은 언제 실행될까
cleanup은 보통 두 가지 시점에 실행됩니다.
- 컴포넌트가 언마운트될 때
- 의존성 배열이 바뀌어 effect가 다시 실행되기 직전
이 점이 중요합니다. 많은 경우 cleanup은 “컴포넌트가 사라질 때만 실행되는 것”처럼 생각하기 쉬운데, 실제로는 이전 effect를 정리하고 다음 effect를 실행하기 전에도 호출됩니다.
useEffect(() => {
console.log('userId 변경에 따라 effect 실행', userId);
return () => {
console.log('이전 userId effect 정리', userId);
};
}, [userId]);
즉, userId가 바뀔 때마다 이전 작업을 정리한 뒤 새 작업을 시작하는 흐름입니다.
cleanup이 꼭 필요한 대표 상황
1. 이벤트 리스너 등록
가장 흔한 예시 중 하나입니다. 윈도우 스크롤, resize, keydown 같은 이벤트를 등록해놓고 정리하지 않으면, 컴포넌트가 다시 마운트될 때마다 리스너가 계속 쌓일 수 있습니다.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
이런 경우 cleanup이 없으면 나중에 resize 이벤트가 한 번 발생했을 때 핸들러가 여러 번 실행되는 현상이 생길 수 있습니다.
2. setInterval / setTimeout 사용
타이머도 정리하지 않으면 대표적으로 문제가 생깁니다.
useEffect(() => {
const timer = setInterval(() => {
console.log('3초마다 실행');
}, 3000);
return () => {
clearInterval(timer);
};
}, []);
이걸 정리하지 않으면 화면을 벗어난 뒤에도 타이머가 계속 살아 있는 것처럼 보일 수 있습니다.
3. 구독(subscription)이나 외부 연결
웹소켓, Firebase listener, custom event bus, 외부 라이브러리 구독 같은 경우도 cleanup이 중요합니다.
useEffect(() => {
const unsubscribe = chatClient.subscribe(roomId, message => {
console.log(message);
});
return () => {
unsubscribe();
};
}, [roomId]);
이런 코드는 이전 roomId의 구독을 해제하지 않으면, 메시지가 여러 번 들어오거나 이전 채널 데이터를 계속 듣는 문제가 생길 수 있습니다.
API 요청에서는 cleanup을 어떻게 봐야 할까
API 요청은 이벤트 리스너처럼 단순히 remove하는 개념과는 조금 다릅니다. 핵심은 이미 의미가 없어졌는데도 이전 요청 결과가 상태를 바꾸지 않도록 막는 것입니다.
예를 들어 검색어가 빠르게 바뀌는 상황을 생각해보면, 이전 요청이 늦게 도착해서 최신 결과를 덮어쓰는 문제가 생길 수 있습니다.
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await response.json();
setResult(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}
fetchData();
return () => {
controller.abort();
};
}, [query]);
이런 방식으로 cleanup에서 요청을 취소하면, 이미 필요 없어진 이전 요청이 뒤늦게 상태를 바꾸는 문제를 줄일 수 있습니다.
실무에서 자주 하는 실수 1: cleanup이 필요한데 안 쓰는 경우
가장 흔한 실수는 effect 안에서 뭔가를 시작해놓고, 그것을 정리해야 한다는 사실을 놓치는 것입니다.
예를 들어 이런 코드입니다.
useEffect(() => {
window.addEventListener('scroll', handleScroll);
}, []);
처음엔 동작합니다. 그런데 나중에 페이지 이동, 조건부 렌더링, 모달 재오픈 같은 흐름이 생기면 이벤트가 중복 등록될 수 있습니다.
즉, 기준은 간단합니다.
- effect 안에서 무언가를 등록했다면
- effect 안에서 지속되는 작업을 시작했다면
- effect 바깥 환경에 영향을 주는 연결을 만들었다면
cleanup이 필요한지 먼저 의심하는 편이 좋습니다.
실무에서 자주 하는 실수 2: 의존성 변경을 고려하지 않는 경우
cleanup은 언마운트 때만 생각하고, 의존성 변경 때도 호출된다는 사실을 놓치는 경우가 많습니다.
useEffect(() => {
const socket = connectSocket(roomId);
return () => {
socket.close();
};
}, [roomId]);
이 코드는 roomId가 바뀔 때마다 이전 소켓을 닫고 새 소켓을 열게 됩니다. 그런데 이 흐름을 이해하지 못하면, 왜 갑자기 연결이 끊기고 다시 열리는지 헷갈릴 수 있습니다.
즉, 의존성이 있는 effect에서는 cleanup이 “재실행 전 정리” 역할도 한다는 점을 항상 염두에 두는 것이 좋습니다.
실무에서 자주 하는 실수 3: 오래된 값(stale closure) 문제를 놓치는 경우
cleanup과 함께 자주 헷갈리는 것이 오래된 상태값을 참조하는 문제입니다. 예를 들어 타이머나 이벤트 핸들러가 예전 state를 기억하고 있는 경우가 있습니다.
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, []);
이 코드는 처음 렌더링 시점의 count만 계속 찍을 수 있습니다. cleanup 자체는 하고 있어도, 로직은 기대와 다를 수 있습니다.
이런 경우는 의존성 배열을 다시 보거나, useRef 또는 상태 업데이트 방식을 조정해야 합니다.
cleanup이 필요 없는 경우도 있다
모든 useEffect에 cleanup이 필요한 것은 아닙니다. 예를 들어 단순히 문서 제목을 바꾸는 정도라면 정리할 것이 없을 수 있습니다.
useEffect(() => {
document.title = '대시보드';
}, []);
이 경우 effect는 실행되지만, 특별히 해제하거나 종료할 대상은 없습니다.
즉, cleanup은 “무조건 넣는 문법”이 아니라, 정리할 자원이 있을 때 쓰는 도구라고 보는 것이 맞습니다.
React Strict Mode에서 두 번 실행되는 것처럼 보이는 이유
개발 환경에서 useEffect가 두 번 실행되는 것처럼 보여서 당황하는 경우도 많습니다. 특히 React Strict Mode에서는 부작용 코드를 더 잘 드러내기 위해 개발 모드에서 effect와 cleanup이 예상보다 자주 실행되는 것처럼 보일 수 있습니다.
이때 중요한 건 “왜 두 번 돌지?”만 보는 것이 아니라, 내 effect가 여러 번 실행되어도 안전한 구조인지를 확인하는 것입니다. cleanup이 제대로 되어 있다면 개발 모드에서 이런 동작이 오히려 문제를 빨리 발견하는 데 도움이 되기도 합니다.
실무에서 점검할 때 보는 기준
effect를 작성할 때 아래 질문을 한 번씩 해보면 cleanup이 필요한지 빠르게 판단할 수 있습니다.
- 이 effect 안에서 이벤트를 등록했는가?
- 타이머를 시작했는가?
- 구독이나 외부 연결을 만들었는가?
- 이전 요청 결과가 뒤늦게 상태를 바꿀 수 있는가?
- 의존성이 바뀔 때 이전 작업을 먼저 정리해야 하는가?
이 질문 중 하나라도 “그렇다”면 cleanup을 먼저 고려하는 편이 안전합니다.
한 줄 정리
React에서 useEffect cleanup은 단순한 문법이 아니라, effect가 만든 부작용을 정리해서 다음 렌더링과 언마운트 시점에 문제를 막는 장치입니다.
- 이벤트 리스너 등록
- 타이머 실행
- 구독 및 외부 연결
- 취소 가능한 비동기 요청
이런 작업에서는 cleanup이 빠지면 나중에 디버깅이 꽤 까다로운 문제가 생길 수 있습니다.
실무에서는 “effect를 어떻게 실행할까”만 보는 것보다, 이 effect를 언제 어떻게 정리할까까지 함께 보는 습관이 더 중요합니다.