"성능 최적화를 위해 useMemo와 useCallback을 쓰라"는 조언을 자주 듣지만, 무조건 쓴다고 빨라지는 걸까요? 오히려 성능이 나빠지는 경우는 없을까요?

개념 정의

  • useMemo: 계산 결과를 캐싱하여 의존성이 변하지 않으면 ** 이전 결과를 재사용 **합니다
  • useCallback: 함수 정의를 캐싱하여 의존성이 변하지 않으면 ** 같은 함수 참조를 재사용 **합니다
JSX
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedFn = useCallback((x) => doSomething(x, a), [a]);

// useCallback은 useMemo의 특수한 형태
// useCallback(fn, deps) === useMemo(() => fn, deps)

왜 필요한가

React 함수형 컴포넌트는 매 렌더링마다 함수 본문이 다시 실행됩니다. 이때 내부에서 생성되는 객체, 배열, 함수는 ** 매번 새로운 참조 **를 갖습니다.

JSX
function Parent() {
  const [count, setCount] = useState(0);

  // 매 렌더링마다 새로운 함수 참조
  const handleClick = () => console.log('clicked');

  // 매 렌더링마다 새로운 배열 참조
  const options = ['a', 'b', 'c'];

  return <Child onClick={handleClick} options={options} />;
}

이 새로운 참조가 문제가 되는 경우는 다음과 같습니다.

  1. React.memo로 감싼 자식 컴포넌트의 props 비교
  2. useEffect의 의존성 배열
  3. 비용이 큰 계산의 반복 실행

내부 동작

useMemo

JSX
function ProductList({ products, filter }) {
  // products나 filter가 변하지 않으면 이전 결과 재사용
  const filteredProducts = useMemo(() => {
    console.log('필터링 실행');
    return products
      .filter(p => p.category === filter)
      .sort((a, b) => b.rating - a.rating);
  }, [products, filter]);

  return (
    <ul>
      {filteredProducts.map(p => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

의존성 비교는 Object.is로 수행됩니다. 의존성이 하나라도 변하면 함수를 다시 실행하고 결과를 캐싱합니다.

useCallback

JSX
function SearchPage() {
  const [query, setQuery] = useState('');

  // query가 변하지 않으면 같은 함수 참조 유지
  const handleSearch = useCallback((term) => {
    fetch(`/api/search?q=${term}`);
  }, []);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {/* SearchResults가 React.memo로 감싸져 있다면 효과적 */}
      <SearchResults onSearch={handleSearch} />
    </div>
  );
}

const SearchResults = React.memo(function SearchResults({ onSearch }) {
  // onSearch 참조가 안정적이면 불필요한 리렌더링 방지
  return <div>검색 결과</div>;
});

실제로 효과가 있는 경우

1. React.memo + useCallback

JSX
const ExpensiveChild = React.memo(function ExpensiveChild({ data, onUpdate }) {
  console.log('ExpensiveChild 렌더링');
  return (
    <div>
      {data.map(item => <ComplexItem key={item.id} item={item} />)}
      <button onClick={onUpdate}>업데이트</button>
    </div>
  );
});

function Parent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState(initialItems);

  // useCallback 없이: count가 바뀔 때마다 ExpensiveChild 리렌더
  // useCallback 사용: items가 바뀔 때만 리렌더
  const handleUpdate = useCallback(() => {
    setItems(prev => [...prev, newItem()]);
  }, []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <ExpensiveChild data={items} onUpdate={handleUpdate} />
    </div>
  );
}

2. useEffect 의존성 안정화

JSX
function ChatRoom({ roomId }) {
  // ❌ 매 렌더링마다 새 객체 → useEffect 무한 루프
  const options = { roomId, serverUrl: 'https://chat.example.com' };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 매번 새 객체라 매번 실행

  // ✅ useMemo로 참조 안정화
  const options = useMemo(() => ({
    roomId,
    serverUrl: 'https://chat.example.com',
  }), [roomId]);

  // ✅✅ 더 나은 방법: 의존성을 원시값으로 분리
  useEffect(() => {
    const connection = createConnection({ roomId, serverUrl: 'https://chat.example.com' });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

3. 비용이 큰 계산

JSX
function DataGrid({ rows, sortConfig }) {
  // 10만 행을 정렬하는 비용이 큰 작업
  const sortedRows = useMemo(() => {
    console.time('정렬');
    const result = [...rows].sort((a, b) => {
      return sortConfig.direction === 'asc'
        ? a[sortConfig.key] - b[sortConfig.key]
        : b[sortConfig.key] - a[sortConfig.key];
    });
    console.timeEnd('정렬');
    return result;
  }, [rows, sortConfig]);

  return <Table rows={sortedRows} />;
}

사용하지 말아야 하는 경우

단순한 계산

JSX
// ❌ 과도한 최적화 — 메모이제이션 비용 > 계산 비용
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// ✅ 그냥 계산
const fullName = `${firstName} ${lastName}`;

React.memo 없는 자식

JSX
function Parent() {
  // ❌ Child가 React.memo로 감싸져 있지 않으면
  // useCallback을 써도 Child는 Parent가 리렌더될 때마다 리렌더
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />; // Child가 memo 아니면 무의미
}

렌더링 중 직접 사용하는 JSX

JSX
// ❌ JSX를 useMemo로 감싸는 것은 대부분 불필요
const header = useMemo(() => (
  <h1>제목</h1>
), []);

// ✅ 별도 컴포넌트로 분리하는 것이 더 나음
function Header() {
  return <h1>제목</h1>;
}

메모이제이션의 비용

메모이제이션도 공짜가 아닙니다.

  1. **의존성 비교 **: 매 렌더링마다 Object.is로 비교하는 비용
  2. ** 메모리 **: 캐싱된 값이 메모리를 차지
  3. ** 코드 복잡성 **: 의존성 관리가 추가됨
  4. ** 디버깅 **: 캐싱으로 인해 예상과 다른 동작 가능
JSX
// 메모이제이션 비용이 계산 비용보다 큰 경우
const result = useMemo(() => a + b, [a, b]);
// Object.is(prevA, a) + Object.is(prevB, b) 비교 비용 > 단순 덧셈

React Compiler와의 관계

React Compiler가 도입되면, 컴파일 타임에 코드를 분석하여 ** 자동으로 메모이제이션을 적용 **합니다.

JSX
// React Compiler 이후: 그냥 작성하면 됨
function ProductList({ products, filter }) {
  // 컴파일러가 자동으로 메모이제이션 적용
  const filtered = products.filter(p => p.category === filter);
  const sorted = filtered.sort((a, b) => b.price - a.price);

  const handleSelect = (id) => {
    // 컴파일러가 자동으로 안정적 참조 유지
    selectProduct(id);
  };

  return sorted.map(p => (
    <ProductItem key={p.id} product={p} onSelect={handleSelect} />
  ));
}

현재는 선택적 도입 단계이며, 기존 useMemo/useCallback 코드와 공존할 수 있습니다.

판단 기준 체크리스트

useMemo/useCallback 사용 전에 확인할 것들입니다.

  1. ** 실제로 성능 문제가 있는가?** React DevTools Profiler로 확인
  2. ** 자식이 React.memo로 감싸져 있는가?** 아니면 useCallback이 무의미
  3. ** 계산이 정말 비용이 큰가?** 단순 연산이면 그냥 매번 계산
  4. ** 의존성이 자주 변하지 않는가?** 매번 변하면 메모이제이션 의미 없음

주의할 점

React.memo 없이 useCallback만 쓰는 실수

자식 컴포넌트가 React.memo로 감싸져 있지 않으면, 부모가 리렌더될 때 자식도 무조건 리렌더됩니다. useCallback으로 함수 참조를 안정시켜도 ** 자식의 리렌더링을 막을 수 없습니다 **. 반드시 React.memo와 함께 사용해야 효과가 있습니다.

의존성이 자주 변하는 useMemo

의존성이 매 렌더링마다 변하면, 매번 캐시를 무효화하고 다시 계산합니다. 의존성 비교 비용까지 추가되어 메모이제이션 없이 직접 계산하는 것보다 ** 오히려 느려집니다 **.

메모이제이션의 숨겨진 비용

메모이제이션은 공짜가 아닙니다. 의존성 비교(매 렌더링), 캐싱된 값의 메모리 차지, 코드 복잡성 증가를 수반합니다. 단순한 문자열 연결이나 간단한 산술 연산에 useMemo를 쓰면, 비교 비용이 계산 비용을 초과합니다.

정리

항목설명
useMemo** 계산 결과 **를 캐싱 — 비용이 큰 연산에 사용
useCallback** 함수 참조 **를 캐싱 — React.memo와 함께 사용해야 효과적
핵심 전제React.memo가 없으면 useCallback이 무의미
사용 기준먼저 측정(Profiler)하고, 문제가 있을 때만 적용
비용의존성 비교 + 메모리 + 코드 복잡성 — 단순 계산에는 오버헤드
React Compiler자동 메모이제이션으로 수동 사용 필요성 감소 추세

"모든 곳에 useMemo/useCallback을 붙이자"가 아니라, "왜 필요한지 이해하고 필요한 곳에만 쓰자"가 핵심입니다.

댓글 로딩 중...