React의 useMemo와 useCallback: 정말 필요할 때는?

1. 개요

useMemo와 useCallback은 보통 직관에 의존하여 사용됩니다: 모든 것을 감싸거나(“확실하게 하려고”), 아니면 절대 사용하지 않거나. 둘 다 틀렸습니다. 이 글은 두 hook의 메커니즘을 설명하고, 실제로 유용한 세 가지 상황을 제시하며, 피해야 할 경우를 보여줍니다. 테스트로 메모이제이션의 효과를 증명하는 방법도 작성하게 됩니다.

2. 근본 문제: 매 렌더링마다 새로 생성된다

component가 다시 렌더링될 때마다, 함수 본체는 처음부터 끝까지 다시 실행됩니다. 내부에 선언된 모든 object, array, function은 새로 생성됩니다.

function Checkout({ customer }) {
  const shipping = { city: customer.city, express: true };
  const handlePay = () => console.log('결제 처리:', customer.name);

  return <PaymentForm shipping={shipping} onPay={handlePay} />;
}

Checkout이 렌더링될 때마다, shipping과 handlePay는 새로운 참조이지만, 내용은 동일합니다. JavaScript는 object와 function을 참조로 비교합니다:

{ city: 'Huế' } === { city: 'Huế' }   // false
(() => 1) === (() => 1)               // false

보통 이것은 해롭지 않습니다. object 생성이 매우 비용이 적기 때문입니다. 문제는 참조를 비교하는 무언가가 있을 때만 생깁니다. 그것은 React.memo, useEffect의 dependency 배열, 또는 다른 hook일 수 있습니다. useMemo와 useCallback은 렌더링 사이에서 참조를 안정적으로 유지하기 위해 존재합니다.

3. useMemo: 값 메모이제이션하기

useMemo(calculate, deps)는 첫 번째 렌더링에서 calculate를 호출하고 결과를 저장합니다. 이후에는 React가 deps의 요소 중 하나라도 변경될 때만 다시 계산합니다. Object.is로 비교합니다.

3.1. 매 렌더링마다 비용 높은 계산 피하기

import { useMemo, useState } from 'react';

function OrderReport({ orders }) {
  const [keyword, setKeyword] = useState('');
  const [darkMode, setDarkMode] = useState(false);

  const visibleOrders = useMemo(() => {
    const kw = keyword.toLowerCase();
    return orders
      .filter((o) => o.customer.toLowerCase().includes(kw))
      .sort((a, b) => b.total - a.total);
  }, [orders, keyword]);

  return (
    <div className={darkMode ? 'dark' : ''}>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setDarkMode((d) => !d)}>테마 변경</button>
      <ul>
        {visibleOrders.map((o) => (
          <li key={o.id}>{o.customer}: {o.total}</li>
        ))}
      </ul>
    </div>
  );
}

설명:

  • filter와 sort는 orders나 keyword가 변할 때만 실행됩니다.
  • “테마 변경” 버튼을 클릭하면, darkMode가 바뀌고 component가 다시 렌더링됩니다. 이때 useMemo는 이미 저장된 배열을 반환하므로 다시 필터링하지 않습니다.
  • 여기서 sort는 안전합니다. filter가 이미 새 배열을 반환하기 때문입니다. prop orders를 직접 수정하는 작업은 없습니다.

계산이 “비싼지” 어떻게 알까요? 추측하지 말고 측정하세요:

console.time('filter orders');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('filter orders');
filter orders: 0.08ms

실용적인 기준: 개발 머신에서 계산이 1ms 미만이면 거의 메모이제이션이 필요 없습니다. production 빌드에서 측정하는 것을 잊지 마세요. 더 나으려면 DevTools에서 CPU 스로틀링을 켜세요. 사용자 기기는 보통 당신의 머신보다 느립니다.

3.2. 객체 참조를 안정적으로 유지하여 전달하기

두 번째 경우는 계산 속도와 무관합니다. 여기서는 dependency로 사용할 참조 안정적인 object가 필요합니다:

function CustomerOrders({ customerId, status }) {
  const [orders, setOrders] = useState([]);

  const query = useMemo(() => ({ customerId, status }), [customerId, status]);

  useEffect(() => {
    fetchOrders(query).then(setOrders);
  }, [query]);

  return <OrderTable orders={orders} />;
}

useMemo 없이는, query가 매 렌더링마다 새 object가 됩니다. 그러면 effect가 계속 다시 실행되고, setOrders가 렌더링을 유발하므로 무한 API 호출 루프에 빠질 수 있습니다.

주의: 이 예제에는 더 간단한 방법이 있습니다. effect 내부에서 object를 만들고 primitive 값을 dependency에 넣으세요: useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);. 이 방법을 우선하세요. object가 effect 바깥에 있어야 할 때만 useMemo를 사용하세요. 예를 들어 자식 component로 전달해야 할 때 말입니다.

4. useCallback: 함수 메모이제이션하기

useCallback(fn, deps)는 useMemo(() => fn, deps)와 동등합니다. 당신이 전달한 함수 자체를 반환하고 deps가 변할 때까지 참조를 유지합니다. useCallback이 함수를 더 빠르게 실행하지는 않습니다. 이 hook은 함수가 어딘가에서 비교될 때만 의미가 있습니다.

4.1. React.memo와 함께 사용

React.memo는 props가 이전과 같으면(얕은 비교) 자식 component의 렌더링을 건너뜁니다. 만약 props 중 하나가 매번 만들어지는 함수라면, memo는 무의미해집니다.

import { memo, useCallback, useState } from 'react';

const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
  onRender?.(order.id);
  return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});

export function OrderList({ orders, onRender }) {
  const [selectedId, setSelectedId] = useState(null);
  const [note, setNote] = useState('');

  const handleSelect = useCallback((id) => setSelectedId(id), []);

  return (
    <>
      <input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
      <ul>
        {orders.map((o) => (
          <OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
        ))}
      </ul>
      <p>선택됨: {selectedId ?? '없음'}</p>
    </>
  );
}

설명:

  • handleSelect의 dependency는 비어 있습니다. setSelectedId는 항상 안정적이기 때문입니다(React가 useState setter를 보장합니다).
  • 메모 입력창에 입력하면, OrderList가 다시 렌더링됩니다. 하지만 각 OrderRow는 같은 order, 같은 handleSelect, 같은 onRender를 받으므로 memo는 이들을 건너뜁니다.
  • handleSelect를 일반 arrow function으로 바꾸면, 키 입력할 때마다 모든 줄이 다시 렌더링됩니다.

기억할 점: useCallback이 받는 쪽에서 memo를 사용하지 않으면 거의 쓸모없습니다. 메모이제이션된 callback을 DOM <button>에 전달하는 것은 아무것도 절약하지 않습니다.

4.2. 함수를 dependency로 사용

function useOrderPolling(customerId) {
  const [orders, setOrders] = useState([]);

  const refresh = useCallback(async () => {
    const data = await fetchOrders({ customerId });
    setOrders(data);
  }, [customerId]);

  useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10_000);
    return () => clearInterval(id);
  }, [refresh]);

  return { orders, refresh };
}

refresh는 effect에서도 사용되고 외부 component로 반환되기도 합니다. useCallback 덕분에 interval은 customerId가 변할 때만 다시 만들어집니다. refresh를 dependency로 사용하는 component도 불필요하게 다시 실행되지 않습니다.

custom hook을 작성할 때 이 규칙을 지키세요: 외부로 반환하는 함수는 useCallback으로 감싸세요. hook 사용자가 그 함수를 dependency에 넣을지, 아니면 memo로 전달할지 모르기 때문입니다.

5. 테스트로 검증하기

메모이제이션이 “아마 작동한다”고 믿는 대신, 테스트를 작성하세요. Vitest와 React Testing Library를 설치합니다:

npm install -D vitest jsdom @testing-library/react

vite.config.js에서 jsdom 환경을 설정합니다:

export default {
  test: { environment: 'jsdom' },
};

섹션 4.1의 OrderList에 대한 테스트:

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';

const orders = [
  { id: 1, customer: 'Lan' },
  { id: 2, customer: 'Minh' },
];

describe('OrderList', () => {
  it('메모를 입력할 때 OrderRow를 다시 렌더링하지 않음', () => {
    const onRender = vi.fn();
    render(<OrderList orders={orders} onRender={onRender} />);
    expect(onRender).toHaveBeenCalledTimes(2);

    fireEvent.change(screen.getByLabelText('note'), { target: { value: '긴급 배송' } });

    expect(onRender).toHaveBeenCalledTimes(2);
  });

  it('주문 선택 시에도 여전히 업데이트됨', () => {
    render(<OrderList orders={orders} />);
    fireEvent.click(screen.getByText('Minh'));
    expect(screen.getByText('선택됨: 2')).toBeTruthy();
  });
});
 ✓ OrderList > 메모를 입력할 때 OrderRow를 다시 렌더링하지 않음
 ✓ OrderList > 주문 선택 시에도 여전히 업데이트됨

useCallback을 제거하고 다시 실행해보세요. 첫 번째 테스트가 실패합니다. 기대값 2 대신 4번 호출된다고 보이죠. 이것이 hook이 제대로 작동하고 있다는 명확한 증거입니다. 테스트가 실패하지 않으려면 onRender를 외부에서 선언하고 같은 참조를 전달하세요. 그렇지 않으면 prop 자체가 memo를 깨뜨립니다.

테스트 외에도, React DevTools의 Profiler 탭에서 “Highlight updates” 옵션을 사용하여 어떤 component가 다시 렌더링되는지 시각적으로 확인하세요.

6. 하면 안 될 때

메모이제이션은 무료가 아닙니다. React는 이전 값을 저장하고, dependency 배열을 저장하고, 매번 비교합니다. 코드도 읽기 어려워집니다. 이런 경우는 피하세요:

  • 계산이 저렴할 때: 몇 개 숫자를 더하기, 문자열 연결, 수십 개 요소를 map으로 처리하는 것.
  • 값이 primitive일 때: useMemo(() => a + b, [a, b])는 숫자를 반환합니다. 숫자는 이미 값으로 비교되므로 메모이제이션은 도움이 아닙니다.
  • callback이 DOM 요소나 memo로 감싸지 않은 component에 전달될 때.
  • 자식 component가 매번 변하는 prop을 받을 때. 예: <OrderRow order={{ ...o }} />나 JSX인 children 전달. JSX도 매번 새 object이므로 memo는 항상 다르다고 판단합니다.
  • 대신 구조를 개선할 수 있을 때. state를 사용 위치 가까이 내리기, 또는 무거운 component를 children으로 전달하기가 보통 메모이제이션보다 더 효과적으로 불필요한 렌더링을 제거합니다.

주의: 로직 정확성을 보장하기 위해 useMemo에 의존하지 마세요. React는 캐시를 삭제할 권리가 있습니다(예: Suspense의 일부 경우 또는 향후). 메모이제이션이 없어도 코드가 제대로 작동해야 합니다. 이 hook은 성능 최적화일 뿐입니다. 마찬가지로 API 호출이나 localStorage 쓰기 같은 side effect를 useMemo에 넣지 마세요. 그것들은 useEffect나 event handler의 몫입니다.

가장 흔한 버그는 dependency 누락으로, stale closure가 발생합니다:

const handleSave = useCallback(() => {
  saveOrder({ ...order, note });
}, [order]); // 누락: `note`

handleSave는 항상 처음 만들어질 때의 note 값을 사용합니다. eslint-plugin-react-hooks의 react-hooks/exhaustive-deps 규칙을 켜세요. 경고를 무시하지 마세요.

7. React Compiler가 바꾸는 것

React Compiler는 2025년 말부터 안정화되었으며, 빌드 시점에 component를 분석하고 손수 작성하는 메모이제이션보다 세분화된 수준에서 자동으로 최적화를 삽입합니다. Vite 또는 Babel 프로젝트에서 시도하려면:

npm install -D babel-plugin-react-compiler

Compiler가 활성화되면, useMemo, useCallback, memo를 거의 쓸 필요가 없습니다. 기존 hook은 여전히 작동합니다. 이들은 effect의 특정 dependency를 정밀하게 제어해야 할 때 유용합니다. Compiler는 React의 규칙을 따르는 코드만 최적화할 수 있습니다: 순수 component, props나 state를 직접 수정하지 않는 코드. 따라서 이 글에서 두 hook을 이해하는 것은 여전히 필수입니다. 기존 코드를 읽고, Compiler가 놓친 component를 디버깅하기 위해서입니다.

8. 결론

useMemo와 useCallback은 같은 문제를 해결합니다: 렌더링 사이에서 참조를 안정적으로 유지하는 것. 이들이 정말 가치 있는 세 가지 상황:

  1. 측정 가능하게 비용이 많이 드는 계산.
  2. 값 또는 함수가 React.memo로 감싼 component에 전달됨.
  3. 값 또는 함수가 useEffect나 다른 hook의 dependency에 있음. 여기에는 custom hook이 반환하는 함수도 포함됩니다.

이 경우들 외에는 코드를 단순하게 두세요. Profiler로 측정하고, 테스트로 증명하며, React Compiler를 활성화하여 이 작업을 도구가 하도록 하는 것을 고려하세요.

reacthooksusememousecallbackperformance