useCallback

김동현·2026년 3월 17일

useCallback

useCallback은 리렌더링 사이에 함수 정의를 캐시할 수 있게 해주는 React Hook이에요.

const cachedFn = useCallback(fn, dependencies)

React Compiler는 자동으로 값과 함수를 메모이제이션해서, 수동으로 useCallback을 호출할 필요성을 줄여줘요. 컴파일러를 사용해서 메모이제이션을 자동으로 처리할 수 있어요.

💡 부연 설명: React Compiler는 아직 실험적인 기능이지만, 미래에는 useCallback이나 useMemo를 직접 쓰지 않아도 컴파일러가 알아서 최적화해주는 세상이 올 수 있어요!


레퍼런스 {/reference/}

useCallback(fn, dependencies) {/usecallback/}

컴포넌트의 최상위 레벨에서 useCallback을 호출해서 리렌더링 사이에 함수 정의를 캐시하세요:

import { useCallback } from 'react';

export default function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

아래에서 더 많은 예제를 볼 수 있어요.

매개변수 {/parameters/}

  • fn: 캐시하고 싶은 함수 값이에요. 어떤 인자든 받을 수 있고 어떤 값이든 반환할 수 있어요. React는 초기 렌더링 중에 함수를 (호출하지 않고!) 그대로 반환해줘요. 다음 렌더링에서는, dependencies가 마지막 렌더링 이후로 변경되지 않았다면 같은 함수를 다시 줄 거예요. 변경됐다면, 현재 렌더링 중에 전달한 함수를 주고, 나중에 재사용할 수 있도록 저장해요. React는 함수를 호출하지 않아요. 함수가 여러분에게 반환되니까, 언제 그리고 호출할지 말지를 여러분이 결정할 수 있어요.

  • dependencies: fn 코드 안에서 참조하는 모든 반응형 값(reactive values)의 목록이에요. 반응형 값에는 props, state, 그리고 컴포넌트 본문 안에서 직접 선언된 모든 변수와 함수가 포함돼요. 린터가 React용으로 설정되어 있다면, 모든 반응형 값이 의존성으로 올바르게 지정되었는지 확인해줘요. 의존성 목록은 항목 수가 일정해야 하고 [dep1, dep2, dep3] 같이 인라인으로 작성해야 해요. React는 Object.is 비교 알고리즘을 사용해서 각 의존성을 이전 값과 비교해요.

💡 부연 설명: Object.is===와 거의 같지만, NaN끼리 비교할 때와 +0-0을 비교할 때 다르게 동작해요. 대부분의 경우에는 ===와 동일하다고 생각해도 괜찮아요.

반환값 {/returns/}

초기 렌더링에서 useCallback은 전달한 fn 함수를 반환해요.

이후 렌더링에서는, 의존성이 변경되지 않았다면 마지막 렌더링에서 이미 저장해둔 fn 함수를 반환하거나, 이번 렌더링 중에 전달한 fn 함수를 반환해요.

주의사항 {/caveats/}

  • useCallback은 Hook이기 때문에, 컴포넌트의 최상위 레벨이나 여러분만의 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그렇게 해야 한다면, 새로운 컴포넌트를 추출하고 상태를 그 안으로 옮기세요.
  • React는 특별한 이유가 없는 한 캐시된 함수를 버리지 않아요. 예를 들어, 개발 모드에서는 컴포넌트 파일을 편집하면 캐시를 버려요. 개발 모드와 프로덕션 모드 모두에서, 컴포넌트가 초기 마운트 중에 suspend되면 캐시를 버려요. 앞으로 React는 캐시를 버리는 것을 활용하는 더 많은 기능을 추가할 수 있어요—예를 들어, 향후 React가 가상화된 리스트에 대한 내장 지원을 추가한다면, 가상화된 테이블 뷰포트에서 스크롤되어 벗어난 항목의 캐시를 버리는 것이 합리적일 거예요. useCallback을 성능 최적화로 사용한다면 이게 여러분의 기대와 맞을 거예요. 그렇지 않다면, state 변수ref가 더 적절할 수 있어요.

사용법 {/usage/}

컴포넌트의 리렌더링 건너뛰기 {/skipping-re-rendering-of-components/}

렌더링 성능을 최적화할 때, 자식 컴포넌트에 전달하는 함수를 캐시해야 할 때가 있어요. 먼저 이걸 어떻게 하는지 문법을 살펴보고, 어떤 경우에 유용한지 알아볼게요.

컴포넌트의 리렌더링 사이에 함수를 캐시하려면, 함수 정의를 useCallback Hook으로 감싸세요:

import { useCallback } from 'react';

function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);
  // ...

useCallback에 두 가지를 전달해야 해요:

  1. 리렌더링 사이에 캐시하고 싶은 함수 정의.
  2. 함수 안에서 사용되는 컴포넌트 내의 모든 값을 포함하는 의존성 목록.

초기 렌더링에서, useCallback에서 받게 될 반환된 함수는 여러분이 전달한 함수예요.

이후 렌더링에서, React는 의존성을 이전 렌더링에서 전달한 의존성과 비교해요. 의존성 중 아무것도 변경되지 않았다면 (Object.is로 비교), useCallback은 이전과 같은 함수를 반환해요. 변경됐다면, useCallback이번 렌더링에서 전달한 함수를 반환해요.

다시 말해서, useCallback은 의존성이 변경될 때까지 리렌더링 사이에 함수를 캐시해요.

이게 언제 유용한지 예제를 통해 살펴볼게요.

ProductPage에서 ShippingForm 컴포넌트로 handleSubmit 함수를 전달한다고 해볼게요:

function ProductPage({ productId, referrer, theme }) {
  // ...
  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );

theme prop을 토글하면 앱이 잠시 멈추는 걸 발견했는데, JSX에서 <ShippingForm />을 제거하면 빠르게 느껴져요. 이건 ShippingForm 컴포넌트를 최적화해볼 만하다는 걸 알려줘요.

기본적으로, 컴포넌트가 리렌더링되면 React는 모든 자식을 재귀적으로 리렌더링해요. 그래서 ProductPage가 다른 theme로 리렌더링되면, ShippingForm 컴포넌트도 마찬가지로 리렌더링돼요. 리렌더링에 많은 계산이 필요하지 않은 컴포넌트라면 괜찮아요. 하지만 리렌더링이 느리다는 걸 확인했다면, ShippingFormmemo로 감싸서 props가 지난 렌더링과 같을 때 리렌더링을 건너뛰도록 할 수 있어요:

import { memo } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  // ...
});

이 변경으로, ShippingForm은 모든 props가 지난 렌더링과 같으면 리렌더링을 건너뛸 거예요. 이때 함수를 캐시하는 게 중요해져요! useCallback 없이 handleSubmit을 정의했다고 해볼게요:

function ProductPage({ productId, referrer, theme }) {
  // theme가 변경될 때마다, 이건 다른 함수가 될 거예요...
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }

  return (
    <div className={theme}>
      {/* ... 그래서 ShippingForm의 props는 절대 같지 않을 거고, 매번 리렌더링될 거예요 */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

JavaScript에서, function () {}이나 () => {}는 항상 다른 함수를 생성해요, {} 객체 리터럴이 항상 새 객체를 생성하는 것과 비슷하게요. 보통은 이게 문제가 되지 않지만, ShippingForm의 props가 절대 같지 않을 것이고, memo 최적화가 작동하지 않을 거라는 뜻이에요. 여기서 useCallback이 유용해져요:

function ProductPage({ productId, referrer, theme }) {
  // React에게 리렌더링 사이에 함수를 캐시하라고 알려줘요...
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]); // ...이 의존성들이 변경되지 않는 한...

  return (
    <div className={theme}>
      {/* ...ShippingForm은 같은 props를 받고 리렌더링을 건너뛸 수 있어요 */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

handleSubmituseCallback으로 감싸면, 리렌더링 사이에 같은 함수임을 보장해요 (의존성이 변경될 때까지). 특별한 이유가 없다면 함수를 useCallback으로 감쌀 필요가 없어요. 이 예제에서 이유는, memo로 감싼 컴포넌트에 전달하기 때문이고, 이것이 리렌더링을 건너뛸 수 있게 해줘요. useCallback이 필요한 다른 이유들은 이 페이지에서 더 설명해요.

useCallback은 성능 최적화로만 사용해야 해요. 이것 없이 코드가 작동하지 않는다면, 근본적인 문제를 먼저 찾아서 고치세요. 그다음에 useCallback을 다시 추가할 수 있어요.

useMemouseCallback과 함께 자주 보게 될 거예요. 둘 다 자식 컴포넌트를 최적화하려고 할 때 유용해요. 전달하는 것을 메모이제이션 (다시 말해, 캐시)할 수 있게 해줘요:

import { useMemo, useCallback } from 'react';

function ProductPage({ productId, referrer }) {
  const product = useData('/product/' + productId);

  const requirements = useMemo(() => { // 함수를 호출하고 결과를 캐시해요
    return computeRequirements(product);
  }, [product]);

  const handleSubmit = useCallback((orderDetails) => { // 함수 자체를 캐시해요
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      <ShippingForm requirements={requirements} onSubmit={handleSubmit} />
    </div>
  );
}

차이점은 무엇을 캐시할 수 있게 해주느냐에 있어요:

  • useMemo는 함수를 호출한 결과를 캐시해요. 이 예제에서는, product가 변경되지 않는 한 computeRequirements(product) 호출 결과를 캐시해요. 이렇게 하면 ShippingForm을 불필요하게 리렌더링하지 않고 requirements 객체를 전달할 수 있어요. 필요할 때, React는 렌더링 중에 전달한 함수를 호출해서 결과를 계산해요.
  • useCallback함수 자체를 캐시해요. useMemo와 달리, 제공한 함수를 호출하지 않아요. 대신, productIdreferrer가 변경되지 않는 한 handleSubmit 자체가 변경되지 않도록 함수를 캐시해요. 이렇게 하면 ShippingForm을 불필요하게 리렌더링하지 않고 handleSubmit 함수를 전달할 수 있어요. 코드는 사용자가 폼을 제출할 때까지 실행되지 않아요.

💡 부연 설명: 정리하자면!

  • useMemo(() => 함수(), deps) → 함수를 실행한 결과값을 캐시
  • useCallback(함수, deps)함수 자체를 캐시
    이 차이를 잘 기억해두세요!

이미 useMemo에 익숙하다면, useCallback을 이렇게 생각하면 도움이 될 수 있어요:

// 단순화된 구현 (React 내부)
function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

useMemouseCallback의 차이에 대해 더 알아보기.

모든 곳에 useCallback을 추가해야 하나요? {/should-you-add-usecallback-everywhere/}

여러분의 앱이 이 사이트처럼 대부분의 상호작용이 대략적인 것(페이지나 전체 섹션을 교체하는 것 같은)이라면, 메모이제이션은 보통 불필요해요. 반면에 앱이 드로잉 에디터 같고 대부분의 상호작용이 세밀한 것(도형을 이동하는 것 같은)이라면, 메모이제이션이 매우 도움이 될 수 있어요.

useCallback으로 함수를 캐시하는 것이 가치 있는 경우는 몇 가지뿐이에요:

  • memo로 감싼 컴포넌트에 prop으로 전달할 때. 값이 변경되지 않았다면 리렌더링을 건너뛰고 싶은 거예요. 메모이제이션을 사용하면 의존성이 변경되었을 때만 컴포넌트가 리렌더링돼요.
  • 전달하는 함수가 나중에 어떤 Hook의 의존성으로 사용될 때. 예를 들어, useCallback으로 감싼 다른 함수가 이것에 의존하거나, useEffect에서 이 함수에 의존할 때.

다른 경우에는 함수를 useCallback으로 감싸는 것에 이점이 없어요. 그렇게 하는 것에 큰 해도 없어서, 어떤 팀들은 개별 사례를 고려하지 않고 가능한 한 많이 메모이제이션하기로 선택하기도 해요. 단점은 코드가 덜 읽기 쉬워진다는 거예요. 또한 모든 메모이제이션이 효과적인 것은 아니에요: "항상 새로운" 단일 값이면 전체 컴포넌트의 메모이제이션을 깨뜨리기에 충분해요.

useCallback이 함수 생성을 방지하지는 않는다는 걸 기억하세요. 여러분은 항상 함수를 생성하고 있어요 (그리고 그건 괜찮아요!), 하지만 아무것도 변경되지 않았다면 React는 그걸 무시하고 캐시된 함수를 돌려줘요.

실제로, 몇 가지 원칙을 따르면 많은 메모이제이션을 불필요하게 만들 수 있어요:

  1. 컴포넌트가 시각적으로 다른 컴포넌트를 감쌀 때, JSX를 children으로 받도록 하세요. 그러면 래퍼 컴포넌트가 자체 state를 업데이트해도, React는 children이 리렌더링될 필요가 없다는 걸 알아요.
  2. 로컬 state를 선호하고, 필요 이상으로 state를 끌어올리지 마세요. 폼이나 항목이 호버됐는지 같은 일시적인 state를 트리의 최상위나 전역 state 라이브러리에 보관하지 마세요.
  3. 렌더링 로직을 순수하게 유지하세요. 컴포넌트를 리렌더링하는 것이 문제를 일으키거나 눈에 띄는 시각적 결함을 만든다면, 컴포넌트의 버그예요! 메모이제이션을 추가하는 대신 버그를 고치세요.
  4. state를 업데이트하는 불필요한 Effect를 피하세요. React 앱의 대부분의 성능 문제는 Effect에서 시작되는 업데이트 체인이 컴포넌트를 반복적으로 렌더링하게 만드는 것 때문이에요.
  5. Effect에서 불필요한 의존성을 제거하려고 해보세요. 예를 들어, 메모이제이션 대신 어떤 객체나 함수를 Effect 안이나 컴포넌트 밖으로 이동하는 것이 종종 더 간단해요.

특정 상호작용이 여전히 느리게 느껴진다면, React Developer Tools 프로파일러를 사용해서 어떤 컴포넌트가 메모이제이션으로부터 가장 많은 이점을 얻는지 확인하고, 필요한 곳에 메모이제이션을 추가하세요. 이 원칙들은 컴포넌트를 디버그하고 이해하기 쉽게 만들어줘서, 어떤 경우든 따르는 게 좋아요. 장기적으로는, 이 문제를 완전히 해결하기 위해 자동으로 메모이제이션하는 것을 연구하고 있어요.

useCallbackmemo로 리렌더링 건너뛰기 {/skipping-re-rendering-with-usecallback-and-memo/}

이 예제에서, ShippingForm 컴포넌트는 인위적으로 느리게 만들어져 있어서 렌더링하는 React 컴포넌트가 진짜 느릴 때 어떤 일이 일어나는지 볼 수 있어요. 카운터를 증가시키고 테마를 토글해보세요.

카운터를 증가시키면 느리게 느껴지는데, 느리게 만들어진 ShippingForm이 리렌더링되기 때문이에요. 카운터가 변경되었으니 사용자의 새로운 선택을 화면에 반영해야 하므로 이건 예상된 동작이에요.

다음으로 테마를 토글해보세요. useCallbackmemo 덕분에, 인위적인 느림에도 불구하고 빨라요! ShippingForm이 리렌더링을 건너뛰었는데, handleSubmit 함수가 변경되지 않았기 때문이에요. handleSubmit 함수가 변경되지 않은 이유는 productIdreferrer (useCallback의 의존성들) 모두 마지막 렌더링 이후 변경되지 않았기 때문이에요.

// App.js
import { useState } from 'react';
import ProductPage from './ProductPage.js';

export default function App() {
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Dark mode
      </label>
      <hr />
      <ProductPage
        referrerId="wizard_of_oz"
        productId={123}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}
// ProductPage.js
import { useCallback } from 'react';
import ShippingForm from './ShippingForm.js';

export default function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

function post(url, data) {
  // Imagine this sends a request...
  console.log('POST /' + url);
  console.log(data);
}
// ShippingForm.js
import { memo, useState } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  const [count, setCount] = useState(1);

  console.log('[ARTIFICIALLY SLOW] Rendering <ShippingForm />');
  let startTime = performance.now();
  while (performance.now() - startTime < 500) {
    // Do nothing for 500 ms to emulate extremely slow code
  }

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    const orderDetails = {
      ...Object.fromEntries(formData),
      count
    };
    onSubmit(orderDetails);
  }

  return (
    <form onSubmit={handleSubmit}>
      <p><b>Note: <code>ShippingForm</code> is artificially slowed down!</b></p>
      <label>
        Number of items:
        <button type="button" onClick={() => setCount(count - 1)}></button>
        {count}
        <button type="button" onClick={() => setCount(count + 1)}>+</button>
      </label>
      <label>
        Street:
        <input name="street" />
      </label>
      <label>
        City:
        <input name="city" />
      </label>
      <label>
        Postal code:
        <input name="zipCode" />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
});

export default ShippingForm;
label {
  display: block; margin-top: 10px;
}

input {
  margin-left: 5px;
}

button[type="button"] {
  margin: 5px;
}

.dark {
  background-color: black;
  color: white;
}

.light {
  background-color: white;
  color: black;
}

항상 컴포넌트를 리렌더링하기 {/always-re-rendering-a-component/}

이 예제에서도 ShippingForm 구현이 인위적으로 느리게 만들어져 있어서, 렌더링하는 React 컴포넌트가 진짜 느릴 때 어떤 일이 일어나는지 볼 수 있어요. 카운터를 증가시키고 테마를 토글해보세요.

이전 예제와 달리, 테마를 토글하는 것도 이제 느려요! 이건 이 버전에서는 useCallback 호출이 없기 때문에 handleSubmit이 항상 새로운 함수이고, 느리게 만들어진 ShippingForm 컴포넌트가 리렌더링을 건너뛸 수 없기 때문이에요.

// App.js
import { useState } from 'react';
import ProductPage from './ProductPage.js';

export default function App() {
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Dark mode
      </label>
      <hr />
      <ProductPage
        referrerId="wizard_of_oz"
        productId={123}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}
// ProductPage.js
import ShippingForm from './ShippingForm.js';

export default function ProductPage({ productId, referrer, theme }) {
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }

  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

function post(url, data) {
  // Imagine this sends a request...
  console.log('POST /' + url);
  console.log(data);
}
// ShippingForm.js
import { memo, useState } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  const [count, setCount] = useState(1);

  console.log('[ARTIFICIALLY SLOW] Rendering <ShippingForm />');
  let startTime = performance.now();
  while (performance.now() - startTime < 500) {
    // Do nothing for 500 ms to emulate extremely slow code
  }

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    const orderDetails = {
      ...Object.fromEntries(formData),
      count
    };
    onSubmit(orderDetails);
  }

  return (
    <form onSubmit={handleSubmit}>
      <p><b>Note: <code>ShippingForm</code> is artificially slowed down!</b></p>
      <label>
        Number of items:
        <button type="button" onClick={() => setCount(count - 1)}></button>
        {count}
        <button type="button" onClick={() => setCount(count + 1)}>+</button>
      </label>
      <label>
        Street:
        <input name="street" />
      </label>
      <label>
        City:
        <input name="city" />
      </label>
      <label>
        Postal code:
        <input name="zipCode" />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
});

export default ShippingForm;
label {
  display: block; margin-top: 10px;
}

input {
  margin-left: 5px;
}

button[type="button"] {
  margin: 5px;
}

.dark {
  background-color: black;
  color: white;
}

.light {
  background-color: white;
  color: black;
}

하지만 여기에 인위적인 느림을 제거한 같은 코드가 있어요. useCallback이 없는 게 눈에 띄게 느껴지나요, 아닌가요?

// App.js
import { useState } from 'react';
import ProductPage from './ProductPage.js';

export default function App() {
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Dark mode
      </label>
      <hr />
      <ProductPage
        referrerId="wizard_of_oz"
        productId={123}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}
// ProductPage.js
import ShippingForm from './ShippingForm.js';

export default function ProductPage({ productId, referrer, theme }) {
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }

  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

function post(url, data) {
  // Imagine this sends a request...
  console.log('POST /' + url);
  console.log(data);
}
// ShippingForm.js
import { memo, useState } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  const [count, setCount] = useState(1);

  console.log('Rendering <ShippingForm />');

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    const orderDetails = {
      ...Object.fromEntries(formData),
      count
    };
    onSubmit(orderDetails);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Number of items:
        <button type="button" onClick={() => setCount(count - 1)}></button>
        {count}
        <button type="button" onClick={() => setCount(count + 1)}>+</button>
      </label>
      <label>
        Street:
        <input name="street" />
      </label>
      <label>
        City:
        <input name="city" />
      </label>
      <label>
        Postal code:
        <input name="zipCode" />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
});

export default ShippingForm;
label {
  display: block; margin-top: 10px;
}

input {
  margin-left: 5px;
}

button[type="button"] {
  margin: 5px;
}

.dark {
  background-color: black;
  color: white;
}

.light {
  background-color: white;
  color: black;
}

메모이제이션 없이도 코드가 잘 작동하는 경우가 꽤 많아요. 상호작용이 충분히 빠르다면, 메모이제이션이 필요 없어요.

앱을 실제로 느리게 만드는 것이 무엇인지 현실적으로 파악하려면, React를 프로덕션 모드로 실행하고, React Developer Tools를 비활성화하고, 앱 사용자들이 사용하는 것과 비슷한 기기를 사용해야 한다는 걸 기억하세요.


메모이제이션된 콜백에서 state 업데이트하기 {/updating-state-from-a-memoized-callback/}

때로는 메모이제이션된 콜백에서 이전 state를 기반으로 state를 업데이트해야 할 수 있어요.

handleAddTodo 함수는 다음 todos를 계산하기 위해 todos를 의존성으로 지정하고 있어요:

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos([...todos, newTodo]);
  }, [todos]);
  // ...

보통 메모이제이션된 함수가 가능한 한 적은 의존성을 갖기를 원해요. 다음 state를 계산하기 위해서만 어떤 state를 읽는다면, 대신 업데이터 함수를 전달해서 그 의존성을 제거할 수 있어요:

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos(todos => [...todos, newTodo]);
  }, []); // ✅ todos 의존성이 필요 없어요
  // ...

여기서, todos를 의존성으로 만들고 안에서 읽는 대신, state를 어떻게 업데이트할지에 대한 지시(todos => [...todos, newTodo])를 React에 전달해요. 업데이터 함수에 대해 더 알아보기.

💡 부연 설명: 업데이터 함수 패턴은 useCallback의 의존성을 줄이는 데 매우 유용한 테크닉이에요! state를 직접 참조하지 않고 함수형으로 업데이트하면, 의존성 배열이 비어있어도 항상 최신 state를 기반으로 업데이트할 수 있어요.


Effect가 너무 자주 실행되는 것 방지하기 {/preventing-an-effect-from-firing-too-often/}

때로는 Effect 안에서 함수를 호출하고 싶을 수 있어요:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    // ...

이건 문제를 만들어요. 모든 반응형 값은 Effect의 의존성으로 선언해야 해요. 하지만 createOptions를 의존성으로 선언하면, Effect가 채팅방에 계속 재연결하게 만들어요:

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 🔴 문제: 이 의존성은 매 렌더링마다 바뀌어요
  // ...

이걸 해결하려면, Effect에서 호출해야 하는 함수를 useCallback으로 감쌀 수 있어요:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); // ✅ roomId가 변경될 때만 바뀌어요

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // ✅ createOptions가 변경될 때만 바뀌어요
  // ...

이렇게 하면 roomId가 같으면 리렌더링 사이에 createOptions 함수가 같다는 걸 보장해요. 하지만 함수 의존성이 필요 없게 만드는 것이 훨씬 더 좋아요. 함수를 Effect 안으로 이동하세요:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() { // ✅ useCallback이나 함수 의존성이 필요 없어요!
      return {
        serverUrl: 'https://localhost:1234',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ roomId가 변경될 때만 바뀌어요
  // ...

이제 코드가 더 간단해지고 useCallback이 필요 없어졌어요. Effect 의존성 제거에 대해 더 알아보기.

💡 부연 설명: 함수를 Effect 안에 넣으면, 그 함수는 Effect의 의존성이 될 필요가 없어요. Effect 안에 있는 코드는 의존성이 아니라 Effect의 일부이기 때문이에요. 이게 가장 깔끔한 해결책인 경우가 많아요!


커스텀 Hook 최적화하기 {/optimizing-a-custom-hook/}

커스텀 Hook을 작성한다면, 반환하는 모든 함수를 useCallback으로 감싸는 것이 권장돼요:

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = <useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

이렇게 하면 여러분의 Hook을 사용하는 쪽에서 필요할 때 자체적으로 코드를 최적화할 수 있어요.

💡 부연 설명: 커스텀 Hook의 소비자가 반환된 함수를 useEffect의 의존성으로 사용하거나 memo된 컴포넌트에 전달할 수 있으니까, 미리 useCallback으로 감싸두면 소비자 쪽에서 추가 작업 없이 최적화된 함수를 사용할 수 있어요.


문제 해결 {/troubleshooting/}

컴포넌트가 렌더링될 때마다 useCallback이 다른 함수를 반환해요 {/every-time-my-component-renders-usecallback-returns-a-different-function/}

의존성 배열을 두 번째 인자로 지정했는지 확인하세요!

의존성 배열을 깜빡하면, useCallback은 매번 새로운 함수를 반환해요:

function ProductPage({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }); // 🔴 매번 새로운 함수를 반환해요: 의존성 배열이 없어요
  // ...

이건 의존성 배열을 두 번째 인자로 전달하도록 수정한 버전이에요:

function ProductPage({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]); // ✅ 불필요하게 새 함수를 반환하지 않아요
  // ...

이래도 도움이 안 된다면, 문제는 의존성 중 적어도 하나가 이전 렌더링과 다르다는 거예요. 의존성을 콘솔에 수동으로 로깅해서 이 문제를 디버그할 수 있어요:

  const handleSubmit = useCallback((orderDetails) => {
    // ..
  }, [productId, referrer]);

  console.log([productId, referrer]);

그다음 콘솔에서 서로 다른 리렌더링의 배열을 우클릭하고 둘 다 "전역 변수로 저장(Store as a global variable)"을 선택할 수 있어요. 첫 번째가 temp1로, 두 번째가 temp2로 저장됐다고 가정하면, 브라우저 콘솔을 사용해서 두 배열의 각 의존성이 같은지 확인할 수 있어요:

Object.is(temp1[0], temp2[0]); // 배열 간에 첫 번째 의존성이 같나요?
Object.is(temp1[1], temp2[1]); // 배열 간에 두 번째 의존성이 같나요?
Object.is(temp1[2], temp2[2]); // ... 모든 의존성에 대해 이런 식으로 확인 ...

메모이제이션을 깨뜨리는 의존성을 찾으면, 제거할 방법을 찾거나, 그것도 메모이제이션하세요.


반복문에서 각 리스트 항목에 대해 useCallback을 호출해야 하는데, 허용되지 않아요 {/i-need-to-call-usememo-for-each-list-item-in-a-loop-but-its-not-allowed/}

Chart 컴포넌트가 memo로 감싸져 있다고 가정해볼게요. ReportList 컴포넌트가 리렌더링될 때 리스트의 모든 Chart가 리렌더링되는 것을 건너뛰고 싶어요. 하지만 반복문 안에서는 useCallback을 호출할 수 없어요:

function ReportList({ items }) {
  return (
    <article>
      {items.map(item => {
        // 🔴 이런 식으로 반복문 안에서 useCallback을 호출할 수 없어요:
        const handleClick = useCallback(() => {
          sendReport(item)
        }, [item]);

        return (
          <figure key={item.id}>
            <Chart onClick={handleClick} />
          </figure>
        );
      })}
    </article>
  );
}

대신, 개별 항목을 위한 컴포넌트를 추출하고, 거기에 useCallback을 넣으세요:

function ReportList({ items }) {
  return (
    <article>
      {items.map(item =>
        <Report key={item.id} item={item} />
      )}
    </article>
  );
}

function Report({ item }) {
  // ✅ 최상위 레벨에서 useCallback을 호출하세요:
  const handleClick = useCallback(() => {
    sendReport(item)
  }, [item]);

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
}

또는, 마지막 코드 조각에서 useCallback을 제거하고 대신 Report 자체를 memo로 감쌀 수도 있어요. item prop이 변경되지 않으면, Report가 리렌더링을 건너뛸 거고, 그래서 Chart도 리렌더링을 건너뛸 거예요:

function ReportList({ items }) {
  // ...
}

const Report = memo(function Report({ item }) {
  function handleClick() {
    sendReport(item);
  }

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
});

💡 부연 설명: Hook은 반드시 컴포넌트의 최상위 레벨에서만 호출해야 한다는 규칙이 있어요. 반복문이나 조건문 안에서 Hook을 호출하면 안 돼요! 이 문제를 해결하는 가장 좋은 방법은 별도의 컴포넌트를 추출하는 거예요. 컴포넌트를 잘게 쪼개면 이런 제약을 자연스럽게 해결할 수 있어요.


Sitemap

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글