useMemo와 useCallback, 언제 사용해야 할까?

reactusememousecallback

1. 최적화에 대한 막연한 기대

'무한 렌더링' 문제를 해결하면서, 자연스럽게 React의 렌더링 최적화에 더 깊은 관심을 두게 되었다. 그 과정에서 useMemouseCallback의 사용에 대한 고민을 정리해 보려고 한다.

처음에는 이 훅들의 효용성에 매료되어, 예방적 최적화라는 명목으로 코드의 많은 부분에 적용하기 시작한다. 모든 함수 선언을 useCallback으로, 조금이라도 계산이 들어가는 값은 useMemo로 감싼다. 코드가 훅으로 점차 채워지는 것을 보며, 애플리케이션의 성능이 한 단계 더 견고해질 것이라 막연히 기대하게 되는 것이다.

하지만 이 접근법은 이내 새로운 질문들을 가져오게 된다. 코드의 가독성은 떨어졌고, '이것이 정말 효과적인가?'에 대한 확신은 떨어진다. 최적화의 본질을 제대로 이해하지 못하고 있었던 것이다.

2. useMemouseCallback은 공짜가 아니다

가장 먼저 인지해야 할 사실은 모든 최적화에는 비용이 따른다는 점이다. useMemouseCallback은 성능 개선을 제공하지만, 다음과 같은 비용을 청구한다.

  1. 코드 복잡성과 가독성 저하
    훅으로 감싸진 코드는 직관성이 떨어진다. 간단한 값 할당이나 함수 선언이 복잡한 구문으로 변경되어 동료 개발자의 코드 이해를 방해할 수 있다.
  2. 메모리 사용량 증가
    메모이제이션(Memoization)은 계산된 값을 메모리에 저장해두고 재사용하는 기법이다. 이는 CPU 사용량을 줄이는 대신 메모리 사용량을 늘리는 명백한 트레이드오프 관계에 있다.
  3. 비교 연산의 오버헤드
    React는 매 렌더링마다 훅의 의존성 배열을 이전 렌더링의 것과 비교한다. 이 비교 과정 자체에도 미미한 연산 비용이 발생하게 된다. 메모이제이션으로 얻는 성능 이점보다 이 비교 비용이 더 크다면 오히려 손해인 것이다.

"섣부른 최적화는 모든 악의 근원이다." (Premature optimization is the root of all evil.)

우리는 최적화가 필요하다는 명확한 근거가 있을 때만 신중하게 접근해야 한다.

3. useMemouseCallback의 올바른 사용 시점

그렇다면 이 비용을 지불할 가치가 있는, 명확한 사용 시점은 언제일까?

useMemo가 필요한 순간

  1. 반복적인 고비용 연산을 피할 때

`고비용 연산'이란?: 수천 개의 배열을 필터링/정렬/매핑하거나, 복잡한 데이터를 시각화를 위해 재가공하는 등, 실행에 수 밀리초(ms) 이상 소요되어 UI의 반응성에 영향을 주는 계산을 의미한다.

  • 판단 기준: 해당 연산으로 인해 다른 UI 인터랙션(e.g., 타이핑)이 버벅거리는 것이 느껴진다면, useMemo 사용을 고려해야 한다.
// 10,000개의 항목을 필터링하는 '고비용' 계산
function TodoList({ todos, filter }) {
  // filter가 바뀔 때만 이 복잡한 계산을 다시 수행한다.
  const visibleTodos = useMemo(() => {
    console.log('고비용 필터링 연산 실행...');
    return todos.filter(t => t.text.includes(filter));
  }, [todos, filter]);
 
  return <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
  1. 자식 컴포넌트의 렌더링을 방지하기 위한 참조 동일성 유지
    React.memo로 최적화된 자식 컴포넌트에 객체나 배열을 prop으로 전달할 때, 부모가 리렌더링되면 새로운 참조가 생성되어 React.memo를 무력화 시킨다. useMemo는 이때 참조 동일성을 보장하여 자식의 불필요한 리렌더링을 막는다.
function UserProfile({ user }) {
  // user.isAdmin 값이 바뀔 때만 styleConfig 객체를 새로 생성한다.
  const styleConfig = useMemo(() => ({
    color: user.isAdmin ? 'red' : 'blue',
    fontWeight: 'bold'
  }), [user.isAdmin]);
 
  // styleConfig의 참조가 유지되므로 MemoizedAvatar는 불필요하게 리렌더링되지 않는다.
  return <MemoizedAvatar style={styleConfig} />;
}
const MemoizedAvatar = React.memo(Avatar);

useCallback이 필요한 순간

useCallback(fn, deps)은 useMemo(() => fn, deps)와 기능적으로 동일하다. 함수의 참조 동일성을 보장하는 데 특화되어 있는 것이다.

  1. React.memo로 최적화된 자식 컴포넌트에 함수를 전달할 때

useMemo의 두 번째 케이스와 같은 맥락이다. 부모 리렌더링 시 함수가 새로 생성되는 것을 막아 자식의 불필요한 리렌더링을 방지한다.

function Parent() {
  const [count, setCount] = useState(0);
 
  // 의존성 배열이 비어있으므로, handleClick은 항상 동일한 참조를 유지한다.
  const handleClick = useCallback(() => {
    console.log('Button clicked!');
  }, []);
 
  return <MemoizedButton onClick={handleClick} />;
}
const MemoizedButton = React.memo(Button);
  1. 다른 훅(e.g., useEffect)의 의존성으로 함수가 사용될 때
    컴포넌트 내에 정의된 함수를 useEffect의 의존성으로 사용하면, useCallback 없이는 매 렌더링마다 이펙트가 재실행될 수 있다. 이는 의도치 않은 API 중복 호출 등의 심각한 버그로 이어질 수 있다.
function SearchComponent({ query }) {
  // query가 변경될 때만 fetchData 함수가 새로 생성된다.
  const fetchData = useCallback(() => {
    api.get(`/search?q=${query}`).then(/* ... */);
  }, [query]);
 
  useEffect(() => {
    // fetchData의 참조가 안정적이므로, query가 바뀔 때만 이펙트가 실행된다.
    fetchData();
  }, [fetchData]);
 
  return <input value={query} /* ... */ />;
}

4. 최적화를 바라보는 더 넓은 시야

useMemouseCallback은 유용한 도구지만, React 최적화의 일부일 뿐이다. 때로는 더 구조적인 접근이 훨씬 효과적으로 작용한다.

  1. 측정하고, 추측하지 마라
    최적화의 첫걸음은 항상 React DevTools의 Profiler로 시작해야 한다. 어떤 컴포넌트가, 왜, 얼마나 자주 리렌더링되는지 데이터에 기반하여 병목 지점을 정확히 파악해야 한다.

  2. 컴포넌트 구조 개선

  • 상태 하향 배치: 전역적인 상태를 사용하는 컴포넌트 가까이로 내려보내면, 불필요한 전파를 막고 리렌더링 범위를 좁힐 수 있다. 이는 가장 효과적인 최적화 중 하나이다.
  • 콘텐츠 분리: 리렌더링이 잦은 컴포넌트에서 정적인 부분을 children prop으로 분리해 전달하면, 해당 부분의 리렌더링을 자연스럽게 방지할 수 있다.
  1. 대규모 목록에는 가상화(Virtualization) 적용
    수백 개 이상의 아이템을 렌더링해야 한다면 useMemo만으로는 한계가 있다. 화면에 보이는 부분만 렌더링하는 react-window나 TanStack Virtual 같은 가상화 라이브러리 도입을 검토해야야 할 타이밍일 수 있는 것이다.

5. 균형 잡힌 최적화를 위해

최적화는 모든 곳에 훅을 적용하는 기계적인 작업이 아니다. 그것은 성능적 이점과 코드의 복잡성 및 유지보수 비용을 저울질하여, 가장 합리적인 결정을 내리는 사고 과정이다.

useMemouseCallback은 특정 문제를 해결하기 위한 도구이다. 이 도구들을 언제, 왜 사용해야 하는지 이해하고, Profiler라는 객관적인 데이터를 근거로 사용할 때 비로소 그 가치가 빛을 발할 것이다.