프로그래밍/ReactJS

React 성능 최적화의 핵심: useCallback 완벽 이해

shimdh 2025. 10. 15. 09:22
728x90

React 개발자라면 누구나 한 번쯤 성능 최적화에 대한 고민을 해봤을 것입니다. 특히, 불필요한 리렌더링으로 인한 성능 저하는 애플리케이션의 사용자 경험(UX)을 크게 저해하는 주된 요인입니다. 오늘 다룰 useCallback 훅은 이러한 문제를 해결하는 React의 강력한 도구입니다. 이 포스팅을 통해 useCallback이 무엇인지, 왜 사용해야 하는지, 그리고 실제 코드 예시와 함께 어떻게 활용하는지 자세히 알아보겠습니다. 초보자부터 고급 개발자까지, useCallback을 마스터하고 React 앱을 더 빠르게 만드는 팁을 공유하겠습니다!

728x90

useCallback이란 무엇인가요?

useCallback은 React에서 제공하는 훅(Hook) 중 하나로, 콜백 함수를 메모이제이션(memoization) 하여 성능 최적화에 기여합니다. 메모이제이션은 이전에 계산한 값을 메모리에 저장해두었다가 동일한 입력이 들어오면 다시 계산하지 않고 저장된 값을 즉시 반환하는 프로그래밍 기법입니다. 이는 컴퓨팅 자원을 절약하고, 앱의 속도를 높이는 데 핵심적입니다.

useCallback은 특히 함수가 불필요하게 다시 생성되는 것을 방지해주는데, 이는 다음과 같은 상황에서 매우 유용합니다:

  • 콜백 함수를 자식 컴포넌트에 props로 전달할 때: 자식 컴포넌트가 React.memo 등으로 최적화되어 있더라도, 부모 컴포넌트가 리렌더링될 때마다 새로운 함수 인스턴스를 props로 받으면 자식 컴포넌트도 불필요하게 리렌더링될 수 있습니다.
  • 다른 훅의 의존성 배열에 콜백 함수를 사용할 때: useEffectuseMemo와 같은 훅의 의존성 배열에 함수가 포함되어 있다면, 해당 함수가 리렌더링될 때마다 새롭게 생성되어 훅이 의도치 않게 다시 실행될 수 있습니다.

이처럼 useCallback은 함수의 참조 안정성(reference stability) 을 보장하여 React의 렌더링 사이클을 효율적으로 만듭니다.

왜 useCallback을 사용해야 할까요?

컴포넌트 내부에 함수를 정의하면, 해당 컴포넌트가 리렌더링될 때마다 그 함수는 '새롭게' 생성됩니다. JavaScript에서 함수는 객체와 마찬가지로 참조 타입이기 때문에, 내용이 같더라도 메모리 주소가 달라지면 다른 객체로 인식됩니다. 이러한 특성은 다음과 같은 경우에 성능 문제를 야기할 수 있습니다:

1. 자식 컴포넌트의 불필요한 리렌더링 방지

React.memo와 같은 최적화 기법을 사용하여 자식 컴포넌트를 감싸더라도, 부모 컴포넌트에서 전달하는 함수 prop이 매번 새로 생성되면 React.memo는 이를 새로운 prop으로 인식하여 자식 컴포넌트를 리렌더링합니다. useCallback을 사용하면 의존성 배열의 값이 변경되지 않는 한 항상 동일한 함수 인스턴스가 반환되도록 보장할 수 있습니다. 이는 자식 컴포넌트가 불필요하게 리렌더링되는 것을 막아 애플리케이션의 전반적인 성능을 향상시킵니다.

2. 훅의 안정적인 의존성 유지

useEffect, useMemo 등 React의 다른 훅들은 의존성 배열에 있는 값들이 변경될 때만 콜백 함수를 재실행하거나 값을 재계산합니다. 만약 의존성 배열에 포함된 함수가 리렌더링될 때마다 새로 생성된다면, 의도치 않게 해당 훅이 계속해서 재실행될 수 있습니다. useCallback은 이러한 함수의 안정적인 참조를 제공하여 훅이 예상대로 동작하도록 돕습니다.

간단히 말해, useCallback 없이 함수를 사용하면 작은 상태 변화 하나로 전체 컴포넌트 트리가 재렌더링될 수 있지만, 이를 사용하면 선택적 최적화가 가능해집니다.

useCallback 기본 문법

useCallback의 기본 문법은 다음과 같습니다:

const memoizedCallback = useCallback(() => {
  // 여기에 로직 작성
}, [dependencies]);
  • memoizedCallback: 메모이제이션된 콜백 함수의 결과물입니다. 이제 이 변수는 의존성이 변경되지 않는 한 항상 동일한 함수 인스턴스를 참조합니다.
  • dependencies: 콜백 함수가 다시 생성되도록 유발할 값들의 배열입니다. 이 배열 안의 값 중 하나라도 변경되면 memoizedCallback은 새로운 함수 인스턴스를 반환합니다. 의존성 배열이 비어있다면 ([]), 이 함수는 컴포넌트가 마운트될 때 단 한 번만 생성되며, 이후로는 변경되지 않습니다.

주의: 의존성 배열에 모든 필요한 값을 포함시키는 것이 중요합니다. ESLint의 react-hooks/exhaustive-deps 규칙을 사용하면 이를 자동으로 체크할 수 있습니다.

useCallback 실제 예시

상태를 관리하고 두 개의 자식 컴포넌트를 렌더링하는 부모 컴포넌트의 예를 살펴보겠습니다. 한 자식 컴포넌트는 카운트 값을 표시하고, 다른 자식 컴포넌트는 이 카운트를 증가시키는 버튼을 가집니다. 여기서 ChildComponentReact.memo로 감싸 최적화 효과를 명확히 보이도록 하겠습니다.

useCallback 없이 (성능 문제 발생)

먼저, useCallback 없이 구현한 버전입니다. 부모가 리렌더링될 때마다 자식도 재렌더링됩니다.

import React, { useState } from 'react';

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

  // useCallback 없이 정의: 매 리렌더링마다 새 함수 생성
  const incrementCount = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <h1>부모 컴포넌트 (useCallback 없이)</h1>
      <p>현재 카운트: {count}</p>
      <DisplayCount count={count} />
      <ChildComponent increment={incrementCount} />
    </div>
  );
}

const DisplayCount = React.memo(({ count }) => {
  console.log('DisplayCount 렌더링');
  return <p>디스플레이 카운트: {count}</p>;
});

const ChildComponent = React.memo(({ increment }) => {
  console.log('ChildComponent 렌더링'); // 매번 출력됨!
  return <button onClick={increment}>증가</button>;
});

이 경우, count가 변경될 때마다 incrementCount의 참조가 바뀌어 ChildComponent가 불필요하게 리렌더링됩니다.

useCallback 사용 (최적화 적용)

이제 useCallback을 적용한 버전입니다. 자식 컴포넌트의 리렌더링이 최소화됩니다.

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

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

  // useCallback으로 메모이제이션: 참조 안정성 보장
  const incrementCount = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []); // 의존성 없음: 한 번만 생성

  return (
    <div>
      <h1>부모 컴포넌트 (useCallback 적용)</h1>
      <p>현재 카운트: {count}</p>
      <DisplayCount count={count} />
      <ChildComponent increment={incrementCount} />
    </div>
  );
}

const DisplayCount = React.memo(({ count }) => {
  console.log('DisplayCount 렌더링');
  return <p>디스플레이 카운트: {count}</p>;
});

const ChildComponent = React.memo(({ increment }) => {
  console.log('ChildComponent 렌더링'); // count 변경 시에도 출력되지 않음!
  return <button onClick={increment}>증가</button>;
});

이 예시에서 incrementCount 함수는 useCallback으로 감싸져 있습니다. 의존성 배열이 비어있기 때문에, 이 함수는 ParentComponent가 처음 렌더링될 때 한 번만 생성됩니다. ChildComponentincrementCount 함수를 props로 받는데, useCallback 덕분에 ParentComponent가 리렌더링되더라도 incrementCount의 참조가 변경되지 않아 ChildComponent는 불필요하게 리렌더링되지 않습니다. 콘솔 로그를 통해 차이를 직접 확인해보세요!

useCallback의 일반적인 사용 사례

useCallback은 React 애플리케이션의 다양한 상황에서 성능 최적화를 위해 활용될 수 있습니다:

  1. Props로 콜백 전달: React.memo와 같은 최적화를 구현하는 깊이 중첩된 자식에게 콜백을 전달할 때 유용합니다. 콜백 함수의 참조를 안정적으로 유지하여 자식 컴포넌트의 불필요한 리렌더링을 방지합니다.
  2. 다른 훅과 함께 사용: useEffect와 같은 훅과 함께 사용하여, 효과 정리 또는 특정 변경 사항에 따른 효과 트리거를 위한 안정적인 참조를 원할 때 사용합니다. 예를 들어, useEffect의 의존성 배열에 함수가 있다면 해당 함수를 useCallback으로 감싸주는 것이 좋습니다.
  3. const handleFetchData = useCallback(async () => { // 데이터 페칭 로직 }, [userId]); // userId 변경 시에만 재생성 useEffect(() => { handleFetchData(); }, [handleFetchData]); // 안정적인 참조 덕분에 불필요한 재실행 방지
  4. 이벤트 핸들러: JSX 요소 내에 직접 연결된 이벤트 핸들러가 다시 렌더링되는 동안 불필요하게 재정의되지 않도록 보장합니다. 이는 특히 많은 이벤트 핸들러가 있는 복잡한 컴포넌트(예: 대시보드)에서 성능 향상에 기여할 수 있습니다.

추가 팁: 큰 리스트나 테이블 컴포넌트에서 onRowClick 같은 핸들러를 전달할 때도 useCallback을 활용하면 DOM 재렌더링을 줄일 수 있습니다.

결론

useCallback은 React에서 불필요한 렌더링을 방지하고 애플리케이션의 성능을 최적화하는 데 필수적인 훅입니다. 렌더링 주기마다 새로운 함수 인스턴스가 생성되어 발생하는 업데이트를 방지함으로써 개발자가 더 효율적인 애플리케이션을 만들 수 있습니다. useCallback을 효과적으로 활용하는 시점과 방법을 이해하는 것은 고급 React 개발 환경에서 사용자 경험과 애플리케이션 효율성을 크게 향상시킬 수 있습니다.

물론, 모든 함수에 useCallback을 적용할 필요는 없습니다. 과도한 사용은 오히려 코드의 가독성을 해치고 미미한 성능 향상에 그칠 수 있습니다. 따라서 useCallback은 위에서 언급한 React.memo를 사용한 자식 컴포넌트에 함수를 전달하거나, 다른 훅의 의존성 배열에 함수를 포함시키는 등 실제 성능 문제가 발생할 수 있는 상황에서 전략적으로 사용하는 것이 중요합니다. 현명한 useCallback 활용으로 여러분의 React 애플리케이션을 더욱 빠르고 효율적으로 만들어보세요!

728x90