본문으로 건너뛰기

안동민 개발노트

본문 시작

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

React.memo·useMemo·useCallback의 적용 조건을 구분하고 측정된 렌더링 비용이 있을 때만 메모이제이션합니다.

웹 애플리케이션 성능은 초기 로딩 속도만으로 결정되지 않습니다.

사용자 상호작용에 대한 응답성(Responsiveness)과 UI 업데이트의 부드러움도 핵심 지표입니다.

React 기반 Next.js에서 이 응답성을 해치는 대표 원인 중 하나가 불필요한 리렌더링(Re-rendering)입니다.

렌더 횟수뿐 아니라 각 렌더의 비용을 측정해 병목을 찾습니다. 다시 렌더하더라도 결과가 같으면 실제 DOM 변경이 없을 수 있습니다.

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


리렌더링과 성능 문제

다음 변화는 React의 렌더를 유발할 수 있습니다. 같은 상태값의 업데이트나 최적화에 의해 일부 작업이 생략될 수도 있습니다.

  • state가 변경될 때: 컴포넌트의 로컬 상태(useState)가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
  • props가 변경될 때: 부모 컴포넌트로부터 전달받은 props가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
  • 부모 컴포넌트가 리렌더링될 때: 기본적으로 부모 렌더는 자식 렌더로 이어질 수 있으며, prop이 같다는 사실만으로 항상 생략되지는 않습니다.
  • context가 변경될 때: 컴포넌트가 구독하고 있는 Context의 값이 변경되면 해당 컨텍스트를 사용하는 모든 컴포넌트들이 리렌더링됩니다.
  • forceUpdate() 호출: (권장되지 않음)

문제는 props가 실제로 변경되지 않았음에도 부모의 리렌더링으로 인해 자식 컴포넌트가 불필요하게 리렌더링되는 경우입니다.

이 불필요한 리렌더링이 많아질수록 가상 DOM 비교(Reconciliation) 및 실제 DOM 업데이트 과정에서 오버헤드가 발생하여 애플리케이션의 응답성이 떨어질 수 있습니다.


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

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

이를 통해 불필요한 계산을 줄여 성능을 향상시킬 수 있습니다.

React에서는 이 원리를 사용해 컴포넌트의 렌더, 계산 결과, 콜백 참조를 각각 재사용합니다.


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

React.memo는 컴포넌트를 감싸고, props가 같을 때 부모 렌더에 따른 자식 렌더를 생략할 수 있게 하는 최적화 도구입니다. 렌더 생략을 보장하는 정확성 계약은 아닙니다.

기본 비교는 각 prop에 Object.is를 적용합니다. 자식의 state나 구독한 context 변경은 별도로 렌더를 유발할 수 있습니다.

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

실습: React.memo 적용

아래 설명은 React Compiler의 자동 메모이제이션을 사용하지 않는 수동 최적화 예제입니다. 개발 Strict Mode는 렌더·계산을 추가 호출할 수 있고 캐시도 폐기될 수 있으므로, 콘솔 줄 수를 고정 기대값으로 삼지 않습니다.

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

src/app/memo-test/page.tsx
"use client"; // 클라이언트 컴포넌트

import { useCallback, useEffect, useState } 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;
}

// 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;

결과 확인: count의 타이머 업데이트는 부모 렌더를 요청합니다. 일반 자식은 기본적으로 함께 렌더하고, 메모이제이션된 자식은 같은 문자열과 콜백 참조를 재사용하는 조건을 갖춥니다. 콘솔로 이 차이를 관찰하되 타이머의 정확한 간격이나 초기 1회만의 출력을 보장으로 해석하지 않습니다.


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

useMemo 훅은 계산 비용이 높은 값을 메모이제이션하여, 특정 의존성 배열(deps)의 값이 변경될 때만 해당 값을 다시 계산하도록 합니다.

이는 컴포넌트 렌더링 시마다 반복적으로 수행되는 복잡한 계산을 최적화하는 데 유용합니다.

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

실습: useMemo 적용

아래는 부모 컴포넌트의 변경 부분입니다. 기존 import에 useMemo를 추가하고 생략된 코드를 함께 유지합니다. 10개 원소의 계산은 동작 시연이며, 성능 병목이 측정된 작업은 아닙니다.

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를 사용하여 계산 결과 메모이제이션
  // items가 같고 캐시가 유지되면 이전 계산 결과를 재사용합니다.
  const expensiveResult = useMemo(() => {
    console.log('복잡한 계산 실행 (useMemo)');
    // 실제로는 여기에 복잡한 연산이 들어감
    return items.filter(item => item % 2 === 0).reduce((acc, curr) => acc + curr, 0);
  }, [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>
  );
}

결과 확인: count만 바뀌면 items 참조는 유지되어 계산 결과를 재사용할 수 있습니다. 버튼은 새 배열을 만들므로 재계산 대상이 됩니다. 원문 계산상 초기 짝수 합은 30이며, 11을 추가해도 합은 30입니다. 결과값이 같다는 사실만으로 계산을 생략했다고 판단하지 않습니다.


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은 캐시된 함수 참조를 반환합니다.
  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}
        />
      </div>
      {/* ... (나머지 UI) ... */}
    </div>
  );
}
src/components/MemoizedChild.tsx는 그대로 사용됩니다.

결과 확인: MemoizedChild에 전달하는 handleClick 참조가 유지되면, 같은 data와 함께 prop 비교를 통과해 부모 렌더에 따른 자식 렌더를 생략할 수 있습니다.


메모이제이션이 재사용하는 것

캐시 재사용 조건과 비용을 비교한 표입니다. 세 도구 모두 프로그램의 정확성을 보장하기 위한 저장소로 사용하지 않습니다.

메모이제이션이 재사용하는 것
대상재사용 조건과 효과남는 작업 또는 경계
컴포넌트memo: 각 prop이 Object.is로 같으면 부모 렌더에 따른 자식 렌더를 생략할 수 있습니다.자식의 state·context 변경은 별개입니다. 렌더 생략 자체가 보장은 아닙니다.
계산값useMemo: 의존성이 같고 캐시가 유지되면 계산 결과를 재사용합니다.첫 계산과 의존성 비교는 남습니다. React가 캐시를 폐기할 수도 있습니다.
콜백useCallback: 의존성이 같고 캐시가 유지되면 같은 함수 참조를 반환합니다.새 함수 표현식의 생성은 막지 않습니다. 콜백을 호출하는 것도 아닙니다.
컴포넌트
재사용 조건과 효과: memo: 각 prop이 Object.is로 같으면 부모 렌더에 따른 자식 렌더를 생략할 수 있습니다.
남는 작업 또는 경계: 자식의 state·context 변경은 별개입니다. 렌더 생략 자체가 보장은 아닙니다.
계산값
재사용 조건과 효과: useMemo: 의존성이 같고 캐시가 유지되면 계산 결과를 재사용합니다.
남는 작업 또는 경계: 첫 계산과 의존성 비교는 남습니다. React가 캐시를 폐기할 수도 있습니다.
콜백
재사용 조건과 효과: useCallback: 의존성이 같고 캐시가 유지되면 같은 함수 참조를 반환합니다.
남는 작업 또는 경계: 새 함수 표현식의 생성은 막지 않습니다. 콜백을 호출하는 것도 아닙니다.

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

메모이제이션을 적용하기 전에 먼저 Server Component와 Client Component의 경계를 구분해야 합니다.

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

실제로 적용할 때는 부모가 다시 렌더링된 이유, 자식에게 전달되는 props 참조가 안정적인지, 자식 렌더링 비용이 충분히 큰지를 순서대로 확인하면 좋습니다.

메모이제이션은 렌더링 비용이 확인된 구간에 제한적으로 적용해야 합니다.

적용 전후를 프로파일러로 비교해 효과를 확인하세요.