React useEffect 무한 루프 에러 완벽 해결법: 의존성 배열(dependency array) 딥다이브
1. useEffect 무한 루프란 무엇이며 왜 발생하는가?
React 애플리케이션을 개발하다 보면 누구나 한 번쯤 겪는 가장 당혹스러운 에러 중 하나가 바로 useEffect 훅(Hook)으로 인한 무한 루프입니다. 브라우저의 화면이 완전히 멈춰버리고 콘솔 창에는 수천 개의 에러 메시지가 쏟아지는 이 현상은, 컴포넌트의 생명주기(Lifecycle)와 상태(State) 업데이트 메커니즘을 정확히 이해하지 못했을 때 발생합니다.
가장 흔한 시나리오는 useEffect 내부에서 상태를 업데이트하는 함수(예: setState)를 호출하는데, 그 useEffect의 의존성 배열(Dependency Array)에 해당 상태가 포함되어 있거나 아예 의존성 배열이 생략된 경우입니다. 의존성 배열이 생략되면 컴포넌트가 렌더링될 때마다 이펙트가 실행되고, 이펙트가 상태를 변경하면 다시 렌더링이 트리거되어 영원히 끝나지 않는 굴레에 빠지게 됩니다.
특히 서버 API에서 데이터를 가져와서 상태에 저장하는 비동기 처리 과정에서 이 실수를 많이 범합니다. 컴포넌트 마운트 시 한 번만 호출하려고 했으나, 배열을 누락하는 순간 수백 번의 API 요청이 백엔드 서버로 날아가며 뜻하지 않은 디도스(DDoS) 공격을 유발하는 아찔한 경험을 할 수 있습니다.
2. 원시 타입과 참조 타입의 차이에서 오는 함정
단순한 숫자나 문자열 같은 원시 타입(Primitive Type)은 값이 같으면 React가 리렌더링을 발생시키지 않습니다. 하지만 객체(Object)나 배열(Array), 함수(Function)와 같은 참조 타입(Reference Type)은 렌더링이 일어날 때마다 완전히 새로운 메모리 주소를 할당받게 됩니다. 즉, 내부의 값이 동일하더라도 React의 얕은 비교(Shallow Compare) 로직에 의하면 "이전 값과 다른 새로운 값"으로 인식되는 것입니다.
예를 들어, 컴포넌트 내부에서 객체를 정의하고 이를 useEffect의 의존성 배열에 넣었다면, 컴포넌트가 렌더링될 때마다 새로운 객체가 생성되므로 useEffect는 값이 변경되었다고 착각하고 다시 실행됩니다. 이것이 겉보기에는 아무런 문제가 없어 보이는 코드에서 무한 루프가 발생하는 핵심적인 이유입니다.
// 무한 루프를 유발하는 안 좋은 코드 예시
const fetchConfig = { method: 'GET' }; // 렌더링마다 새로운 참조 할당useEffect(() => {
fetchData(fetchConfig);
}, [fetchConfig]); // 매번 다른 객체로 인식하여 무한 실행
3. 무한 루프 완벽 해결을 위한 5가지 전략
- 1) 의존성 배열 점검하기: 상태 업데이트 로직이 이펙트 안에 있다면, 반드시 필요한 값만 배열에 넣으세요. Lint 경고를 무시하고 빈 배열
[]을 억지로 넣는 행위는 최신 상태를 반영하지 못하는 버그를 낳습니다. - 2) 함수형 업데이트 사용하기:
setCount(count + 1)대신setCount(prev => prev + 1)와 같이 이전 상태를 인자로 받는 콜백 패턴을 사용하면, 의존성 배열에서count를 안전하게 제거할 수 있습니다. 상태 값에 직접 의존하지 않게 되므로 불필요한 렌더링 사이클을 끊어낼 수 있습니다. - 3) useMemo와 useCallback의 적극적인 활용: 객체나 배열, 함수를 의존성 배열에 넣어야 한다면 반드시
useMemo로 값을 메모이제이션하거나useCallback으로 함수 참조를 고정해야 합니다. 이를 통해 불필요한 렌더링 사이클을 완벽히 통제할 수 있습니다. - 4) 이펙트 외부로 로직 분리: 렌더링과 관련 없는 순수 유틸리티 함수나 상수 객체는 아예 컴포넌트 바깥(스코프 외부)으로 빼서 선언하세요. 그러면 매 렌더링 시 재생성되는 문제를 원천적으로 차단할 수 있습니다.
- 5) 상태 분리 및 리듀서 사용: 너무 많은 상태가 하나의 useEffect에 얽혀 있다면
useReducer를 도입하여 상태 업데이트 로직을 컴포넌트 외부의 리듀서로 위임하는 것을 고려해 보세요. 의존성 관리가 훨씬 수월해집니다.
4. 실제 실무 사례 분석: 커스텀 훅에서의 함정
실무에서는 단순한 컴포넌트가 아닌, 여러 단계로 중첩된 커스텀 훅(Custom Hook) 내부에서 무한 루프가 발생하는 경우가 많습니다. 예를 들어 useFetchData라는 훅을 만들었는데, 그 안에서 인자로 받은 options 객체를 useEffect의 의존성에 넣었다고 가정해 봅시다. 이 훅을 사용하는 부모 컴포넌트가 useFetchData({ status: 'active' }) 처럼 객체를 리터럴로 넘길 경우, 부모가 렌더링될 때마다 새로운 옵션 객체가 생성되어 훅 내부의 useEffect가 무한으로 도는 끔찍한 연쇄 반응이 일어납니다. 이럴 때는 커스텀 훅 내부에서 깊은 비교(Deep Compare)를 수행하는 useDeepCompareEffect 라이브러리를 쓰거나, 애초에 부모에서 useMemo로 감싸서 넘기도록 강제해야 합니다.
5. 자주 묻는 질문 (FAQ)
Q. eslint-plugin-react-hooks의 exhaustive-deps 경고를 꺼도 되나요?
절대 권장하지 않습니다. 이 경고는 억울하게 울리는 것이 아니라, 미래에 발생할 버그를 정확히 짚어주는 나침반과 같습니다. 경고를 끄기보다는 useCallback을 사용해 함수를 감싸고, 그 함수를 의존성 배열에 안전하게 포함시키는 방향으로 코드를 리팩토링해야 합니다.
Q. useRef를 사용해서 무한 루프를 막을 수 있나요?
네, 가능합니다. useRef에 저장된 값은 변경되어도 컴포넌트를 리렌더링시키지 않으며, 컴포넌트의 전 생애주기 동안 동일한 참조를 유지합니다. 따라서 렌더링에 영향을 주지 않아야 하는 최신 값을 보관하고 useEffect 내부에서 읽어오는 용도로 useRef는 매우 훌륭한 탈출구(Escape Hatch)가 됩니다. 최신 값을 읽기만 하고 그 변화에 반응할 필요가 없을 때는 ref가 최고의 선택입니다.
Q. 개발 환경(strict mode)에서 useEffect가 두 번 실행되는 건 무한 루프의 전조증상인가요?
아닙니다. React 18부터 Strict Mode에서는 컴포넌트가 마운트될 때 고의로 마운트 -> 언마운트 -> 마운트 과정을 거칩니다. 이는 당신의 클린업(cleanup) 함수가 제대로 작동하는지 테스트하기 위한 React의 의도된 동작이므로 안심하셔도 됩니다. 프로덕션 빌드에서는 정상적으로 한 번만 실행됩니다.
OMANGAZI 편집팀
최신 IT 기술, 오픈소스 AI 생태계, 그리고 모던 웹 개발 트렌드를 연구하고 분석합니다. 단순한 정보 전달을 넘어 개발자들의 실무에 도움이 되는 깊이 있는 인사이트를 제공합니다.