컴포넌트와 함수의 무거운 연산을 기억해 두는 메모이제이션
2024-04-05

모던 리액트 Deep Dive를 읽으며 정리한 내용입니다.

리액트에서 제공하는 최적화 기법

useMemo

리랜더 사이의 결과 계산을 캐시할 수 있는 React Hook입니다. 첫번째 랜더 이후에 값을 저장하고 재사용하기 때문에 첫번째 랜더링을 더 빠르게 만들지는 않습니다. 업데이트 시 불필요한 작업을 건너뛰는데에만 도움이 됩니다.

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

매개변수로 계산을 위한 함수와 의존성 배열이 전달됩니다.

첫번째로 전달되는 매개변수는 캐시할 값을 계산하는 함수입니다. 이 함수는 순수함수여야 하고, 인자를 받지 않고 반드시 어떤 값을 반환해야합니다. React의 초기 랜더링에서 이 함수가 실행되며 이후에는 의존성 배열을 확인하여 이전 랜더링 이후에 변경되지 않았다면 동일한 값을 반환합니다. 만약 의존성이 변경되었다면 함수를 다시 실행하고 이후에 재사용할 수 있도록 저장합니다.

두번째로 전달되는 값은 첫번째 전달된 함수 내에서 참조되는 모든 반응형 값들의 목록입니다. 반응형 값에는 props, state 및 컴포넌트 내에서 직접 선언된 모든 변수, 함수가 포함됩니다. React는 Object.is 비교 알고리즘을 사용하여 각 의존성을 이전 값과 비교합니다.

  • Object.is 두 값이 같은 값인지 결정하는 정적 메서드입니다. 부호있는 0과 NaN 값에 대한 처리가 ===와 다릅니다. -0+0, NaNNaN을 같게 처리하지 않는다는 점에서 ===, ==과 다릅니다. Object.is와 === 비교 연산자의 비교

useCallback

리랜더 사이에 함수 정의를 캐시합니다.

const handleSubmit = useCallback((orderDetails) => {
  post('/product/' + productId + '/buy', {
    referrer,
    orderDetails,
  });
}, [productId, referrer]);

첫번째 매개변수는 캐시하려는 함수값 입니다. 어떤 인자던지 받을 수 있고 반환할 수 있습니다. 초기 랜더링을 하는 동안 함수를 반환합니다.(호출X) 다음 랜더링에서는 마지막 랜더 이후 의존성이 변경되지 않았다면 동일한 함수를 다시 제공합니다.

두번째 매개변수는 의존성 배열입니다. 이 부분은 useMemo와 동일합니다.

  • useCallback이 필요한 이유 리랜더가 일어날 때 함수도 다시 생성됩니다. 이때 함수를 prop으로 전달받는 컴포넌트는 동일한 함수를 전달받음에도 불구하고 리랜더가 발생할 수 있습니다. 이 때 useCallback을 사용하면 리랜더 사이에 함수를 재사용하여 컴포넌트에 불필요한 리랜더가 발생하는 것을 막을 수 있습니다.

위 두 훅 모두 사용했을 때 성능 최적화가 이루어지는 경우에 사용합니다. 그렇지 않은 경우에는 state나 ref가 더 적합할 수 있습니다.

memo

컴포넌트를 감싸 메모화된 버전의 컴포넌트를 얻을 수 있습니다. 메모화된 컴포넌트는 props가 변경되지 않는 한 부모 컴포넌트가 리랜더링할 때 같이 리렌더링하지 않습니다. React는 이전 prop과 새 prop을 얕은 비교로 동등성을 파악합니다. 때문에 prop에 객체나 배열이 있을 경우 내용이 동일하더라도 새 객체, 함수를 만들어 전달하면 변경된 것으로 간주하여 리랜더가 발생합니다.(함수도 마찬가지) 이를 방지하려면 부모 컴포넌트에서 prop을 단순화하거나 메모화하는 것이 좋습니다.

그러나 메모이제이션은 성능을 최적화하지만 보장된 것은 아니어서, 여전히 리렌더가 발생할 수도 있습니다.

비용이 많이 드는지 알 수 있는 방법

일반적으로 수천 개의 객체를 만들거나 반복하는 경우가 아니라면 비용이 많이 들지 않을 것이긴 하지만 좀 더 확실하게 눈으로 확인하고 싶다면 console.time, console.timeEnd를 활용할 수 있습니다.

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

이 때 기록된 시간이 길다면 메모를 하는 것이 좋습니다.

개발자의 컴퓨터가 사용자의 컴퓨터보다 빠를 수 있기 때문에 테스트를 할 때는 컴퓨터의 속도를 인위적으로 낮춰서 테스틑하는 것이 좋습니다.(예, Chrome의 CPU 쓰로틀링)

또, 개발 중에 성능을 측정하는 것으로 정확한 결과를 확인할 수 없다는 점을 유의해야합니다. (예. Strict Mode로 인한 두 번 랜더링) 가장 정확한 방법은 상용 앱을 빌드하고 사용자가 사용하는 것과 동일한 기기를 사용하는 것입니다.

최적화를 사용하는 경우 예시

  • 계산이 눈에 띄게 느리고 의존성이 거의 변하지 않는 경우
  • prop이 바뀌지 않는 한 리랜더링을 건너뛰고 싶은 경우
  • useMemo의 결과 값이 다른 useMemo나 useEffect에 의존성이 있는 경우

모든 곳에서 최적화? 필요한 곳에만 최적화?

  • 메모이제이션에 드는 비용
    1. 값을 비교하고 랜더링 또는 재계산이 필요한지 확인하는 작업
    2. 이전에 결과물을 저장해 두었다가 다시 꺼내는 작업

리액트에서 최적화에 대한 선택권을 개발자에게 넘겼다는 것에서 이 방법이 은탄환이 아니라는걸 방증합니다. 하지만 잘못된 메모이제이션으로 인해 지출해야할 비용이 그렇게 크지 않기 때문에 모든 곳에서 사용해도 괜찮다는 의견이 있습니다.

memo의 경우 props를 비교하여 컴포넌트의 결과물을 메모하는데, 리액트의 재조정 알고리즘 특성상 이전 랜더링의 결과를 저장하고 있기 떄문에 memo를 사용하므로써 지불해야할 비용은 props의 얕은 복사밖에 없습니다. 물론 props의 구조가 복잡하다면 이 비용 또한 커질 수 있지만 메모하지 않음으로인해 발생할 수 있는 잠재적인 비용들이 더 클 수도 있습니다.

참고

  • https://react-ko.dev/reference/react/useMemo
  • https://react-ko.dev/reference/react/useCallback