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 적용

부모 컴포넌트 생성 (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>
  );
}
메모이제이션된 자식 컴포넌트 생성 (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;
일반 자식 컴포넌트 생성 (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 (위의 부모 컴포넌트에서 수정)
// ... (기존 임포트 및 상태) ...

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 (위의 부모 컴포넌트에서 수정)
// ... (기존 임포트 및 상태) ...

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

목차