June 25, 2025

과도한 리렌더링: 불필요한 컴포넌트, 콜백, 값의 비메모이징 🐢

JavaScript
React
성능
컴포넌트

Summary

React에서 부모 컴포넌트의 리렌더링 시 자식 컴포넌트에게 새로운 참조의 props(특히 함수, 객체, 배열)를 넘겨주면, 자식 컴포넌트가 불필요하게 리렌더링되어 성능 저하를 야기할 수 있습니다. 이를 해결하기 위해 React.memo, useCallback, useMemo를 사용하여 props의 참조 동일성을 유지하고 불필요한 렌더링을 방지해야 합니다. 단, 성능 프로파일링 후 필요한 곳에만 적용하는 것이 중요합니다.

Why Wrong?

React는 가상 DOM을 사용하여 효율적으로 UI를 업데이트하지만, propsstate가 변경될 때 컴포넌트가 리렌더링되는 방식으로 동작합니다. 문제는 JavaScript에서 객체(함수, 배열, 일반 객체 등)는 참조 타입(reference type)이라는 점입니다. 부모 컴포넌트가 렌더링될 때마다, 자식 컴포넌트에 전달하는 함수, 배열, 객체 등의 props를 새로 생성하여 전달하면, React는 이전 props와 참조가 달라졌다고 판단하여 자식 컴포넌트가 비록 React.memo로 감싸져 있더라도 불필요하게 리렌더링하게 됩니다. 💡 특히 함수형 컴포넌트 내에서 선언된 함수는 해당 컴포넌트가 리렌더링될 때마다 새로운 함수 인스턴스가 생성됩니다.

이러한 불필요한 리렌더링은 다음과 같은 문제를 야기합니다:

  1. 성능 저하: 불필요한 비교 및 렌더링 과정은 CPU 리소스를 소모하여 애플리케이션의 반응성을 떨어뜨리고, 특히 복잡한 컴포넌트 트리나 많은 양의 데이터를 다룰 때 사용자 경험에 부정적인 영향을 미칩니다.
  2. 배터리 소모 증가: 모바일 환경에서는 불필요한 연산이 배터리 소모로 이어질 수 있습니다.
  3. useEffect의 의도치 않은 재실행: useEffect의 의존성 배열에 참조 타입의 값이 포함되어 있을 때, 해당 값이 매번 새로운 참조로 생성되면 의도치 않게 이펙트 함수가 불필요하게 재실행될 수 있습니다. 이는 네트워크 요청 중복, 애니메이션 버그 등으로 이어질 수 있습니다.
  4. UX 저해: 미묘한 렌더링 지연이나 깜빡임 현상이 발생할 수 있습니다.

How to Fix?

React는 불필요한 리렌더링을 방지하기 위한 강력한 메모이제이션(Memoization) 훅과 API를 제공합니다. 적절한 상황에서 이들을 활용하여 컴포넌트의 렌더링 최적화를 달성할 수 있습니다.

  1. React.memo: 함수형 컴포넌트를 React.memo로 감싸면, 해당 컴포넌트의 props가 변경되지 않는 한 리렌더링을 방지합니다. 이는 클래스 컴포넌트의 PureComponent와 유사하게 동작합니다. 주로 자식 컴포넌트가 무거운 연산을 하거나, 많은 자식 컴포넌트를 렌더링할 때 유용합니다.
  2. useCallback: 함수를 메모이제이션하여, 의존성 배열에 있는 값들이 변경되지 않는 한 동일한 함수 인스턴스를 반환합니다. 부모 컴포넌트에서 자식 컴포넌트로 콜백 함수를 prop으로 넘길 때, 자식 컴포넌트가 React.memo로 감싸져 있다면 useCallback을 사용하여 불필요한 자식 컴포넌트의 리렌더링을 막을 수 있습니다.
  3. useMemo: 무거운 계산 결과나 객체, 배열과 같은 값을 메모이제이션하여, 의존성 배열에 있는 값들이 변경되지 않는 한 이전에 계산된 값을 재사용합니다. 복잡한 데이터 구조를 prop으로 전달하거나, 컴포넌트 내부에서 비싼 연산 결과를 캐싱할 때 유용합니다.

⚠️ 중요: 이들 메모이제이션 기법을 모든 곳에 남용하는 것은 오히려 오버헤드를 발생시킬 수 있습니다. 메모이제이션 자체에도 비용(메모리 사용, 비교 연산)이 들기 때문입니다. 따라서 React 개발자 도구의 프로파일러 등을 사용하여 실제 성능 병목 지점을 파악하고, 해당 부분에 선택적으로 적용하는 것이 가장 효과적인 접근 방법입니다.

Before Code (Bad)

// BeforeCode.js
import React, { useState } from 'react';

const HeavyComponent = ({ onButtonClick, data }) => {
  console.log('HeavyComponent renders!');
  return (
    <div>
      <h3>무거운 컴포넌트</h3>
      <p>데이터: {data.value}</p>
      <button onClick={onButtonClick}>클릭하세요</button>
    </div>
  );
};

const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const handleButtonClick = () => {
    setCount(prev => prev + 1);
  };

  const data = { value: '일부 데이터' };

  return (
    <div>
      <h1>부모 컴포넌트</h1>
      <p>카운트: {count}</p>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="텍스트 입력 (부모 리렌더링 유발)"
      />
      <HeavyComponent onButtonClick={handleButtonClick} data={data} />
    </div>
  );
};

export default ParentComponent;

After Code (Good)

// AfterCode.js
import React, { useState, useCallback, useMemo } from 'react';

const HeavyComponent = React.memo(({ onButtonClick, data }) => {
  console.log('HeavyComponent renders!');
  return (
    <div>
      <h3>무거운 컴포넌트</h3>
      <p>데이터: {data.value}</p>
      <button onClick={onButtonClick}>클릭하세요</button>
    </div>
  );
});

const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const handleButtonClick = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  const data = useMemo(() => ({
    value: '일부 데이터'
  }), []);

  return (
    <div>
      <h1>부모 컴포넌트</h1>
      <p>카운트: {count}</p>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="텍스트 입력 (부모 리렌더링 유발)"
      />
      <HeavyComponent onButtonClick={handleButtonClick} data={data} />
    </div>
  );
};

export default ParentComponent;