React 18/Next.js의 무한 루프 에러: useEffect 의존성 배열 지옥 탈출법

React 18 Next.js useEffect 무한루프 오류 해결 및 의존성 배열 최적화 가이드
3초 요약: 이렇게 고치세요
  • 원인: useEffect가 렌더링마다 새로 만들어지는 객체/배열을 의존성 배열에 그대로 넣거나, 이펙트 내부에서 그 의존성 자신을 수정해서 발생
  • 해결: 객체는 원시값(문자열/숫자)으로 분해해서 의존성에 넣거나 useMemo로 캐싱 / 최초 1회만 실행할 로직은 의존성 배열을 빈 배열로
  • 주의: API 응답 배열/객체를 그대로 의존성에 넣는 실수가 실무에서 가장 흔함 — 참조가 매번 바뀌어 얕은 비교에 걸림
useEffect(() => { fetchUserData(user); }, [user.type]); // 객체 통째로 X, 원시값만
↓ 왜 이런 에러가 나는지 원리 보기

React나 Next.js로 컴포넌트를 개발하다가 브라우저가 갑자기 멈추거나, 개발자 도구 콘솔에 수천 개의 API 요청이 찍히며 과부하가 걸린 경험이 있으실 겁니다. 이런 '무한 렌더링 루프'는 거의 대부분 useEffect 훅과 의존성 배열(Dependency Array)의 잘못된 설계 때문에 생깁니다.


useEffect 무한 루프는 왜 발생하는가

useEffect 훅은 컴포넌트가 렌더링된 이후 특정 상태의 변화에 따라 부수 효과(Side Effect)를 실행합니다. 이때 상태의 변화를 감지하는 기준이 바로 의존성 배열입니다.

무한 루프는 보통 이런 순서로 만들어집니다.

  1. 컴포넌트가 렌더링됩니다.
  2. useEffect가 실행되고 내부에서 상태(State)를 업데이트합니다.
  3. 상태가 바뀌었으므로 컴포넌트가 다시 렌더링됩니다.
  4. 의존성 배열의 값이 바뀌었다고 판단되어 useEffect가 또 실행됩니다.
  5. 2번~4번 과정이 무한히 반복되며 브라우저가 마비됩니다.

API 응답으로 받은 배열이나 객체를 그대로 의존성 배열에 넣었다가 이 증상을 겪는 경우가 실무에서 특히 많습니다.

무한 루프의 위험성 및 피해 요약

useEffect 무한 루프의 연쇄 피해

1. 사용자 경험 파괴 (CPU 점유율 100%)
브라우저 메인 스레드가 무한 루프 연산에 갇혀 스크롤이 버벅이고 화면이 응답 없음 상태에 빠집니다. 메모리 누수까지 겹치면 탭이 강제 종료되기도 합니다.
2. 서버 비용 폭탄 (API 요청 폭발)
렌더링마다 백엔드 서버에 API 요청을 보낸다면 수십 초 만에 수만 번의 트래픽이 발생하여 API 요금 폭탄이나 IP 차단을 당할 수 있습니다.

자주 저지르는 2대 실수와 해결책

1. 의존성 배열에 '객체(Object)나 배열'을 넣는 실수

자바스크립트에서 객체와 배열은 참조 타입(Reference Type)입니다. 겉보기에는 내용물이 똑같더라도 렌더링될 때마다 새로운 메모리 주소에 할당됩니다. React는 이를 "전혀 다른 새로운 값으로 변경되었다"고 판단(얕은 비교)하여 useEffect를 재실행합니다.

[잘못된 예시]

// 매 렌더링마다 { type: 'guest' } 객체가 새로 생성되어 무한 루프 발생
const user = { type: 'guest' };

useEffect(() => {
  fetchUserData(user);
}, [user]); // 객체 참조값 불일치로 계속 실행됨

[올바른 해결 코드]

const user = { type: 'guest' };

useEffect(() => {
  fetchUserData(user);
}, [user.type]); // 원시 타입(string)은 값이 같으면 재실행되지 않음

실무에서는 이 형태가 필터 객체에서 특히 잘 나옵니다. 검색 페이지에서 {'{'} category, sortBy {'}'} 같은 필터 객체를 컴포넌트 안에서 매번 새로 만들어 의존성에 넣었다가, 필터를 바꾸지도 않았는데 네트워크 탭에 같은 요청이 계속 찍히는 걸 보고 나서야 원인을 알아챈 적이 있습니다. 필터 값 자체는 안 바뀌었는데 객체 참조만 매번 달라지고 있었습니다.

2. useEffect 내부에서 의존성 배열에 담긴 상태를 수정할 때

API 호출 결과를 로컬 상태에 저장하는 로직에서 흔히 하는 실수입니다. 의존성 배열에 상태를 넣으면, 데이터 저장 작업 자체가 새로운 이펙트를 트리거하는 지옥이 열립니다.

[잘못된 예시]

const [data, setData] = useState([]);

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(result => setData(result)); // 1. 상태(data) 업데이트 -> 2. 재렌더링
}, [data]); // 3. data가 바뀌었으므로 useEffect 재실행 (무한루프)

[올바른 해결 코드]

const [data, setData] = useState([]);

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(result => setData(result));
}, []); // 최초 1회만 호출 보장

의존성 배열 지옥을 피하는 방법

방법 A: 함수 컴포넌트 외부로 변수 추출하기

상태(State)와 관계없는 상수 성격의 객체나 함수는 컴포넌트 외부에 선언하면 렌더링 시 재생성되지 않으므로 의존성 배열에 넣을 필요조차 없어집니다.

// 컴포넌트 외부에 선언하여 메모리 주소를 고정시킵니다.
const CONFIG = { theme: 'dark' };

export default function MyComponent() {
  useEffect(() => {
    applyTheme(CONFIG);
  }, []); // 의존성 배열에 CONFIG를 넣지 않아도 됨
}

방법 B: useCallback과 useMemo로 메모이제이션하기

컴포넌트 내의 유동적인 변수를 의존성 배열에 안전하게 사용해야 한다면, 변수의 재생성을 막기 위해 리액트의 내장 캐싱 훅을 적용합니다.

const [id, setId] = useState(1);

// id가 바뀔 때만 객체가 재생성되도록 캐싱
const fetchParams = useMemo(() => ({ userId: id }), [id]);

useEffect(() => {
  getUserData(fetchParams);
}, [fetchParams]); // fetchParams가 실제로 변할 때만 효과적으로 동작

참고로 개발 모드에서 콘솔에 API 요청이 두 번씩 찍힌다고 전부 무한 루프는 아닙니다. React 18의 StrictMode는 이펙트가 제대로 정리(cleanup)되는지 검증하려고 개발 모드에서만 마운트 시점 이펙트를 일부러 한 번 더 실행합니다. 이건 두 번으로 끝나고 프로덕션 빌드에서는 일어나지 않는 반면, 진짜 무한 루프는 콘솔에 로그가 멈추지 않고 계속 쌓이거나 브라우저가 버벅이는 걸로 구분됩니다.


요약: useEffect 사용 전 체크리스트

정리하면, 의존성 배열에 객체나 배열을 그대로 넣으면 참조값 불일치로 렌더링마다 무한 루프가 발생하므로 원시 값으로 분해해서 넣거나 useMemo로 캐싱해야 합니다. 이펙트 내부에서 의존성 배열에 담긴 상태를 그대로 수정하면 재렌더링과 재실행이 꼬리를 무는 루프가 생기므로 의존성을 빈 배열로 지정하는 편이 안전합니다. 컴포넌트 내부에 정적인 변수를 두면 렌더링마다 재할당되어 불필요한 이펙트가 실행되므로 아예 컴포넌트 파일 바깥으로 옮겨두는 것이 좋습니다.

공식 참고 레퍼런스

React의 이펙트 종속성 관리에 대한 자세한 가이드는 공식 문서에서 확인할 수 있습니다.

호마다의 웹개발 팁

useEffect 무한 루프는 결국 렌더링과 상태 업데이트가 어떤 순서로 도는지 감을 잡아야 피할 수 있습니다. 이 흐름이 헷갈리신다면 [호마다의 IT 개발 입문 블로그] 의 자바스크립트 이벤트 루프 글도 함께 참고해보세요.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법