Published on

React 렌더 폭주? useEffect 의존성 루프 디버깅

Authors

사용자 제보로 가장 자주 받는 React 이슈 중 하나가 “페이지가 멈춘다”, “네트워크 요청이 무한히 나간다”, “CPU가 100%를 찍는다” 같은 증상입니다. 콘솔에는 에러가 없거나, 있어도 Maximum update depth exceeded 정도만 보이는 경우가 많습니다.

대부분의 경우 범인은 useEffect 의존성 배열과 상태 업데이트가 서로를 자극하는 의존성 루프입니다. 이 글에서는 렌더 폭주가 왜 생기는지, 어떤 식으로 디버깅해야 빠르게 원인을 좁힐 수 있는지, 그리고 실무에서 안전하게 고치는 패턴을 코드로 정리합니다.


렌더 폭주가 생기는 구조: “effect가 state를 바꾸고, state가 effect를 다시 부른다”

React의 렌더 사이클을 아주 단순화하면 아래 흐름입니다.

  1. 상태 변경 setState 발생
  2. 컴포넌트 재렌더
  3. 커밋 이후 useEffect 실행
  4. effect 안에서 다시 setState
  5. 1로 돌아감

여기서 핵심은 useEffect가 “렌더 이후 실행”된다는 점입니다. 즉, effect가 상태를 변경하면 그 다음 렌더를 촉발할 수 있습니다. 문제는 이 상태가 다시 effect 의존성에 포함되거나, 의존성으로 들어간 값이 매 렌더마다 새로 생성되면 루프가 됩니다.


가장 흔한 재현 코드 3가지

1) 의존성에 넣은 값을 effect가 직접 갱신

import { useEffect, useState } from "react";

export function BadCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // count가 바뀔 때마다 실행되고
    // 실행될 때마다 count를 바꾸니 무한 루프
    setCount(count + 1);
  }, [count]);

  return <div>count: {count}</div>;
}

이 패턴은 눈에 잘 띄어서 금방 고치지만, 실무에서는 더 교묘한 형태로 나타납니다.


2) 의존성에 “매 렌더마다 새로 만들어지는 객체/함수”가 들어감

import { useEffect, useState } from "react";

export function BadObjectDeps() {
  const [data, setData] = useState<string | null>(null);

  const params = { q: "react" }; // 매 렌더마다 새 객체

  useEffect(() => {
    // params가 매번 새로 만들어지니 effect가 매번 실행됨
    setData(JSON.stringify(params));
  }, [params]);

  return <div>{data}</div>;
}

params는 내용이 같아도 참조가 바뀝니다. 의존성 비교는 얕은 비교이므로 매 렌더마다 effect가 실행됩니다.


3) 부모에서 내려온 콜백/옵션이 매번 새로 생성됨

부모 컴포넌트가 아래처럼 prop으로 함수를 내려주고, 자식이 그것을 의존성에 넣으면 루프가 시작될 수 있습니다.

// Parent
export function Parent() {
  const onChange = (v: string) => {
    console.log(v);
  };

  return <Child onChange={onChange} />;
}

// Child
import { useEffect } from "react";

function Child({ onChange }: { onChange: (v: string) => void }) {
  useEffect(() => {
    onChange("mounted");
  }, [onChange]);

  return <div />;
}

부모가 렌더될 때마다 onChange는 새 함수가 되므로 자식 effect가 계속 실행될 수 있습니다.


디버깅 1단계: “무엇이 렌더를 트리거했는지”부터 분리

렌더 폭주를 잡을 때 가장 먼저 해야 할 일은 “렌더가 왜 반복되는지”를 관찰 가능한 형태로 만드는 것입니다.

렌더 횟수 카운팅

import { useEffect, useRef } from "react";

export function RenderCounter() {
  const renders = useRef(0);
  renders.current += 1;

  useEffect(() => {
    console.log("committed renders:", renders.current);
  });

  return <div>renders: {renders.current}</div>;
}
  • 렌더 자체는 동기적으로 증가
  • effect 로그는 커밋 이후 찍힘

이 둘을 같이 보면 “렌더가 폭주하는지”, “effect가 매번 실행되는지”를 구분할 수 있습니다.

useEffect 실행 지점에 원인 로그 남기기

useEffect(() => {
  console.log("effect run", { depA, depB });
  return () => console.log("cleanup", { depA, depB });
}, [depA, depB]);

cleanup이 계속 찍힌다면 effect가 다시 실행되고 있다는 뜻입니다.


디버깅 2단계: 의존성 배열을 “값”이 아니라 “참조 안정성” 관점에서 본다

렌더 폭주의 70%는 아래 중 하나입니다.

  • 의존성에 객체/배열/함수가 들어가는데 참조가 안정적이지 않음
  • effect 내부에서 상태를 갱신하는데, 그 상태가 의존성에 포함됨
  • effect가 외부 상태(전역 store, URL state, form state)를 갱신하고 그 변화가 다시 effect 조건을 만족시킴

참조가 바뀌는지 확인하는 도구 함수

import { useEffect, useRef } from "react";

export function useWhyChanged<T>(name: string, value: T) {
  const prev = useRef<T | undefined>(undefined);

  useEffect(() => {
    if (prev.current !== value) {
      console.log("changed:", name, { prev: prev.current, next: value });
      prev.current = value;
    }
  }, [name, value]);
}

사용 예:

useWhyChanged("params", params);
useWhyChanged("onChange", onChange);

객체/함수라면 대부분 “내용은 같아 보이는데 changed가 계속 찍히는” 상황을 바로 확인할 수 있습니다.


수정 패턴 1: 객체/배열 의존성은 useMemo로 고정

import { useEffect, useMemo, useState } from "react";

export function GoodObjectDeps() {
  const [data, setData] = useState<string | null>(null);

  const params = useMemo(() => ({ q: "react" }), []);

  useEffect(() => {
    setData(JSON.stringify(params));
  }, [params]);

  return <div>{data}</div>;
}

주의점:

  • useMemo는 “캐시”이지 “불변 보장”이 아닙니다.
  • 하지만 의존성 배열이 올바르면 같은 참조를 유지해 effect 폭주를 막는 데 매우 유효합니다.

수정 패턴 2: 함수 의존성은 useCallback으로 고정하거나, 의존성을 바꾸는 설계를 한다

부모가 내려주는 콜백이 매번 바뀌는 문제는 아래처럼 고칩니다.

import { useCallback } from "react";

export function Parent() {
  const onChange = useCallback((v: string) => {
    console.log(v);
  }, []);

  return <Child onChange={onChange} />;
}

혹은 자식에서 “정말로 onChange가 바뀔 때마다 effect를 재실행해야 하는가”를 재검토합니다. 마운트 시 1회만 호출하고 싶다면 의존성에서 제외하고, ESLint 경고를 무시하는 대신 설계를 명확히 하는 편이 낫습니다.

예를 들어 useEffect 대신 이벤트 핸들러에서 호출하거나, 부모에서 마운트 시점 로직을 처리하는 방식으로 책임을 이동할 수 있습니다.


수정 패턴 3: effect에서 state를 만들기보다 “파생 상태”는 렌더에서 계산

아래는 매우 흔한 안티패턴입니다.

const [fullName, setFullName] = useState("" ուշ);

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

이건 effect가 필요 없습니다. 파생값은 렌더에서 계산하는 게 맞습니다.

const fullName = `${firstName} ${lastName}`;

이렇게 하면 불필요한 상태 업데이트가 사라지고, 루프 가능성도 줄어듭니다.


수정 패턴 4: “이전 값과 다를 때만 setState”로 루프 차단

effect에서 상태를 갱신해야 한다면, 무조건 setState를 호출하지 말고 변화가 있을 때만 호출해야 합니다.

useEffect(() => {
  const next = computeExpensiveValue(input);

  setValue(prev => {
    if (Object.is(prev, next)) return prev;
    return next;
  });
}, [input]);

특히 서버 응답을 정규화하거나, URL 쿼리와 내부 상태를 동기화할 때 이 패턴이 없으면 “같은 값을 계속 set”하면서 렌더가 불필요하게 반복될 수 있습니다.


수정 패턴 5: 비동기 요청 폭주에는 취소와 최신성 보장이 필요

렌더 폭주가 네트워크 폭주로 이어지는 경우가 많습니다. effect가 계속 실행되면 fetch가 계속 나가고, 응답이 돌아오면서 또 상태가 바뀌어 루프가 강화됩니다.

아래는 최소한의 방어 코드입니다.

import { useEffect, useState } from "react";

export function Search({ query }: { query: string }) {
  const [result, setResult] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    let alive = true;

    async function run() {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal,
      });
      const text = await res.text();

      // 최신 요청만 반영
      if (!alive) return;
      setResult(text);
    }

    run().catch(err => {
      if ((err as { name?: string }).name === "AbortError") return;
      throw err;
    });

    return () => {
      alive = false;
      controller.abort();
    };
  }, [query]);

  return <div>{result}</div>;
}

이 코드는 루프 자체를 고치진 않지만, 루프가 있을 때도 장애(요청 폭주, 상태 경합)를 완화합니다.

요청 폭주를 다루는 백오프/재시도 설계는 클라이언트에서도 중요합니다. 비슷한 관점으로는 이 글의 재시도 전략도 참고할 만합니다.


디버깅 3단계: Strict Mode에서 “두 번 실행”과 “무한 루프”를 구분

개발 환경에서 React Strict Mode는 일부 생명주기 동작을 의도적으로 한 번 더 실행해 부작용을 찾습니다. 이때 effect가 2번 실행되는 것을 무한 루프로 오해하는 경우가 있습니다.

구분법:

  • Strict Mode: 보통 마운트 직후 effect가 2번 실행되고 안정화됨
  • 의존성 루프: 렌더와 effect가 끝없이 반복되며 로그가 멈추지 않음

Strict Mode 때문에 “두 번 요청”이 문제라면, 위의 AbortController와 최신성 보장으로 대부분 해결됩니다.


실무 체크리스트: 10분 안에 범인 찾기

  1. effect 안에 setState가 있는지 찾기
  2. setState가 바꾸는 상태가 의존성에 포함되는지 확인
  3. 의존성 배열에 객체/배열/함수가 있는지 확인
  4. 그 객체/함수의 생성 위치가 컴포넌트 바디인지 확인
  5. 부모에서 내려오는 prop이 매번 새로 만들어지는지 확인
  6. 파생 상태를 effect로 만들고 있진 않은지 확인
  7. “같은 값 set”을 막는 가드가 있는지 확인
  8. 비동기 요청이면 취소 처리와 최신성 보장이 있는지 확인

이 과정을 따르면 대부분의 렌더 폭주는 “원인 파악”까지는 빠르게 도달합니다.


자주 나오는 함정: 상태 동기화 effect가 서로를 갱신

예: URL 쿼리와 내부 상태를 맞추는 effect를 양방향으로 두면 루프가 쉽게 생깁니다.

// 안티패턴 예시(개념)
useEffect(() => {
  setFormState(parse(location.search));
}, [location.search]);

useEffect(() => {
  navigate(serialize(formState));
}, [formState]);

해결 방향은 보통 셋 중 하나입니다.

  • 단방향으로 만들기(한쪽을 source of truth로)
  • 변경 원인을 구분하는 플래그 두기
  • 변경이 “의미 있을 때만” 반영하도록 Object.is 가드/딥 비교/정규화 적용

인증 리다이렉트나 콜백 URL 처리에서도 비슷한 “상태 동기화 루프”가 자주 나오는데, 인프라 레벨에서 리다이렉트가 꼬일 때는 아래 글처럼 원인을 분리하는 접근이 도움이 됩니다.


결론: useEffect는 “동기화” 도구이지 “계산” 도구가 아니다

렌더 폭주의 대부분은 effect가 과한 책임을 갖는 데서 시작합니다.

  • 계산은 렌더에서
  • 외부 시스템과의 동기화만 effect에서
  • 의존성은 값이 아니라 “참조 안정성”까지 고려
  • 비동기는 취소와 최신성 보장

이 원칙을 지키면 useEffect 의존성 루프는 재발률이 크게 줄고, 디버깅 시간도 눈에 띄게 짧아집니다.