[React] useMemo 와 useCallback

2026. 8. 21. 14:32React

useMemo와 useCallback, 언제 써야 할까?

리액트를 쓰다 보면 언젠가 반드시 마주치는 질문이 있다.

"이 값, useMemo로 감싸야 하나?", "이 함수, useCallback 안 쓰면 안 되나?"

둘 다 "메모이제이션(memoization)"이라는 같은 개념을 사용하지만, 감싸는 대상이 다르고 쓰는 이유도 조금씩 다르다.

이번 글에서는 두 훅을 언제 쓰고, 언제 안 써도 되는지를 실제 예시와 함께 정리해보았다.

 

 

먼저, 왜 이런 훅이 필요할까

리액트 컴포넌트는 state나 props가 바뀌면 다시 렌더링이 된다.

문제는 컴포넌트 함수가 다시 실행될 때마다, 그 안에서 만든 배열, 객체, 함수도 매번 새로 생성된다는 점이다.

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

  // 렌더링될 때마다 새로운 배열, 새로운 함수가 만들어짐
  const list = [1, 2, 3].map((n) => n * 2);
  const handleClick = () => console.log('clicked');

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

 

list와 handleClick은 내용은 똑같아도 매 렌더링마다 "다른 객체"로 취급된다.

자바스크립트에서 {} === {}가 false인 것과 같은 원리로 이게 문제가 되는 상황은 크게 두 가지이다.

 

  1. 계산 비용이 큰 작업을 매번 다시 하는 경우 — 배열을 필터링하거나 정렬하는 로직이 무겁다면, 렌더링마다 다시 계산하는 게 낭비일 수 있다.
  2. 참조가 바뀌었다는 이유만으로 불필요한 재렌더링이나 재실행이 일어나는 경우 — React.memo로 감싼 자식 컴포넌트, 혹은 useEffect의 의존성 배열이 대표적이다.

 

useMemo는 을, useCallback은 함수캐싱해서 이 문제를 다룬다.

 

 

 

 


useMemo:  값을 재계산하지 않고 재사용하기

사용 예시 1 — 무거운 계산 캐싱

function ProductList({ products, keyword }) {
  const filteredProducts = useMemo(() => {
    return products.filter((p) => p.name.includes(keyword));
  }, [products, keyword]);

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

 

상품 개수가 수천 개고 필터링 로직이 무겁다면, products나 keyword가 바뀔 때만 다시 계산하는 게 합리적이다.

관련 없는 state(예: 모달 열림 여부)가 바뀌어서 이 컴포넌트가 리렌더링되더라도, 필터링은 다시 돌지 않는다.

 

 

 

사용 예시 2 — 참조 동일성이 필요한 값

function SearchPage({ rawFilters }) {
  const options = useMemo(
    () => ({ category: rawFilters.category, sort: rawFilters.sort }),
    [rawFilters.category, rawFilters.sort]
  );

  useEffect(() => {
    fetchResults(options);
  }, [options]); // options가 매번 새 객체면 이 effect가 매 렌더링마다 실행됨

  // ...
}

 

useEffect나 다른 훅의 의존성 배열에 객체·배열을 넣을 때,

그 값이 매번 새로 만들어지면 의존성이 "바뀐 것"으로 판정되어 의도치 않게 매번 실행된다.

이럴 때 useMemo로 참조를 고정해주면 해결된다.

 

 

 

여기서 의문을 가지게 되는 건,

그럼 useState에 그냥 저장을하고 useEffect을 호출해서 상태 변경과 UI를 감당하면 되지 않아? 

 

 

 

1. 흔한 실수 — props에서 파생된 값을 굳이 state로 복제하기

useMemo를 잘 쓰려면, "왜 이 값을 useState+useEffect 조합으로 만들면 안 되는가"를 이해하는 게 도움이 된다.

다음처럼 props로 받은 값을 필터링해서 별도 state로 저장하는 패턴을 종종 보게 된다.

 
// ❌ 흔히 하는 실수: props에서 파생된 값을 state + effect로 동기화

function ProductList({ products, keyword }) {
  const [filtered, setFiltered] = useState([]);
  const [isModalOpen, setModalOpen] = useState(false);

  useEffect(() => {
    setFiltered(products.filter((p) => p.name.includes(keyword)));
  }, [products, keyword]);

  // ...
}

 

먼저 짚어야 할 오해가 하나 있다.

isModalOpen처럼 관련 없는 state가 바뀌어서 이 컴포넌트가 리렌더링돼도, filtered는 저절로 사라지거나 재계산되지 않는다.

리액트의 state는 컴포넌트 함수가 다시 호출되는 것과 별개로 파이버(fiber)에 저장되어 있기 때문에, 관련 없는 리렌더링에는 영향을 받지 않는다. 이 부분은 useMemo와 동일하다.

 

문제는 products나 keyword가 실제로 바뀌었을 때 일어난다. 이 패턴은 다음과 같은 사이클을 거친다.

  1. props 변경으로 리렌더링 발생 → 이 시점엔 아직 effect가 실행되기 전이라 filtered는 이전 값 그대로
  2. 화면이 오래된 값으로 한 번 그려짐 (1차 페인트)
  3. 페인트가 끝난 뒤 useEffect가 실행되고, 그 안에서 setFiltered 호출
  4. state가 바뀌었으니 다시 리렌더링 → 화면이 새 값으로 다시 그려짐 (2차 페인트)

 

 

반면 useMemo를 쓰면 이렇게 된다.

// ✅ useMemo: 렌더링과 계산이 같은 사이클 안에서 끝남
function ProductList({ products, keyword }) {
  const [isModalOpen, setModalOpen] = useState(false);

  const filtered = useMemo(
    () => products.filter((p) => p.name.includes(keyword)),
    [products, keyword]
  );

  // ...
}

 

props가 바뀌면 리액트는 어차피 컴포넌트 함수를 다시 실행한다.

다만 그 실행 도중에 useMemo의 계산도 같이 끝나버리기 때문에, 화면은 처음부터 최신 값을 반영한 채로 한 번만 그려진다.

여기서 정확히 짚어야 할 부분은, "렌더링이 아예 일어나지 않는다"가 아니라 "렌더링과 계산이 같은 렌더 사이클 안에서 동시에 끝나기 때문에 추가 렌더 사이클이 필요 없다" 는 점이다.

useState+useEffect 조합은 이 계산이 렌더링 이후로 밀려나기 때문에 사이클이 한 번 더 도는 것.

 

 

 

 

 

정리비교 

방식 관련 없는 state 변경 시 재계산?  products/keyword 실제 변경 시
useMemo(() => ..., [products, keyword]) ❌ 안 됨 렌더링 중 바로 계산, 추가 리렌더링 없음
useState(products) + 그대로 유지 ❌ 안 됨 props가 바뀌어도 자동 갱신 안 됨 (수동 동기화 필요)
useState(products) + useEffect로 동기화 ❌ 안 됨 계산은 되지만, 오래된 값이 먼저 그려졌다가 다시 리렌더링
 

정리하면, props가 유일한 진실의 원천(source of truth)이 되고 useMemo는 거기서 매번 값을 계산하되

비용이 클 때만 캐싱해주는 역할을 한다.

파생 가능한 값을 굳이 별도 state로 복제해서 effect로 동기화할 필요가 없다는 게 리액트에서 흔히 말하는 "state는 최소화하고, 파생되는 값은 파생시켜라"는 원칙의 핵심이다

 

 

 

 

 

참고로 useState(expensiveCalc(products))처럼 초기값 자리에 함수 호출을 직접 넣으면, 그 계산은 리렌더링마다 실행되고 버려진다 (리액트는 최초 렌더링 이후엔 이 값을 무시하고 저장해둔 state를 쓴다).
최초 한 번만 계산하고 싶다면 useState(() => expensiveCalc(products))처럼 함수를 넘기는 지연 초기화(lazy initializer)를 써야 한다. 

 

 

1. 초기값을 함수 호출로 넘기는 경우

// ⚠️ 이렇게 쓰면 문제가 있다
const [productsState] = useState(expensiveTransform(products));
 

useState(expensiveTransform(products))라고 쓰면, expensiveTransform(products)는 자바스크립트 함수 호출 인자이기 때문에 컴포넌트가 리렌더링될 때마다 실행된다.

리액트는 첫 렌더링 이후에는 이 값을 무시하고 저장해둔 state를 쓰지만, 그 계산 자체는 매번 실행되고 버려진다.

isModalOpen이 바뀌어서 리렌더링될 때도 expensiveTransform은 계속 호출된다.

이걸 피하려면 lazy initializer를 쓴다.

 

// ✅ 함수를 넘기면 최초 렌더링에서만 실행된다
const [productsState] = useState(() => expensiveTransform(products));

 

 

 

 

 

 

 


useCallback: 함수의 참조를 고정하기

useCallback은 사실 useMemo의 특수한 형태이다.

useCallback(fn, deps)는 useMemo(() => fn, deps)와 동작이 같다. 다만 함수를 감싸는 용도로 워낙 자주 쓰이다 보니 별도 API로 분리된 것뿐이다.

 

사용 예시 1 — React.memo로 감싼 자식에게 콜백을 넘길 때

 

const ExpensiveChild = React.memo(function ExpensiveChild({ onSelect }) {
  console.log('ExpensiveChild 렌더링');
  // 내부에 무거운 렌더링 로직이 있다고 가정
  return <button onClick={() => onSelect(1)}>선택</button>;
});

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

  const handleSelect = useCallback((id) => {
    console.log('선택된 id:', id);
  }, []); // 의존성이 없으니 한 번만 생성

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
      <ExpensiveChild onSelect={handleSelect} />
    </div>
  );
}

 

여기서 핵심은 ExpensiveChild가 React.memo로 감싸져 있다는 점이다. React.memo는 props가 이전과 "같으면" 리렌더링을 건너뛴다. 그런데 handleSelect를 그냥 일반 함수로 선언하면 Parent가 리렌더링될 때마다 새 함수가 생성되어 React.memo의 props 비교가 항상 "다름"으로 판정되고, 결국 React.memo가 있으나 마나 한 상태가 된다.

useCallback으로 함수 참조를 고정해야 React.memo가 실제로 효과를 발휘한다.

 

 

 

 

사용 예시 2 — 커스텀 훅이나 useEffect의 의존성으로 쓰일 때

 
function useWindowResize(onResize) {
  useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, [onResize]); // onResize가 매번 바뀌면 매 렌더링마다 리스너를 등록/해제
}

function App() {
  const handleResize = useCallback(() => {
    console.log(window.innerWidth);
  }, []);

  useWindowResize(handleResize);
  // ...
}

 

이벤트 리스너 등록·해제처럼 "함수 참조가 바뀔 때마다 부수효과가 다시 실행되는" 구조라면, useCallback으로 불필요한 재등록을 막아줄 수 있다.

 

 

 

 

 

 

안 써도 되는 경우들

여기가 사실 이 글에서 가장 중요한 부분이다.

useMemo/useCallback은 공짜가 아니다. 캐시를 유지하기 위한 메모리, 의존성 배열을 매 렌더링마다 비교하는 연산 비용이 든다.

그리고 무엇보다 코드를 읽기 어렵게 만드므로 아래 상황에서는 굳이 쓸 필요가 없다.

 

 

1. 계산이 가볍다면

 
// 이 정도 계산에 useMemo는 과하다
const fullName = useMemo(() => `${first} ${last}`, [first, last]);

// 그냥 이렇게 써도 충분하다
const fullName = `${first} ${last}`;

 

문자열 결합, 간단한 산술, 짧은 배열의 map/filter 정도는 리렌더링될 때마다 다시 계산해도 사람이 체감할 수 있는 성능 차이가 거의 없다.오히려 useMemo의 의존성 비교 비용이 더 클 수도 있다.

 

 

 

2. React.memo로 감싸지 않은 컴포넌트에 넘기는 콜백

 
function RegularChild({ onClick }) {
  return <button onClick={onClick}>클릭</button>;
}

function Parent() {
  // RegularChild가 React.memo로 감싸져 있지 않다면
  // 어차피 Parent가 리렌더링될 때 RegularChild도 같이 리렌더링된다.
  // useCallback으로 참조를 고정해도 얻는 게 없다.
  const handleClick = () => console.log('clicked');
  return <RegularChild onClick={handleClick} />;
}

 

useCallback의 존재 이유는 "참조가 바뀌면 불이익이 있는 곳"에 넘겨줄 때이다. 자식이 React.memo로 감싸져 있지 않다면, 애초에 부모가 리렌더링될 때 자식도 리렌더링되는 게 정상 동작이라 함수 참조를 고정해도 얻는 이득이 없다.

 

 

 

3. state로 관리해도 되는 값을 억지로 useMemo로 흉내 내는 경우

 
// 이렇게 하기보다
const doubled = useMemo(() => count * 2, [count]);

// 파생값이 정말 단순하면 그냥 렌더링 중에 계산
const doubled = count * 2;

 

파생 상태(derived state)는 대부분 렌더링 중에 그냥 계산해도 된다.

useMemo는 "재계산 비용이 실제로 문제가 될 때"를 위한 도구지, 모든 파생값에 습관적으로 씌우는 패턴이 아니다.

 

 

 

4. 아직 성능 문제를 겪지 않았다면

가장 흔한 실수가 "혹시 모르니까" 미리 최적화하는 것이다.

리액트 팀도 공식 문서에서 이 점을 강조하는데, 프로파일링 없이 감으로 useMemo/useCallback을 여기저기 붙이면 코드만 복잡해지고 실제 성능 이득은 없는 경우가 많다.

React DevTools의 Profiler로 실제 병목을 확인한 뒤에 최적화하는 게 순서에 맞다.

 

 

5. 참고: React Compiler가 있다면 상황이 또 다르다

2025년 10월에 정식 출시된 React Compiler는 빌드 타임에 코드를 분석해서 필요한 메모이제이션을 자동으로 삽입해준다.

Next.js 16 등 최신 툴체인에서 이 컴파일러를 활성화했다면, 대부분의 useMemo/useCallback은 더 이상 수동으로 작성할 필요가 없다.

다만 아래와 같은 경우엔 여전히 수동 처리가 필요합니다.

  • 서드파티 라이브러리가 함수/객체의 참조 동일성 자체를 API 계약으로 요구하는 경우 (예: 외부 이벤트 시스템에 콜백을 등록할 때)
  • try/catch 안에 있는 등 컴파일러가 제어 흐름을 완전히 분석하기 어려운 코드
  • 아직 컴파일러를 도입하지 않은 기존 프로젝트, 혹은 컴파일러를 지원하지 않는 빌드 환경

즉 컴파일러가 있는 프로젝트라면 "일단 자연스럽게 코드를 작성하고, 실제로 참조 동일성이 계약 조건인 경우에만 수동으로 훅을 추가한다"는 방향으로 접근하는 게 맞다.

 

 

 

정리하면

상황 useMemo/useCallback 필요?
배열 필터링/정렬 등 계산 비용이 큰 로직 ✅ 필요
React.memo로 감싼 자식에게 함수를 props로 전달 ✅ 필요 (useCallback)
useEffect 등의 의존성 배열에 객체/함수를 넣어야 할 때 ✅ 필요
props에서 파생되는 값을 state+effect로 동기화하려는 경우 ✅ useMemo로 대체 권장
문자열 결합, 간단한 산술 등 가벼운 계산 ❌ 불필요
일반 컴포넌트(React.memo 미적용)에 넘기는 콜백 ❌ 불필요
프로파일링 없이 "혹시 몰라서" 미리 감싸는 경우 ❌ 불필요
React Compiler를 도입한 프로젝트의 일반적인 파생값/콜백 ❌ 대부분 불필요

 

결국 핵심 기준은 하나이다.

 

"이 값/함수의 참조(혹은 재계산 비용)가 바뀌는 게 실제로 문제를 일으키는가?"

 

이 질문에 명확히 "그렇다"고 답할 수 있을 때만 useMemo/useCallback을 쓰는 게, 코드도 깔끔하고 성능도 챙기는 길이다.