- 브라우저 이벤트 리스너 등록과 해제
- 타이머와 인터벌 설정
- 차트나 지도 같은 비 React 위젯 연결
- 웹소켓 등 연결 유지가 필요한 통신
- 외부 저장소 구독
이 파트에서 다루는 내용
이펙트는 '외부 시스템과 맞추는 일'에만 씁니다
useEffect를 '렌더링 후에 실행되는 코드' 정도로 이해하면 거의 반드시 오용하게 됩니다. 공식 문서가 정의하는 목적은 명확합니다. React 바깥의 시스템과 상태를 동기화하는 것입니다.
React 바깥이란 브라우저 API, 타이머, 외부 라이브러리 위젯, 네트워크 연결처럼 React가 관리하지 않는 영역을 말합니다.
반대로 말하면, React 안에서 끝나는 일(값 계산, 이벤트 처리, 상태 갱신)에는 이펙트가 필요 없습니다. 이 구분이 이 파트의 핵심입니다.
- props나 state로 값을 계산하는 일
- 버튼 클릭 등 사용자 동작에 대한 반응
- 부모에게 변경을 알리는 일
- 다른 상태를 연쇄적으로 바꾸는 일
의존성 배열은 목록이 아니라 결과입니다
의존성 배열은 개발자가 마음대로 정하는 설정값이 아닙니다. 이펙트 안에서 사용하는 값 중 렌더링에 따라 달라지는 것을 모두 넣어야 합니다.
값을 빼면 오래된 값을 계속 참조하게 되고, 화면과 동작이 어긋납니다. 린트 경고를 끄고 배열을 비우는 방식이 가장 위험합니다.
거의 쓸 일이 없습니다. 안에서 상태를 바꾸면 무한 반복에 빠집니다.
마운트 시 한 번만 실행합니다. 다만 안에서 쓰는 값이 있는데 배열을 비우면 값이 갱신되지 않습니다.
가장 일반적입니다. 이펙트에서 참조하는 모든 반응형 값을 넣습니다.
이펙트 안에서 상태를 바꾸고, 그 상태가 의존성에 들어 있는 구조입니다. 객체나 배열을 의존성에 넣으면 매번 새 참조라 같은 문제가 생깁니다.
// 문제: options는 렌더링마다 새 객체입니다
const options = { status: "PAID" };
useEffect(() => {
fetchOrders(options).then(setOrders);
}, [options]); // 매번 다른 값으로 인식 -> 계속 실행
// 해결 1: 원시값만 의존성에 둡니다
const status = "PAID";
useEffect(() => {
fetchOrders({ status }).then(setOrders);
}, [status]);
// 해결 2: 객체 생성을 이펙트 안으로 옮깁니다
useEffect(() => {
const options = { status: "PAID" };
fetchOrders(options).then(setOrders);
}, []);의존성 문제 대부분은 객체와 함수를 의존성에 넣으면서 생깁니다. 가능하면 원시값 단위로 쪼개서 넣습니다.
클린업은 선택이 아닙니다
이펙트가 무언가를 시작했다면 정리하는 코드도 함께 있어야 합니다. 클린업 함수를 반환하면 컴포넌트가 사라질 때와 이펙트가 다시 실행되기 직전에 호출됩니다.
정리하지 않으면 리스너와 타이머가 쌓이고, 이미 사라진 컴포넌트의 상태를 갱신하려다 문제가 생깁니다.
개발 환경에서는 이펙트가 의도적으로 두 번 실행됩니다. 클린업이 제대로 작성됐는지 검사하기 위한 장치이며 운영 빌드에서는 한 번만 실행됩니다.
실행 횟수를 억지로 막기보다 클린업이 빠졌는지 확인합니다. 두 번 실행에서 깨지는 코드는 실제로도 문제가 있는 코드입니다.
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
useEffect(() => {
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, []);// 문제: 빠르게 검색어를 바꾸면 이전 요청 결과가 나중에 도착해
// 최신 결과를 덮어쓸 수 있습니다
useEffect(() => {
let ignore = false;
fetchOrders(keyword).then((data) => {
if (!ignore) {
setOrders(data); // 최신 요청의 결과만 반영합니다
}
});
return () => {
ignore = true;
};
}, [keyword]);이 문제를 매번 직접 처리하는 것은 번거롭습니다. 서버 데이터 전용 도구를 쓰는 이유 중 하나이며 Part 9에서 다룹니다.
대부분의 이펙트는 없애는 것이 정답입니다
공식 문서가 별도 장으로 다룰 만큼 흔한 문제입니다. 아래 다섯 가지는 이펙트로 작성됐지만 이펙트가 필요 없는 대표적인 경우입니다.
props나 state로 만들어 낼 수 있는 값을 이펙트로 상태에 저장하지 않습니다. 렌더링이 두 번 일어나고 값이 어긋날 수 있습니다.
버튼을 눌렀을 때 할 일은 핸들러에 씁니다. 이펙트로 옮기면 무엇 때문에 실행됐는지 알 수 없게 됩니다.
특정 값이 바뀔 때 내부 상태를 전부 리셋해야 한다면, 컴포넌트에 key를 주는 편이 간단하고 정확합니다.
이펙트가 상태를 바꾸고 그것이 또 다른 이펙트를 부르는 구조는 추적이 어렵습니다. 핸들러에서 한 번에 계산합니다.
상태가 바뀐 뒤 이펙트로 부모 콜백을 부르지 말고, 상태를 바꾸는 그 자리에서 함께 호출합니다.
// 불필요한 이펙트: 렌더링이 두 번 일어납니다
const [orders, setOrders] = useState([]);
const [visibleOrders, setVisibleOrders] = useState([]);
useEffect(() => {
setVisibleOrders(orders.filter((o) => o.status === filter));
}, [orders, filter]);
// 수정: 렌더링 중에 계산합니다
const [orders, setOrders] = useState([]);
const visibleOrders = orders.filter((o) => o.status === filter);Part 3에서 다룬 '계산 가능한 값을 state로 두지 않는다'와 같은 원칙입니다. 이펙트로 상태를 만들어 내는 코드를 보면 대부분 이 경우입니다.
이펙트를 쓰기 전에 물어봅니다. 이 코드가 React 바깥의 무언가와 맞추는 일인가? 아니라면 렌더링 중 계산이나 이벤트 핸들러로 옮길 수 있습니다.
버전별 참고
본문은 React 19 기준입니다. 18과 달라지는 부분은 아래에 정리합니다.
- React 18부터 개발 모드에서 이펙트가 두 번 실행됩니다. 18과 19 모두 동일하며 운영 빌드에는 영향이 없습니다.
- 데이터 조회 목적의 이펙트는 React 19의 폼·비동기 관련 훅이나 서버 상태 라이브러리로 대체하는 경우가 많습니다. Part 9에서 다룹니다.
- 외부 저장소를 구독할 때는 useSyncExternalStore를 쓰는 편이 안전합니다. 18 이상에서 사용할 수 있습니다.