Diary

[개발일기] react의 memo, useCallback, useMemo

브라더 코드 2023. 7. 7. 18:42

(2023.06.15) react 앱의 최적화 방법 3가지 정리해본다.

우선 '메모이제이션'이라는 걸 알아야 한다. 최적화 최적화하는데 기본적인 개념을 알고 들어가야지 뭉툭하게 '좋아진다' 정도로 알면 안된다. 메모이제이션은 저장해둔다는 뜻이다. 컴퓨터는 사람 대신 계산을 하는 장치인데 똑같은걸 계속 계산하면 비효율적이다. 똑같은걸 계산한다는 건 똑같은 결과를 만든다는 의미. 혹여 굉장히 오래 걸리는 일을 반복한다면 앱의 성능은 저하된다. 반복하지 않아도 되는 작업은 저장, 메모이제이션해두고 그걸 쓰는 것이다. 이로써 성능을 향상시키고 최적화를 만든다.

 

본격적으로 들어가기 앞서 리액트의 컴포넌트가 랜더링되는 경우 3가지.

1) state가 변경될때, 2) props가 변경될때, 그리고 마지막의 경우는 좀 길게 말한다.

3) 리액트는 특별한 설정값이 없을때 부모가 재랜더링되면 자식도 덩달아 재랜더링된다. 이것이 매우 매우 중요하다.

 

리액트에선 3가지를 메모이제이션함으로써 앱을 최적화한다. 

바로 컴포넌트, 함수, 변수이다.

 

1. 컴포넌트 메모이제이션

앞서 말한 컴포넌트가 랜더링 되는 경우 세번째를 막기 위해서 한다.

부모가 자신의 컴포넌트 안에서 state가 변경되어 재랜더링될때 아무 관련 없는 자식도 재랜더링되면 리소스 낭비다. 만약 자식이 랜더링될때 엄청 큰 작업이 소요된다면 그건 매우 비효율적인 작업인 것.

컴포넌트 메모이제이션은 memo를 쓴다.

import React, { memo } from "react";

// 함수 컴포넌트를 memo()로 감싸준다.
const Child = memo(() => {
  return <h1>Child</h1>;
});

export default Child;

위의 Child 컴포넌트가 App이라는 컴포넌트의 자식이라고 하자. memo로 처리하면 App이 아무리 재랜더링되어도 Child는 그대로 있게 된다. App에서 Child로 넘기는 Props가 변경되지 않는 한..!

 

2. 함수 메모이제이션

위의 예를 이어서 가보자. 이번엔 App에서 Child로 함수를 props로 넘겨보자.

const App = () => {
  const [num, setNum] = useState(0);
  
  const onClickReset = () => {
    setNum(0);
  }
  
  <Child onClickReset={onClickReset} />
}

App의 state를 바꾸는 함수를 props로 넘겼다. Child에서 onClickReset함수를 실행하면 App의 num이 0으로 바뀌면서 재랜더링된다. 근데 이 경우 Child도 재랜더링된다(Child에 콘솔을 찍으면 나옴). 

왜일까? 하나씩 가보자.

요즘은 리액트에서 대부분 함수형 컴포넌트를 쓴다. 그런데 함수형 컴포넌트도 '함수'이다. 그래서 랜더링이 되면 함수가 실행되는 것이고 그 안에 있는 함수도 전부 다시 실행된다(일반적으로 생각하자. 함수를 실행하면 안에 있는 로직들이 다 실행되지 않나).

즉, App컴포넌트는 재랜더링으로 실행되는데 안에 있는 onClickReset도 다시 실행되는 것이다. 그런데 가만 보자, 자바스크립트에선 객체를 똑같은 형태로 만들어도 이전꺼와 다르지 않은가(값이 담긴 메모리 주소를 참조하고 그게 다르기 때문). js에선 함수도 객체다. 일급객체.

Child컴포넌트 입장에선 이전과 다른 props를 받은 것, 그러니 재랜더링된다.

 

설명이 많았다. 이럴 경우엔 App컴포넌트에서 onClickReset함수를 메모이제이션해야 한다.

함수 메모이제이션은 useCallback을 쓴다.

// 함수를 useCallback()으로 감싸준다.
const onClickReset = useCallback(() => {
  setNum(0);
}, []);

// 중요한 개념
// useEffect처럼 useCallback의 두번째 인자에 의존성 배열을 넣어야 한다.
// 빈 배열을 넣으면 최초 실행될때 함수를 메모이제이션한다. 그리고 계속 그 함수를 쓴다.
// *** 위 예에서는 최초 실행한 () => { setNum(0) } 함수를 저장했다가 반환한다. ***
// 의존성 배열에 값이 있으면 그 값이 바뀔때 함수를 재실행한다.

 

3. 변수 메모이제이션

마지막이다. 어떤 함수형 컴포넌트에서 변수를 만들어 쓴다고 하자. 그런데 이 변수를 만들기 위해선 작업이 많이 필요하다. 예를 들어 서버를 갔다와야 하거나, 루프를 여러번 돌려야 한다. 그럼 이 변수를 저장해뒀다가 쓰는게 낫지 않은가.

변수 메모이제이션은 useMemo()를 쓴다.

// 값을 반환하는 함수를 useMemo()로 감싸준다.
const memoizedValue = useMemo(() => test(), []);

// useCallback()과 마찬가지로 두번째 인자에 의존성 배열을 넣어야 한다.
// *** 위 예에서는 최초 실행한 () => test() 함수의 결괏값을 저장했다가 반환한다. ***

 

최초 실행일때 혹은 의존성 배열의 값이 변할때,

useMemo는 첫번째 인자인 함수를 실행하고 useCallback은 함수를 반환한다. 

난 사실 이 개념이 너무 헷갈렸다. 정확히 말하면 useMemo는 몰랐다는것.. 그런데 이제야 정리가 됐다.

useMemo는 함수를 실행해서 나온 '값'을 저장해두는 것이고, useCallback은 '함수 자체'를 저장해두는 것이다. 더이상 방황하지 말자.

그래서 둘은 아래와 같은 형태일때 똑같다.

useMemo((...)=> fn, deps) === useCallback(fn, deps)

 

 

이렇게 3가지 메모이제이션이 정리되었다.

 

하지만 무턱대고 쓰는 건 금물. '메모이제이션하는 작업'도 컴퓨터의 계산임을 잊지말자. 굳이 안해도 되는 곳에 할 필요는 없다. 오히려 메모이제이션을 함으로써 성능이 낮아질수도 있는 법. 작은 규모의 앱, 단순한 연산이 들어가는 함수 등은 메모이제이션을 안해도 되겠다.

하지만 정확히 어떤 기준을 잡아야 하는지 모르겠다. 우선은 세가지 방법에 대해 개념을 정확히 이해하자. 근데 useCallback 같은 경우는 되도록 쓰는것이 좋은듯 하다(유명 개발자분이 말했음).