본문 바로가기

useCallback과 useMemo 기초

함수와 계산값의 참조를 재사용하는 두 훅을 구분하고 useTransition·useDeferredValue로 느린 업데이트의 우선순위를 조절합니다.

이 글에서는 함수의 참조와 계산값을 재사용하는 최적화 도구인 useCallback과 useMemo를 자세히 다룹니다. 두 훅의 작동 원리와 정확한 사용법, 실제로 적용할 때 고려할 점을 알아보고, 느린 갱신의 우선순위를 나누는 useTransition과 useDeferredValue도 함께 살펴봅니다. 앞 글의 memo 비교와 함수형 state 갱신을 이어서 씁니다.

UseCallbackDemo와 UseMemoDemo는 독립된 예제이며 JSX가 들어 있으므로 .jsx로 저장하고, App.jsx에서 하나씩 가져와 놓은 뒤 새로고침해 처음 상태에서 시작합니다. 문법 설명 코드의 dependency1, dependency2와 ...는 실제 프로젝트에서 읽는 값으로 의존성을 채우라는 자리 표시자이므로, 그 코드를 완성된 예제처럼 복사하지 않습니다.

UseMemoDemo의 반복문은 일부러 계산 비용을 만드는 실습 코드입니다. 입력은 우선 0부터 10까지의 정수만 쓰고, 큰 값을 넣어 화면을 멈추게 하는 실험은 하지 않습니다. 반복 횟수를 N이라고 하면 0부터 N−1까지의 합은 N(N−1)/2이므로, 입력이 1이면 N이 1,000,000이라 결과는 499,999,500,000이고, 처음 값인 10이면 N이 10,000,000이라 결과는 49,999,995,000,000입니다. 이 코드는 아무 입력이나 검증하는 서비스용 계산기가 아닙니다.


useCallback과 useMemo는 왜 필요한가?

React의 함수 컴포넌트는 렌더링할 때마다 함수 본문 전체가 다시 실행되고, 그 안의 표현식도 다시 평가됩니다. 객체·배열·함수 리터럴은 새 참조를 만들지만, state나 전달받은 참조까지 모두 새 객체가 되는 것은 아닙니다.

여기서 세 가지 문제가 생깁니다. 첫째, 컴포넌트가 리렌더링될 때마다 onClick, onChange 같은 이벤트 처리 함수가 새로 만들어져 이전 렌더링의 함수와 다른 참조를 가집니다. 둘째, 이렇게 새로 만든 함수를 React.memo로 감싼 자식에게 prop으로 넘기면, React.memo가 얕은 비교로 prop이 바뀌었다고 판단해 자식을 불필요하게 리렌더링합니다. 셋째, 컴포넌트 안에서 복잡한 계산으로 얻은 객체나 배열이 렌더링할 때마다 다시 계산되고 만들어진다면 그것만으로도 느려지고, 그 값을 자식의 prop으로 넘기면 마찬가지로 React.memo를 무력하게 만듭니다.

useCallback과 useMemo는 이처럼 렌더링할 때마다 새로 만들어지는 함수와 객체, 배열 같은 참조 타입의 값을 재사용해, 불필요한 리렌더링과 계산을 줄이는 데 씁니다.


useCallback: 함수 메모이제이션

useCallback 훅은 함수를 메모이제이션합니다. 인자로 넘기는 함수 표현식은 렌더마다 평가되며, useCallback은 의존성을 Object.is로 비교해 같으면 지금 받은 함수 대신 캐시해 둔 참조를 돌려줄 수 있습니다. 함수가 만들어지는 것 자체를 막는 훅은 아닙니다.

useCallback 문법
const memoizedCallback = useCallback(
  () => {
    // 실행될 함수 로직
  },
  [dependency1, dependency2, ...], // 의존성 배열
);

첫 번째 인자는 메모이제이션할 함수의 정의이고, 두 번째 인자인 의존성 배열(deps)의 값이 바뀌면 현재 렌더에서 넘긴 함수의 참조를 돌려줍니다. 빈 배열은 캐시가 유지되는 동안 같은 참조를 재사용하게 합니다.

가장 흔한 쓰임은 React.memo로 감싼 자식에게 콜백 함수를 prop으로 넘길 때로, 함수를 메모이제이션해 자식의 불필요한 리렌더링을 막습니다. 또한 useEffect나 useMemo의 의존성으로 함수를 쓸 때, 함수가 매번 새로 만들어지면 그 Effect나 계산이 불필요하게 다시 실행될 수 있으므로 useCallback으로 감싸 이를 막을 수 있습니다(React 문서의 useCallback).

src/components/UseCallbackDemo.jsx
import React, { useState, useCallback } from 'react';

// ✅ React.memo로 감싸진 자식 컴포넌트
const ButtonWithMemo = React.memo(({ onClick, label }) => {
  console.log(`ButtonWithMemo [${label}] 렌더링됨`); // 이 로그가 찍히는 시점을 확인!
  return (
    <button onClick={onClick} className="button" style={{ marginRight: '10px' }}>
      {label}
    </button>
  );
});

function UseCallbackDemo() {
  const [parentCount, setParentCount] = useState(0); // 부모 컴포넌트의 상태
  const [childCount, setChildCount] = useState(0);   // 자식 컴포넌트에게 전달될 상태
  const [textInput, setTextInput] = useState('');     // 텍스트 입력 상태

  // 🚨 일반 함수: UseCallbackDemo가 리렌더링될 때마다 재생성됨
  const handleRegularClick = () => {
    setChildCount(prev => prev + 1);
  };

  // ✅ useCallback 적용 (의존성 없음): 캐시된 함수 참조 재사용
  const handleOptimizedClickNoDeps = useCallback(() => {
    setChildCount(prev => prev + 1);
    console.log("Optimized Click (No Deps) executed.");
  }, []); // 의존성 배열이 비어있음

  // ✅ useCallback 적용 (의존성 있음): textInput이 변경될 때만 재생성됨
  const handleOptimizedClickWithDeps = useCallback(() => {
    alert(`Current Text Input: ${textInput}`);
    console.log("Optimized Click (With Deps) executed.");
  }, [textInput]); // textInput이 변경될 때만 함수가 재생성됨

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useCallback` 기초 예시</h2>
      <p>Parent Count: {parentCount}</p>
      <p>Child Count: {childCount}</p>
      <p>Text Input: {textInput}</p>

      <button onClick={() => setParentCount(parentCount + 1)} className="button" style={{ marginBottom: '15px' }}>
        Parent Count 증가 (부모 리렌더링)
      </button>
      <br/>
      <input
        type="text"
        value={textInput}
        onChange={(e) => setTextInput(e.target.value)}
        placeholder="텍스트 입력"
        style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <hr style={{ margin: '20px 0' }}/>

      {/* 1. 일반 함수 프롭스: 부모 리렌더링 시 ButtonWithMemo도 매번 리렌더링됨 */}
      <ButtonWithMemo onClick={handleRegularClick} label="Regular Button" />

      {/* 2. useCallback (의존성 없음) 프롭스: 부모 리렌더링 시 ButtonWithMemo는 리렌더링 안 함 (childCount는 이 버튼의 prop이 아님) */}
      <ButtonWithMemo onClick={handleOptimizedClickNoDeps} label="Optimized Button (No Deps)" />

      {/* 3. useCallback (의존성 있음) 프롭스: textInput 변경 시에만 ButtonWithMemo가 리렌더링됨 */}
      <ButtonWithMemo onClick={handleOptimizedClickWithDeps} label="Optimized Button (With Deps)" />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Parent Count 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `Regular Button`은 부모가 리렌더링될 때마다 함께 리렌더링되지만,<br/>
        `Optimized Button (No Deps)`는 `handleOptimizedClickNoDeps` 함수 참조가 고정되어 불필요하게 리렌더링되지 않습니다.<br/>
        `Optimized Button (With Deps)`는 `textInput`을 변경할 때만 리렌더링됩니다.
      </p>
    </div>
  );
}

export default UseCallbackDemo;

의존성 배열이 비어 있으면([]) 캐시가 유지되는 동안 처음 만든 함수의 참조를 재사용합니다. 이 함수는 클로저(closure)를 만들어 처음 렌더링할 때의 상태나 props를 기억하므로, 함수 안에서 최신 상태나 props를 읽어야 한다면 주의해야 합니다. setCount(count + 1) 대신 setCount(prevCount => prevCount + 1)처럼 함수형 업데이트를 쓰면 이 클로저 문제를 피할 수 있으며, 예제의 handleOptimizedClickNoDeps가 바로 그렇게 합니다. 의존성을 [dep1, dep2]처럼 넣으면 그 가운데 하나라도 Object.is 비교에서 달라질 때 현재 렌더의 함수 참조를 돌려주므로, 최신 값을 쓰면서도 불필요한 재생성을 막을 수 있습니다. 예제의 handleOptimizedClickWithDeps는 textInput을 읽으므로 [textInput]을 의존성으로 둡니다.


useMemo: 값 메모이제이션

useMemo 훅은 비용이 많이 드는 계산의 결과를 메모이제이션합니다. 의존성 배열의 값들이 바뀌지 않는 한 이전에 계산한 값을 재사용하고 새로 계산하지 않습니다.

useMemo 문법
const memoizedValue = useMemo(
  () => computeExpensiveValue(dep1, dep2),
  [dep1, dep2, ...], // 의존성 배열
);

첫 번째 인자는 값을 돌려주는 계산 함수이고, 두 번째 인자인 의존성 배열의 값 가운데 하나라도 바뀌면 useMemo는 계산 함수를 다시 실행해 새 값을 돌려줍니다. 빈 배열이라도 개발 모드 Strict Mode의 순수성 검사나 캐시를 버리는 일 때문에 다시 계산될 수 있으므로, 계산은 순수해야 하고 정확성을 캐시에 기대면 안 됩니다(React 문서의 useMemo).

useMemo는 대규모 데이터의 필터링이나 정렬, 복잡한 통계처럼 렌더링할 때마다 되풀이하기에는 비싼 계산의 결과를 캐시할 때 씁니다. 또한 React.memo로 감싼 자식에게 객체나 배열 prop을 넘길 때, 그 값이 렌더링할 때마다 새로 만들어져 React.memo를 무력하게 만드는 것을 막을 때도 씁니다.

src/components/UseMemoDemo.jsx
import React, { useState, useMemo } from 'react';

// ✅ React.memo로 감싸진 자식 컴포넌트 (배열 프롭스를 받음)
const ItemList = React.memo(({ items }) => {
  console.log('ItemList 렌더링됨'); // 이 로그가 찍히는 시점을 확인!
  return (
    <div style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '5px', marginTop: '15px' }}>
      <h4>아이템 목록 ({items.length}개)</h4>
      <ul>
        {items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
});

// 비용이 많이 드는 계산을 시뮬레이션하는 함수
const calculateExpensiveValue = (num) => {
  console.log('비용이 많이 드는 계산 수행됨...');
  let sum = 0;
  for (let i = 0; i < num * 1000000; i++) { // 의도적으로 지연을 만듦
    sum += i;
  }
  return sum;
};

function UseMemoDemo() {
  const [count, setCount] = useState(0); // 다른 상태 변화를 유도하여 리렌더링 테스트
  const [inputValue, setInputValue] = useState(10); // useMemo의 의존성

  // 🚨 useMemo를 사용하지 않으면, count가 변경될 때마다 calculateExpensiveValue가 다시 실행됨
  // const expensiveResult = calculateExpensiveValue(inputValue);

  // ✅ useMemo를 사용하여 inputValue가 변경될 때만 계산 결과를 다시 계산
  const expensiveResult = useMemo(() => {
    return calculateExpensiveValue(inputValue);
  }, [inputValue]); // inputValue가 변경될 때만 다시 계산

  // ✅ useMemo를 사용하여 배열 참조를 고정
  // inputValue만 변경될 때 ItemList가 이전 items 참조를 받을 수 있음
  const itemsArray = useMemo(() => {
    console.log('itemsArray 생성됨'); // 이 로그가 찍히는 시점을 확인!
    return [`Item A - ${count}`, `Item B`, `Item C`]; // count가 바뀌면 의존성이 달라져 새 배열을 계산함
  }, [count]); // count가 변경될 때만 itemsArray가 재생성됨

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useMemo` 기초 예시</h2>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)} className="button" style={{ marginBottom: '15px' }}>
        Count 증가 (부모 리렌더링)
      </button>

      <p>Input Value for Expensive Calculation: {inputValue}</p>
      <input
        type="number"
        value={inputValue}
        onChange={(e) => setInputValue(Number(e.target.value))}
        style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <p>Expensive Result: {expensiveResult}</p>

      <hr style={{ margin: '20px 0' }}/>

      {/* ItemList 컴포넌트: itemsArray가 변경될 때만 리렌더링됨 */}
      <ItemList items={itemsArray} />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Count 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `비용이 많이 드는 계산 수행됨...` 로그는 `inputValue`가 변경될 때만 찍히고,<br/>
        `itemsArray 생성됨` 로그는 `count`가 변경될 때만 찍힙니다.<br/>
        이를 통해 `ItemList` 컴포넌트의 불필요한 리렌더링도 방지됩니다.
      </p>
    </div>
  );
}

export default UseMemoDemo;

useCallback과 마찬가지로 useMemo의 의존성 배열은 언제 다시 계산할지를 정하므로 정확하게 적어야 합니다. 이 예제에서 비싼 계산은 inputValue만, 목록 배열은 count만 읽으므로 각각 [inputValue]와 [count]를 의존성으로 둡니다. 숫자 입력을 1로 바꾸면 비싼 계산은 다시 실행되어 499,999,500,000을 보여 주지만, 목록 계산이 읽는 count는 그대로이므로 캐시를 재사용하는 경로에서 목록 배열은 이전 참조를 유지할 수 있고 ItemList도 다시 렌더링하지 않을 수 있습니다.


useCallback과 useMemo 비교

함수 참조와 계산 결과를 구분해 재사용한다 — 상태 추적

같은 deps와 변경된 deps에서 useCallback 참조/useMemo 결과의 재사용 여부가 달라진다. 콜백을 반환하는 것과 계산을 실행하는 것을 같은 렌더별 값으로 보여 줄 수 있다.

useCallback은 함수 참조를 반환하고 useMemo는 계산 함수를 실행한 결과를 반환한다. 각 Hook의 완전한 의존성 목록이 재사용과 갱신을 결정하며, 캐시는 정확성 보장이 아니다.

USECALLBACK / USEMEMO / DEPENDENCIES

함수 참조와 계산 결과를 구분해 재사용한다

두 Hook은 각 의존성을 Object.is로 비교한다. 캐시가 유지되고 모두 같으면 이전 대상을 반환한다. 달라지면 useCallback은 현재 함수, useMemo는 새 계산 결과를 반환한다.

함수 참조와 계산 결과를 구분해 재사용한다useCallback은 함수 참조를 반환하고 useMemo는 계산 함수를 실행한 결과를 반환한다. 각 Hook의 완전한 의존성 목록이 재사용과 갱신을 결정하며, 캐시는 정확성 보장이 아니다.
useCallback(fn, deps)반환 대상 = 함수 참조
연결 관계useCallback(fn, deps) · 반환 대상 = 함수 참조 → 같음 → 이전 fn · 변경 → 현재 fn
같음 → 이전 fn변경 → 현재 fn
useMemo(calc, deps)반환 대상 = 계산 결과
연결 관계useMemo(calc, deps) · 반환 대상 = 계산 결과 → 같음 → 이전 값 · 변경 → calc 실행
같음 → 이전 값변경 → calc 실행
함수 참조와 계산 결과의 두 독립 경로useCallback의 함수 참조 반환 경로와 useMemo의 계산 결과 반환 경로를 세로로 나누어 배치한다. 각 경로의 의존성 같음/변경을 두 번째 노드에서 구분한다. 두 Hook 사이에는 호출이나 변환을 뜻하는 간선이 없다. 캐시와 순수성 조건은 원 지원 설명을 유지한다.
useCallback(fn, deps)반환 = 함수 참조
연결 관계useCallback(fn, deps) · 반환 = 함수 참조 → 같음 → 이전 fn · 변경 → 현재 fn
같음 → 이전 fn변경 → 현재 fn
useMemo(calc, deps)반환 = 계산 결과
연결 관계useMemo(calc, deps) · 반환 = 계산 결과 → 같음 → 이전 값 · 변경 → calc 실행
같음 → 이전 값변경 → calc 실행

FUNCTION / USECALLBACK

함수를 실행하거나 생성 차단하지 않는다

fn 표현식은 렌더에서 평가된다. useCallback은 그 함수 대신 캐시된 함수 참조를 반환할 수 있다. 예제의 [] 콜백은 setChildCount(prev => prev + 1)로 최신 state를 갱신한다.

VALUE / USEMEMO

계산별로 다른 의존성을 비교한다

UseMemoDemo의 비싼 계산은 [inputValue], itemsArray는 [count]에 의존한다. count가 바뀌면 배열과 ItemList가 갱신되고, inputValue만 바뀌면 기존 배열을 재사용할 수 있다.

DEPENDENCIES / COMPLETE

읽는 반응형 값을 모두 포함한다

textInput을 읽는 콜백은 [textInput]이 필요하다. 의존성을 누락해 참조를 고정하면 이전 렌더의 값을 읽을 수 있다. 함수형 state 업데이트는 이전 state를 직접 읽을 필요를 줄인다.

CACHE / LIMITS

순수 계산과 기능을 먼저 보장한다

첫 렌더에서는 계산한다. 개발 Strict Mode는 순수성 검사를 위해 계산을 추가로 호출할 수 있고 캐시도 폐기될 수 있다. 저장이 반드시 필요한 값은 state·ref 등의 목적에 맞는 도구를 사용한다.

useTransition·useDeferredValue는 갱신 우선순위와 값 반영을 조절한다. 참조 캐시나 백그라운드 스레드가 아니며, 하나의 동기 filter 호출을 중간에서 끊지 않는다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명useCallback의 함수 참조 반환 경로와 useMemo의 계산 결과 반환 경로를 세로로 나누어 배치한다. 각 경로의 의존성 같음/변경을 두 번째 노드에서 구분한다. 두 Hook 사이에는 호출이나 변환을 뜻하는 간선이 없다. 캐시와 순수성 조건은 원래 설명을 유지한다.
원도표 설명함수를 실행하거나 함수 생성을 차단하지 않는다
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
초기 cacheuseCallback: 함수 참조 / useMemo: 계산 값useCallback은 fn 참조, useMemo는 calc 결과를 반환한다useCallback이 fn을 여기서 실행하는 것은 아니다
같은 depsfn: 이전 참조 / 값: 이전 계산 결과동일한 전체 deps 비교에서 이전 결과를 재사용할 수 있다cache는 성능 최적화이며 정확성 수명 보장이 아니다
변경 depsfn: 현재 함수 참조 / calc: 재계산 결과useCallback은 현재 fn을, useMemo는 다시 실행한 calc 결과를 반환한다의존성을 누락해 낡은 값을 고정하지 않는다

함수 참조와 계산 결과를 구분해 재사용한다 — 상태 추적

같은 deps와 변경된 deps에서 useCallback 참조/useMemo 결과의 재사용 여부가 달라진다. 콜백을 반환하는 것과 계산을 실행하는 것을 같은 렌더별 값으로 보여 줄 수 있다.

useCallback은 함수 참조를 반환하고 useMemo는 계산 함수를 실행한 결과를 반환한다. 각 Hook의 완전한 의존성 목록이 재사용과 갱신을 결정하며, 캐시는 정확성 보장이 아니다.

USECALLBACK / USEMEMO / DEPENDENCIES

함수 참조와 계산 결과를 구분해 재사용한다

두 Hook은 각 의존성을 Object.is로 비교한다. 캐시가 유지되고 모두 같으면 이전 대상을 반환한다. 달라지면 useCallback은 현재 함수, useMemo는 새 계산 결과를 반환한다.

함수 참조와 계산 결과를 구분해 재사용한다useCallback은 함수 참조를 반환하고 useMemo는 계산 함수를 실행한 결과를 반환한다. 각 Hook의 완전한 의존성 목록이 재사용과 갱신을 결정하며, 캐시는 정확성 보장이 아니다.
useCallback(fn, deps)반환 대상 = 함수 참조
연결 관계useCallback(fn, deps) · 반환 대상 = 함수 참조 → 같음 → 이전 fn · 변경 → 현재 fn
같음 → 이전 fn변경 → 현재 fn
useMemo(calc, deps)반환 대상 = 계산 결과
연결 관계useMemo(calc, deps) · 반환 대상 = 계산 결과 → 같음 → 이전 값 · 변경 → calc 실행
같음 → 이전 값변경 → calc 실행
함수 참조와 계산 결과의 두 독립 경로useCallback의 함수 참조 반환 경로와 useMemo의 계산 결과 반환 경로를 세로로 나누어 배치한다. 각 경로의 의존성 같음/변경을 두 번째 노드에서 구분한다. 두 Hook 사이에는 호출이나 변환을 뜻하는 간선이 없다. 캐시와 순수성 조건은 원 지원 설명을 유지한다.
useCallback(fn, deps)반환 = 함수 참조
연결 관계useCallback(fn, deps) · 반환 = 함수 참조 → 같음 → 이전 fn · 변경 → 현재 fn
같음 → 이전 fn변경 → 현재 fn
useMemo(calc, deps)반환 = 계산 결과
연결 관계useMemo(calc, deps) · 반환 = 계산 결과 → 같음 → 이전 값 · 변경 → calc 실행
같음 → 이전 값변경 → calc 실행

FUNCTION / USECALLBACK

함수를 실행하거나 생성 차단하지 않는다

fn 표현식은 렌더에서 평가된다. useCallback은 그 함수 대신 캐시된 함수 참조를 반환할 수 있다. 예제의 [] 콜백은 setChildCount(prev => prev + 1)로 최신 state를 갱신한다.

VALUE / USEMEMO

계산별로 다른 의존성을 비교한다

UseMemoDemo의 비싼 계산은 [inputValue], itemsArray는 [count]에 의존한다. count가 바뀌면 배열과 ItemList가 갱신되고, inputValue만 바뀌면 기존 배열을 재사용할 수 있다.

DEPENDENCIES / COMPLETE

읽는 반응형 값을 모두 포함한다

textInput을 읽는 콜백은 [textInput]이 필요하다. 의존성을 누락해 참조를 고정하면 이전 렌더의 값을 읽을 수 있다. 함수형 state 업데이트는 이전 state를 직접 읽을 필요를 줄인다.

CACHE / LIMITS

순수 계산과 기능을 먼저 보장한다

첫 렌더에서는 계산한다. 개발 Strict Mode는 순수성 검사를 위해 계산을 추가로 호출할 수 있고 캐시도 폐기될 수 있다. 저장이 반드시 필요한 값은 state·ref 등의 목적에 맞는 도구를 사용한다.

useTransition·useDeferredValue는 갱신 우선순위와 값 반영을 조절한다. 참조 캐시나 백그라운드 스레드가 아니며, 하나의 동기 filter 호출을 중간에서 끊지 않는다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명useCallback의 함수 참조 반환 경로와 useMemo의 계산 결과 반환 경로를 세로로 나누어 배치한다. 각 경로의 의존성 같음/변경을 두 번째 노드에서 구분한다. 두 Hook 사이에는 호출이나 변환을 뜻하는 간선이 없다. 캐시와 순수성 조건은 원래 설명을 유지한다.
원도표 설명함수를 실행하거나 함수 생성을 차단하지 않는다
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
초기 cacheuseCallback: 함수 참조 / useMemo: 계산 값useCallback은 fn 참조, useMemo는 calc 결과를 반환한다useCallback이 fn을 여기서 실행하는 것은 아니다
같은 depsfn: 이전 참조 / 값: 이전 계산 결과동일한 전체 deps 비교에서 이전 결과를 재사용할 수 있다cache는 성능 최적화이며 정확성 수명 보장이 아니다
변경 depsfn: 현재 함수 참조 / calc: 재계산 결과useCallback은 현재 fn을, useMemo는 다시 실행한 calc 결과를 반환한다의존성을 누락해 낡은 값을 고정하지 않는다

위 그림은 useCallback이 함수 참조를, useMemo가 계산 함수를 실행한 결과를 돌려준다는 차이와, 각 Hook의 완전한 의존성 목록이 재사용과 갱신을 정한다는 점을 보여 줍니다. 그림의 요약처럼 두 Hook은 각 의존성을 Object.is로 비교해, 캐시가 유지되고 모두 같으면 이전 대상을 돌려주고, 달라지면 useCallback은 현재 함수를, useMemo는 새 계산 결과를 돌려줍니다. 두 경로는 나란히 놓여 있을 뿐 서로 호출하거나 변환하지 않으며, 캐시가 정확성을 보장하지는 않습니다.

그림 아래의 네 칸은 이 글의 예제와 이어집니다. useCallback은 함수를 실행하거나 만들어지는 것을 막지 않고, 렌더에서 평가된 함수 대신 캐시한 참조를 돌려줄 수 있으며, 예제의 [] 콜백은 setChildCount(prev => prev + 1)로 최신 state를 갱신합니다. UseMemoDemo의 비싼 계산은 [inputValue]에, itemsArray는 [count]에 기대므로, count가 바뀌면 배열과 ItemList가 갱신되고 inputValue만 바뀌면 기존 배열을 재사용할 수 있습니다. textInput을 읽는 콜백에는 [textInput]이 필요하며, 의존성을 빠뜨려 참조를 고정하면 이전 렌더의 값을 읽을 수 있습니다. 처음 렌더에서는 언제나 계산하고, 개발 모드의 Strict Mode는 순수성 검사를 위해 계산을 한 번 더 부를 수 있으며 캐시도 버려질 수 있으므로, 반드시 저장해야 하는 값은 state나 ref처럼 목적에 맞는 도구를 씁니다. 그림의 마지막 설명처럼 뒤에서 볼 useTransition과 useDeferredValue는 갱신의 우선순위와 값의 반영을 조절할 뿐, 참조 캐시나 백그라운드 스레드가 아니고 하나의 동기 filter 호출을 중간에 끊지도 않습니다.

특징useCallbackuseMemo
목적함수 자체를 메모이제이션함수의 실행 결과를 메모이제이션
반환값메모이제이션된 함수메모이제이션된 값(함수의 실행 결과)
주요 사용처React.memo 컴포넌트에 콜백 함수 전달, useEffect·useMemo의 의존성으로 함수 사용비싼 계산 결과의 캐시, React.memo 컴포넌트에 객체·배열 prop 전달

핵심 차이는 useCallback(fn, deps)가 넘긴 함수의 참조를 재사용하고, useMemo(() => value, deps)가 value라는 값을 기억한다는 것입니다.


동시성 Hook: useTransition, useDeferredValue

입력창에 타이핑할 때 목록의 필터링이나 정렬이 무거우면 키 입력이 끊기는 것처럼 느껴질 수 있습니다. 이때 동시성(Concurrent) Hook을 쓰면 사용자 입력에 대한 반응과 무거운 렌더링의 우선순위를 나눌 수 있습니다.

두 예제와 뒤의 App.tsx는 TypeScript로 적었으며, 두 예제는 각각 별도의 파일로 실행합니다. TypeScript 프로젝트는 React 컴포넌트와 타입스크립트 통합처럼 Vite의 react-ts 템플릿으로 만들 수 있습니다. 이 교재의 자바스크립트 템플릿 프로젝트라면 type Item 줄과 : { items: Item[] }, : React.ChangeEvent<HTMLInputElement> 같은 타입 표기를 지우고 .jsx 파일로 저장하며, App.tsx도 App.jsx로 씁니다. 타입 표기는 실행되는 값이 아니어서 지워도 동작은 같습니다.

useTransition: 상태 갱신의 우선순위 낮추기

src/components/SearchWithTransition.tsx
import React, { useMemo, useState, useTransition } from 'react';

type Item = { id: number; name: string };

export default function SearchWithTransition({ items }: { items: Item[] }) {
  const [keyword, setKeyword] = useState('');
  const [filter, setFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  const filteredItems = useMemo(
    () => items.filter((item) => item.name.toLowerCase().includes(filter.toLowerCase())),
    [items, filter],
  );

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    setKeyword(next); // 입력 반응은 즉시
    startTransition(() => {
      setFilter(next); // 무거운 목록 갱신은 낮은 우선순위
    });
  };

  return (
    <div>
      <input value={keyword} onChange={handleChange} placeholder="검색어 입력" />
      {isPending && <p>목록 갱신 중...</p>}
      <ul>{filteredItems.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
    </div>
  );
}

handleChange는 입력창에 보이는 keyword를 Transition 바깥에서 곧바로 갱신하고, 목록을 거르는 filter만 startTransition 안에서 낮은 우선순위로 갱신합니다. 목록의 갱신이 진행 중인 동안에는 isPending이 true가 되어 “목록 갱신 중...”이 보입니다(React 문서의 useTransition).

useDeferredValue: 느린 값을 늦게 반영하기

src/components/SearchWithDeferredValue.tsx
import { useDeferredValue, useMemo, useState } from 'react';

type Item = { id: number; name: string };

export default function SearchWithDeferredValue({ items }: { items: Item[] }) {
  const [keyword, setKeyword] = useState('');
  const deferredKeyword = useDeferredValue(keyword);

  const filteredItems = useMemo(
    () => items.filter((item) => item.name.toLowerCase().includes(deferredKeyword.toLowerCase())),
    [items, deferredKeyword],
  );

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="검색어 입력" />
      <ul>{filteredItems.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
    </div>
  );
}

이 예제는 state를 하나만 가지고, 목록 계산에는 useDeferredValue로 늦게 반영되는 deferredKeyword를 넘깁니다. 입력창은 언제나 최신 keyword를 보여 주고, 목록은 React가 여유가 있을 때 새 값으로 다시 계산합니다(React 문서의 useDeferredValue).

useTransition은 갱신 자체의 우선순위를 낮추고, useDeferredValue는 특정 값의 반영을 늦춘다는 점에서 목적이 다릅니다. 두 훅은 백그라운드 스레드를 만들거나 하나의 동기 filter 호출을 중간에서 끊지 않습니다. 위 코드는 우선순위를 나누는 최소한의 예시이며, 큰 목록에서는 느린 자식의 memo 적용과 가상화 같은 방법도 측정하며 검토합니다.

작은 데이터로 출력부터 확인하기

두 파일의 default export를 한 파일에 합치지 않습니다. App.tsx에서 다음처럼 한 컴포넌트만 골라 items를 넘기고, 두 번째 실험에서는 import 경로와 고른 컴포넌트를 SearchWithDeferredValue로 함께 바꿉니다.

src/App.tsx
import SearchWithTransition from './components/SearchWithTransition';

const items = [
  { id: 1, name: 'Alpha' },
  { id: 2, name: 'Beta' },
  { id: 3, name: 'Gamma' },
];

export default function App() {
  return <SearchWithTransition items={items} />;
}

처음에는 세 항목이 보입니다. 입력에 be를 넣고 갱신이 끝나면 id 2인 Beta만 남아야 합니다. Transition 예제는 입력창의 keyword와 목록 필터용 filter를 따로 가지며 setKeyword를 Transition 바깥에 두고, DeferredValue 예제는 state를 하나만 가지고 목록 계산에 늦게 반영되는 값을 넘깁니다. 진행 중에는 입력창이 be를 보여 주면서 목록은 이전 입력에 따른 세 항목을 보여 줄 수 있습니다. 작은 데이터에서는 이 차이가 눈에 보이지 않아도 잘못 실행된 것이라고 단정하지 않습니다.

더 깊이: Transition으로 표시되는 범위와 버전에 따른 차이

startTransition에 넘긴 함수는 바로 호출되며, 그 함수가 실행되는 동안 일어난 state 갱신만 Transition으로 표시됩니다(React 문서의 useTransition). 그래서 그 안에서 setTimeout으로 미룬 갱신은 Transition이 아니고, React 19에서 async 함수를 넘기더라도 await 뒤의 갱신은 다시 startTransition으로 감싸야 Transition이 됩니다. React 문서는 이것을 자바스크립트에서 React가 비동기 문맥을 잃어 생기는 알려진 한계로 설명합니다. 또 Transition으로 표시된 갱신은 다른 갱신에 밀려날 수 있어서, 차트를 다시 렌더링하는 도중에 입력이 들어오면 React는 입력을 먼저 처리한 뒤 차트의 렌더링을 다시 시작합니다.

여러 Transition이 동시에 진행될 때의 동작은 버전에 따라 다릅니다. React 문서의 주의사항은 진행 중인 Transition이 여럿이면 지금은 하나로 묶어 처리하며, 이 제한은 나중에 없어질 수 있다고 적습니다. 그런데 React의 변경 기록을 보면 19.3.0에서 Transition을 하나의 렌더로 엮지 않고 따로 렌더링하도록 바꿔, 느린 Transition이 관계없는 Transition을 붙잡지 않게 했습니다. 그러니 두 Transition이 끝나는 순서에 기대는 코드라면 설치된 React의 버전을 먼저 확인합니다.


최적화를 적용할 때 주의할 점

useCallback과 useMemo도 의존성 배열을 비교하고 값을 메모리에 저장하는 비용이 있으므로, 최적화로 얻는 이득이 이 비용보다 커야 의미가 있습니다. 성능 문제가 생기기 전에 모든 함수와 값을 메모이제이션하는 섣부른 최적화(Premature Optimization)는 오히려 코드를 복잡하게 하고 디버깅을 어렵게 만들 수 있으므로, 언제나 React Developer Tools의 Profiler로 실제 병목을 파악한 뒤에 적용합니다.

의존성 배열도 정확히 적어야 합니다. 함수나 계산 안에서 쓰는 변수를 의존성에 넣지 않으면 오래된(stale) 클로저의 값을 읽어 버그가 생기고, 필요 없는 값을 넣으면 그 값이 바뀔 때마다 함수나 값이 다시 만들어져 메모이제이션의 효과가 줄어듭니다. 이처럼 의존성 배열은 메모이제이션의 효과뿐 아니라 값의 정확성도 정하므로, 참조를 오래 유지해야 하는 경우와 최신 값을 반드시 반영해야 하는 경우를 구분합니다. 때로는 메모이제이션보다 컴포넌트를 더 작게 나누거나 상태를 필요한 곳으로 내리는 구조 개선이 더 근본적인 해결책이 됩니다.


정리

  • useCallback은 의존성이 같을 때 캐시한 함수 참조를, useMemo는 캐시한 계산 결과를 돌려주며, 함수 표현식이 렌더마다 평가되는 것 자체는 막지 않습니다.
  • 두 훅 모두 React.memo로 감싼 자식에게 안정적인 참조를 넘기거나, 다른 훅의 의존성을 안정시키는 데 씁니다.
  • 의존성 배열에는 함수나 계산이 읽는 반응형 값을 모두 넣고, 읽지 않는 값까지 넣지는 않습니다.
  • 캐시는 성능 최적화일 뿐이므로 계산은 순수해야 하고, 반드시 저장해야 하는 값은 state나 ref에 둡니다.
  • useTransition은 갱신의 우선순위를 낮추고 useDeferredValue는 값의 반영을 늦추며, 둘 다 별도 스레드에서 계산하는 것이 아닙니다.

연습 문제

1. 계산 의존성과 목록 의존성

UseMemoDemo를 새로고침하면 inputValue는 10, count는 0이고 목록은 Item A - 0, Item B, Item C입니다. 숫자 입력을 1로 바꾼 뒤 Count를 두 번 늘리면 마지막 count와 비싼 계산의 결과, 세 목록 문자열은 무엇일까요? 두 useMemo에 모두 [inputValue, count]를 넣는 것이 틀리지는 않지만 덜 알맞은 이유도 설명해 보세요.

해설 보기

count는 2이고 inputValue는 1이므로 계산 결과는 계속 499,999,500,000이며, 목록은 Item A - 2, Item B, Item C입니다. 비싼 계산은 inputValue를, 목록 계산은 count를 읽습니다.

두 의존성 배열에 두 값을 모두 넣으면 쓰지 않는 값이 바뀔 때도 다시 계산하므로 각각의 갱신 범위가 넓어집니다. 예를 들어 Count를 누를 때마다 비싼 계산이 다시 실행됩니다. 정확성을 위해 읽는 반응형 값을 빼면 안 되지만, 관련 없는 값까지 한꺼번에 넣을 필요도 없습니다. 로그의 횟수는 개발 모드의 Strict Mode와 캐시 조건에 따라 달라질 수 있으므로 값과 참조의 관계를 먼저 확인합니다.

2. useCallback의 최신 값

UseCallbackDemo에서 Parent Count를 한 번 늘리고, Regular Button을 한 번, Optimized Button (No Deps)를 두 번 누른 뒤, 입력에 TS를 넣고 Optimized Button (With Deps)를 한 번 누릅니다. 마지막 세 상태와 alert의 문구를 적어 보세요. 마지막 버튼의 콜백이 textInput을 읽는데 의존성을 []로 비우면 왜 출력이 틀릴 수 있을까요?

해설 보기

parentCount는 1, childCount는 3, textInput은 'TS'입니다. 일반 버튼과 빈 의존성 버튼 모두 setter의 함수형 업데이트로 childCount를 늘리므로 1 + 2 = 3이며, alert에는 “Current Text Input: TS”가 보여야 합니다.

textInput을 의존성에서 빼고 처음 만든 콜백을 재사용하면, 그 콜백은 처음 렌더의 빈 문자열을 읽어 “Current Text Input: ”만 보여 줄 수 있습니다. 이때 렌더링이 줄었다는 이유로 성공이라고 판단하면 사용자의 최신 입력을 잃게 됩니다.

3. 입력과 목록 갱신 나누기

두 검색 예제에 ga를 입력하면 갱신이 끝난 뒤 어떤 항목이 남아야 할까요? setKeyword까지 Transition 안으로 옮기거나, 응답성을 시험하려고 startTransition 콜백 안에서 아주 긴 반복문을 실행하면 어떤 목적과 부딪힐까요? 이 두 훅이 일정한 지연 시간이나 네트워크 요청 횟수를 보장하는지도 구분해 보세요.

해설 보기

마지막 목록은 id 3인 Gamma입니다. Transition 예제의 keyword와 filter, DeferredValue 예제의 keyword와 늦게 반영되는 값은 갱신이 안정된 뒤 모두 ga에 대응합니다. 제어하는 입력의 상태는 Transition으로 갱신하지 않고 곧바로 반영해야 타이핑 자체가 낮은 우선순위로 밀리지 않습니다. 또한 startTransition에 넘긴 함수는 곧바로 실행되므로, 그 안의 긴 동기 반복문은 호출한 흐름을 막습니다. 이 예제는 목록 상태 갱신의 우선순위를 나누는 것이지, 자바스크립트 반복문을 다른 스레드로 옮기는 방법이 아닙니다.

useDeferredValue는 정해진 밀리초 타이머가 아니며, 입력할 때마다 생기는 네트워크 요청을 저절로 줄여 주지도 않습니다. 실제 요청을 늦추거나 합치는 정책이 필요하다면 그 동작을 따로 설계해야 하며, 여기서는 로컬 배열을 거르므로 네트워크 요청이 없습니다.

다음 글 대규모 리스트 렌더링 최적화에서는 항목이 아주 많은 목록을 빠르게 렌더링하는 방법을 알아보고 10장을 마무리합니다.