5 분 소요

1. 메모이제이션(Memoization)

컴포넌트 최적화를 위해 자주 사용되는 훅은 useMemo와 useCallback이 있다. useMemo에서 memo는 메모이제이션(memoization)을 뜻한다.

메모이제이션이란 맨 처음 값을 메모리에 저장해서 필요할 때마다 또 다시 계산하지 않고 메모리에서 꺼내서 재사용하는 기법이다.

즉, 자주 필요한 값을 맨 처음 계산할 때 캐싱을 해둬서 그 값이 필요할 때마다 (다시 계산하지 않고) 캐시에서 꺼내서 사용한다는 것이다. 메모이제이션이 필요한 상황에 대해 알아보자.


우선 함수형 컴포넌트는 말 그대로 함수라는 사실을 기억해야 한다. 함수형 컴포넌트가 렌더링된다는 것은, 컴포넌트 함수가 호출된다는 것을 의미한다. 함수는 호출될 때마다 내부 변수가 초기화된다. 예를 들어,

function Component() {
  const value = calculate();
  return <div>{value}</div>;
}

<Component />에는 value라는 상수가 있고, value는 calculate라는 함수로부터 값을 받아온다. 컴포넌트에서 state가 변경돼서 새로 렌더될 때마다 calculate 함수는 반복적으로 호출될 것이다.


그런데 만약 calculate 함수가 어떤 무거운 작업을 하는 함수라면, 매우 비효율적일 것이다. useMemo를 활용해서 메모이제이션을 해주면 이런 반복되는 상황을 해결해줄 수 있다.

function Component() {
  const value = useMemo(() => calculate(), []);
  return <div>{value}</div>;
}




2. useMemo()의 구조

useMemo는 두 개의 인자를 받는다. 첫 번째 인자는 콜백함수, 두 번째 인자로는 배열을 받는다. 첫 번째 인자인 콜백함수는 우리가 메모이제이션 해줄 값을 계산해서 리턴해주는 함수다. 이 콜백함수가 리턴해주는 값이 바로 useMemo가 리턴해주는 값이 된다. 두 번째 인자인 배열은 의존성 배열이라고도 불린다. useMemo는 배열 안의 요소의 값이 업데이트 될 때만 콜백함수를 다시 호출해서 메모이제이션 된 값을 업데이트해서 다시 메모이제이션을 해준다. 만약 빈 배열일 경우 맨 처음 컴포넌트가 마운트되었을 때만 값을 계산하고, 이후에는 항상 메모이제이션 된 값을 꺼내와서 사용한다.


useMemo도 무분별하게 남용하는 것은 좋지 않다. 오히려 성능에 무리가 갈 수 있기 때문이다. useMemo를 사용한다는 건, 값을 재활용하기 위해서 따로 메모리를 소비한다는 걸 말한다. 그렇기 때문에 불필요한 값까지 모두 메모이제이션을 해버리면 오히려 성능이 악회될 수 있다.




예제1

import { useState } from "react";

const hardCalculate = (number) => {
  console.log("어려운 계산");
  for (let i = 0; i < 99999999; i++) {
    // 의미없이 오래걸리는 시간
    return number + 10000;
  }
};

const App = () => {
  const [hardNumber, setHardNumber] = useState(1);
  const hardSum = hardCalculate(hardNumber);

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

export default App;

위와 같은 App 컴포넌트가 있을 때, hardNumber라는 state가 변경될 때마다, 새로 렌더 되고, 컴포넌트 함수 내부의 변수는 모두 초기화된다. hardSum이라는 변수도 계속해서 다시 초기화될 것이다. 그 말은, 매우 무거운 함수라고 치자인 hardCalculate라는 함수가 반복적으로 호출되어 hardSum 변수에게 값을 할당해준다는 것을 의미한다.

콘솔에 찍어보면 숫자를 변화시킬 때마다 계속해서 ‘어려운 계산’이 찍혀 나오는 걸 볼 수 있다. 그리고 for문을 돌기 때문에 조금씩 딜레이가 생길 수 있다. 여기에 가벼운 연산을 하는 ‘쉬운 계산’을 추가해보자.

import { useState } from "react";

const hardCalculate = (number) => {
  console.log("어려운 계산");
  for (let i = 0; i < 99999999999999999; i++) {
    // 의미없이 오래걸리는 시간
    return number + 10000;
  }
};

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

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

  const hardSum = hardCalculate(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;

easyNumber가 변화되면 easyCalculate 자체는 가볍지만, hardCalculate와 마찬가지로 딜레이가 생긴다. 그 이유는 easyNumber의 변화될 때마다 리렌더되고, hardSum과 easySum 변수는 초기화되어 그때마다 hardCalculate와 easyCalculate 모두 호출되어 변수에게 값을 할당해준다.

즉, 어떤 스테이트가 변경되건간에 의미 없는 for loop가 실행되어야 하고, 따라서 딜레이가 생기게 되는 것이다.


만약 easyNumber라는 스테이트가 변경될 때에는 hardCalculate 함수가 실행되지 않게 된다면 좋지 않을까??

그때 리액트가 제공하는 useMemo라는 훅을 사용하면 좋다. useMemo 훅을 사용하면, 어떤 조건이 만족됐을 때만 변수들이 초기화되게 할 수 있다. 그 조건이 만족되지 않으면, App 컴포넌트가 다시 렌더링이 되더라도 초기화를 시켜주는 것이 아니라 이미 갖고 있던 값을 사용하게 해주는 것이다.

이걸 다른 말로 메모이제이션이라 한다.


hardNumber가 변경될 때에만 hardCalculate가 호출되도록 해보자. 우선 useMemo를 import 해준다. useMemo는 두 가지 인자를 받는데, 첫 번째는 실행해줄 함수를 반환해줄 콜백함수를, 두 번째 인자에는 의존성 배열(dependency array)을 넣어준다. 이 배열의 요소로는 콜백함수를 실행시켜줄 조건이 들어가게 된다.

이 상황에서는 hardCalculate를 반환해주는 콜백함수를 useMemo의 첫 번째 인자로, hardNumber를 요소로 갖는 의존성 배열을 두 번째 인자로 넣어주면 된다.

import { useMemo, useState } from "react";

const hardCalculate = (number) => {
  console.log("어려운 계산");
  for (let i = 0; i < 99999999999999999; i++) {
    // 의미없이 오래걸리는 시간
    return number + 10000;
  }
};

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

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

  const hardSum = useMemo(() => {
    return hardCalculate(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;

콘솔을 보자. 짱쉬운 계산기만 이용하면(easynumber만 바뀌면) ‘어려운 계산’이 나오지 않게 된다. 그 이유는 hardNumber가 바뀌지 않으면, hardSum이라는 변수는 리렌더 시에도 초기화되지 않고 메모아이즈 된 값이 그대로 남아있기 때문이다.

물론, 리액트 개발에서 위의 경우처럼 1초 이상 걸리는 함수로 컴포넌트 내부의 변수를 초기화하는 일은 드물다. 조금 더 현실적인 예시를 보자.




예제 2

import { useEffect, useMemo, useState } from "react";

const 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)}
      />
      <p></p>
      <hr />
      <p></p>
      <h2>어느 나라에 있나요?</h2>
      <p>나라: {location.country}</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자!</button>
    </div>
  );
};

export default App;

useEffect의 의존성 배열에 location이 들어 있기 때문에, number 스테이트가 변경되어도 useEffect가 호출되지 않을 것이라 예상했지만 그렇지 않다. useEffect의 의존성 배열에 원시값이 아닌 객체가 들어가게 되면, 의존성 배열 내의 요소가 아닌 다른 스테이트가 변경되어도 useEffect가 호출된다.

그 이유는, number라는 state를 변경하면 리렌더가 일어나는데, 그러면 location이라는 변수도 이전과 똑같이 생긴 객체를 다시 할당 받게 된다. 그러나, 눈으로 보기에는 똑같지만 사실 다른 메모리상 공간에 저장된 새롭게 생성된 객체를 할당받게(주소를 참조하게) 되는 것이다. 즉, 리액트 입장에서는 location이 다른 주소를 참조하고 있기 때문에, location이 변경됐다고 인식하게 되어 useEffect를 호출하는 것이다.

이걸 해결하기 위해서는, App 컴포넌트가 렌더링이 됐을 때 location 변수가 다시 초기화되는 것을 막아주면 된다. 즉, location 변수는 isKorea 스테이트가 변경되었을 때만 초기화되게 해주자.

import { useEffect, useMemo, useState } from "react";

const 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)}
      />
      <p></p>
      <hr />
      <p></p>
      <h2>어느 나라에 있나요?</h2>
      <p>나라: {location.country}</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자!</button>
    </div>
  );
};

export default App;

useMemo가 리턴할 값으로 location에 할당될 값을 넣어주고, 의존성 배열 안에 isKorea를 넣어줌으로써, isKorea 스테이트가 변경되지 않으면 기존의 값을 그대로 유지하도록 메모아이즈 해두는 것이다.

따라서 ‘비행기 타자!’ 버튼이 눌려서 isKorea 스테이트 값이 변경될 때만 useEffect가 실행된다. (만약 useEffect 내부에서 실행되는 작업이 무거울수록 개이득이다.)

‘하루에 몇 끼 먹나요?’라는 질문에 number 스테이트 값이 변경되어도, location은 객체를 값으로 가짐에도 불구하고 이전 값이 메모아이즈 되어 있기 때문에 location이 새로운 객체를 할당받지 않게 되고, 즉 새로운 주소를 참조하지 않게 되기 때문에 useEffect는 location이 변화되지 않아 실행되지 않는다.


📌 별코딩 님의 React Hooks에 취한다 - useMemo 제대로 사용하기를 참고했습니다!

댓글남기기