본문으로 건너뛰기
개발 머꼬
개발 노트React
hohyeon.dev27

React.memo를 붙였는데 여전히 매번 리렌더링된 이유

  • #Engineering Note
  • #Performance
  • #React

문제 발생

무거운 자식 컴포넌트를 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로 넘어오는 값들이 실제로 안정적인 참조를 유지하는가가 관건입니다.

해결 방안

  1. 함수 props는 useCallback으로 감싸 참조를 안정화합니다.
const handleSelect = useCallback((id: string) => {
  console.log(id);
}, []); // 의존성이 없다면 항상 같은 함수 참조 유지
  1. 객체/배열 props는 useMemo로 감싸거나, 애초에 렌더링 중에 새로 만들지 않도록 구조를 바꿉니다.
const filteredItems = useMemo(() => data.filter(predicate), [data]);
  1. useCallback/useMemo 자체도 공짜가 아닙니다 — 의존성 배열을 비교하는 비용이 있으므로, 정말로 무거운 자식이 React.memo로 보호되고 있을 때만 이런 안정화가 실질적인 이득이 됩니다. 가벼운 컴포넌트에 남발하면 오히려 코드만 복잡해지고 이득은 미미할 수 있습니다.
  2. React Compiler(Next.js에서 옵트인 가능)가 도입되면 이런 메모이제이션을 컴파일러가 자동으로 처리해주는 방향으로 가고 있습니다 — 다만 이 프로젝트처럼 아직 도입 전이라면, 성능이 실제로 문제가 되는 지점에서 수동으로 useCallback/useMemo를 쓰는 것이 현재의 표준적인 방법입니다.
  3. 최적화를 적용하기 전에 React DevTools Profiler로 실제로 그 컴포넌트가 병목인지 먼저 측정하는 것이 순서입니다 — 모든 컴포넌트를 memo로 감싸는 것은 그 자체로 비교 비용을 추가하는 트레이드오프입니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.