TIL:38, React: useMemo / useCallback 이 mojo?

sunghoonKim·2021년 1월 24일
1

useMemo()

아래와 같은 어플리케이션을 가정해보자.

import { useState } from 'react';
import styled from 'styled-components';

function App() {
  const [number, setNumber] = useState('');
  const [numberList, setNumberList] = useState([]);

  const handleChange = (e) => {
    setNumber(e.target.value);
  };

  const addToList = () => {
    setNumber('');
    setNumberList([...numberList, Number(number)]);
  };

  const getSum = (list) => {
    console.log('합계 계산중...');
    return list.reduce((acc, curr) => acc + curr, 0);
  };

  return (
    <AppContainer>
      <input type="number" onChange={handleChange} value={number} />
      <button onClick={addToList}>리스트에 추가</button>
      <ul>
        <span>숫자 리스트</span>
        {numberList.map((el, index) => {
          return <li key={index}>{el}</li>;
        })}
      </ul>
      <div>{`합계는 : ${getSum(numberList)}`}</div>
    </AppContainer>
  );
}

export default App;

간단한 페이지인데, 숫자를 입력 하는 인풋 창이 하나 있고, 입력 후 버튼을 누를 때 마다 리스트에 입력된 숫자가 추가된다. 숫자가 추가되면, 리스트 안에 있는 숫자의 합이 계산되어 보여진다.

인풋은 onChange 이벤트를 감지하고, 해당 이벤트가 일어날때 마다, setNumber 를 실행한다. setNumber 에 의해서 재렌더링이 발생하고, 그때마다 getSum 함수가 실행되어 합계가 계산되는 것.

그런데, 버튼을 클릭하여 인풋에 입력된 숫자를 리스트에 포함시키지 전까지는 getSum의 결과 값에 변동은 없다. 즉, 페이지의 구조상, 변동이 없는 똑같은 실행을 반복하는 것이다.

우리의 예제에서는 간단한 함수였기 때문에 별 문제가 없지만, 만약 실행 코스트가 비싼 함수의 경우, 매우 많은 컴퓨터 자원이 낭비될 것이다.

따라서, list 가 변화할때만 합계 함수를 실행하도록 설계를 해주는 것으로 최적화를 해줄 필요가 있다.

여러가지 방법이 있을 것이다. 한 가지 예로, useEffect 를 통하는 방법이 있다.

import { useState, useEffect } from 'react';
import styled from 'styled-components';

function App() {
  const [number, setNumber] = useState('');
  const [numberList, setNumberList] = useState([]);
  const [sum, setSum] = useState(0);

  const handleChange = (e) => {
    setNumber(e.target.value);
  };

  const addToList = async () => {
    setNumber('');
    setNumberList([...numberList, Number(number)]);
  };

  useEffect(() => {
    setSum(getSum(numberList));
  }, [numberList]);

  const getSum = (list) => {
    console.log('합계 계산중...');
    return list.reduce((acc, curr) => acc + curr, 0);
  };

  return (
    <AppContainer>
      <input type="number" onChange={handleChange} value={number} />
      <button onClick={addToList}>리스트에 추가</button>
      <ul>
        <span>숫자 리스트</span>
        {numberList.map((el, index) => {
          return <li key={index}>{el}</li>;
        })}
      </ul>
      <div>{`합계는 : ${sum}`}</div>
    </AppContainer>
  );
}

export default App;

useEffect 의 안에서 합계를 구하는 함수를 실행시키고, 의존성 배열에 [list] 를 포함시켜 주었다. 덕분에, 함수의 실행이 list 에 변화가 생길때만 일어난다.

하지만 이렇게 하면, 합계가 담길 state 를 추가로 생성을 해주어야 한다는 조건이 달린다.

만약, 새로 무엇가를 추가하지 않고 기존의 것들로만 위의 최적화를 구현하기 위해선, useMemo 훅을 사용하면 된다.

여기서 MemoMemoization을 말한다.

In computing, memoization or memoisation is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again. 

Memoization 이란, 코스트가 비싼 함수에 대해서 결과 값을 미리 저장해두고 (cached), 만약 동일한 인풋이 들어온다면, 함수를 재실행 하는 것 대신에 저장해 둔 값을 반환해 주는 것으로 속도를 향상시키는 최적화 기법을 말한다.

우리 예제의 경우, 우리가 버튼을 눌러 리스트에 숫자를 넣어주기 전까지는 평균 계산에 들어가는 인풋이 동일할 것이고, 따라서, 해당 결과 값을 저장해 둔 뒤, 이후에는(만약 인풋이 동일하다면) 저장해 둔 값을 그냥 반환해 주는 것이 더 효율적일 것이다.

useMemo 의 사용법은 아래와 같이 사용된다.

const memoizedValue = useMemo(()=> {},[]);

첫번째 인자로 memoization을 적용할 함수를 받고, 두번째 인자로 의존성 배열이 들어온다.

예를 들어,

const getSum = (list) => {
    console.log(‘합계 계산중...');
    return list.reduce((acc, curr) => acc + curr, 0);
}
   
const memoizedValue = useMemo(() => getSum(numberList), [numberList]);

위와 같이 useMemo 에 합계를 구하는 함수를 첫번째 인자로, 그리고 의존성 배열에 getSum 함수에 인자로 들어가는 numberList 를 포함 시켜주면 numberList 가 변했을 시에만, getSum 함수를 실행 하여 값을 구하고, numberList 가 변하지 않았다면, 함수를 재실행 시키지 않고 미리 저장해둔 값을 반환한다.


useCallback()

(아래의 내용은 이 블로그 페이지를 번역한 내용입니다.)

Function Equality Check 이해하기

먼저 함수의 경우 equality check 가 어떻게 이루어지는지 이해하여야 한다.

function factory() {
  return (a, b) => a + b;
}

const sum1 = factory();
const sum2 = factory();

sum1(1, 2); // => 3
sum2(1, 2); // => 3

sum1 === sum2; // => false
sum1 === sum1; // => true

sum1sum2 의 경우, 내용은 완전히 동일하지만, 서로에 대해 equality check를 해보면 false 가 출력된다.

왜냐하면, js 에서는 함수는 객체이기도 하기 때문에, 생성될 시에 다른 메모리 주소를 가지게 된다. 그렇기 때문에, 두 함수가 같은 내용을 가진다 하더라도 독립적으로 생성되었다면 equality check 에서는 false 이다.

useCallback() 을 사용하는 목적

리액트에서는 코드의 내용은 같지만 동일하지는 않은 함수들을 종종 볼수 있다.

예로,

import React from 'react';

function MyComponent() {
  // handleClick is re-created on each render
  const handleClick = () => {
    console.log('Clicked!');
  };
  // ...
}

hancleClick 함수는 매 렌더링 마다 새로 생성되게 된다. 즉, 기능면에서는 동일하겠지만, 각각은 다른 함수이다! (이런식으로 컴포넌트 안에서 함수를 새로 생성하는 것은 비용이 크지 않기 때문에, 렌더링 마다 함수를 계속해서 생성하는 것은 성능 면에서 별 문제가 되지 않는다.)

하지만, 함수가 동일해야 하는 경우가 있는데,

  1. 함수가 다른 훅의 의존성 배열에 포함된 경우
  2. (1번과 비슷한 개념인데) React.memo 에 의해 감싸진 컴포넌트에 함수가 프롭스로 넘겨진 경우.

가 있다. 즉, 함수가 비교의 대상이 되는 경우들이다.

예로, useEffect 의 의존성 배열에 함수가 들어가 있을 경우, 해당 함수를 동일하게 유지시켜 주지 않으면, 렌더링 마다 함수는 새롭게 생성되기 때문에 함수가 계속 변화하는 것으로 받아드려 useEffect가 항상 발생하게 된다. 즉, 의존성 배열에 함수를 넣어준 의미가 사라지게 된다.

이때, useCallback 훅을 사용하면 된다.

useCallback 훅은 렌더링 마다 새롭게 함수를 생성하지 않고, 기존에 생성되었던 함수를 재활용하여 사용하게 해준다. 덕분에 함수는 동일하게 유지가 되고, 의존성 배열에 포함시켜주는 것이 비로소 의미를 가지게 된다.

예로,

import React, { useCallback } from 'react';

function MyComponent() {
  // handleClick is the same function object
  const handleClick = useCallback(() => {
    console.log('Clicked!');
  }, []);
  // ...
}

위의 경우, MyComponent 가 렌더링 될때, handleClick 은 항상 동일한 함수 객체 이다.

예제

긴 아이템 리스트를 렌더링 하는 컴포넌트를 가정해보자.

import React from 'react';
import useSearch from './fetch-items';

function MyBigList({ term, onItemClick }) {
  const items = useSearch(term);

  const map = item => <div onClick={onItemClick}>{item}</div>;

  return <div>{items.map(map)}</div>;
}

export default React.memo(MyBigList);

리스트에 담긴 아이템이 많기 때문에, 불필요하게 재렌더링을 하는 것은 코스트가 클 것이다. 그것을 방지하기 위해서 MyBigListReact.memo 로 감싸주었다.

React.memouseMemo / useCallback 과 비슷한 최적화를 위한 개념이다. props 로 넘겨지는 값들을 지켜보다, 만약 props 값에 변동이 없으면 재렌더링을 하지 않도록 해준다. props 값에 변동이 생길 시에만 재렌더링을 한다.

MyBigList 의 부모 컴포넌트는 아래와 같다.

import React, { useCallback } from 'react';

export default function MyParent({ term }) {
  const onItemClick = useCallback(event => {
    console.log('You clicked ', event.currentTarget);
  }, [term]);

  return (
    <MyBigList
      term={term}
      onItemClick={onItemClick}
    />
  );
}

부모에서는 MyBigListtermonItemClick 프롭스를 넘겨주는데, onItemClick 은 함수이다.

여기서, onItemClickuseCallback 통해서 동일한 함수 객체로 유지된다. 그렇기 때문에, term 이 변화하여, onItemClick 이 새로 생성되지 않는 한, 불필요한 재렌더링은 일어나지 않는다.

만약, onItemClickuseCallback 을 통하여 기억되지 않으면, MyParent 가 렌더링 될때 마다 마다 onItemClick 이 새로 생성될 것이고, 따라서 MyBigList 는 매번 재렌더된다. 결국, React.memoMyBigList 를 감싸준 의미가 없어질 것이다. 최적화를 위해 코드가 추가되었으므로, 으히려 React.memo 를 감싸주지 않은 것만 못하게 된다.


4개의 댓글

comment-user-thumbnail
2021년 1월 25일

잘 보고 갑니당!! 너무 잘 이해하게 정리해 주셨네욤ㅎㅎㅎㅎㅎ

답글 달기
comment-user-thumbnail
2021년 2월 1일

잘보고갑니다. ㅎㅎ

답글 달기
comment-user-thumbnail
2021년 2월 14일

잘 읽었습니다 :)

답글 달기
comment-user-thumbnail
2021년 2월 18일

토니... 잘 보고 갑니다 당신은 나의 세이비어...

답글 달기