그 동안 왜 나는 무한 렌더링에 고통 받았나? — useEffect 의 고질적인 문제

TLDR Dev 에서 본 react.doctor 글 정리. useEffect 가 왜 그렇게 자주 폭주하는지, 진짜 범인은 함수도 fetch 도 아닌 '레퍼런스' 였다는 이야기.

Jun Noh

React 를 처음 배울 때부터 익숙해진 지금까지, 매번 나를 멈춰 세우는 부분이 있다.

바로 useEffect 의 의존성 배열.

너무 빡빡하게 주자니 — 매번 effect 가 돌면서 무한 루프로 빠지고, 너무 느슨하게 주자니 — 정작 변경된 값을 못 읽고 stale 한 상태로 멈춰 있고.

그 사이 어디쯤이 정답인 건지, 매번 잡힐 듯 안 잡힌다. 결국 console.log 박아가면서 감으로 맞추고, 어찌어찌 돌면 그게 정답인 척하고 넘어간 게 한두 번이 아니다.

오늘 TLDR Dev 에서 react.doctor 의 이 글 을 봤는데 — 그 애매했던 감각의 정체를 정확히 짚어줘서, 한 번 정리해두고 가야겠다 싶었다.


도입: useEffect 는 생각보다 자주 돈다

글의 첫 줄이 일단 정곡을 찌른다.

“useEffect runs more frequently than developers expect.”

useEffect 는 개발자가 생각하는 것보다 훨씬 자주 돈다. 그 결과:

  • 무한 루프
  • 같은 API 요청 폭주
  • 브라우저 freeze

원인은 한 줄로 — 의존성 배열에 대한 오해.

react.doctor 가 이걸 두 가지 함정으로 나눠서 설명한다.


함정 1. 클래식 무한 루프 (The Render Loop)

dep 배열 자체를 빼먹은 경우.

useEffect(() => {
  setSomething(...);
});  // ← dep 배열 없음 = 매 render 마다 실행

매 render 마다 effect 실행 → setState → re-render → 또 effect 실행 → 무한.

이건 ESLint 가 잡아주는 영역이고, 한 번 당하면 다시 안 당한다.

진짜 문제는 다음 거다.


함정 2. 위장한 무한 루프 (The Disguised Loop)

dep 배열을 분명히 적었는데도 무한 루프 도는 경우.

function SearchPage({ query }) {
  const filters = { query, sort: "recent" };
  
  useEffect(() => {
    fetchResults(filters);
  }, [filters]);
}

dep 명시했고, ESLint 통과하고, 코드 자체도 멀쩡해 보인다. 그런데도 무한 루프.

내가 그 동안 dep 배열 앞에서 멈춰 섰던 순간들이 사실 다 이거였다. 분명히 넣었는데 왜 자꾸 돌지.

답은 의외로 자바스크립트 기초에 있었다.


핵심: 객체는 reference 로 비교된다

{ query: 'react', sort: 'recent' } === { query: 'react', sort: 'recent' }
// false

자바스크립트 객체 비교는 내용이 아니라 메모리 주소 (reference) 비교다.

React 의 dep 배열 비교도 결국 Object.is 기반이라 — 객체에 대해서는 reference 비교.

그러면 위 코드의 흐름:

render #1 → filters 객체 새로 만듦 (주소 A) → effect 실행
effect 에서 setState → re-render
render #2 → filters 객체 또 새로 만듦 (주소 B) → React: "주소 바뀌었네" → effect 실행
... 무한 ...

내용은 똑같은데 React 만 다르다고 본다.

React only checks identity. (글 원문)

자바스크립트의 본성을 — React 의 dep 배열이라는 추상화가 가려놨던 거다. 그래서 한참을 못 봤다.


그래서 어떻게 해야 하나

글에서 제시하는 해결책 정리.

1. useMemo 로 reference 고정

const filters = useMemo(
  () => ({ query, sort: "recent" }),
  [query]
);

query 가 안 바뀌면 같은 reference 유지. 함수면 useCallback.

2. primitive 만 dep 에 넣기

useEffect(() => {
  fetchResults({ query, sort: "recent" });
}, [query]);

이게 보통 정석이다. 객체는 effect 안에서 만들고, dep 에는 string / number / boolean 같은 primitive 만.

useMemo 까지 동원하지 않아도 되는 경우가 의외로 많다.

3. react-hooks/exhaustive-deps ESLint 룰 켜기

dep 빠진 거 자동 잡아준다.

다만 — 객체 reference 문제까지는 못 잡는다. dep 에 객체 넣어도 ESLint 입장에선 “넣었네 OK” 임. ESLint 만 신뢰하면 안 된다는 거.

4. 애초에 useEffect 가 맞는지 다시 생각

글의 진짜 마지막 메시지가 이거다.

데이터 페칭 같은 건 — useEffect 보다 React Query / SWR 같은 라이브러리에 맡기는 게 안전하다. 또는 submit 핸들러 로 옮기기. 사용자가 키 칠 때마다 호출할 게 아니라 명시적으로 트리거할 때 호출.

useEffect 는 외부 시스템과의 동기화 용 도구지, 데이터 페칭의 1순위 트리거 가 아니다.

React 18 이후로 점점 강조되는 방향이다.


마치며

이 글에서 가장 박힌 한 줄.

dep 배열은 formality 가 아니다. effect 가 언제 다시 도는가에 대한 전체 contract 다.

그 동안 dep 배열을 “넣어야 하는 거 같으니 넣는 항목” 정도로 봤다. 빼먹거나, 뭐 넣을지 헷갈리거나, eslint-disable 박고 도망간 적이 한두 번이 아니다.

근데 이걸 “effect 가 어떤 조건에서 다시 돌아야 하는지” 의 contract 라는 관점으로 바꾸면 — dep 에 객체 넣기 전에 한 번씩 멈추게 된다. 매 render 마다 새로 만들어지는 객체인지, 그게 의도된 변경 신호인지.

추상화는 편하지만, 그 밑에 깔린 자바스크립트의 본성을 모르면 — 어느 순간 그 편함이 비용으로 돌아온다.

useEffect 와 진짜 친해지려면, 결국 한 단계 아래의 자바스크립트로 다시 내려가야 하는 거 같다.

마침.

다른 글 보기