React Hook - useMemo, useCallback

김소희·2025년 11월 2일

컴포넌트 최적화

컴포넌트 성능을 최적화하는 방법에는 useMemo와 useCallback이 있다.

주의: 성능 최적화는 실제 성능 문제가 발생했을 때 적용하는 것이 좋다.
조기 최적화는 오히려 코드를 복잡하게 만들 수 있다.

useMemo를 알아보기 전에 꼭 기억할 것!
js에서 함수도 객체이므로 함수도 변수에 할당이 가능하다.
함수형 컴포넌트는 함수이고, 렌더링이 되면 함수가 호출되어 모든 내부 변수가 초기화된다.
대부분의 리액트 앱은 state와 props의 변화로 인해 수많은 렌더링을 거친다.

useMemo

useMemo의 memo는 Memoization이라는 단어에서 따왔다. 메모이제이션이란 동일한 값을 리턴하는 함수를 반복적으로 호출해야 한다면, 맨 처음 계산한 값을 메모리에 저장해서 두 번째부터는 계산을 하지 않고 메모리에서 값을 불러와 재사용하는 기법이다. 자주 쓰는 값을 메모리에 캐싱해두었다가 필요할 때마다 캐시에서 꺼내 쓴다고 이해해도 좋다.

useMemo 사용법

useMemo는 2개의 인자를 받는다.

const value = useMemo(() => {
  return calculate();
}, [item]);
  • 첫 번째 인자: 콜백 함수로 메모이제이션할 값을 계산해서 리턴해주는 함수다. 콜백 함수가 리턴하는 값이 useMemo가 리턴하는 값이 된다.
  • 두 번째 인자: 의존성 배열이라고도 불리는데, useMemo는 배열 안의 값이 업데이트될 때만 콜백 함수를 다시 호출해서 메모이제이션된 값을 업데이트하고 다시 메모이제이션을 해준다. 만약 [] 빈 배열을 넣었을 경우 맨 처음 컴포넌트가 마운트되었을 때만 값을 계산하고, 이후에는 메모이제이션에서 값을 꺼내주기만 한다.

useMemo는 꼭 필요할 때만!

성능 최적화를 위한 useMemo이지만 무분별하게 남용하면 오히려 성능이 저하될 수 있다. 메모이제이션이 값을 메모리에 저장해둔다는 뜻이므로, 불필요한 값들까지 메모리에 저장하면 성능이 나빠지기 때문이다.

useMemo 예제 1: 무거운 계산 최적화

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

const hardCalculate = (number) => {
  console.log('어려운 계산');
  for (let i = 0; i < 999999999; i++) {
    // 생각하는 시간
  }
  return number + 10000;
};

const easyCalculate = (number) => {
  console.log('쉬운 계산');
  return number + 1;
};

function App() {
  const [hardNumber, setHardNumber] = useState(1);
  const [easyNumber, setEasyNumber] = useState(1);

  // useMemo를 사용하지 않으면 매번 hardCalculate가 실행됨
  const hardSum = useMemo(() => {
    return hardCalculate(hardNumber);
  }, [hardNumber]); // hardNumber가 바뀔 때만 실행됨

  const easySum = easyCalculate(easyNumber);

  return (
    <div>
      <h3>어려운 계산기</h3>
      <input
        type="number"
        value={hardNumber}
        onChange={(e) => setHardNumber(parseInt(e.target.value))}
      />
      <span> + 10000 = {hardSum}</span>

      <h3>쉬운 계산기</h3>
      <input
        type="number"
        value={easyNumber}
        onChange={(e) => setEasyNumber(parseInt(e.target.value))}
      />
      <span> + 1 = {easySum}</span>
    </div>
  );
}

export default App;

이 코드에서 useMemo를 사용하지 않으면, 쉬운 계산기 작업을 해도 매 렌더링마다 hardCalculate가 다시 실행되어 1초 정도의 딜레이가 느껴질 만큼 느리다. easy를 사용할 때는 hard를 사용하지 않고 불러올 수는 없는지에 대한 고민에서 탄생한 것이 useMemo다.

배열을 [hardNumber]로 선언했으므로 hardNumber가 변경될 때만 hardCalculate가 실행된다. 변경되지 않았을 경우에는 메모리에 저장된 값을 사용한다. useMemo를 사용하면 이제 쉬운 계산기를 증가시킬 때는 빠르게 계산되는 것을 볼 수 있다.

useMemo 예제 2: 객체 참조 동일성 유지

사실 리액트 개발에서 1초 이상 걸리는 함수로 컴포넌트 내부의 값을 초기화하는 경우가 많지는 않다. 따라서 useMemo가 진짜 빛을 내는 순간은 따로 있다. 바로 객체나 배열의 참조 동일성을 유지해야 할 때다.

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

function App() {
  const [number, setNumber] = useState(0);
  const [isKorea, setIsKorea] = useState(true);

  const location = {
    country: isKorea ? '한국' : '외국'
  };

  useEffect(() => {
    console.log('useEffect 호출');
  }, [location]);

  return (
    <div>
      <h2>하루에 밥 몇 끼 먹어요?</h2>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />

      <hr />

      <h2>어느 나라에 있어요?</h2>
      <p>나는 {location.country}에 있어요</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자</button>
    </div>
  );
}

export default App;

App 컴포넌트는 2개의 state를 가지고 있다. number State는 0으로 초기화되어 있고, isKorea State는 true로 초기화되어 있다.

만약 location의 타입이 원시 타입인 문자열이 아니라 객체 타입 object라면 문제가 발생한다. useEffect는 의존성으로 받은 location이 변경될 때만 호출되어야 하는데, number를 변경해도 useEffect가 호출되는 것을 볼 수 있다.

왜냐하면 객체 타입은 크기가 커서 메모리상에 올려두고 변수에는 메모리 주소를 할당하기 때문이다. 컴포넌트가 렌더링될 때마다 location 객체가 새로 생성되고, 내용은 같지만 메모리 주소가 다른 새로운 객체가 되어버린다. 따라서 리액트는 location이 참조하고 있는 주소가 바뀌었다고 판단하게 된다.

이럴 때 useMemo를 사용하면 location 변수가 불필요하게 초기화되지 않도록 할 수 있다.

import React, { useState, useEffect, useMemo } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  const [isKorea, setIsKorea] = useState(true);

  const location = useMemo(() => {
    return {
      country: isKorea ? '한국' : '외국'
    };
  }, [isKorea]);

  useEffect(() => {
    console.log('useEffect 호출');
  }, [location]);

  return (
    <div>
      <h2>하루에 밥 몇 끼 먹어요?</h2>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />

      <hr />

      <h2>어느 나라에 있어요?</h2>
      <p>나는 {location.country}에 있어요</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자</button>
    </div>
  );
}

export default App;

이제 isKorea가 변경될 때만 location 객체가 새로 생성되고, number가 변경될 때는 메모이제이션된 location 객체를 재사용하게 된다. 따라서 useEffect는 isKorea가 변경될 때만 호출된다.

useCallback

useCallback은 useMemo와 거의 동일한 역할이지만, 값이 아닌 함수를 메모이제이션한다.

useCallback은 컴포넌트가 리렌더링될 때마다 함수를 새로 만들지 않도록, 그 함수를 메모이제이션해서 재사용하게 만드는 역할을 한다. 그래서 의존성 배열에 들어있는 값들이 그대로라면, React는 이전에 만들어둔 함수 객체(참조한 주소값)를 그대로 돌려준다. 결국 불필요하게 함수를 다시 생성하거나 실행할 필요가 없어지고, 그만큼 성능 최적화에 도움이 된다.

컴포넌트가 렌더링될 때마다 내부의 함수들도 다시 생성된다.
일반적으로 큰 문제가 없지만, 자식 컴포넌트에 props로 함수를 전달할 때 문제가 된다.
함수가 새로 생성되면 참조값이 바뀌기 때문에 자식 컴포넌트는 props가 변경되었다고 판단하고 불필요한 리렌더링이 발생한다.

useCallback 사용법

function Component() {

  const calculate = useCallback((num) => {
    return num + 1
  }, [item]);

  return <div>{ value }</div>
}

<Component />가 렌더링 되면 Component()함수를 초기화하여 호출되고 두번째 호출부터는 Memoize된 함수를 재사용한다.

  • 첫 번째 인자: 메모이제이션할 콜백 함수
  • 두 번째 인자: 의존성 배열 - 배열 안의 값이 변경될 때만 함수를 다시 생성한다

useCallback 예제

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

function App() {
  const [number, setNumber] = useState(0);

  const someFunction = useCallback(() => {
    console.log(`someFunc: number: ${number}`);
    return;
  }, []);

  useEffect(() => {
    console.log('someFunction이 변경되었습니다.');
  }, [someFunction]);

  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onClick={someFunction}>Call someFunc</button>
    </div>
  );
}

export default App;

위 코드에서 의존성 배열이 빈 배열 []이므로 함수는 처음 렌더링될 때 생성된 함수가 재사용된다.

처음 렌더링 시 number가 0이었다면, 함수 내부에 캡처된 number 값도 0으로 고정된다. 이후 number가 5, 10으로 변경되어도 함수는 재생성되지 않으므로, 함수 내부의 number는 계속 0을 참조한다. 이것이 버튼을 클릭할 때마다 0이 출력되는 이유다.

useCallback은 함수를 메모이제이션(기억) 한다.
처음 렌더링할 때 함수가 만들어지고, 의존성 배열이 [] 라면 이후 렌더링에서는 이 함수는 재생성되지 않는다.

따라서 최신 number를 반영하고 싶다면 의존성 배열에 number를 넣어야 한다.

const someFunction = useCallback(() => {
  console.log(`someFunc: number: ${number}`);
}, [number]);

useEffect와 비교

useEffect(() => {
  console.log("someFunction이 변경되었습니다.");
}, [someFunction]);

이 useEffect는 someFunction의 참조가 변경될 때마다 실행된다. 의존성 배열에 포함된 값이 언제 재실행/재생성되느냐를 결정한다는 점에서 useCallback과 useEffect는 동일한 원리로 동작한다.

정리

  • useMemo: 무거운 계산의 결과값이나 객체/배열의 참조를 유지하고 싶을 때 사용한다
  • useCallback: 함수의 참조를 유지하고 싶을 때, 특히 자식 컴포넌트에 props로 함수를 전달할 때 사용한다
  • 두 훅 모두 꼭 필요한 경우에만 사용해야 한다. 무분별한 사용은 오히려 메모리를 낭비하고 성능을 저하시킬 수 있다

참고자료

유튜브 - 별코딩

profile
개발자 소희의 노트

0개의 댓글