Develop

React useMemo, useCallback 성능 최적화 — 렌더링 최소화하는 방법

리액트 컴포넌트의 렌더링 지연을 해소하기 위한 useMemo와 useCallback 훅의 올바른 작동 기제와, 측정 없이 남용했다가 되려 메모이제이션 오버헤드로 앱 성능을 망가뜨리는 함정의 극복 방안을 알아봅니다.

ReactuseMemouseCallback성능최적화frontend
React DevTools Profiler에서 컴포넌트 렌더링 시간을 분석하는 화면 — useMemo, useCallback 최적화 전후 비교
  • ·useMemo는 의존성 배열이 바뀔 때만 값을 재계산하고, useCallback은 함수 참조를 메모이제이션한다
  • ·모든 컴포넌트에 useMemo, useCallback을 쓰면 오히려 메모이제이션 비교 비용으로 성능이 나빠질 수 있다
  • ·React.memo로 감싼 컴포넌트에 함수 props를 전달할 때 useCallback이 효과적이다
  • ·React 19에서는 React Compiler가 자동으로 메모이제이션을 적용해서 수동 최적화가 필요 없어진다
초기에 컴포넌트 반응 속도가 어딘지 미묘하게 느리다는 기분이 들자, 모든 이벤트 핸들러 함수와 선언문에 생각 없이 `useCallback` 과 `useMemo` 를 떡칠하듯 기재해 둔 적이 있습니다. 하지만 결과는 오히려 메모리 점유율이 늘고 렌더링 스피드가 이전보다 떨어지는 굴욕을 맛보았죠. React DevTools Profiler 탭을 켜서 계측을 해보니, 찰나의 순간에 끝나는 얕은 함수 연산을 보존하겠다고 의존성 배열 메모리를 돌리고 참조 비교문을 수행하는 오버헤드 비용이 렌더링 자체 비용보다 커진 것이 원인이었습니다. 이 뼈아픈 실측 경험 이후, 무작정 최적화를 하려 들지 않고 프로파일러 지표로 병목 구간을 먼저 증명해 낸 뒤 맞춤형 핀포인트 메모이징을 도입하는 확실한 최적화 기준을 확립했습니다.

1. 훅의 본질적인 동작 기제

React useMemo 적용을 통한 무거운 연산 재계산 방지법

React의 리렌더링 주기가 돌 때마다 컴포넌트 함수 내부에 기재된 계산식들은 매번 처음부터 끝까지 다시 실행됩니다. 수천 장의 카드 데이터를 정렬하거나 복잡한 그래프 차트 정보를 매핑하는 계산식이 매 프레임 재시행되면 프레임 드랍이 유발되죠. useMemo 는 이 계산 결과물의 알맹이를 메모리 스택에 조용히 캐싱하고, 내가 의존성 배열에 매핑한 타겟 변수가 변경되지 않았다면 이전 결과를 그대로 재활용해 질주하는 최적화 장치입니다. 성능 비용이 비싸고 무거운 가공 로직에만 핀포인트로 걸어두어야 비로소 연산 낭비를 지워내 줍니다.

React useCallback으로 함수 참조를 유지해 하위 렌더링 차단하기

자바스크립트의 특성상, 컴포넌트 함수가 리렌더링될 때마다 내부에 기재된 이벤트 핸들러 함수들도 완전히 새로운 메모리 주소(참조)를 부여받으며 새로 생성됩니다. 이 함수를 하위 자식 컴포넌트에 Props 매개변수로 밀어 넣어주고 있다면, 자식 컴포넌트는 실제로 속성 데이터가 아무것도 바뀌지 않았음에도 함수의 주소 값이 새로워졌다는 이유만으로 불필요한 동반 리렌더링을 단행합니다. useCallback 은 이 함수 객체의 원본 참조 주소를 보존 락킹해 둠으로써, 부모가 몇 백 번 다시 렌더링되더라도 하위 자식 컴포넌트의 얕은 비교 검사기(Shallow Equal)를 안전하게 속여 불필요한 동반 렌더링 지연을 미연에 봉쇄합니다.

2. 최적화 남용의 위험성과 함정

불필요한 React 메모이제이션 비용이 초래하는 성능 저하 함정

성능을 지키기 위해 고안된 메모이징 훅들은 엄연히 무인 무료 서비스가 아닙니다. 이전의 계산 결과값들을 RAM의 어딘가에 적재 보관해 둬야 하는 추가 메모리 비용이 발생하고, 렌더링 시점마다 의존성 어레이 내부의 값들이 달라졌는지 얕은 루프 비교 연산 비용이 상시 지출됩니다. 따라서 단순 문자열 조립이나 작은 사칙연산 식에까지 기계적으로 useMemo를 휘두르면, 메모리 비교 오버헤드가 메모이징 이득을 훌쩍 능가하여 리액트 앱이 전체적으로 더 무거워지는 기현상을 빚습니다. 훅은 무조건 많이 쓴다고 좋은 것이 아닌, 트레이드오프를 재보고 덤벼야 하는 실무 공학의 대상입니다.

3. 렌더링 프로파일링 실측 기법

React DevTools Profiler를 이용한 최적화 효과 실측 요령

타당한 튜닝 지점을 정밀 검출하려면 React DevTools의 Profiler 탭 사용법을 완전 마스터해야 합니다. 녹화 스위치를 켠 뒤, 타겟 클릭 액션을 지르고 녹화를 종료하면 화면에 각 컴포넌트들이 몇 회 렌더링을 단행했고 소요된 리소스가 몇 밀리초(ms)인지 바 형태의 그래프로 직관화되어 표시됩니다. '이 컴포넌트는 부모 렌더링 때문에 덩달아 렌더링되었음' 등의 이유 설명이 친절히 안내되죠. 여기에 맞춰 자식 컴포넌트를 React.memo로 감싸고 부모 측의 핸들러 함수에 useCallback을 이식해 두는 정석 교정을 마친 뒤, 다시 녹화를 떠서 리렌더링 막대 개수가 획기적으로 가라앉은 것을 육안 검증해야만 비로소 가치 있는 성능 다이어트 튜닝이 완결됩니다.

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

// 자식 컴포넌트를 React.memo 로 감싸 props 변화가 없으면 렌더링을 완전히 잠금 처리
const HeavyButton = React.memo(({ onClick, label }) => {
  console.log('HeavyButton 렌더링 실행');
  return (
    <button onClick={onClick} className="px-4 py-2 bg-blue-500">
      {label}
    </button>
  );
});
HeavyButton.displayName = 'HeavyButton';

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

  // useCallback 으로 함수 참조 주소를 고정해두어, 
  // Parent 가 리렌더링되어도 HeavyButton 의 동반 리렌더링을 완벽하게 방어
  const handleClick = useCallback(() => {
    console.log('버튼 클릭 수신');
  }, []);

  return (
    <div className="p-4">
      <p>카운트: {count}</p>
      <button onClick={() => setCount(prev => prev + 1)}>카운터 올리기</button>
      <HeavyButton onClick={handleClick} label="안전 최적화 버튼" />
    </div>
  );
}

자주 묻는 질문

React 19 버전의 React Compiler가 도입되면 useMemo나 useCallback은 이제 완전히 역사 속으로 사라지나요?+

네, React Compiler는 빌드 컴파일 단계에서 스스로 코드를 추론하여 불필요한 동반 렌더링을 막는 최적화 훅들을 소스 코드에 자동으로 알아서 삽입해 줍니다. 따라서 React 19 이상 버전 기반에서는 개발자가 직접 타이핑하여 수동 메모이징 코드를 쓸 공수가 대부분 해소될 전망입니다.

useCallback의 의존성 배열을 그냥 비워둔 채 빈 배열([])로 선언해 쓰면 어떤 버그가 일어나나요?+

함수가 최초 렌더링 시점의 상태(state) 정보만 기억하고 굳어 버리는 Stale Closure(오래된 클로저) 버그가 발생합니다. 함수 내부에서 호출해 사용하는 모든 리액트 상태 변수와 Props 인자들은 빼놓지 말고 의존성 배열에 충실히 매핑해 두어야 최신 데이터로 올바르게 작동합니다.

관련 글