Skip to main content

Command Palette

Search for a command to run...

React 성능 최적화의 핵심: useCallback과 Props 비교 심층 분석

Published
5 min readView as Markdown

프론트엔드 개발에서 React의 성능 최적화는 떼려야 뗄 수 없는 주제입니다.
특히, 컴포넌트의 불필요한 리렌더링을 제어하는 useCallbackReact.memo는 핵심 도구입니다.

이 두 최적화 기법의 원리와 내부 동작 방식(특히 props 비교)을 깊이 있게 파헤쳐 보겠습니다.


1. useCallback 이해하기: 함수 메모이제이션의 필요성

React 컴포넌트가 리렌더링될 때마다, 그 컴포넌트 내부에 정의된 모든 함수는 메모리상에서 새로 생성됩니다.
자바스크립트에서 함수는 객체이므로, 새로운 함수는 이전 함수와 다른 메모리 주소(Reference)를 가지게 됩니다.

이 새로운 함수를 props로 자식 컴포넌트에 전달할 때 문제가 발생합니다.

💡 useCallback의 역할

useCallback Hook은 이 문제를 해결하기 위해 콜백 함수를 메모이제이션합니다.

const memoizedCallback = useCallback(
  () => {
    // 실행할 로직
  },
  [dependency1, dependency2], // 의존성 배열
);
  1. 메커니즘: useCallback은 의존성 배열([dependency1, dependency2])의 값이 변경되지 않는 한, 최초에 생성했던 동일한 함수 인스턴스를 계속해서 반환합니다.

  2. 효과: 동일한 함수 인스턴스를 자식에게 전달함으로써, 자식 컴포넌트가 함수 props 때문에 불필요하게 리렌더링되는 것을 방지합니다.

⚠️ 의존성 배열과 Stale Closure (오래된 클로저)

useCallback을 사용할 때 가장 주의해야 할 점은 의존성 배열입니다.
콜백 함수 내부에서 사용하는 모든 state, props, 혹은 다른 변수는 반드시 의존성 배열에 포함되어야 합니다.

  • 문제점: 외부 변수를 빠뜨리면, useCallback최초 렌더링 시점의 값(Stale Value)을 포함하는 함수를 계속 사용합니다. 이를 오래된 클로저(Stale Closure) 현상이라고 부르며, 런타임 버그의 주요 원인이 됩니다.

2. 최적화의 핵심 파트너: React.memouseCallback 시너지

useCallback이 함수 자체를 메모이제이션한다면, React.memo는 컴포넌트 자체의 리렌더링 여부를 결정합니다.

React.memo로 감싼 컴포넌트는 새로운 props가 이전 props와 다를 때만 리렌더링됩니다.

🔍 React.memo의 Props 비교 방식: 얕은 비교 (Shallow Comparison)

React.memo는 기본적으로 얕은 비교(Shallow Comparison)를 사용하여 props 변경 여부를 판단합니다.

타입

비교 기준

리렌더링 조건 (결과가 false일 때)

원시 타입

값(Value)이 같은지 비교

prevProps.count !== nextProps.count

객체 타입

메모리 주소(Reference)가 같은지 비교

prevProps.data !== nextProps.data

가장 중요한 점: 객체 타입(객체, 배열, 함수)의 경우, 내용물이 같더라도 부모 컴포넌트가 리렌더링될 때 새로운 메모리 주소를 가진 객체가 생성되면 React.memo는 이를 '다른 props'로 판단하고 불필요하게 리렌더링을 합니다.

🎯 useCallbackReact.memo의 시너지 예시 코드

useCallback을 사용하여 함수 props의 메모리 주소를 고정해줘야만, React.memo의 얕은 비교가 성공적으로 true를 반환하며 리렌더링을 건너뛸 수 있습니다.
아래 예시는 의존성 배열의 두 가지 경우를 보여줍니다.

자식 컴포넌트 (OptimizedButton.jsx)

import React from 'react';

// 1. React.memo로 감싸서 props 변경 시에만 리렌더링하도록 최적화
const OptimizedButton = React.memo(({ onClick, label }) => {
  // 이 로그는 onClick props가 변경될 때만 찍혀야 합니다.
  console.log(`[OptimizedButton] ${label} 렌더링됨`);

  return (
    <button onClick={onClick}>
      {label}
    </button>
  );
});

export default OptimizedButton;

부모 컴포넌트 (ParentComponent.jsx)

import React, { useState, useCallback } from 'react';
import OptimizedButton from './OptimizedButton';

const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [multiplier, setMultiplier] = useState(1);

  // ----------------------------------------------------
  // Case 1: 의존성 배열이 빈 배열 ([])인 경우 (함수 인스턴스 고정)
  // ----------------------------------------------------
  const handleEmptyDepClick = useCallback(() => {
    // 경고: 빈 배열이지만 count의 최신 상태를 참조해야 하므로 함수형 업데이트 사용
    setCount(prevCount => prevCount + 1);
  }, []); // 의존성 배열이 비어있으므로, ParentComponent가 리렌더링되어도 이 함수는 절대 새로 생성되지 않음

  // ----------------------------------------------------
  // Case 2: 의존성 배열에 값이 있는 경우 ([multiplier])
  // ----------------------------------------------------
  const handleDepWithValueClick = useCallback(() => {
    // multiplier의 현재 값을 참조하여 count를 증가시킴
    setCount(prevCount => prevCount + multiplier);
  }, [multiplier]); // multiplier 값이 변경될 때만 이 함수는 새로 생성됨

  return (
    <div>
      <p>Count: {count} | Multiplier: {multiplier}</p>

      {/* 부모 컴포넌트 리렌더링을 유발하는 버튼 */}
      <button onClick={() => setMultiplier(m => m + 1)}>
        Multiplier State 변경 (부모 리렌더링 유발)
      </button>

      {/* Case 1 버튼 */}
      {/* multiplier가 변하여 부모가 리렌더링되어도 handleEmptyDepClick은 변하지 않아, */}
      {/* OptimizedButton은 리렌더링되지 않음. (최적화 성공) */}
      <OptimizedButton 
        onClick={handleEmptyDepClick} 
        label="Case 1: Empty Dep Call (최적화 성공)" 
      />

      {/* Case 2 버튼 */}
      {/* multiplier가 변하면 handleDepWithValueClick이 새로 생성되어, */}
      {/* OptimizedButton의 props가 변경된 것으로 간주되어 리렌더링됨. (의도된 리렌더링) */}
      <OptimizedButton 
        onClick={handleDepWithValueClick} 
        label="Case 2: Value Dep Call (의존성 변경 시 렌더링)" 
      />
    </div>
  );
};

export default ParentComponent;

시너지 설명

  1. 최적화 적용: OptimizedButtonReact.memo로 감싸져 있어, onClick props가 변하지 않는 한 리렌더링을 건너뜁니다.

  2. Case 1 (Empty Dep): handleEmptyDepClickuseCallback([], [])으로 정의되었습니다.

    • ParentComponent가 리렌더링되어도 이 함수의 메모리 주소는 절대 변하지 않습니다.

    • 따라서 OptimizedButton은 리렌더링을 건너뛰어 최적화가 성공합니다.

  3. Case 2 (Value Dep): handleDepWithValueClickuseCallback([multiplier])로 정의되었습니다.

    • multiplier 값이 변경되면 useCallback새로운 함수 인스턴스를 생성하여 반환합니다.

    • OptimizedButton은 새로운 onClick 함수(다른 메모리 주소)를 받으므로, React.memo는 props가 변경되었다고 판단하여 의도된 리렌더링을 수행합니다.

    • 이를 통해 함수가 최신 multiplier 값을 참조하도록 하면서도, multiplier가 변할 때만 리렌더링되도록 세밀하게 제어할 수 있습니다.


3. useCallback의 의존성 배열 비교 방식

useCallback이 의존성 배열의 변경을 감지하는 방식 역시 React.memo와 마찬가지로 얕은 비교를 사용합니다.

useCallback은 매 렌더링 시마다 의존성 배열 내의 모든 값에 대해 이전 값과 === 연산을 수행합니다.

$$\text{deps}[i]_{\text{prev}} === \text{deps}[i]_{\text{next}}$$

  • 원시 타입: 값이 다를 때만 함수를 새로 생성합니다.

  • 객체 타입: 메모리 주소가 다를 때만 함수를 새로 생성합니다.

💡 useCallbackuseMemo의 연계 사용

만약 useCallback의 의존성 배열 안에 객체나 배열이 포함되어 있다면, 해당 객체나 배열 역시 useMemo를 사용하여 메모이제이션해야 합니다.

// 매번 새로 생성되는 객체를 useMemo로 메모이제이션
const options = useMemo(() => ({ throttle: 300 }), []);

// options의 메모리 주소가 변할 때만 콜백 함수를 새로 생성
const handleScroll = useCallback((event) => { /* ... */ }, [options]);

useMemo를 통해 options 객체의 메모리 주소를 고정해주어야만, useCallback이 매 렌더링마다 options를 '다른 값'으로 인식하여 불필요하게 함수를 재생성하는 것을 막을 수 있습니다.


4. 최적화의 경계: 오버헤드(Overhead)

useCallbackReact.memo는 강력한 도구이지만, 무분별한 사용은 오히려 성능을 저해할 수 있습니다.

  • Hook 사용 비용: useCallbackReact.memo 자체도 Hook을 호출하고, 의존성 배열을 비교하며, 메모리에 이전 값을 저장하는 작은 비용(Overhead)을 발생시킵니다.

  • 사용 지양 시점:

    1. 자식 컴포넌트가 React.memo로 최적화되지 않은 경우 (함수를 메모이제이션할 이유가 사라짐).

    2. 컴포넌트의 리렌더링 비용이 Hook 사용 및 비교 비용보다 현저히 낮을 경우.

진정한 성능 최적화는 필요한 곳에만 적절히 이 도구들을 사용하는 것에서 시작됩니다.
성능 측정을 통해 리렌더링 문제가 발생하는 지점을 정확히 파악하고, 그 부분에만 useCallbackReact.memo를 적용하는 것이 중요합니다.


🧾 작성 참고

본 글은 Gemini를 활용하여 작성되었습니다. 기술 정보의 정확성을 위해 항상 노력하지만, 혹시 내용에 보완할 점이 있다면 언제든지 의견을 나눠주세요!

More from this blog

LangGraph의 astream_events: 에이전트 실행을 실시간으로 들여다보기

💡 한 줄 요약: astream_events()는 LangGraph 그래프 실행 중 발생하는 모든 이벤트(LLM 토큰, 툴 호출, 노드 시작/종료 등)를 실시간 스트림으로 받을 수 있는 비동기 API입니다. 들어가며 AI 에이전트가 응답을 생성하는 데 25초가 걸린다고 상상해보세요. 사용자는 평균 8초 후에 페이지를 이탈합니다. 하지만 스트리밍을 도입

Jun 20, 20266 min read

LLM은 어떻게 글을 읽고 쓰는가 — 입력부터 출력까지

대화형 AI에게 질문을 던지면, 마치 사람처럼 문장을 이해하고 답을 써 내려가는 것처럼 보인다.하지만 그 안에서 벌어지는 일은 생각보다 단순하고, 또 생각보다 기계적이다.이 글에서는 두 가지 질문에 답해 본다.LLM은 어떻게 글을 만들어내는가,그리고 우리가 입력한 프롬프트는 모델에 어떤 모습으로 들어가는가. 1부. LLM은 어떻게 글을 만들어내는가 핵심은

Jun 10, 20264 min read

useEffect 지옥이란 무엇이며, 어떻게 안전하게 사용하는가

React를 어느 정도 사용하다 보면 한 번쯤은“useEffect 지옥에 빠졌다”는 말을 듣거나 직접 느껴본 적이 있을 것이다. useEffect가 계속 늘어나고 의존성 배열은 점점 길어지고 왜 실행되는지 이해하기 어려워지며 eslint-disable-next-line 이 늘어나는 상태 하지만 많은 사람들이 오해한다.useEffect 지옥은 useEffect가 많아서 생기는 문제가 아니다. 이 글에서는 ‘useEffect 지옥’의 ...

Jan 12, 20263 min read

Zod란 무엇인가: TypeScript에서 런타임 검증과 타입 안정성을 동시에 해결하는 방법

TypeScript 프로젝트를 하다 보면 반드시 마주치는 문제가 있다.바로 “타입은 있는데, 데이터는 믿을 수 없다”는 것이다. TypeScript는 컴파일 타임에는 강력하지만,런타임에 들어오는 데이터에 대해서는 아무런 보장을 해주지 않는다. API 요청 데이터 사용자 입력(Form) 환경 변수 (process.env) 외부 JSON / 설정 파일 이 모든 것은 타입 시스템 바깥에서 들어온다. 이 문제를 해결하기 위해 등장한 라이브러...

Jan 10, 20263 min read

Flatpak을 이해하기 위한 배경 지식

배포판, 라이브러리 충돌, 그리고 OSTree까지 리눅스 데스크톱에서 Flatpak이 표준처럼 자리 잡은 데에는 분명한 이유가 있다.그 이유를 이해하려면 단순히 “Flatpak은 패키징 시스템이다”를 넘어서,리눅스 배포판 구조, 라이브러리 버전 충돌, OSTree라는 기술까지 함께 이해해야 한다. 이 글에서는 다음 질문에 답해본다. 리눅스에서 말하는 배포판이란 무엇인가? 라이브러리 버전 충돌은 왜 발생하는가? Flatpak은 이 문제를 어...

Jan 8, 20265 min read

Dev note

32 posts