무한 렌더링은 왜 발생할까? - React의 렌더링 메커니즘 깊이 이해하기
무한 렌더링은 왜 발생할까? - React의 렌더링 메커니즘 깊이 이해하기
1. "Maximum update depth exceeded"
Warning: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.
React로 개발하다보면 거의 99% 마주치는 에러일 것이다.
"도대체 뭐가 잘못된 거지?"
console.log를 찍어봐도 같은 로그가 수천 개씩 찍히고, React DevTools의 Profiler는 불꽃놀이를 하고 있을 것이다.
나도 같은 경험이 있다. 단순한 Select 컴포넌트를 만들고 있었을 뿐인데.
2. React의 렌더링 사이클 이해하기
무한 렌더링을 이해하려면, 먼저 React가 어떻게 렌더링하는지 알아야 한다.
렌더링 ≠ DOM 업데이트
- 많은 개발자가 착각하는 것
- "setState하면 바로 화면이 바뀐다" > X
- "렌더링 = 화면 그리기" > X
실제로는 3단계로 나뉜다.
- Trigger Phase (트리거 단계): 렌더링이 시작되는 시점
- 초기 렌더링
- 상태 업데이트 (
setState
,useState
의 dispatch 함수 호출)
- Render Phase (렌더 단계): React가 컴포넌트를 호출하여 변경 사항을 계산하는 과정
- 컴포넌트 함수를 실행하여 JSX를 호출.
- JSX는
React.createElement
를 통해 React 엘리먼트(일반 JavaScript 객체)로 변환됨. - 이전 렌더 결과(Virtual DOM)와 비교하여 변경된 부분을 찾는다.
이 단계는 반드시 순수해야 한다!
React의 동시성 렌더링(Concurrent Rendering) 아키텍처 때문에, Render Phase는 언제든지 중단, 재시작, 또는 폐기될 수 있다. 만약 이 단계에서setState
같은 부수 효과(Side Effect)가 있다면, 우리가 예측할 수 없는 시점에 여러 번 실행되어 상태를 엉망으로 만들 수 있기 때문이다. 이것이 바로 렌더링 중setState
호출이 금지되는 근본적인 이유다.
- Commit Phase (커밋 단계): Render Phase에서 계산된 변경 사항을 실제 DOM에 적용하는 과정
- 실제 DOM 노드를 생성, 수정, 삭제한다.
useLayoutEffect
가 동기적으로 실행된다.- 브라우저 페인팅 이후
useEffect
가 비동기적으로 실행되도록 예약된다.
React는 Render Phase에서 한 번의 업데이트 사이클 동안 setState
가 비정상적으로 많이(현재 기준 약 50회) 중첩 호출되면, 무한 루프로 간주하고 에러를 던진다.
3. 무한 렌더링의 네 가지 근본 패턴
패턴 1: 렌더링 중 상태 업데이트
function BadComponent() {
const [count, setCount] = useState(0);
// 렌더링 중에 setState 호출
if (count < 5) {
setCount(count + 1); // 무한 루프
}
return <div>{count}</div>;
}
- 원인과 결과:
- 컴포넌트 렌더링 → setCount 호출.
- setCount가 상태 변경 예약 → 즉시 다음 렌더링 트리거.
- 다시 컴포넌트 렌더링 → 또 setCount 호출.
- 무한 반복!
패턴 2: 의존성 배열의 참조 불안정성
function UnstableComponent() {
const [data, setData] = useState([]);
// 매번 새로운 객체 생성
const config = { sortBy: 'name' };
useEffect(() => {
setData(sortData(data, config));
}, [data, config]); // config는 매번 새 객체
return <List data={data} />;
}
- 원인과 결과:
- JavaScript에서 객체와 함수는 내용이 같아도 참조(메모리 주소)가 다르면 다른 것으로 취급된다 ( !== ).
- UnstableComponent가 렌더링될 때마다 새로운 config 객체와 sortData 함수가 메모리에 새로 할당된다.
- useEffect는 의존성 배열의 config와 sortData가 이전 렌더링과 "달라졌다"고 판단한다.
- 이펙트 콜백이 실행되고 setData가 호출된다.
- 상태가 변경되어 리렌더링이 발생한다.
- 2번 과정부터 무한 반복 발생.
패턴 3: Context의 value 재생성
이 패턴은 패턴 2의 변형으로, Context.Provider의 value prop에 매번 새로운 객체나 함수를 전달할 때 발생한다.
function ContextProvider({ children }) {
const [user, setUser] = useState(null);
// 매번 새로운 객체 생성
const value = {
user,
login: () => { /* ... */ },
logout: () => { /* ... */ }
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
- 원인과 결과: value 객체가 매번 새로 생성되므로, 이 Context를 구독하는 모든 자식 컴포넌트들이 불필요하게 리렌더링된다. 만약 자식 중 하나가 이 Provider를 다시 렌더링하는 로직을 가지고 있다면 무한 루프에 빠질 수 있다.
패턴 4: 잘못된 조건부 파생 상태 업데이트
useEffect
를 사용해 다른 상태로부터 파생된 상태를 만들 때 흔히 발생하는 실수다.
function ConditionalUpdate({ items }) {
const [filteredItems, setFilteredItems] = useState([]);
// 필터링 결과와 원본의 '길이'만 비교하는 로직의 함정
useEffect(() => {
const newFiltered = items.filter(item => item.active);
if (filteredItems.length !== newFiltered.length) {
setFilteredItems(newFiltered);
}
}, [items, filteredItems]);
return <ItemList items={filteredItems} />;
}
- 원인과 결과: items 배열의 내용은 그대로인데 active 상태만 바뀌는 경우, 필터링된 결과(newFiltered)는 매번 같은 내용의 새로운 배열이 된다. setFilteredItems가 호출되면 filteredItems의 참조가 바뀌고, 이로 인해
useEffect
가 다시 실행되면서 무한 루프를 만들 수 있다.
4. 실전 사례: Select 컴포넌트 개발 회고
실제로 내가 Select 컴포넌트를 개발하며 겪었던 사례를 재구성해보자.
// Select 컴포넌트 내부
function Select({ options, value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState(value);
// Context에서 테마 정보 가져오기
const theme = useTheme();
// 문제의 시작점
useEffect(() => {
// 외부 value와 내부 state 동기화
if (value !== internalValue) {
setInternalValue(value);
}
}, [value, internalValue]);
// 더 큰 문제
useEffect(() => {
// 내부 값이 바뀌면 외부에 알림
onChange(internalValue);
}, [internalValue, onChange]);
return (
// Select UI
);
}
거미줄처럼 얽힌 의존성의 연쇄 반응이 렌더링 이슈를 만들었다.
무엇이 잘못됐나?
이 코드는 두 개의 useEffect
가 서로를 자극하며 무한 루프의 연쇄 반응을 일으킨다.
- 외부 value 변경 -> 부모 컴포넌트에서 value prop이 변경된다.
- 1번
useEffect
실행 -> value !== internalValue 조건이 참이 되어 setInternalValue(value)가 호출된다. - 리렌더링 & internalValue 변경 -> Select 컴포넌트가 리렌더링되고, internalValue가 새로운 값으로 업데이트 된다.
- 2번
useEffect
실행 -> 의존성인 internalValue가 변경되었으므로 두 번째useEffect
가 실행되고, onChange(internalValue)가 호출된다. - 부모 상태 변경 및 리렌더링 -> onChange는 부모의 상태를 변경하는 함수이다. 이 호출로 부모 컴포넌트가 리렌더링 된다.
- onChange 함수 재생성 (핵심 원인) -> 부모가 리렌더링되면서 새로운 onChange 함수가 생성된다. 이 함수는 이전 onChange 함수와 기능은 같지만 참조(메모리 주소)가 다르다.
- 또다시 2번
useEffect
실행 -> Select 컴포넌트도 새로운 onChange prop을 받는다. 2번useEffect
의 의존성 [internalValue, onChange] 중 onChange가 새 참조를 가지므로, 이펙트는 또 실행된다. - 4~7번 과정의 무한 반복이 일어난다.🤪 onChange 호출이 다시 부모를 리렌더링하고, 새로운 onChange 함수를 만들어내며 뫼비우스의 띠에 갇히게 된다.
5. 체계적인 디버깅 방법론
이런 당황스러운 상황에서 우리는 어떻게 문제를 풀어나가야 할까.
Step 0: React의 도움 받기 - StrictMode 활성화
디버깅을 시작하기 전에, React가 제공하는 가장 강력한 예방 도구를 활용하자.
// index.js
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
StrictMode는 개발 모드에서 잠재적인 문제를 감지하기 위해 의도적으로 컴포넌트를 두 번씩 렌더링한다. 이 과정에서 렌더링 단계의 부수 효과나 불안정한 참조로 인한 문제를 미리 발견하고 콘솔에 경고를 띄워주므로, 무한 루프의 원인을 조기에 파악하는 데 큰 도움이 된다.
Step 1: React DevTools Profiler로 "범인" 찾기
- Profiler 탭 열기
- "Record" 버튼 클릭
- 문제 상황 재현
- 어떤 컴포넌트가 계속 렌더링되는지 확인
// 디버깅용 코드 추가
function MyComponent() {
console.log('MyComponent rendered');
useEffect(() => {
console.log('Effect ran:', { deps });
});
}
Step 2: 렌더링 트리거 추적 (feat. useWhyDidYouUpdate)
// 커스텀 훅으로 렌더링 이유 추적
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef();
useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps = {};
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changedProps[key] = {
from: previousProps.current[key],
to: props[key]
};
}
});
if (Object.keys(changedProps).length) {
console.log('[why-did-you-update]', name, changedProps);
}
}
previousProps.current = props;
});
}
Step 3: "왜 리렌더링됐지?" 체크리스트
- 부모 컴포넌트가 리렌더링됐나?
- Props가 변경됐나? (참조가 바뀌었나?)
- State가 변경됐나?
- Context value가 변경됐나?
- forceUpdate가 호출됐나?
6. 예방을 위한 설계 원칙
원칙 1: 상태의 단일 진실 원천(Single Source of Truth) 유지
Select 컴포넌트를 개발하며 빠진 첫 번째 함정은 외부에서 받은 value와 내부 상태 internalValue를 모두 가지려 한 것이다. 이는 상태의 진실 원천을 두 개로 만들어 복잡성과 버그를 유발한다.
// Bad: 외부 상태(props)와 내부 상태(state)를 동기화하려는 시도
function BadSelect({ value, onChange }) {
const [internalValue, setInternalValue] = useState(value);
// useEffect로 둘을 동기화하는 로직... 복잡하고 위험하다.
}
// Good: 외부에서 완전히 제어되는 '제어 컴포넌트'
function GoodSelect({ value, onChange }) {
// 상태는 부모가 전적으로 관리하고, 이 컴포넌트는 그저 렌더링만 한다.
return <select value={value} onChange={e => onChange(e.target.value)} />;
}
원칙 2: 파생 상태 대신 렌더링 중 계산 (feat. useMemo
)
// Bad: props로부터 파생된 상태를 별도로 관리
function BadList({ items }) {
const [filteredItems, setFilteredItems] = useState([]);
// 불필요한 useEffect와 추가 렌더링 유발
useEffect(() => {
setFilteredItems(items.filter(item => item.active));
}, [items]);
}
// Good: 렌더링 중에 파생 상태를 계산
function GoodList({ items }) {
// items가 바뀔 때만 재계산된다. 추가 렌더링 없음.
const filteredItems = useMemo(
() => items.filter(item => item.active),
[items]
);
// ...
}
원칙 3: 의존성의 참조 안정성 보장 (feat. useMemo, useCallback)
// Bad: Provider의 value나 컴포넌트의 prop으로 매번 새로운 객체/함수 전달
function BadApp() {
const user = { name: 'Alice' };
const handleLogin = () => { /* ... */ };
return <SomeComponent config={user} onAction={handleLogin} />
}
// Good: useMemo와 useCallback으로 참조 안정성 확보
function GoodApp() {
const user = useMemo(() => ({ name: 'Alice' }), []);
const handleLogin = useCallback(() => { /* ... */ }, []);
return <SomeComponent config={user} onAction={handleLogin} />
}
useMemo
는 객체나 배열의 참조를, useCallback
은 함수의 참조를 메모이제이션하여 불필요한 리렌더링과 이펙트 실행을 막아준다.
7. 결론: 렌더링을 이해하자.
무한 렌더링은 신비로운 버그가 아니다. React의 렌더링 메커니즘을 이해하면 예측 가능하고 디버깅 가능한 문제일 뿐이다. (발생하지 않도록 모든 면을 예측해서 개발하면 좋겠지만 놓치는 부분이 발생할 수 있다. 문제가 발생했을 때 빠르게 이슈를 트래킹하고 근본적 문제를 해결하는 것도 실력이다.)
- 렌더링 단계에서 부수효과를 일으키지 마라
- 의존성의 참조 안정성을 보장하라
- 상태는 한 곳에서만 관리하라
- 파생 상태보다는 계산을 선호하라
다음에 "Maximum update depth exceeded"를 만난다면, 당황하지 말고 체계적으로 접근하자.
"무한 렌더링은 React가 우리에게 보내는 메시지다: 너의 데이터 흐름을 다시 생각해봐."