본문 바로가기

useMemo

useMemo는 React의 메모이제이션(memoization) 훅으로, 컴포넌트의 성능을 최적화하는데 사용됩니다. 메모이제이션이란 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장해 두었다가 동일한 입력이 들어오면 저장된 값을 재사용하는 프로그래밍 기법입니다.

예를 들어, 쇼핑몰에서 장바구니의 총 금액을 계산하는 경우를 생각해봅시다. 장바구니에 변화가 없다면 장바구니에 들어갈 때 마다 매번 같은 계산을 반복할 필요가 없겠죠?

useMemo는 이런 상황에서 이전 계산 결과를 재사용하여 불필요한 연산을 줄이고 애플리케이션의 전반적인 성능을 향상시킬 수 있습니다.

1. 기본 구조

useMemo의 구조를 살펴보겠습니다.

const memo = useMemo(() => add(a, b), [a, b]);

useMemo는 두 개의 인자를 받습니다.

  • 첫 번째 인자: 계산을 수행하는 함수
  • 두 번째 인자: 의존성 배열 (이 값들이 변경될 때만 계산 함수가 다시 실행됨)

1.1 동작 방식

컴포넌트가 처음 렌더링될 때, useMemo는 첫 번째 인자로 전달된 계산 함수를 실행하고 결과값을 메모리에 저장합니다.

렌더링 발생 시 의존성 배열의 형태에 따라 동작 방식이 달라집니다.

  1. 의존성 배열에 값이 있는 경우

    const value1 = useMemo(() => expensiveCalculation(), [a, b]);
    

    React는 의존성 배열의 모든 값을 렌더링 이전 값과 비교합니다.

    • 값이 변경된 경우 : 의존성 배열 속 값이 하나라도 변경되었다면 계산 함수를 다시 실행하고, 새로 계산된 결과값을 메모리에 저장합니다.
    • 값이 동일한 경우 : 의존성 배열의 모든 값이 이전과 같다면 계산 함수를 실행하지 않고, 메모리에 저장해둔 이전 결과값을 그대로 반환합니다.

    예를 들어, a가 1에서 2로 변경되면 expensiveCalculation(2, b)가 실행되어 새 값을 계산합니다. 하지만 a와 b 모두 변경되지 않았다면, 이전에 계산해둔 값을 재사용하므로 불필요한 연산을 건너뛸 수 있습니다. useMemo의 의존성 배열 검사, 계산 함수 실행, 값의 저장 및 반환은 모두 렌더링 과정 중에 동기적으로 일어납니다.


  2. 의존성 배열이 생략된 경우

    const value2 = useMemo(() => expensiveCalculation(a, b));
    

    렌더링이 일어날 때마다 계산 함수를 실행하고 새 값을 저장하고 반환합니다. 이 경우는 메모이제이션의 이점을 얻지 못합니다.


  3. 의존성 배열이 비어있는 경우

    const value2 = useMemo(() => expensiveCalculation(), []);
    

    초기 렌더링 시에만 한 번 계산 함수를 실행하고 그 결과를 저장합니다. 이후의 모든 렌더링에서는 항상 이 초기값을 재사용합니다.

useMemo로 저장한 값은 컴포넌트가 화면에서 완전히 사라질 때 자동으로 메모리에서 정리됩니다.

좀 더 자세히 설명하면, useMemo가 저장한 값은 해당 컴포넌트 인스턴스에 연결되어 있습니다. 컴포넌트가 화면에서 사라지면 그 컴포넌트와 연결된 모든 데이터(state, ref, memoized 값 등)에 대한 참조가 끊어집니다. 이렇게 참조가 끊어진 데이터는 JavaScript의 가비지 컬렉터(Garbage Collector)가 자동으로 메모리에서 제거합니다.

따라서 useMemo 사용 후 별도로 메모리를 정리하는 코드를 작성할 필요가 없습니다. 다만, 같은 컴포넌트가 화면에 계속 남아있는 동안에는 메모이제이션된 값도 메모리에 유지된다는 점을 기억해두세요.

2. useMemo 실전 활용

2.1 복잡한 계산 최적화

부하가 발생하는 코드에서 useMemo를 사용했을 때와 사용하지 않았을 때의 차이를 확인해보겠습니다.

import { useState } from 'react';

function load() {
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  return sum;
}

function App() {
  const [count, setCount] = useState(0);
  let result = load();

  const handleCountUp = () => {
    setCount((prevCount) => prevCount + 1);
    // 업데이트 전의 count 값을 출력합니다.
    console.log(count);
  };

  return (
    <div>
      <h1>계산 결과 : {result}</h1>
      <div>{count}</div>
      <button onClick={handleCountUp}>UP!</button>
    </div>
  );
}
export default App;

load 함수는 10억 번의 덧셈 계산을 수행하는 복잡한 함수입니다.

이 예시에서는 useMemo를 사용하지 않았기 때문에, 컴포넌트가 리렌더링될 때마다 load 함수가 실행됩니다. 즉, 버튼을 클릭하여 count 상태가 변경될 때마다 불필요하게 load 함수가 다시 실행되어 부하가 발생해 성능에 부담을 줄 수 있습니다.

페이지에 접속해 확인해보면, 버튼을 누를 때마다 복잡한 계산이 실행되기 때문에 업데이트된 count 값이 화면에 반영되는 데 시간이 오래 걸립니다.


이제 useMemo를 사용하여 성능을 최적화해보겠습니다.

import { useMemo, useState } from 'react';

function load() {
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  return sum;
}

function App() {
  const [count, setCount] = useState(0);
  const result = useMemo(() => load(), []);

  const handleCountUp = () => {
    setCount((prevCount) => prevCount + 1);
    // 업데이트 전의 count 값을 출력합니다.
    console.log(count);
  };

  return (
    <div className="app-container">
      <h1>계산 결과 : {result}</h1>
      <div>{count}</div>
      <button onClick={handleCountUp}>UP!</button>
    </div>
  );
}

export default App;

useMemo의 첫 번째 인자로 복잡한 계산을 수행하는 load 함수를 전달하고, 두 번째 인자로 빈 배열을 전달합니다. 이렇게 하면 초기 렌더링 시에만 한 번 계산 함수를 실행하고 그 결과를 저장합니다. 따라서 처음 계산된 결과가 메모이제이션되어, 이후 버튼을 클릭하여 count 상태가 변경되더라도 load 함수는 다시 실행되지 않습니다.

이렇게 최적화된 버전에서는 버튼을 클릭할 때마다 복잡한 계산이 매번 다시 실행되지 않기 때문에 count 값이 즉시 업데이트되는 것을 확인할 수 있습니다.

특정 변수가 변할 때마다 load 함수를 실행하도록 해보겠습니다. 특정 변수는 load 함수 내에서 10억번 더해지는 값으로 사용됩니다.

import { useMemo, useState } from 'react';

function load(num) {
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += num;
  }
  return sum;
}

function App() {
  const [count, setCount] = useState(0);
  const [countTwo, setCountTwo] = useState(0);

  const result = useMemo(() => load(countTwo), [countTwo]);

  const handleCountUp = () => {
    setCount((prevCount) => prevCount + 1);
    // 업데이트 전의 count 값을 출력합니다.
    console.log(count);
  };

  const handleCountUpTwo = () => {
    setCountTwo((prevCountTwo) => prevCountTwo + 1);
    // 업데이트 전의 countTwo 값을 출력합니다.
    console.log(countTwo);
  };

  return (
    <div className="app-container">
      <h1>계산 결과 : {result}</h1>
      <div>{count}</div>
      <button onClick={handleCountUp}>UP!</button>
      <div>{countTwo}</div>
      <button onClick={handleCountUpTwo}>Up!</button>
    </div>
  );
}

export default App;

useMemo의 두 번째 인자로 전달된 countTwo는 useState의 state로 선언되었습니다. 두 번째 버튼을 클릭할 때마다 countTwo가 1씩 증가합니다. React가 countTwo의 변화를 감지하면 load 함수를 다시 실행해 새로운 결과를 반환하고 저장합니다. 그러면 countTwo를 10억 번 더한 결과값이 화면에 표시됩니다.

이 예제는 useMemo가 의존성 배열의 값이 변경될 때만 계산을 다시 수행한다는 것을 보여주기 위한 것입니다. 첫 번째 버튼(count)을 누르면 countTwo가 변경되지 않았으므로 load 함수가 실행되지 않고, 저장된 값을 그대로 사용하여 화면이 즉시 업데이트됩니다. 반면 두 번째 버튼(countTwo)을 누르면 의존성 배열의 값이 변경되어 load 함수가 다시 실행되므로 화면 업데이트가 느려집니다.

즉, 같은 화면에서 두 버튼의 반응 속도 차이를 통해 useMemo가 불필요한 재계산을 효과적으로 방지하고 있음을 직접 체감할 수 있습니다.

2.2 비용이 큰 연산 최적화

복잡한 계산의 결과를 저장하고 필요할 때 다시 재사용하여 연산을 최적화하는 예시를 살펴보겠습니다.

이 예제는 useMemo의 기본 사용법과 원리를 보여주기 위한 것입니다. 실제 애플리케이션에서는 더 복잡하고 계산 비용이 높은 연산에 useMemo를 적용하면 더 큰 성능 이점을 얻을 수 있습니다.

이름과 아이디를 입력받아 회원 목록과 등록된 회원 수를 화면에 보여주는 코드를 작성합니다. 먼저 useMemo를 사용하지 않았을 때 어떤 일이 일어나는지 확인해보겠습니다.

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

function App() {
  const nameRef = useRef(null);
  const idRef = useRef(null);
  const [userInfo, setUserInfo] = useState([]);
  const [name, setName] = useState('');
  const [id, setId] = useState('');

  function handleInputName(e) {
    setName(e.target.value);
    console.log('렌더링 - 이름 입력');
  }

  function handleInputId(e) {
    setId(e.target.value);
    console.log('렌더링 - 아이디 입력');
  }

  function handleSubmit(e) {
    e.preventDefault();
    const newInfo = [...userInfo, { name, id }];
    setUserInfo(newInfo);
    nameRef.current.value = '';
    idRef.current.value = '';
    nameRef.current.focus();
    console.log('렌더링 - 제출');
  }

  // 렌더링이 발생할 때마다 호출되어 실행됩니다.
  function getNum(list) {
    console.log('렌더링!');
    return list.length;
  }

  return (
    <div className="app-container">
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="이름을 입력하세요"
          onChange={handleInputName}
          ref={nameRef}
        />
        <input
          type="text"
          placeholder="아이디를 입력하세요"
          onChange={handleInputId}
          ref={idRef}
        />
        <button type="submit">회원 등록</button>
      </form>
      {/* getNum 함수를 매 렌더링마다 호출해 실행하고, 결과가 화면에 표시됩니다. */}
      <span>현재 회원 수: {getNum(userInfo)}</span>
      <ul>
        {userInfo.map((value, index) => (
          <li key={index}>
            <h3>이름: {value.name}</h3>
            <strong>아이디: {value.id}</strong>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

이 예제에서는 이름과 아이디를 입력하는 input 요소를 가리키는 ref 객체로 nameRef와 idRef를 선언했습니다. 입력한 이름과 아이디를 저장하는 name, id상태를 useState로 선언하고 name과 id 값을 객체로 만들어 유저 정보를 담는 배열인 userInfo 상태도 useState로 선언했습니다.


입력 전 화면등록 후 화면
이름과 이메일 입력 전 화면회원 등록 화면

이름과 아이디를 입력하고 회원 등록 버튼을 클릭하면 현재 회원 수가 증가하면서 현재 회원 수 문구 아래에 추가된 회원의 정보가 표시됩니다. 현재 회원 수를 화면에 표시하기 위해 getNum 함수가 호출되어 실행되고 그 결과가 화면에 표시됩니다.


컴포넌트 내 state가 바뀌면 렌더링이 발생합니다. 현재 코드에서 값이 변하는 경우는 3가지입니다. 1. 이름을 입력하여 setName 함수가 실행되는 경우, 2. 아이디를 입력하여 setId 함수가 실행되는 경우, 3. 회원 등록 버튼을 눌러 setUserInfo 함수가 실행되는 경우입니다.

콘솔창을 통한 getNum 함수 실행 시점 확인

콘솔창을 열어 getNum 함수가 언제 실행되는지 확인해보면, 이름과 아이디를 입력할 때마다 렌더링! 문구가 출력됩니다. 버튼을 클릭하지 않으면 회원 목록에 추가되지 않기 때문에 getNum 함수가 실행될 필요가 없습니다. 그러나 현재는 이름이나 아이디를 입력하는 것만으로도 getNum 함수가 실행됩니다.


이러한 불필요한 연산을 막기 위해 useMemo를 사용해봅시다.

이름과 아이디를 입력하고 버튼을 눌러야만 userInfo 값이 바뀝니다. userInfo의 값이 바뀔 때에만 getNum 함수를 실행해야 하므로, useMemo 훅의 의존성 배열에 userInfo를 전달해줍니다.

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

function App() {
  const nameRef = useRef(null);
  const idRef = useRef(null);
  const [userInfo, setUserInfo] = useState([]);
  const [name, setName] = useState('');
  const [id, setId] = useState('');
  const num = useMemo(() => getNum(userInfo), [userInfo]);

  function handleInputName(e) {
    setName(e.target.value);
    console.log('렌더링 - 이름 입력');
  }

  function handleInputId(e) {
    setId(e.target.value);
    console.log('렌더링 - 아이디 입력');
  }

  function handleSubmit(e) {
    e.preventDefault();
    const newInfo = [...userInfo, { name, id }];
    setUserInfo(newInfo);
    nameRef.current.value = '';
    idRef.current.value = '';
    nameRef.current.focus();
    console.log('렌더링 - 제출');
  }

  // 컴포넌트가 처음 렌더링될 때와 userInfo 값이 변경될 때만 실행됩니다.
  function getNum(list) {
    console.log('렌더링!');
    return list.length;
  }

  return (
    <div className="app-container">
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="이름을 입력하세요"
          onChange={handleInputName}
          ref={nameRef}
        />
        <input
          type="text"
          placeholder="아이디를 입력하세요"
          onChange={handleInputId}
          ref={idRef}
        />
        <button type="submit">회원 등록</button>
      </form>
      {/* getNum 함수를 매 렌더링마다 호출해 실행하고, 결과가 화면에 표시됩니다. */}
      <span>현재 회원 수: {num}</span>
      <ul>
        {userInfo.map((value, index) => (
          <li key={index}>
            <h3>이름: {value.name}</h3>
            <strong>아이디: {value.id}</strong>
          </li>
        ))}
      </ul>
    </div>
  );
}

useMemo를 사용한 후, getNum 함수는 컴포넌트가 처음 렌더링될 때와 userInfo 값이 바뀔 때만 실행됩니다. userInfo 값이 변하지 않았다면, 다른 state의 값이 바뀌어 컴포넌트가 다시 렌더링되어도 getNum 함수는 재실행되지 않고 이전에 저장했던 계산 결과를 재사용합니다.

콘솔창을 열어 getNum 함수가 언제 실행되는지 확인해보세요. 이제는 회원 등록 버튼을 클릭할 때만 '렌더링!' 메시지가 출력되는 것을 확인할 수 있습니다. 이러한 방식으로 useMemo를 사용하면, 불필요한 계산을 줄이고 애플리케이션의 성능을 향상시킬 수 있습니다.

useMemo를 사용하여 콘솔창을 통한 getNum 함수 실행 시점 확인

2.2 React.memo를 사용한 최적화

React에서는 기본적으로 부모 컴포넌트가 리렌더링되면 모든 자식 컴포넌트도 함께 리렌더링됩니다. 그러나 자식 컴포넌트의 props가 변경되지 않았다면, 이러한 리렌더링은 불필요할 수 있습니다. 특히 자식 컴포넌트가 복잡하거나 데이터 처리가 많은 경우, 이러한 불필요한 리렌더링은 애플리케이션의 성능을 저하시킬 수 있습니다. 이러한 불필요한 리렌더링을 방지하기 위해 useMemo와 React.memo를 함께 사용할 수 있습니다.

React.memo는 컴포넌트의 props가 변경되지 않은 경우에 리렌더링이 발생하지 않도록 합니다. React.memo의 인자로는 메모이제이션하려는 컴포넌트를 전달합니다.

const ChildComponent = React.memo(function Hello({ name }) {
  return <h1>Hello {name}</h1>;
});

Hello 컴포넌트는 name props가 변경되지 않으면 부모 컴포넌트가 리렌더링되어도 리렌더링되지 않습니다. name props가 변경될 때만 ChildComponent가 리렌더링됩니다.

useMemo와 React.memo를 함께 사용하여 컴포넌트 리렌더링을 방지하여 보겠습니다.

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

const NonMemoizedChild = ({ data }) => {
  console.log('NonMemoizedChild 렌더링');
  return (
    <div className="child-component">
      최적화되지 않은 컴포넌트: {data}
      <br />
      (렌더링 시간: {new Date().toLocaleTimeString()})
    </div>
  );
};

const MemoizedChild = React.memo(({ data }) => {
  console.log('MemoizedChild 렌더링');
  return (
    <div className="child-component">
      최적화된 컴포넌트: {data}
      <br />
      (렌더링 시간: {new Date().toLocaleTimeString()})
    </div>
  );
});

const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [dataForChild, setDataForChild] = useState(0);

  // useMemo를 사용해 dataForChild 객체 메모이제이션
  const memoizedData = useMemo(() => ({ value: dataForChild }), [dataForChild]);

  return (
    <div className="parent-component">
      <div className="button-container">
        <button
          className="button count-button"
          onClick={() => setCount(count + 1)}
        >
          카운트 증가: {count}
        </button>
        <button
          className="button data-button"
          onClick={() => setDataForChild(dataForChild + 1)}
        >
          자식 데이터 변경: {dataForChild}
        </button>
      </div>
      <NonMemoizedChild data={dataForChild} />
      <MemoizedChild data={memoizedData.value} />
    </div>
  );
};

function App() {
  return (
    <div className="app-container">
      <ParentComponent />
    </div>
  );
}

export default App;

위 코드는 ParentComponent라는 부모 컴포넌트와 두 개의 자식 컴포넌트로 구성되어 있습니다.

부모 컴포넌트에는 두 개의 버튼이 있습니다. '카운트 증가' 버튼은 단순히 숫자를 올리는 역할을 하고, '자식 데이터 변경' 버튼은 자식 컴포넌트들에게 전달할 데이터를 변경합니다. 이 두 버튼은 각각 count와 dataForChild라는 상태를 관리합니다.

자식 컴포넌트는 두 종류가 있습니다. NonMemoizedChild와 MemoizedChild입니다. 두 컴포넌트 모두 부모로부터 받은 데이터와 자신이 렌더링된 시간을 화면에 표시합니다. NonMemoizedChild는 일반적인 React 컴포넌트로, 부모 컴포넌트가 리렌더링될 때마다 함께 리렌더링됩니다. MemoizedChild는 React.memo로 감싸져 있어, 자신에게 전달되는 props가 변경되지 않으면 리렌더링되지 않습니다.

useMemo 예제

카운트 증가 버튼을 클릭할 때 흥미로운 차이점을 발견할 수 있습니다. 이 버튼을 클릭하면 count 상태만 변경되는데, 이때 NonMemoizedChild는 부모 컴포넌트의 리렌더링으로 인해 함께 리렌더링됩니다. 반면 MemoizedChild는 전달받는 데이터(memoizedData.value)가 변경되지 않았기 때문에 리렌더링되지 않습니다.

자식 데이터 변경 버튼을 클릭하면 두 자식 컴포넌트 모두 새로운 데이터를 받아 리렌더링됩니다. 이는 dataForChild 상태가 변경되어 두 컴포넌트 모두에게 새로운 데이터가 전달되기 때문입니다.

이런 차이가 발생하는 이유는 React의 memo와 useMemo라는 기능 때문입니다. MemoizedChild는 React.memo로 감싸져 있어서 자신에게 전달되는 데이터가 실제로 변경되지 않으면 다시 그려지지 않습니다. 또한 useMemo를 사용해 dataForChild가 변경될 때만 새로운 객체를 만들어 전달합니다.

이 예제를 통해 우리는 최적화의 효과를 눈으로 확인할 수 있습니다. 최적화된 컴포넌트는 불필요한 재렌더링을 피하고, 자신과 관계없는 상태 변화에는 반응하지 않습니다. 이는 특히 복잡한 컴포넌트나 큰 규모의 앱에서 성능을 크게 높일 수 있습니다.

이 예제는 React.memo와 useMemo를 적절히 사용하면 불필요한 리렌더링을 방지할 수 있음을 명확하게 보여줍니다. 특히 MemoizedChild는 실제로 사용하는 데이터가 변경될 때만 리렌더링되어, 애플리케이션의 성능을 향상시킬 수 있습니다.

React.memo에 대해 자세하게 알고 싶다면 공식 문서 링크를 남겼으니 참고해주세요.

리액트 공식 문서 - memo

3. useMemo 사용 시 주의사항

useMemo는 React 애플리케이션의 성능을 최적화하는 데 유용한 도구지만, 과도한 사용은 메모리 사용량 증가로 이어질 수 있어 오히려 역효과를 낼 수 있습니다. useMemo를 효과적으로 활용하기 위해서는 언제 사용하고, 언제 사용을 피해야 하는지 정확히 이해하는 것이 중요합니다.

먼저 useMemo 사용이 효과적인 경우를 살펴보겠습니다. 대규모 배열의 필터링이나 정렬, 복잡한 수학적 계산, 데이터 변환 작업과 같이 계산 비용이 큰 작업에서는 useMemo 사용이 매우 유용합니다. 특히 이러한 계산 결과가 자주 변경되지 않는 경우, useMemo를 통한 메모이제이션은 성능 향상에 크게 기여할 수 있습니다. 또한 React.memo로 최적화된 자식 컴포넌트에 객체나 배열을 props로 전달할 때도 useMemo가 필요합니다. JavaScript에서 객체나 배열은 매 렌더링마다 새로운 참조가 생성되므로, 이를 메모이제이션하지 않으면 React.memo의 효과를 제대로 얻을 수 없습니다. 특히 큰 리스트나 테이블을 렌더링하는 경우, 이러한 최적화는 더욱 중요해집니다.

useMemo를 효과적으로 사용하기 위해서는 의존성 배열을 신중히 관리해야 하며, 코드의 가독성과 최적화 사이의 균형을 고려해야 합니다.

또한, useMemo 적용 전후의 성능을 실제로 측정하고 비교하는 것이 중요합니다. 때로는 예상과 달리 성능 개선 효과가 미미할 수 있기 때문입니다. React DevTools의 Profiler를 활용하면 컴포넌트의 렌더링 시간과 빈도를 측정할 수 있으며, 브라우저의 Performance 탭에서는 더 자세한 성능 분석이 가능합니다. 이러한 도구들을 활용하여 useMemo 적용 전후의 성능을 비교하고, 실제로 의미 있는 성능 향상이 있는지 확인해야 합니다.

실무에서 useMemo를 활용할 때는 항상 "이 최적화가 정말 필요한가?" 라는 질문을 먼저 해보아야 합니다. 성능 최적화는 실제 문제가 있을 때 적용하는 것이 바람직하며, 예측적 최적화는 피하는 것이 좋습니다. 성능 측정을 통해 병목 지점을 정확히 파악하고, 필요한 곳에 적절히 useMemo를 적용한다면, React 애플리케이션의 성능을 효과적으로 개선할 수 있을 것입니다.

useMemo - React 에센셜 | 위니버시티