본문 바로가기
웹/React

[React] useMemo 사용하기

by 천무지 2024. 8. 10.
반응형

 

 

오늘은 useMemo 라는 리액트 훅을 사용해보자.

 

useMemo 리렌더링 사이에서 계산 결과를 저장할 수 있다.

 

useMemo는 컴포넌트 최상단에서 호출한다.


1. useMemo의 개념을 간단하게 알아보자.

const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]
  );

 

위 코드는 아주 간단한 사용예다.

 

매개변수로는 2개의 값을 가진다.

 

첫번째로는 calculateValue로 저장하려는 값을 계산하는 함수다. 이 함수는 조건이 존재한다.

1. 순수함수

2. 인자를 받지 않음

3. 어떤 타입이든 값을 반환해야 함

 

리액트는 맨 처음 랜더링 중에 이 함수를 호출한다. 그리고 나서 이후의 렌더링에서는 두번째 인자로 들어가는 의존성 배열이 변경되지 않았다면 동일한 값을 반환한다.

 

만약 의존성 배열이 변경되었다면 calculateValue를 호출하고 그 결과를 반환한다. 그리고 나서 이 값을 나중에 재사용하기 위해서 저정한다.

 

두번째로 들어가는 값은 위에서 언급한 의존성 배열이다. calculateValue 코드 내에서 참조되는 모든 값들의 목록이다.

 

이 값에서는 props, state 등 컴포넌트 내에서 직접 선언된 모든 변수와 함수가 들어갈 수 있다. 

 

useMemo는 반환값을 가지는데 맨 처음 렌더링에서는 calculateValue를 호출한 결과를 반환하며, 이후의 렌더링에서는 의존성 배열이 변경되지 않으면 가장 최근의 저장된 값을 반환하고, 의존성 배열이 변경되었다면 calculateValue를 다시 호출하고 그 결과를 반환한다.


2. useMemo를 사용해보자.

import { useMemo } from 'react';

function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...
}

 

 useMemo는 2가지를 전달받아야한다.

 

첫번째로는 인자를 받지 않고 원하는 값을 계산하여 반환하는 순수함수.

 

두번째로는 컴포넌트 내부에서 계산에 사용되는 모든 값을 포함하는 의존성 배열.

 

맨 처음 렌더링 시에는 useMemo를 통해서 얻는 값은 calculateValue를 호출한 결과값이다.

 

이후의 랜더링부터는 리액트는 이전 렌더링에서 전달받은 의존성 배열과 현재의 의존성 배열을 비교한다. 

 

의존성 배열이 변경되지 않았다면 이전에 계산하여 저장해두었던 값을 반환한다. 

 

의존성 배열이 변경되었다면 리액트는 다시 calculateValue 함수를 호출하고 새로운 값을 반환하여 저장한다.

 

 

 

리액트는 기본적으로 자신이 가진 state가 변경되면 리렌더링이 발생한다. 그리고 리렌더링이 발생할 때마다 컴포넌트 전체 코드를 재실행한다. 

 

만약 위에서 작성한 코드에서 Todolist 함수가 state를 변경하거나 부모로부터 변경된 props를 받는 경우 filterTodos가 다시 실행될 것이다.

 

웬만해서는 계산이 빨라서 크게 문제가 되지 않지만 큰 배열을 필터링하거나 한 번 계산하는 것 자체가 cost가 큰 계산을 할때는 굳이 계산을 해서 cost를 높일 이유가 없다.

 

그래서 todos와 tab이 이전 렌더링과 동일하다면 이전에 이미 계산해놓은 값을 재사용할 수 있다.

 

이러한 저장과정을 memoization(메모이제이션)이라고 한다.

 

 

그렇다면 시간이 오래 걸리는 계산인지는 어떻게 알 수 있을까?

console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');

 

콘솔로그를 통해서 기록된 시간을 알 수 있다.

 

만약에 기록된 시간이 1ms이라면 해당 계산을 메모해두는 것이 좋다.

 

 

 

또한 머릿속에 들 수 있는 생각으로는 "그냥 모든 것들을 useMemo 해버리면 편하지 않아?" 가 있을 수 있다.

 

그렇게 하는 것은 좋지 않다.

 

useMemo를 사용하여 값을 저장하는 것은 결국 메모리에 값을 저장하는 것이다.

 

따라서 모든 값을 useMemo를 통해 메모리에 저장하게 되면 프로그램이 무겁고 느려질 수 있다.

 

 

 

예제를 살펴보자.

// App.jsx
import { useState } from 'react';
import { createTodos } from './utils.js';
import TodoList from './TodoList.js';

const todos = createTodos();

export default function App() {
  const [tab, setTab] = useState('all');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <button onClick={() => setTab('all')}>
        All
      </button>
      <button onClick={() => setTab('active')}>
        Active
      </button>
      <button onClick={() => setTab('completed')}>
        Completed
      </button>
      <br />
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Dark mode
      </label>
      <hr />
      <TodoList
        todos={todos}
        tab={tab}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}
// TodoList.jsx
import { useMemo } from 'react';
import { filterTodos } from './utils.js'

export default function TodoList({ todos, theme, tab }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]
  );
  return (
    <div className={theme}>
      <p><b>Note: <code>filterTodos</code> is artificially slowed down!</b></p>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ?
              <s>{todo.text}</s> :
              todo.text
            }
          </li>
        ))}
      </ul>
    </div>
  );
}
// utils.jsx
export function createTodos() {
  const todos = [];
  for (let i = 0; i < 50; i++) {
    todos.push({
      id: i,
      text: "Todo " + (i + 1),
      completed: Math.random() > 0.5
    });
  }
  return todos;
}

export function filterTodos(todos, tab) {
  console.log('[ARTIFICIALLY SLOW] Filtering ' + todos.length + ' todos for "' + tab + '" tab.');
  let startTime = performance.now();
  while (performance.now() - startTime < 500) {
    // Do nothing for 500 ms to emulate extremely slow code
  }

  return todos.filter(todo => {
    if (tab === 'all') {
      return true;
    } else if (tab === 'active') {
      return !todo.completed;
    } else if (tab === 'completed') {
      return todo.completed;
    }
  });
}

 

여기서 filterTodos 는 인위적으로 매우 느리게 실행되도록 하였다.

 

그리고 visibleTodos 에서 filterTodos를 메모하였고, 의존성 배열로 todos와 tab를 넣었고 이 값이 변경되지 않으면 filterTodos가 실행되지 않도록 하였다.

 

따라서 All, Active, Completed 버튼을 눌러서 todos와 tab을 변경시키지 않는 한, filterTodos는 다시 호출되지 않을 것이다.

 

한번 Dark mode check box를 눌러보면 isDark state가 토글되어 App 컴포넌트의 리렌더링이 발생했음에도 매우 빠르게 다크모드만 적용이 되는 것을 알 수 있다. 이는 filterTodos 가 실행되지 않았음을 시사한다.

 

실제로 이 상태에서 visibleTodos에서 작성한 useMemo를 지우고 const visibleTodos = filterTodos(todos, tab) 으로 작성한다면 Dark mode checkbox를 눌러 App 컴포넌트가 리렌더링 될 때 filterTodos도 다시 호출되어 전환이 느리게 되 것을 확인할 수 있다.

 


3. 컴포넌트의 리렌더링을 건너뛰자.

이전의 예제에서는 변수의 리렌더링을 건너뛰어봤다.

 

이번에는 컴포넌트의 리렌더링을 건너뛰어보자.

 

예시를 들기 위해서 다음과 같은 코드가 있다고 가정하자.

 

export default function TodoList({ todos, tab, theme }) {
  // ...
  const visibleTodos = filterTodos(todos, tab);
  
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

 

여기서 눈 여겨 볼 사항은 TodoList 컴포넌트가 List를 자식 컴포넌트로 가지고 있고, props로는 visibleTodos를 전달한다는 점이다.

 

컴포넌트가 리렌더링되면 리액트는 해당 컴포넌트의 모든 자식 컴포넌트를 재귀적으로 리렌더링 한다.

 

따라서 TodoList가 받는  props가 변하면 TodoList 컴포넌트는 리렌더링되고 이에 따라 자식 컴포넌트인 List 컴포넌트도 리렌더링 될 것이다. 

 

여기서 List 컴포넌트가 리렌더링 되는 것이 매우 느리다고 가정하자.

 

그렇다면 이전과 동일한 props를 가질때 리렌더링을 건너뛸 수 있도록 memo를 통해 List 컴포넌트를 감싸줄 수 있다.

 

import { memo } from 'react';

const List = memo(function List({ items }) {
  // ...
});

 


잠시 memo에 대해서 알고 넘어가자.

 

memo를 사용하면 컴포넌트의 props가 변경되지 않았을 때 리렌더링을 건너 뛸 수 있다.

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)

 

매개변수는 2개를 받는다.

 

첫번째로는 메모화 하려는 컴포넌트이다. 

 

두번째로는 선택적인 함수이며, 인자로는 컴포넌트의 이전 prop과 새로운 prop을 인자로 가진다.

 

새로운 prop이 이전 prop과 같으면 true를 반환하고 같지 않다면 false를 반환한다.

 

따라서 반환값이 true 라면 리렌더링이 발생하지 않고 false라면 리렌더링이 발생할 것이다.

 

memo의 반환값으로는 새로운 리액트 컴포넌트를 반환한다. 

 

memo한 컴포넌트와 똑같이 동작하지만, 부모가 리렌더링 되도라도 자신이 가지는 prop이 변경되지 않는 다면 리액트는 memo한 컴포넌트를 리렌더링 하지 않는다. 


각설하여, 

import { memo } from 'react';

const List = memo(function List({ items }) {
  // ...
});

 

위와 같이 작성한다면 List컴포넌트는 자신이 받는 props가 이전 렌러딩과 같다면 리렌더링 하지 않을 것이다.

 

이제 다시 처음의 코드로 돌아가보자.

export default function TodoList({ todos, tab, theme }) {
  // ...
  const visibleTodos = filterTodos(todos, tab);
  
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

 

List 컴포넌트를 memo하였기 때문에 TodoList 컴포넌트가 리렌더링 되더라도 List 컴포넌트는 리렌더링 되지 않을까?

 

아니다.

 

현재 List 컴포넌트는 visibleTodos라는 값을 prop으로 가진다. visibleTodos는 filterTodos 함수가 호출된 후 반환된 값을 가진다.

 

여기서 filterTodos 함수는 TodoList가 리렌더링 될 때마다 새롭게 생성되고 호출된다. 

 

즉, filterTodos 함수는 항상 이전과 다른 값을 생성한다. 

('2. useMemo를 사용해보자' 에서 filterTodos 함수를 작성했었습니다. filterTodos 함수는 배열을 반환합니다)

 

객체 리터럴이 항상 새로운 객체를 생성하는 것과 비슷하다. 

 

우리는 지금 List 컴포넌트가 리렌더링이 오래 걸린다고 가정했기 때문에 자신이 받은 prop이 변경되지 않으면 리렌더링 되지 않도록 하는 것을 목표로 하고 있다.

 

하지만 매번 visibleTodos의 값이 새로워지기 때문에 List 컴포넌트는 매번 TodoList 컴포넌트가 리렌더링 될 때 함께 리렌더링된다.

 

최적화가 이루어지지 않았다.

 

여기서 useMemo를 사용한다.

 

useMemo를 통해 filterTodos의 의존성 배열의 값이 이전과 동일하다면 filterTodos를 호출하지 않고 이전의 값을 그대로 사용하도록 한다.

export default function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] 
  );
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

 

위와 같이 작성한다면 맨 처음에 TodoList와 List 컴포넌트가 렌더링 되고 이후에 TodoList가 받는 props 중 theme이 변경되었다면, filterTodos의 의존성 배열에 있는 값인 todos와 tab이 변경되지 않았으므로 visibleTodos는 이전과 동일한 값을 가진다.

 

따라서 List 컴포넌트의 prop은 이전과 동일하게되어 List 컴포넌트는 리렌더링이 발생하지 않는다.


4. 다른 객체에 의존한 메모화

컴포넌트에서 직섭 생성한 객체에 useMemo가 의존한다고 가정해보자.

function Dropdown({ allItems, text }) {
  const searchOptions = { matchMode: 'whole-word', text };

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]);
  // ...

 

그리고 useMemo의 의존성 배열에 컴포넌트에서 직접 생성한 searchOptions를 넣는다면 useMemo는 쓸모가 없어질 것이다.


Dropdown 컴포넌트가 리렌더링 될때마다 searchOptions 객체도 새롭게 생성되기 때문에 결국 Dropdown 컴포넌트가 리렌더링되면 visibleItems도 리렌더링될 것이다.

 

이를 막기 위해서는  searchOptions 객체를 의존성 배열에 전달하기 전에 객체 자체를 메모화하면 될 것이다.

function Dropdown({ allItems, text }) {
  const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]);

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]);

 

이제 text가 변경되지 않는다면 searchOptions 객체도 새롭게 생성되지 않을 것이다. 

 

이를 조금 더 개선시키자.

 

searchOption의 선언을 useMemo 함수 내부로 이동시키는 것이다.

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]);

 

반응형