어제 오늘 내일

[ React ] useCallback 사용법: 함수를 메모이제이션해 리렌더링 최적화하기 본문

IT/React

[ React ] useCallback 사용법: 함수를 메모이제이션해 리렌더링 최적화하기

hi.anna 2026. 8. 7. 14:49

이전 글에서 "useCallback은 useMemo로 함수를 감싼 것과 같다"고 잠깐 언급했었죠. 이번 글에서는 그 이야기를 이어서 useCallback을 자세히 다뤄보겠습니다. useCallback은 함수를 메모이제이션하는 Hook으로, React.memo와 함께 쓰일 때 진가를 발휘합니다. 이 글에서는 useCallback이 왜 필요한지, 언제 써야 하는지 예제와 함께 알아보겠습니다.

문제의 시작: 함수는 매번 새로 만들어진다

먼저 자바스크립트의 기본 성질부터 짚고 가야 합니다. 함수도 객체이기 때문에, 컴포넌트가 렌더링될 때마다 함수는 매번 새로 생성됩니다.

function Parent() {
  // 이 handleClick 함수는 Parent가 렌더링될 때마다 새로 만들어짐
  // 내용은 똑같지만, 자바스크립트 입장에서는 매번 "다른 함수"
  const handleClick = () => {
    console.log('클릭!');
  };

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

내용이 완전히 같아도, 자바스크립트에서 함수는 새로 만들면 서로 다른 참조로 취급됩니다.

const fn1 = () => console.log('hi');
const fn2 = () => console.log('hi');
console.log(fn1 === fn2); // false! 내용은 같지만 다른 함수

평소에는 이게 문제가 되지 않지만, React.memo와 만나면 이야기가 달라집니다.

React.memo와 함께 보는 문제 상황

React.memo는 props가 바뀌지 않으면 컴포넌트의 리렌더링을 건너뛰게 해주는 최적화 도구입니다.

import { memo } from 'react';

// React.memo로 감싸면, props가 바뀌지 않는 한 리렌더링을 건너뜀
const Child = memo(function Child({ onClick }) {
  console.log('Child 렌더링'); // 언제 렌더링되는지 확인용
  return <button onClick={onClick}>버튼</button>;
});

이제 부모에 다른 상태가 있어서 리렌더링이 일어난다고 가정해봅시다.

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

  // ❌ Parent가 리렌더링될 때마다 handleClick이 새로 생성됨
  //    Child에 넘기는 onClick의 참조값이 매번 달라짐
  const handleClick = () => {
    console.log('클릭!');
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>카운트 증가</button>

      {/* count만 바뀌었는데 Child도 매번 리렌더링됨 */}
      {/* 왜냐면 handleClick의 참조가 매번 새롭기 때문 */}
      <Child onClick={handleClick} />
    </div>
  );
}

count만 증가시켰을 뿐인데, Child도 함께 리렌더링됩니다. React.memo로 감쌌는데도 말이죠. 이유는 handleClick이 매 렌더링마다 새로 생성되어 참조값이 달라지고, React.memo가 "props가 바뀌었다"고 판단하기 때문입니다.

useCallback으로 해결하기

바로 이 지점에서 useCallback이 등장합니다. useCallback은 함수를 메모이제이션해서, 의존성이 바뀌지 않는 한 같은 함수(같은 참조)를 재사용하게 해줍니다.

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

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

  // ✅ useCallback으로 함수를 감쌈
  //    의존성 배열([])이 바뀌지 않는 한 항상 같은 함수를 반환
  //    → Child에 넘기는 onClick의 참조가 유지됨
  const handleClick = useCallback(() => {
    console.log('클릭!');
  }, []); // 의존성이 없으므로 함수는 최초 1회만 생성

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>카운트 증가</button>

      {/* 이제 handleClick의 참조가 유지되므로 */}
      {/* count가 바뀌어도 Child는 리렌더링되지 않음 */}
      <Child onClick={handleClick} />
    </div>
  );
}

이제 count를 아무리 증가시켜도 Child는 리렌더링되지 않습니다. handleClick의 참조가 유지되어 React.memo가 "props가 그대로다"라고 판단하기 때문입니다.

useCallback의 기본 문법

const memoizedFn = useCallback(() => {
  // 실행할 함수 내용
  doSomething(a, b);
}, [a, b]); // 의존성 배열
  • 첫 번째 인자: 메모이제이션할 함수
  • 두 번째 인자: 의존성 배열
    • 배열 안의 값이 바뀌면 함수를 새로 생성
    • 값이 그대로면 이전에 만든 함수를 그대로 재사용

의존성 배열의 규칙은 useMemo, useEffect와 완전히 동일합니다.

의존성 배열 다루기

함수 내부에서 사용하는 값은 의존성 배열에 넣어줘야 합니다.

function SearchBox({ onSearch }) {
  const [keyword, setKeyword] = useState('');

  // keyword를 함수 내부에서 사용하므로 의존성 배열에 포함
  // keyword가 바뀌면 함수도 새로 생성됨 (최신 keyword를 반영하기 위해)
  const handleSearch = useCallback(() => {
    onSearch(keyword);
  }, [keyword, onSearch]);

  return (
    <div>
      <input
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />
      <button onClick={handleSearch}>검색</button>
    </div>
  );
}

만약 의존성 배열에서 keyword를 빼먹으면, 함수가 처음 만들어질 때의 오래된 keyword(빈 문자열)만 참조하는 버그가 생깁니다. 이를 stale closure(오래된 클로저) 문제라고 부릅니다.

useCallback vs useMemo

이전 글에서 다뤘듯, 이 둘은 형제 같은 관계입니다.

// useMemo: 함수를 "실행한 결과값"을 메모이제이션
const value = useMemo(() => computeValue(a, b), [a, b]);

// useCallback: 함수 "그 자체"를 메모이제이션
const fn = useCallback(() => doSomething(a, b), [a, b]);

// 사실 useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일함
const fn = useMemo(() => () => doSomething(a, b), [a, b]);
구분 useMemo useCallback
메모이제이션 대상 계산된 함수 자체
반환값 함수 실행 결과 함수 그 자체
대표 용도 무거운 계산 결과 캐싱 자식에 함수 props 전달

한 문장으로: 결과값을 기억하고 싶으면 useMemo, 함수를 기억하고 싶으면 useCallback입니다.

최적화 3종 세트: memo + useMemo + useCallback

리렌더링 최적화는 보통 이 세 가지가 팀으로 움직입니다. 각자의 역할을 정리하면 이렇습니다.

  • React.memo: 컴포넌트를 감싸서, props가 그대로면 리렌더링을 건너뜀
  • useMemo: 자식에게 넘기는 객체/배열의 참조를 유지 (또는 무거운 계산 캐싱)
  • useCallback: 자식에게 넘기는 함수의 참조를 유지

즉, React.memo로 자식의 리렌더링을 막고 싶다면, 그 자식에게 넘기는 함수는 useCallback으로, 객체는 useMemo로 참조를 유지시켜줘야 비로소 최적화가 완성됩니다. 셋 중 하나라도 빠지면 최적화가 깨질 수 있습니다.

const Child = memo(function Child({ data, onClick }) {
  return <div onClick={onClick}>{data.title}</div>;
});

function Parent() {
  // 함수는 useCallback으로 참조 유지
  const handleClick = useCallback(() => { /* ... */ }, []);

  // 객체는 useMemo로 참조 유지
  const data = useMemo(() => ({ title: "제목" }), []);

  // 이제 세 요소가 맞물려 Child의 불필요한 리렌더링을 막음
  return <Child data={data} onClick={handleClick} />;
}

남용하지 말 것

useMemo와 마찬가지로, useCallback도 공짜가 아닙니다. 함수를 저장하고 의존성을 비교하는 비용이 들기 때문에, 모든 함수에 붙이는 것은 오히려 손해입니다.

// ❌ React.memo로 감싸지 않은 일반 컴포넌트에 넘기는 함수라면
//    useCallback을 써도 최적화 효과가 없고 오버헤드만 늘어남
const handleClick = useCallback(() => {}, []);
<NormalChild onClick={handleClick} />

// ✅ 그냥 일반 함수로 충분함
const handleClick = () => {};
<NormalChild onClick={handleClick} />

useCallback이 실제로 의미가 있는 경우는 다음과 같습니다.

  • React.memo로 감싼 자식에게 함수를 props로 넘길 때
  • useEffect의 의존성 배열에 함수가 들어갈 때 (함수 참조가 유지되어야 effect가 불필요하게 재실행되지 않음)
  • 커스텀 Hook이 반환하는 함수를 안정적으로 유지하고 싶을 때

이 조건에 해당하지 않는다면 굳이 useCallback을 쓸 필요가 없습니다.

마무리

useCallback은 함수를 메모이제이션해서 참조 동일성을 유지하는 Hook입니다. 그 자체로는 마법을 부리지 않고, React.memouseEffect와 짝을 이룰 때 비로소 의미가 생긴다는 점이 핵심입니다.

정리하면 이렇습니다. 함수는 렌더링마다 새로 생성되어 참조가 달라지고, 이것이 React.memo의 최적화를 무력화시킵니다. useCallback은 이 참조를 붙잡아둠으로써 불필요한 리렌더링을 막아줍니다. 다만 아무 데나 붙이면 오히려 손해이니, "이 함수가 정말 참조 유지가 필요한 곳으로 전달되는가?"를 확인한 뒤 적용하는 것이 좋은 습관입니다.

 

Tip: useMemo 글에서도 언급했듯, React 19의 React Compiler는 이런 수동 메모이제이션을 상당 부분 자동화해줍니다. 하지만 원리를 이해하고 있어야 컴파일러가 없는 환경이나 예외 상황에 대응할 수 있으니, useCallback의 동작 방식은 확실히 익혀두는 것을 추천합니다.

반응형
Comments