icon
12장 : 성능 최적화

메모이제이션 및 리렌더링 최적화

웹 애플리케이션의 성능은 초기 로딩 속도뿐만 아니라, 사용자 상호작용에 따른 응답성(Responsiveness)부드러운 UI 업데이트에도 크게 좌우됩니다. React 기반의 Next.js 애플리케이션에서 이러한 응답성을 저해하는 주요 원인 중 하나는 불필요한 리렌더링(Re-rendering) 입니다. 컴포넌트가 필요 이상으로 자주 또는 비효율적으로 리렌더링되면 애플리케이션의 성능 저하로 이어질 수 있습니다.

이 절에서는 React에서 불필요한 리렌더링을 방지하고 성능을 최적화하기 위한 핵심 기법인 메모이제이션(Memoization) 에 대해 알아보고, Next.js 환경에서 React.memo, useMemo, useCallback 훅을 어떻게 효과적으로 사용하는지 상세히 살펴보겠습니다.


리렌더링과 성능 문제

React 컴포넌트는 다음과 같은 경우에 리렌더링됩니다.

  • state가 변경될 때: 컴포넌트의 로컬 상태(useState)가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
  • props가 변경될 때: 부모 컴포넌트로부터 전달받은 props가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
  • 부모 컴포넌트가 리렌더링될 때: React의 기본 동작은 부모 컴포넌트가 리렌더링되면, props가 변경되지 않았더라도 모든 자식 컴포넌트들이 함께 리렌더링되는 것입니다.
  • context가 변경될 때: 컴포넌트가 구독하고 있는 Context의 값이 변경되면 해당 컨텍스트를 사용하는 모든 컴포넌트들이 리렌더링됩니다.
  • forceUpdate() 호출: (권장되지 않음)

문제는 props가 실제로 변경되지 않았음에도 부모의 리렌더링으로 인해 자식 컴포넌트가 불필요하게 리렌더링되는 경우입니다. 이 불필요한 리렌더링이 많아질수록 가상 DOM 비교(Reconciliation) 및 실제 DOM 업데이트 과정에서 오버헤드가 발생하여 애플리케이션의 응답성이 떨어질 수 있습니다.


메모이제이션(Memoization)이란?

메모이제이션은 컴퓨터 프로그래밍에서 사용되는 최적화 기법 중 하나로, 함수의 결과 값을 저장(캐싱)해 두었다가 동일한 입력이 다시 발생하면 함수를 다시 실행하는 대신 저장된 결과 값을 즉시 반환하는 방식입니다. 이를 통해 불필요한 계산을 줄여 성능을 향상시킬 수 있습니다.

React에서는 이 메모이제이션 원리를 사용하여 컴포넌트의 리렌더링을 제어하고, 값(변수)이나 함수(콜백)의 불필요한 재생성을 방지합니다.


React.memo로 컴포넌트 리렌더링 방지

React.memo는 고차 컴포넌트(Higher-Order Component, HOC)로, 래핑된 컴포넌트의 props가 변경되지 않았다면 리렌더링을 건너뛰도록 지시합니다. props 변경 여부를 얕은 비교(Shallow Comparison) 방식으로 확인합니다.

사용 시점: 순수(Pure) 함수형 컴포넌트이며, props가 자주 변경되지 않지만 부모 컴포넌트의 리렌더링으로 인해 불필요하게 자주 리렌더링되는 경우에 효과적입니다.

실습: React.memo 적용

  1. 부모 컴포넌트 생성 (src/app/memo-test/page.tsx): 이 컴포넌트는 매 초마다 상태가 업데이트되어 리렌더링됩니다.

    src/app/memo-test/page.tsx
    // src/app/memo-test/page.tsx
    "use client"; // 클라이언트 컴포넌트
    
    import { useState, useEffect } from 'react';
    import MemoizedChild from '@/components/MemoizedChild'; // 메모이제이션된 자식 컴포넌트
    import RegularChild from '@/components/RegularChild';   // 일반 자식 컴포넌트
    
    export default function MemoTestPage() {
      const [count, setCount] = useState(0);
      const [text, setText] = useState('Hello');
    
      useEffect(() => {
        const interval = setInterval(() => {
          setCount(prevCount => prevCount + 1);
        }, 1000); // 1초마다 count 업데이트 (부모 리렌더링 유발)
        return () => clearInterval(interval);
      }, []);
    
      const memoizedValue = 'Memoized Value'; // 값이 변하지 않음
      const memoizedCallback = () => { // 함수가 변하지 않음 (useCallback 사용 시 더 좋음)
        console.log('Memoized Callback executed');
      };
    
      console.log('부모 컴포넌트 리렌더링:', count);
    
      return (
        <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #ff9800', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
          <h1 style={{ color: '#ff9800', textAlign: 'center', marginBottom: '20px' }}>메모이제이션 테스트</h1>
          <p style={{ textAlign: 'center', fontSize: '1.2em' }}>
            부모 카운트: <span style={{ fontWeight: 'bold' }}>{count}</span>
          </p>
          <div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '30px' }}>
            <div style={{ flex: 1, padding: '15px', border: '1px solid #ccc', margin: '10px', borderRadius: '8px', backgroundColor: '#e9ffe9' }}>
              <h2>일반 자식 컴포넌트</h2>
              <RegularChild data={text} /> {/* data prop이 변하지 않음 */}
            </div>
            <div style={{ flex: 1, padding: '15px', border: '1px solid #ccc', margin: '10px', borderRadius: '8px', backgroundColor: '#e0f7fa' }}>
              <h2>메모이제이션된 자식</h2>
              <MemoizedChild
                data={memoizedValue}
                onClick={memoizedCallback}
              />
            </div>
          </div>
          <button
            onClick={() => setText(prev => prev === 'Hello' ? 'World' : 'Hello')}
            style={{ display: 'block', margin: '20px auto', padding: '10px 20px', backgroundColor: '#6200ea', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
          >
            텍스트 변경 (일반 자식 props 변경)
          </button>
        </div>
      );
    }
  2. 메모이제이션된 자식 컴포넌트 생성 (src/components/MemoizedChild.tsx)

    src/components/MemoizedChild.tsx
    // src/components/MemoizedChild.tsx
    import React from 'react';
    
    interface MemoizedChildProps {
      data: string;
      onClick: () => void;
    }
    
    // React.memo로 컴포넌트를 래핑하여 props가 변경될 때만 리렌더링되도록 함
    const MemoizedChild = React.memo(function MemoizedChild({ data, onClick }: MemoizedChildProps) {
      console.log('메모이제이션된 자식 컴포넌트 리렌더링');
      return (
        <div style={{ border: '1px dashed #007bff', padding: '10px', marginTop: '10px' }}>
          <p>데이터: <span style={{ fontWeight: 'bold', color: '#007bff' }}>{data}</span></p>
          <button onClick={onClick} style={{ padding: '8px 12px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
            클릭 (Memoized)
          </button>
          <p style={{ fontSize: '0.8em', color: '#555', marginTop: '10px' }}>
            (부모 카운트가 변경되어도 데이터나 onClick 함수가 변하지 않으면 리렌더링되지 않음)
          </p>
        </div>
      );
    });
    
    export default MemoizedChild;
  3. 일반 자식 컴포넌트 생성 (src/components/RegularChild.tsx)

    src/components/RegularChild.tsx
    // src/components/RegularChild.tsx
    interface RegularChildProps {
      data: string;
    }
    
    function RegularChild({ data }: RegularChildProps) {
      console.log('일반 자식 컴포넌트 리렌더링');
      return (
        <div style={{ border: '1px dashed #28a745', padding: '10px', marginTop: '10px' }}>
          <p>데이터: <span style={{ fontWeight: 'bold', color: '#28a745' }}>{data}</span></p>
          <p style={{ fontSize: '0.8em', color: '#555', marginTop: '10px' }}>
            (부모 카운트가 변경될 때마다 리렌더링됨)
          </p>
        </div>
      );
    }
    
    export default RegularChild;

결과 확인: 개발자 도구의 콘솔을 보면, 부모 컴포넌트 리렌더링일반 자식 컴포넌트 리렌더링은 매 초마다 출력되지만, 메모이제이션된 자식 컴포넌트 리렌더링은 초기 1회만 출력되거나 text 상태가 변경될 때만 출력되는 것을 확인할 수 있습니다.


useMemo를 사용한 값 메모이제이션

useMemo 훅은 계산 비용이 높은 값을 메모이제이션하여, 특정 의존성 배열(deps)의 값이 변경될 때만 해당 값을 다시 계산하도록 합니다. 이는 컴포넌트 렌더링 시마다 반복적으로 수행되는 복잡한 계산을 최적화하는 데 유용합니다.

사용 시점: 렌더링 과정에서 복잡한 연산(예: 큰 배열 필터링/정렬, 복잡한 객체 생성)이 필요한 경우.

실습: useMemo 적용

src/app/memo-test/page.tsx
// src/app/memo-test/page.tsx (위의 부모 컴포넌트에서 수정)
// ... (기존 임포트 및 상태) ...

export default function MemoTestPage() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(prevCount => prevCount + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  // 1. useMemo를 사용하여 `expensiveCalculation` 결과 메모이제이션
  // `count`가 변경될 때만 이 계산을 다시 수행합니다.
  const expensiveResult = useMemo(() => {
    console.log('복잡한 계산 실행 (useMemo)');
    // 실제로는 여기에 복잡한 연산이 들어감
    return items.filter(item => item % 2 === 0).reduce((acc, curr) => acc + curr + count, 0);
  }, [count, items]); // 의존성 배열: count 또는 items가 변할 때만 재계산

  console.log('부모 컴포넌트 리렌더링:', count);

  return (
    <div style={{ /* ... 스타일 */ }}>
      {/* ... (기존 UI) ... */}
      <h2 style={{ textAlign: 'center', marginTop: '30px' }}>useMemo 테스트</h2>
      <p style={{ textAlign: 'center' }}>
        복잡한 계산 결과: <span style={{ fontWeight: 'bold', color: '#8800ff' }}>{expensiveResult}</span>
      </p>
      <button
        onClick={() => setItems(prev => [...prev, prev.length + 1])}
        style={{ display: 'block', margin: '20px auto', padding: '10px 20px', backgroundColor: '#6200ea', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
      >
        아이템 추가 (useMemo 재계산 유발)
      </button>
    </div>
  );
}

결과 확인: 콘솔에서 복잡한 계산 실행 (useMemo)count가 변경될 때마다 출력되지 않고, 초기 렌더링 시와 '아이템 추가' 버튼을 눌러 items 상태가 변경될 때만 출력되는 것을 확인할 수 있습니다.


useCallback을 사용한 함수 메모이제이션

useCallback 훅은 함수 자체를 메모이제이션하여, 컴포넌트가 리렌더링될 때마다 새로운 함수 인스턴스가 생성되는 것을 방지합니다. 이는 자식 컴포넌트에 콜백 함수를 props로 전달할 때 특히 중요합니다. 함수 인스턴스가 계속 새로 생성되면 React.memo로 감싸진 자식 컴포넌트가 props가 변경되었다고 판단하여 불필요하게 리렌더링될 수 있기 때문입니다.

사용 시점: React.memo로 최적화된 자식 컴포넌트에 콜백 함수를 props로 전달할 때.

실습: useCallback 적용

src/app/memo-test/page.tsx
// src/app/memo-test/page.tsx (위의 부모 컴포넌트에서 수정)
// ... (기존 임포트 및 상태) ...

export default function MemoTestPage() {
  // ... (기존 count, items 상태) ...

  // 1. useCallback을 사용하여 콜백 함수 메모이제이션
  // `useCallback`의 의존성 배열이 비어있으므로 이 함수는 최초 1회만 생성됩니다.
  const handleClick = useCallback(() => {
    console.log('버튼이 클릭되었습니다.');
    // 여기서는 count 상태를 사용하지 않으므로 의존성 배열에 count를 넣을 필요 없음
  }, []);

  console.log('부모 컴포넌트 리렌더링:', count);

  return (
    <div style={{ /* ... 스타일 */ }}>
      {/* ... (기존 UI) ... */}
      <div style={{ flex: 1, padding: '15px', border: '1px solid #ccc', margin: '10px', borderRadius: '8px', backgroundColor: '#e0f7fa' }}>
        <h2>메모이제이션된 자식</h2>
        <MemoizedChild
          data="Memoized Value"
          onClick={handleClick} // useCallback으로 감싼 함수 전달
        />
      </div>
      {/* ... (나머지 UI) ... */}
    </div>
  );
}

src/components/MemoizedChild.tsx는 그대로 사용됩니다.

결과 확인: MemoizedChild 컴포넌트의 onClick propsuseCallback으로 감싼 handleClick 함수를 전달하면, 부모 컴포넌트가 리렌더링될 때마다 handleClick 함수가 새로 생성되지 않으므로, MemoizedChilddata prop이 변하지 않는 한 리렌더링되지 않습니다.


메모이제이션 사용 시 고려사항 및 팁

  • 남용 금지: 메모이제이션은 분명 성능 최적화에 도움이 되지만, 그 자체로도 오버헤드를 가집니다 (메모리 사용, 비교 연산). 따라서 필요한 곳에만 적용해야 합니다. 모든 컴포넌트, 값, 함수에 무분별하게 적용하는 것은 오히려 성능을 저하시킬 수 있습니다.
  • 성능 측정: 메모이제이션을 적용하기 전에 그리고 적용한 후에 실제 성능 개선이 있는지 프로파일링 도구 (React DevTools Profiler, Chrome Lighthouse)를 사용하여 측정하는 것이 중요합니다.
  • 의존성 배열 정확성: useMemouseCallback의 의존성 배열(deps)을 정확하게 지정하는 것이 매우 중요합니다.
    • 누락: 의존성이 누락되면 오래된(Stale) 값을 사용하거나, React.memo가 기대한 대로 작동하지 않을 수 있습니다.
    • 과도한 포함: 필요 없는 의존성을 포함하면 메모이제이션이 너무 자주 무효화되어 최적화 효과가 사라지거나 오히려 악화될 수 있습니다.
  • 객체 및 배열 비교: React.memoprops를 얕게 비교합니다. props로 객체나 배열이 전달될 때, 내용이 같더라도 참조가 달라지면 React.memo는 변경되었다고 판단하여 리렌더링합니다. 이 경우 useMemouseCallback을 사용하여 참조 동등성을 유지하거나, React.memo의 두 번째 인자로 커스텀 비교 함수를 제공해야 할 수 있습니다.
  • Server Components와의 관계: Next.js App Router의 Server Components는 서버에서 한 번만 렌더링되므로, React.memo, useMemo, useCallback클라이언트 컴포넌트 ("use client") 에서만 의미가 있습니다. Server Components는 리렌더링 개념이 없으므로 이러한 훅들을 사용할 필요가 없습니다.

메모이제이션은 React 애플리케이션의 렌더링 성능을 미세 조정하는 강력한 도구입니다. 적절하게 사용하여 사용자에게 더 빠르고 부드러운 상호작용 경험을 제공하세요.