본문 바로가기
개발/프론트

[프론트엔드] useMemo와 useCallback 제대로 이해하기

by 돼지얍 2025. 7. 11.
반응형

최근 개발하면서 useMemo와 useCallback을 제대로 이해하지 못하고 사용했다가 문제를 겪었습니다. 오히려 성능이 나빠지거나 예상치 못한 버그가 발생하는 경험을 했습니다.

📌 문제의 시작: 무작정 사용했던 최적화 훅들

제가 작성했던 코드 형식의 간단예제입니다:

// 내가 작성했던 코드
function MyComponent({ data }) {
  // useMemo 사용
  const processedData = useMemo(() => {
    return data.map(item => item.value);
  }, []);  // 😱 의존성 배열에 data를 빼먹음

  // "함수는 무조건 useCallback" 이라고 생각
  const simpleHandler = useCallback(() => {
    console.log('clicked');
  }, []);

  return (
    <div onClick={simpleHandler}>
      {processedData.map(item => <div key={item}>{item}</div>)}
    </div>
  );
}

당연히 data가 변경되어도 processedData는 업데이트되지 않았고, 불필요한 곳에 useCallback을 사용하고 있었죠. 이 문제를 해결하려면 먼저 React의 렌더링 메커니즘부터 이해해야 했습니다.

🧐 그래서 원리부터 파헤쳐보기로 했습니다

React의 리렌더링 메커니즘

먼저 React가 언제 컴포넌트를 리렌더링하는지 정리해봤습니다. React는 다음과 같은 경우에 컴포넌트를 다시 렌더링합니다:

  1. State가 변경될 때: useState나 useReducer로 관리하는 상태가 변경되면 해당 컴포넌트가 리렌더링됩니다.
  2. Props가 변경될 때: 부모로부터 받는 props가 변경되면 자식 컴포넌트가 리렌더링됩니다.
  3. 부모 컴포넌트가 리렌더링될 때: 부모가 리렌더링되면 자식도 기본적으로 리렌더링됩니다.
  4. Context 값이 변경될 때: useContext로 구독하는 Context의 값이 변경되면 리렌더링됩니다.

여기서 중요한 점은 React가 props를 비교할 때 얕은 비교(shallow comparison)를 수행한다는 것입니다. 즉, === 연산자로 이전 props와 새로운 props를 비교합니다.

// 리렌더링이 발생하는 경우들을 테스트해본 코드
function ParentComponent() {
  const [count, setCount] = useState(0);
  console.log('Parent 렌더링');

  // 매 렌더링마다 새로운 함수가 생성됨
  const handleClick = () => {
    console.log('clicked');
  };

  // 매 렌더링마다 새로운 객체가 생성됨
  const config = { theme: 'dark' };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ChildComponent onClick={handleClick} config={config} />
    </div>
  );
}

const ChildComponent = ({ onClick, config }) => {
  console.log('Child 렌더링');
  return <div>Child</div>;
};

버튼을 클릭할 때마다 Parent와 Child 모두 리렌더링되는 것을 확인했습니다. 왜일까요?

JavaScript의 참조 비교 이해하기

JavaScript에서 원시값(primitive values)과 참조값(reference values)의 비교 방식이 다르다는 것을 이해해야 합니다:

  • 원시값 (number, string, boolean 등): 값 자체를 비교합니다.
  • 참조값 (object, array, function 등): 메모리 주소를 비교합니다.
// 테스트 코드
const obj1 = { name: 'test' };
const obj2 = { name: 'test' };
console.log(obj1 === obj2); // false

const func1 = () => 'hello';
const func2 = () => 'hello';
console.log(func1 === func2); // false

아하! React는 props를 비교할 때 === 연산자를 사용하니까, 매번 새로운 함수와 객체가 생성되면 props가 변경된 것으로 인식하는 거였네요. 이것이 바로 useMemo와 useCallback이 필요한 이유입니다.

💡 useMemo의 동작 원리 분석

useMemo는 어떻게 값을 기억할까?

useMemo는 "memoization"이라는 최적화 기법을 사용합니다. Memoization은 함수의 결과를 캐싱하여 동일한 입력에 대해 재계산을 피하는 기법입니다.

React 공식 문서와 여러 자료를 찾아보니, useMemo는 이런 식으로 동작한다고 이해했습니다:

// useMemo의 간단한 구현 원리 (실제와는 다름)
let previousDeps;
let previousValue;

function useMemo(factory, deps) {
  // 의존성 배열이 변경되었는지 확인
  const hasChanged = deps.some((dep, i) => dep !== previousDeps[i]);
  
  if (hasChanged) {
    // 변경되었다면 새로 계산
    previousValue = factory();
    previousDeps = deps;
  }
  
  // 이전 값 반환
  return previousValue;
}

실제 React의 구현은 훨씬 복잡하지만, 핵심 아이디어는 동일합니다:

  1. 첫 렌더링 시 계산을 수행하고 결과를 저장합니다.
  2. 이후 렌더링에서는 의존성 배열의 값들이 변경되었는지 확인합니다.
  3. 변경되지 않았다면 저장된 값을 반환하고, 변경되었다면 재계산합니다.

실제 사용 예제로 이해하기

useMemo가 유용한 경우는 크게 두 가지입니다:

1. 계산 비용이 높은 연산을 캐싱할 때:

function ExpensiveComponent({ items, filter }) {
  // filter가 변경될 때만 재계산
  const filteredItems = useMemo(() => {
    console.log('필터링 실행');
    return items.filter(item => item.category === filter);
  }, [items, filter]);

  // 잘못된 사용 예
  const simpleValue = useMemo(() => {
    return count * 2;  // 이런 간단한 계산은 useMemo가 오히려 오버헤드
  }, [count]);

  return <ItemList items={filteredItems} />;
}

위 예제에서 items가 1000개 이상이고 복잡한 필터링 로직이 있다면, useMemo를 사용하는 것이 효과적입니다. 하지만 단순한 곱셈 같은 연산에는 오히려 useMemo의 오버헤드(의존성 비교, 캐시 관리 등)가 더 클 수 있습니다.

2. 참조 동등성을 유지해야 할 때:

function DataProvider({ children }) {
  const [user, setUser] = useState(null);
  const [settings, setSettings] = useState({});

  // 매 렌더링마다 새 객체가 생성되면 모든 Consumer가 리렌더링됨
  const contextValue = useMemo(() => ({
    user,
    settings,
    updateUser: (newUser) => setUser(newUser),
    updateSettings: (newSettings) => setSettings(newSettings)
  }), [user, settings]);

  return (
    <DataContext.Provider value={contextValue}>
      {children}
    </DataContext.Provider>
  );
}

Context API를 사용할 때 Provider의 value가 매번 새로운 객체라면, 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. useMemo를 사용하면 의존성이 변경될 때만 새 객체를 생성합니다.

🎯 useCallback의 동작 원리 분석

useCallback은 useMemo의 특수한 케이스

공부하다 보니 useCallback은 사실 useMemo의 특수한 형태라는 걸 알게 되었습니다:

// useCallback은 이렇게 구현될 수 있음
useCallback(fn, deps) === useMemo(() => fn, deps)

즉, useCallback은 함수 자체를 메모이제이션하는 것입니다. 함수를 생성하는 것이 아니라 함수 자체를 캐싱한다고 이해하면 됩니다.

React.memo와 함께 사용할 때의 효과

React.memo는 고차 컴포넌트(Higher Order Component)로, props가 변경되지 않으면 컴포넌트의 리렌더링을 건너뜁니다. 하지만 함수나 객체를 props로 받는다면, 부모가 리렌더링될 때마다 새로운 참조가 생성되어 React.memo의 최적화가 무효화됩니다.

// ChildComponent를 React.memo로 감싸기
const ChildComponent = React.memo(({ onClick, data }) => {
  console.log('ChildComponent 렌더링');
  return (
    <button onClick={onClick}>
      Data length: {data.length}
    </button>
  );
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([1, 2, 3]);

  // useCallback 사용
  const handleClick = useCallback(() => {
    console.log('Items:', items);
  }, [items]);  // items가 변경될 때만 새 함수 생성

  return (
    <div>
      <div>Count: {count}</div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      
      {/* count가 변경되어도 ChildComponent는 리렌더링 안됨! */}
      <ChildComponent onClick={handleClick} data={items} />
    </div>
  );
}

여기서 중요한 점은 useCallback의 의존성 배열에 items를 포함시켰다는 것입니다. 이렇게 하면:

  • items가 변경되지 않는 한 handleClick은 동일한 함수 참조를 유지합니다.
  • count만 변경될 때는 ChildComponent가 리렌더링되지 않습니다.
  • items가 변경되면 handleClick도 새로 생성되고, ChildComponent도 리렌더링됩니다.

🚨 내가 겪었던 실수들과 해결 방법

실수 1: 의존성 배열 관리 실패

의존성 배열은 useMemo와 useCallback에서 가장 중요한 부분입니다. 여기에 포함되지 않은 값은 훅 내부에서 "stale closure" 문제를 일으킬 수 있습니다.

// 문제가 있었던 코드
function SearchComponent({ onSearch }) {
  const [query, setQuery] = useState('');
  
  const handleSearch = useCallback(() => {
    onSearch(query);  // query를 사용하는데
  }, [onSearch]);  // 의존성에 query가 없음!

  // query가 변경되어도 handleSearch는 예전 query 값을 참조
}

// 수정한 코드
const handleSearch = useCallback(() => {
  onSearch(query);
}, [onSearch, query]);  // query 추가

이 문제는 특히 비동기 작업에서 자주 발생합니다. setTimeout이나 API 호출 내부에서 state를 참조할 때 주의해야 합니다.

실수 2: 모든 곳에 무분별하게 사용

"최적화는 좋은 것"이라는 생각에 모든 값과 함수에 useMemo와 useCallback을 적용했었습니다. 하지만 이는 오히려 성능을 악화시킬 수 있습니다.

// 불필요한 최적화
function TodoItem({ todo }) {
  // 이런 간단한 계산은 그냥 하는게 나음
  const isCompleted = useMemo(() => todo.status === 'done', [todo.status]);
  
  // inline 함수로도 충분한 경우
  const handleClick = useCallback(() => {
    alert(todo.title);
  }, [todo.title]);
  
  // 개선된 코드
  const isCompleted = todo.status === 'done';  // 그냥 계산
  
  return (
    <div onClick={() => alert(todo.title)}>  {/* inline 함수 사용 */}
      {todo.title} - {isCompleted ? '완료' : '진행중'}
    </div>
  );
}

메모이제이션도 비용이 듭니다:

  • 초기 렌더링 시 캐시를 설정하는 비용
  • 매 렌더링마다 의존성을 비교하는 비용
  • 메모리에 이전 값을 저장하는 비용

따라서 실제로 성능 이득이 있는 경우에만 사용해야 합니다.

실수 3: 객체와 배열을 props로 전달할 때

이 부분은 특히 Context API를 사용할 때 자주 실수했던 부분입니다.

// 문제가 있던 코드
function ConfigProvider({ children }) {
  // 매 렌더링마다 새 객체 생성
  return (
    <ConfigContext.Provider value={{ theme: 'dark', lang: 'ko' }}>
      {children}
    </ConfigContext.Provider>
  );
}

// 개선한 코드
function ConfigProvider({ children }) {
  // 객체를 메모이제이션
  const config = useMemo(() => ({
    theme: 'dark',
    lang: 'ko'
  }), []);  // 값이 고정이면 빈 배열

  return (
    <ConfigContext.Provider value={config}>
      {children}
    </ConfigContext.Provider>
  );
}

첫 번째 코드에서는 ConfigProvider가 리렌더링될 때마다 새로운 value 객체가 생성되어, ConfigContext를 구독하는 모든 컴포넌트가 리렌더링됩니다. 두 번째 코드에서는 config 객체가 한 번만 생성되므로 불필요한 리렌더링을 방지할 수 있습니다.

📊 성능 측정으로 검증하기

최적화가 실제로 효과가 있는지 확인하는 것은 매우 중요합니다. "측정할 수 없다면 개선할 수 없다"는 말이 있듯이, 성능 최적화도 측정부터 시작해야 합니다.

// 1. React DevTools Profiler 사용
// 2. Performance API 활용
function MeasuredComponent({ data }) {
  const startTime = performance.now();
  
  const processedData = useMemo(() => {
    const calcStart = performance.now();
    const result = expensiveCalculation(data);
    console.log(`계산 시간: ${performance.now() - calcStart}ms`);
    return result;
  }, [data]);
  
  useEffect(() => {
    console.log(`전체 렌더링 시간: ${performance.now() - startTime}ms`);
  });

  return <div>{/* ... */}</div>;
}

// 3. 커스텀 훅으로 렌더링 횟수 추적
function useRenderCount(componentName) {
  const renderCount = useRef(0);
  renderCount.current += 1;
  
  useEffect(() => {
    console.log(`${componentName} 렌더링 횟수: ${renderCount.current}`);
  });
}

React DevTools의 Profiler 탭은 특히 유용합니다:

  • 컴포넌트별 렌더링 시간을 확인할 수 있습니다.
  • 왜 리렌더링되었는지 이유를 보여줍니다.
  • Flame graph로 성능 병목 지점을 시각적으로 확인할 수 있습니다.

🎓 공부하면서 깨달은 핵심 원칙들

1. 언제 사용해야 하는가?

useMemo를 사용해야 할 때:

  • 계산 비용이 큰 연산의 결과를 캐싱할 때: 대량의 데이터를 필터링, 정렬, 변환하는 경우
  • 하위 컴포넌트에 객체/배열을 props로 전달할 때: 특히 해당 컴포넌트가 React.memo로 최적화된 경우
  • 다른 훅의 의존성 배열에 들어갈 값일 때: useEffect, useCallback 등의 의존성으로 사용되는 경우

useCallback을 사용해야 할 때:

  • React.memo로 최적화된 컴포넌트에 함수를 전달할 때: 함수의 참조 동등성이 중요한 경우
  • 함수가 다른 훅의 의존성 배열에 포함될 때: 특히 useEffect 내에서 사용되는 함수
  • 디바운싱이나 쓰로틀링을 구현할 때: 함수의 참조가 유지되어야 하는 경우

2. 사용하지 말아야 할 때

그렇다고 메모이제이션은 만능도구는 아닙니다. 다음과 같은 경우에는 사용하지 말아야할 예제 인것같습니다:

// ❌ 과도한 최적화의 예
function OverOptimized({ name, age }) {
  // 1. 원시값은 메모이제이션 불필요
  const uppercaseName = useMemo(() => name.toUpperCase(), [name]);
  
  // 2. 자식 컴포넌트가 최적화되지 않은 경우
  const handleClick = useCallback(() => {}, []);
  
  return <button onClick={handleClick}>{uppercaseName}</button>;
}

// ✅ 적절한 사용
function Optimized({ name, age }) {
  const uppercaseName = name.toUpperCase();  // 그냥 계산
  
  return <button onClick={() => {}}>{uppercaseName}</button>;
}

메모이제이션을 피해야 하는 경우:

  • 계산이 매우 간단한 경우 (문자열 조작, 간단한 수학 연산 등)
  • 컴포넌트가 자주 리렌더링되지 않는 경우
  • 의존성이 거의 매번 변경되는 경우
  • 메모이제이션된 값이 한 곳에서만 사용되는 경우
🏁 마무리

제가 생각한 useMemo와 useCallback의 사용핵심을 정리해보자면

  1. 원리를 이해하고 사용하기 - 무작정 사용하지 말고 왜 필요한지 이해하기
  2. 측정하고 최적화하기 - 실제 성능 문제가 있을 때 적용하기
  3. 과도한 최적화 피하기 - 모든 곳에 사용할 필요 없음
반응형