React.memo, useMemo, useCallback 사용하기

Breadman·2021년 10월 29일
0

렌더링 최적화 수단으로 많이 사용되는 3가지가 있다.
3가지에 대해서 알아보자.

React.memo

React는 이전에 rendering된 결과와 비교해서 DOM Update를 결정한다. 만약 이전과 다르면 DOM을 업데이트할 것이다.
이전 렌더링 결과와 현재 렌더링 결과를 비교하는 과정은 빠르게 진행된다. 하지만 이 과정을 좀 더 빠르게 동작하도록 만들 수 있다.
React.memo는 렌더링 결과를 memoizing한다. 그리고 다음 렌더링이 일어날 때, props가 같다면 메모이징된 결과를 재사용함으로서 렌더링 속도를 높여줄 수 있다.

🖐 준비

App
└─ Loop

App 컴포넌트의 하위 컴포넌트로 Loop가 존재한다.
Loop 컴포넌트는 렌더링이 될 때마다 0부터 10억까지 1씩 증가하는 연산을 시행한다.
시간 측정을 위해 Profiler API를 사용했다.
onLoopRender 함수에선 phase(마운트로 인한 렌더링인지, 업데이트로 인한 렌더링인지 식별.)와 actualDuration(Profiler와 하위 컴포넌트들이 렌더링하는데 걸린 시간.)을 console에 찍어준다.

// App.js
const App = () => {
  const [count, setCount] = useState(0);	// 렌더링 횟수
  return (
    <>
      {/* 버튼을 누르면 렌더링 횟수값을 증가시키면서, 리렌더링 발생. */}
      <button onClick={() => setCount(count+1)}>
        increase
      </button>
      <Profiler id="loop" onRender={onLoopRender}>
        <Loop title="re-rendering test" />
      </Profiler>
      {count}
    </>
  )
}

// Loop.js
const Loop = ({ title }) => {
  const LOOP_CNT = 1000000000; // 10억번 (1,000,000,000)
  let cnt = 0;
  for (let i = 0; i < LOOP_CNT; i++) {
    ++cnt;
  }
  return (
    <>
      {title}
    </>
  )
}

App 컴포넌트에서 Loop로 title이라는 props를 전달하고 있다. title은 변하지 않는 props고, Loop 컴포넌트 내부에는 state가 없으므로 스스로 리렌더링할 경우는 존재하지 않는다. 따라서 부모로 인해 리렌더링이 일어난다면 이는 불필요한 작업인 것이다.

🙁 before React.memo

phase - mount
actualDuration: 1026.6000000238419

phase - update
actualDuration: 947.1000000238419

phase - update
actualDuration: 658.5

phase - update
actualDuration: 654.5

phase - update
actualDuration: 655.9000000953674
...

리렌더링을 반복될 수록 actualDuration이 조금씩 줄어들었지만, 3-4번째 렌더링부터 600 ~ 700ms 언저리를 기록했다.

🙂 after React.memo

Loop 컴포넌트를 export하기 전에 React.memo로 래핑하는 코드를 추가했다.

phase - mount
actualDuration: 1453.7000000476837

phase - update
actualDuration: 0

phase - update
actualDuration: 0.19999992847442627

phase - update
actualDuration: 0.10000002384185791

phase - update
actualDuration: 0.10000002384185791

phase - update
actualDuration: 0
...

마운트되는 경우를 제외하면 거의 0에 수렴했다.
왜 '거의 0'인지 잘 모르겠으나, 추측으론 이전 props와 현재 props를 비교하는 과정이 대략 0.1ms정도 소요된 것 같다.

useMemo

useMemo는 리렌더링으로 인해 발생하는 10억회 반복문을 최적화할 수 있다.
첫 번째 인자로 함수를 넣고, 두 번째 인자로 dependencies를 넣어주면 된다. useMemo의 리턴값은 첫 번째 인자인 함수의 리턴값이다.

Loop.js에서 10억번 도는 반복문 부분을 useMemo로 감싸줬다.

// Loop.js
const Loop = ({ title }) => {
  const LOOP_CNT = 1000000000; // 10억번 (1,000,000,000)
  let cnt = 0;
  
  useMemo(() => {    
    for (let i = 0; i < LOOP_CNT; i++) {
      ++cnt;
    }
  }, []);
  return (
    <>
      {title}
    </>
  )
}
phase - mount
actualDuration: 2283

phase - update
actualDuration: 0.30000007152557373

phase - update
actualDuration: 0.10000002384185791

phase - update
actualDuration: 0.20000004768371582

phase - update
actualDuration: 0.2999999523162842

phase - update
actualDuration: 0.09999990463256836
...

완전한 0ms는 아니다. 연산만 막는 것이지, 리렌더링을 방지하는 것은 아니기 때문이다.
"React.memo랑 useMemo를 같이 쓰면 더 빨리되나?"라고 할 수 있는데, 그건 상황마다 다른 것 같다. 리렌더링이 필요한 시점엔 반드시 리렌더링이 발생해야하고, state나 props 등에 따라 연산을 다시 해야하는 경우도 있기 때문에 상황에 따라 적절한 판단이 필요하다.
지금 예시로 만든 Loop 컴포넌트의 경우, 둘을 같이 쓴다해도 더 최적화가 되진 않는다. 이미 React.memo로 인해 Loop 컴포넌트가 리렌더링되지 않으므로, Loop 컴포넌트가 다시 그려질 경우는 없기 때문이다.

useCallback

React.memo는 이전 props와 현재 props 간에 얕은(shallow) 비교를 한다. 때문에 props가 참조타입일 경우, 변수의 내용은 변하지 않았어도 재정의로 인해 주소값은 다를 수 있다. 따라서 의도치않은 리렌더링이 발생할 수 있다.

App 컴포넌트에서 Loop 컴포넌트에 props로 빈 객체{}를 전달하고 리렌더링을 유도했다.

// App.js
  <Loop title="re-rendering test" obj={{}} />

// 결과
phase - mount
actualDuration: 1323.3999999761581

phase - update
actualDuration: 994.2000000476837

phase - update
actualDuration: 657.1000000238419
...

얕은 비교를 통해 props가 변했다고 판단하고 리렌더링이 발생한 걸 확인할 수 있었다.

객체 뿐만 아니라 함수도 참조타입의 변수이며, 컴포넌트의 props로 전달될 수 있기 때문에 불필요하게 재정의되는 것을 막아줘야한다. useCallback은 첫 번째 인자로 들어오는 함수가 재정의되는 것을 막아주는 hook이다.

"useMemo의 첫 번째 인자인 함수가 함수를 리턴하면 굳이 useCallback 쓸 필요 없지 않나?" 할 수 있는데,

useMemo(() => fn, []) === useCallback(fn, [])true 이다. 그래서, 사용법도 useMemo랑 크게 다르지 않다.

useCallback으로 정의한 함수 내부에서 이전 state를 이용해 setState를 호출하는 경우가 있다. 때문에 dependencies에 state를 추가함으로서, 의도치 않은 함수 재정의가 발생할 수 있다.
그냥 dependencies에 state를 추가하지 않는 방법도 있지만, 함수가 인자로 들어가는 setState를 활용함으로서 이를 해결할 수도 있다.

(+ useCallback은 throttle이나 debounce를 구현하는데에 사용될 수 있다.
두 기능 모두 타이머를 사용하고, 이를 유지해야하기 때문이다.)

참고

profile
빵돌입니다. 빵 좋아합니다.

0개의 댓글