리렌더링 문제는 구조적인 원인일 가능성이 높다
Source: Dev.to
React에서 재렌더링 문제는 보통 두 가지 원인 중 하나에서 발생합니다: 트리 상단에 위치한 상태이거나, 부모가 실제로 가질 필요가 없는 컴포넌트를 부모 내부에서 렌더링하는 경우입니다. 먼저 구조를 고치고, 남은 부분을 메모이제이션하세요.
이 글에서는 구조적인 해결 방법을 다루며, 단일 memo나 useCallback을 사용하기 전에 어떻게 컴포넌트 의존성을 재구성하면 불필요한 재렌더링을 줄일 수 있는지 설명합니다.
첫 번째 질문
이 트리에서 실제로 부모의 상태가 필요한 컴포넌트는 어디일까?
부모의 상태에 의존하지 않는 모든 컴포넌트는 격리 대상이 됩니다. 격리되면 해당 컴포넌트는 자체 단위가 되고, 필요하다면 자체 로직과 내부 상태를 가질 수 있으며, 부모가 업데이트될 때 재렌더링될 이유가 사라집니다.
가장 흔한 실수
부모의 render 함수 안에 바로 컴포넌트를 렌더링하는 경우입니다.
// Parent.tsx — Child가 Parent의 render 안에서 생성됨
import Child from "./Child";
export default function Parent() {
const [count, setCount] = useState(0);
return (
<>
{/* ... */}
<Child />
</>
);
}
Child는 count를 사용하지 않지만 count가 바뀔 때마다 재렌더링됩니다. 해결 방법은 Child를 children prop 으로 전달하고, 부모 내부에서 직접 렌더링하지 않는 것입니다.
// App.tsx — Child가 Parent 밖에서 생성됨
// ...
<Parent>
<Child />
</Parent>
// Parent.tsx — 전달받은 children을 렌더링
export default function Parent({ children }: PropsWithChildren) {
const [count, setCount] = useState(0);
return <>{children}</>;
}
이제 Parent는 원하는 만큼 업데이트할 수 있고, Child는 전혀 건드려지지 않습니다.
부모의 상태가 정말 필요할 때
컴포넌트가 부모의 상태, 핸들러, 파생값, 혹은 prop을 실제로 필요로 한다면 위 접근법은 적용되지 않습니다. 이는 다른 문제이며, 이 경우 메모이제이션이 올바른 도구가 됩니다.
메모이제이션은 작동한다
memo, useCallback, useMemo는 정당한 도구이며 React가 제공하는 이유가 있습니다. 여기서 핵심은 “구조를 고치기 전에 바로 메모이제이션에 손을 대면 보통 잘못된 문제를 해결하고 있다”는 점입니다.
export default function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return (
<>
<ExpensiveChild onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>Update</button>
</>
);
}
useCallback은 handleClick을 안정화시켜 ExpensiveChild가 count가 바뀔 때마다 재렌더링되지 않게 합니다. 하지만 요구사항이 바뀌어 handleClick이 최신 count 값을 읽어야 할 경우 문제가 발생합니다.
const handleClick = useCallback(() => {
console.log(count); // 이제 count가 필요함
}, []); // 스테일 클로저 — count는 항상 0
의존성 배열이 잘못되었습니다. count가 빠져 있어 handleClick은 언제나 초기값만 읽습니다. 이를 고치려면 배열에 count를 추가해야 하는데, 그러면 count가 바뀔 때마다 handleClick이 새로 생성되고 결국 ExpensiveChild도 매번 재렌더링됩니다. 메모이제이션이 아무런 이득을 주지 못하게 되는 것이죠.
유지보수 비용
각 의존성 배열은 컴포넌트가 진화함에 따라 업데이트해야 하는 계약입니다. 하나라도 놓치면 조용히 남아 있다가 어느 순간 버그가 드러납니다.
구조적 해결이 가능한 경우
ExpensiveChild가 실제로 Parent 안에 있어야 할 필요가 없다면, 앞서 소개한 컴포지션 접근법으로 문제를 완전히 없앨 수 있습니다. 이때는 useCallback도, 의존성 배열도, 계약도 필요하지 않습니다. 메모이제이션은 부모의 상태와 격리할 수 없는 컴포넌트에만 필요하게 됩니다.
결론
컴포넌트 컴포지션은 성능 트릭이 아니라 구조적인 선택입니다. 컴포넌트 트리를 실제 의존 관계에 맞게 조직하면 메모이제이션은 더 이상 임시 패치가 아니라, 진정으로 필요한 몇몇 지점에서만 정밀하게 사용할 수 있는 도구가 됩니다.
대부분의 재렌더링 문제는 “더 많은 메모이제이션”을 요구하는 것이 아니라 더 깔끔한 구조를 요구합니다. 구조를 먼저 고치면 실제로 메모이제이션이 필요한 영역이 생각보다 훨씬 작다는 것을 알게 될 것입니다.
이 글의 전·후 예시는 github.com/Jancera/react-component-composition에서 확인할 수 있습니다.