그 동안 왜 나는 무한 렌더링에 고통 받았나? — useEffect 의 고질적인 문제
TLDR Dev 에서 본 react.doctor 글 정리. useEffect 가 왜 그렇게 자주 폭주하는지, 진짜 범인은 함수도 fetch 도 아닌 '레퍼런스' 였다는 이야기.
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 와 진짜 친해지려면, 결국 한 단계 아래의 자바스크립트로 다시 내려가야 하는 거 같다.
마침.