icon
10장 : React 성능 최적화 기초

useCallback과 useMemo 기초

이번에는 함수와 값의 재생성을 막아 최적화된 리액트 애플리케이션을 구축하는 데 필수적인 훅인 useCallbackuseMemo 에 대해 심층적으로 다루겠습니다. 두 훅의 작동 원리, 정확한 사용법, 그리고 실제 적용 시 고려사항을 상세히 알아보겠습니다.


useCallbackuseMemo는 왜 필요한가?

리액트 함수형 컴포넌트는 매 렌더링마다 전체 함수 본문이 다시 실행됩니다. 이 과정에서 컴포넌트 내부에 선언된 모든 변수, 객체, 배열, 함수들이 새로 생성됩니다.

문제점

  1. 함수 재생성: 컴포넌트가 리렌더링될 때마다 onClick, onChange 등의 이벤트 핸들러 함수가 새로 생성됩니다. 이 함수들은 이전 렌더링 시의 함수와 다른 메모리 주소(참조) 를 가지게 됩니다.
  2. React.memo 무력화: 만약 이 재생성된 함수를 React.memo로 감싸진 자식 컴포넌트에 프롭스로 전달한다면, React.memo는 얕은 비교를 통해 프롭스가 변경되었다고 판단하여 자식 컴포넌트를 불필요하게 리렌더링하게 됩니다.
  3. 고비용 계산의 반복: 컴포넌트 내에서 복잡한 계산을 통해 얻은 결과값(객체나 배열)이 매 렌더링마다 다시 계산되고 재생성된다면, 이 또한 불필요한 성능 저하를 유발합니다. 이 재생성된 값을 자식 컴포넌트의 프롭스로 전달하면 마찬가지로 React.memo를 무력화시킬 수 있습니다.

useCallbackuseMemo는 이처럼 "매 렌더링마다 재생성되는 참조 타입의 값(함수, 객체, 배열 등)을 최적화"하여 불필요한 리렌더링을 방지하고 성능을 향상시키는 데 사용됩니다.


useCallback: 함수 메모이제이션

useCallback 훅은 특정 함수를 메모이제이션합니다. 즉, 의존성 배열(dependency array)에 있는 값들이 변경되지 않는 한, 이전에 생성된 함수를 재사용하고 새로운 함수를 생성하지 않습니다.

문법

const memoizedCallback = useCallback(
  () => {
    // 실행될 함수 로직
  },
  [dependency1, dependency2, ...], // 의존성 배열
);
  • 첫 번째 인자: 메모이제이션할 함수 정의.
  • 두 번째 인자 (의존성 배열 deps): 이 배열에 포함된 값들 중 하나라도 변경되면, useCallback은 새로운 함수를 생성하여 반환합니다. 배열이 비어있다면([]), 컴포넌트가 마운트될 때 단 한 번만 함수를 생성하고 이후로는 계속 동일한 함수를 재사용합니다.

언제 사용하는가?

  • React.memo로 감싸진 자식 컴포넌트에 콜백 함수를 프롭스로 전달할 때: 가장 흔하고 중요한 사용 사례입니다. useCallback으로 함수를 메모이제이션하여 자식 컴포넌트의 불필요한 리렌더링을 막습니다.
  • useEffectuseMemo의 의존성으로 함수를 사용할 때: 함수가 매번 재생성되면 useEffectuseMemo가 불필요하게 다시 실행될 수 있습니다. 이때 함수를 useCallback으로 감싸주면 이를 방지할 수 있습니다.

예시

src/components/UseCallbackDemo.js
// src/components/UseCallbackDemo.js
import React, { useState, useCallback } from 'react';

// ✅ React.memo로 감싸진 자식 컴포넌트
const ButtonWithMemo = React.memo(({ onClick, label }) => {
  console.log(`ButtonWithMemo [${label}] 렌더링됨`); // 이 로그가 찍히는 시점을 확인!
  return (
    <button onClick={onClick} className="button" style={{ marginRight: '10px' }}>
      {label}
    </button>
  );
});

function UseCallbackDemo() {
  const [parentCount, setParentCount] = useState(0); // 부모 컴포넌트의 상태
  const [childCount, setChildCount] = useState(0);   // 자식 컴포넌트에게 전달될 상태
  const [textInput, setTextInput] = useState('');     // 텍스트 입력 상태

  // 🚨 일반 함수: ParentComponentMemoDemo가 리렌더링될 때마다 재생성됨
  const handleRegularClick = () => {
    setChildCount(prev => prev + 1);
  };

  // ✅ useCallback 적용 (의존성 없음): 컴포넌트 마운트 시 한 번만 생성됨
  const handleOptimizedClickNoDeps = useCallback(() => {
    setChildCount(prev => prev + 1);
    console.log("Optimized Click (No Deps) executed.");
  }, []); // 의존성 배열이 비어있음

  // ✅ useCallback 적용 (의존성 있음): textInput이 변경될 때만 재생성됨
  const handleOptimizedClickWithDeps = useCallback(() => {
    alert(`Current Text Input: ${textInput}`);
    console.log("Optimized Click (With Deps) executed.");
  }, [textInput]); // textInput이 변경될 때만 함수가 재생성됨

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useCallback` 기초 예시</h2>
      <p>Parent Count: {parentCount}</p>
      <p>Child Count: {childCount}</p>
      <p>Text Input: {textInput}</p>

      <button onClick={() => setParentCount(parentCount + 1)} className="button" style={{ marginBottom: '15px' }}>
        Parent Count 증가 (부모 리렌더링)
      </button>
      <br/>
      <input
        type="text"
        value={textInput}
        onChange={(e) => setTextInput(e.target.value)}
        placeholder="텍스트 입력"
        style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <hr style={{ margin: '20px 0' }}/>

      {/* 1. 일반 함수 프롭스: 부모 리렌더링 시 ButtonWithMemo도 매번 리렌더링됨 */}
      <ButtonWithMemo onClick={handleRegularClick} label="Regular Button" />

      {/* 2. useCallback (의존성 없음) 프롭스: 부모 리렌더링 시 ButtonWithMemo는 리렌더링 안 함 (childCount 변경 시만 리렌더링) */}
      <ButtonWithMemo onClick={handleOptimizedClickNoDeps} label="Optimized Button (No Deps)" />
      
      {/* 3. useCallback (의존성 있음) 프롭스: textInput 변경 시에만 ButtonWithMemo가 리렌더링됨 */}
      <ButtonWithMemo onClick={handleOptimizedClickWithDeps} label="Optimized Button (With Deps)" />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Parent Count 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `Regular Button`은 부모가 리렌더링될 때마다 함께 리렌더링되지만,<br/>
        `Optimized Button (No Deps)`는 `handleOptimizedClickNoDeps` 함수 참조가 고정되어 불필요하게 리렌더링되지 않습니다.<br/>
        `Optimized Button (With Deps)`는 `textInput`을 변경할 때만 리렌더링됩니다.
      </p>
    </div>
  );
}

export default UseCallbackDemo;

useCallback 의존성 배열의 중요성

  • 빈 배열 []: 컴포넌트가 마운트될 때 단 한 번만 함수를 생성합니다. 이 함수는 클로저(closure)를 형성하여 마운트 시점의 상태나 프롭스를 "기억"합니다. 만약 이 함수 내에서 최신 상태나 프롭스에 접근해야 한다면 주의해야 합니다. (예: setCount(count + 1) 대신 setCount(prevCount => prevCount + 1)처럼 함수형 업데이트를 사용하면 클로저 문제를 피할 수 있습니다.)
  • 의존성 포함 [dep1, dep2]: 배열 안의 dep1이나 dep2 중 하나라도 변경되면 새로운 함수를 생성합니다. 최신 값을 사용하면서도 불필요한 재생성을 막을 때 사용합니다.

useMemo: 값 메모이제이션

useMemo 훅은 비용이 많이 드는 계산의 결과 값을 메모이제이션합니다. 의존성 배열에 있는 값들이 변경되지 않는 한, 이전에 계산된 값을 재사용하고 새로운 계산을 수행하지 않습니다.

문법

const memoizedValue = useMemo(
  () => computeExpensiveValue(dep1, dep2),
  [dep1, dep2, ...], // 의존성 배열
);
  • 첫 번째 인자: 계산을 수행할 함수. 이 함수는 값을 반환해야 합니다.
  • 두 번째 인자 (의존성 배열 deps): 이 배열에 포함된 값들 중 하나라도 변경되면, useMemo는 첫 번째 인자의 함수를 다시 실행하여 새로운 값을 계산하고 반환합니다. 배열이 비어있다면([]), 컴포넌트 마운트 시 단 한 번만 계산을 수행합니다.

언제 사용하는가?

  • 복잡한 계산 결과 캐싱: 컴포넌트 렌더링 시마다 반복적으로 수행하기에 비용이 많이 드는 계산(예: 대규모 데이터 필터링, 정렬, 복잡한 통계 계산)의 결과를 캐싱할 때.
  • React.memo로 감싸진 자식 컴포넌트에 참조 타입(객체, 배열) 프롭스를 전달할 때: 객체나 배열이 매 렌더링마다 새로 생성되어 React.memo를 무력화하는 것을 방지하기 위해 사용합니다.

예시

src/components/UseMemoDemo.js
// src/components/UseMemoDemo.js
import React, { useState, useMemo } from 'react';

// ✅ React.memo로 감싸진 자식 컴포넌트 (배열 프롭스를 받음)
const ItemList = React.memo(({ items }) => {
  console.log('ItemList 렌더링됨'); // 이 로그가 찍히는 시점을 확인!
  return (
    <div style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '5px', marginTop: '15px' }}>
      <h4>아이템 목록 ({items.length}개)</h4>
      <ul>
        {items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
});

// 비용이 많이 드는 계산을 시뮬레이션하는 함수
const calculateExpensiveValue = (num) => {
  console.log('비용이 많이 드는 계산 수행됨...');
  let sum = 0;
  for (let i = 0; i < num * 1000000; i++) { // 의도적으로 지연을 만듦
    sum += i;
  }
  return sum;
};

function UseMemoDemo() {
  const [count, setCount] = useState(0); // 다른 상태 변화를 유도하여 리렌더링 테스트
  const [inputValue, setInputValue] = useState(10); // useMemo의 의존성

  // 🚨 useMemo를 사용하지 않으면, count가 변경될 때마다 calculateExpensiveValue가 다시 실행됨
  // const expensiveResult = calculateExpensiveValue(inputValue);

  // ✅ useMemo를 사용하여 inputValue가 변경될 때만 계산 결과를 다시 계산
  const expensiveResult = useMemo(() => {
    return calculateExpensiveValue(inputValue);
  }, [inputValue]); // inputValue가 변경될 때만 다시 계산

  // ✅ useMemo를 사용하여 배열 참조를 고정
  // count가 변경될 때 ItemList가 불필요하게 리렌더링되는 것을 방지
  const itemsArray = useMemo(() => {
    console.log('itemsArray 생성됨'); // 이 로그가 찍히는 시점을 확인!
    return [`Item A - ${count}`, `Item B`, `Item C`]; // count 값은 내부에서 사용되지만, 배열 자체는 count 변경 시에만 재생성되지 않음
  }, [count]); // count가 변경될 때만 itemsArray가 재생성됨

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useMemo` 기초 예시</h2>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)} className="button" style={{ marginBottom: '15px' }}>
        Count 증가 (부모 리렌더링)
      </button>

      <p>Input Value for Expensive Calculation: {inputValue}</p>
      <input
        type="number"
        value={inputValue}
        onChange={(e) => setInputValue(Number(e.target.value))}
        style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <p>Expensive Result: {expensiveResult}</p>

      <hr style={{ margin: '20px 0' }}/>
      
      {/* ItemList 컴포넌트: itemsArray가 변경될 때만 리렌더링됨 */}
      <ItemList items={itemsArray} />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Count 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `비용이 많이 드는 계산 수행됨...` 로그는 `inputValue`가 변경될 때만 찍히고,<br/>
        `itemsArray 생성됨` 로그는 `count`가 변경될 때만 찍힙니다.<br/>
        이를 통해 `ItemList` 컴포넌트의 불필요한 리렌더링도 방지됩니다.
      </p>
    </div>
  );
}

export default UseMemoDemo;

useMemo 의존성 배열의 중요성: useCallback과 마찬가지로 useMemo의 의존성 배열은 언제 계산을 다시 수행할지 결정합니다. 의존성을 정확하게 명시하는 것이 중요합니다.


useCallback vs useMemo

특징useCallbackuseMemo
목적함수 자체를 메모이제이션함수의 실행 결과 값을 메모이제이션
반환 값메모이제이션된 함수메모이제이션된 값 (함수 실행 결과)
주요 사용처React.memo 컴포넌트에 콜백 함수 전달, useEffect/useMemo 의존성으로 함수 사용고비용 계산 결과 캐싱, React.memo 컴포넌트에 객체/배열 프롭스 전달

핵심 차이: useCallback(() => fn, deps)fn이라는 함수 자체를 기억하고, useMemo(() => value, deps)value라는 을 기억합니다.


최적화 적용 시 주의사항 및 고려사항

useCallbackuseMemo는 강력한 최적화 도구이지만, 다음과 같은 점을 항상 염두에 두어야 합니다.

  1. 메모이제이션의 비용: useCallbackuseMemo 자체도 의존성 배열을 비교하고 메모리에 값을 저장하는 오버헤드가 있습니다. 최적화로 얻는 이득이 이 오버헤드보다 커야 의미가 있습니다.
  2. 남용 금지 (Premature Optimization): 성능 문제가 발생하기 전에 모든 함수와 값을 메모이제이션하는 것은 오히려 코드의 복잡성을 증가시키고 디버깅을 어렵게 만들 수 있습니다. 항상 React Developer Tools의 Profiler를 사용하여 실제 성능 병목 지점을 파악한 후 최적화를 적용해야 합니다.
  3. 정확한 의존성: 의존성 배열을 올바르게 명시하는 것이 중요합니다.
    • 의존성 누락: 함수나 값 내부에서 사용되는 변수가 의존성 배열에 포함되지 않으면, 오래된(stale) 클로저 값을 참조하게 되어 버그가 발생할 수 있습니다.
    • 불필요한 의존성: 의존성 배열에 불필요한 값을 포함하면, 해당 값이 변경될 때마다 불필요하게 함수나 값이 재생성되어 메모이제이션 효과가 상쇄될 수 있습니다.
  4. 컴포넌트 구조 개선: 때로는 메모이제이션보다 컴포넌트를 더 작게 분리하거나, 상태를 필요한 곳으로 끌어내리는 등 컴포넌트 구조를 개선하는 것이 더 근본적인 성능 향상으로 이어질 수 있습니다.

10장 4절 "useCallback, useMemo 기초"는 여기까지입니다. 이 장에서는 useCallbackuseMemo 훅의 작동 원리를 이해하고, 각각 함수와 값의 불필요한 재생성을 막는 방법을 상세히 살펴보았습니다. 또한, React.memo와 함께 사용하여 React.memo의 얕은 비교 한계를 극복하는 시너지 효과를 확인했습니다.

이제 여러분은 리액트 애플리케이션에서 발생할 수 있는 주요 성능 문제 중 하나인 "불필요한 리렌더링"을 효과적으로 진단하고 방지할 수 있는 기본적인 도구들을 모두 익히셨습니다.