React.memo를 붙였는데 여전히 매번 리렌더링된 이유
문제 발생
무거운 자식 컴포넌트를 React.memo로 감싸 불필요한 리렌더링을 막으려고 했는데, 부모가 리렌더링될 때마다 자식도 여전히 매번 다시 렌더링됐습니다.
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
// 무거운 렌더링
});
function Parent() {
const [count, setCount] = useState(0);
return (
<ExpensiveList
items={items}
onSelect={(id) => console.log(id)} // 매 렌더링마다 새 함수
/>
);
}원인 분석
React.memo는 이전 렌더링의 props와 새 렌더링의 props를 **얕은 비교(shallow comparison, Object.is)**로 비교해서, 모든 props가 같으면 리렌더링을 건너뜁니다. 문제는 onSelect={(id) => ...}처럼 인라인 함수를 매 렌더링마다 새로 정의하면, 그 함수의 내용이 똑같아 보여도 자바스크립트에서는 매번 다른 함수 객체입니다 — Object.is(oldFn, newFn)은 항상 false입니다.
items 배열도 마찬가지입니다 — items={data.filter(...)}처럼 렌더링 중에 새 배열을 만들어 넘기면, 내용이 같아도 매번 다른 참조가 되어 얕은 비교를 통과하지 못합니다. 즉 React.memo를 붙였다는 사실 자체는 아무것도 보장하지 않고, props로 넘어오는 값들이 실제로 안정적인 참조를 유지하는가가 관건입니다.
해결 방안
- 함수 props는
useCallback으로 감싸 참조를 안정화합니다.
const handleSelect = useCallback((id: string) => {
console.log(id);
}, []); // 의존성이 없다면 항상 같은 함수 참조 유지- 객체/배열 props는
useMemo로 감싸거나, 애초에 렌더링 중에 새로 만들지 않도록 구조를 바꿉니다.
const filteredItems = useMemo(() => data.filter(predicate), [data]);useCallback/useMemo자체도 공짜가 아닙니다 — 의존성 배열을 비교하는 비용이 있으므로, 정말로 무거운 자식이React.memo로 보호되고 있을 때만 이런 안정화가 실질적인 이득이 됩니다. 가벼운 컴포넌트에 남발하면 오히려 코드만 복잡해지고 이득은 미미할 수 있습니다.- React Compiler(Next.js에서 옵트인 가능)가 도입되면 이런 메모이제이션을 컴파일러가 자동으로 처리해주는 방향으로 가고 있습니다 — 다만 이 프로젝트처럼 아직 도입 전이라면, 성능이 실제로 문제가 되는 지점에서 수동으로
useCallback/useMemo를 쓰는 것이 현재의 표준적인 방법입니다. - 최적화를 적용하기 전에 React DevTools Profiler로 실제로 그 컴포넌트가 병목인지 먼저 측정하는 것이 순서입니다 — 모든 컴포넌트를
memo로 감싸는 것은 그 자체로 비교 비용을 추가하는 트레이드오프입니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.