useMemo, useCallback

남이섬·2023년 7월 12일
0

useMemo

메모이제이션

메모이제이션(memoization)은 값비싼 함수 호출의 결과를 캐싱하고 동일한 입력이 다시 발생할 때 캐싱된 결과를 반환하는 프로그래밍 기술이다

이 기술은 동일한 입력으로 여러 번 호출되는 함수 또는 컴포넌트가 있을 때 React에서 유용할 수 있다

메모이제이션를 사용하면 동일한 결과를 불필요하게 다시 계산하지 않고, 캐시된 결과를 반환할 수 있다

따라서, useCallback, useMemo와 같은 메모이제이션 훅을 통해 성능을 향상시키고 코드의 복잡성을 줄일 수 있다

메모이제이션 예시 (피보나치 수열)

재귀를 이용해서 피보나치 수열을 구하는 방식

function fib(n) {
  if (n < 2) {
    return n
  }
  return fib(n - 1) + fib(n - 2)
}

console.log(fib(5))
// fib 함수 실행 횟수: 15

메모이제이션을 활용

const memo = [0, 1]

const fib = function (n) {
  // 이미 연산된 값이 있다면 return
  if (memo[n] || n <= 1) return memo[n]

  const result = fib(n - 1) + fib(n - 2)
  // 아니라면 해당 값 계산해서 memo에 저장
  memo[n] = result

  return result
}

console.log(fib(5))
// fib 함수 실행 횟수: 9

memo 배열에 이전에 연산된 값들을 넣어, 이미 연산된 함수의 경우 다시 호출하지 않고 해당 값을 반환해주는 메모이제이션을 적용

이전에는 같은 함수를 15번 호출했지만, 메모이제이션을 적용 후 9번만 실행

useMemo 예시

useMemo는 메모이제이션된 값을 반환하는 리액트 훅이다
앞서 말한 ‘메모이제이션 피보나치 함수’와 같이 직전에 연산된 값이 있다면, 다시 연산을 하지 않고, 해당 값을 반환한다

useMemo 주의할 점

위 예시를 통해 알 수 있듯, 연산을 최소화 하고 효율적인 코드를 위해서 useMemo가 유용하다는 것을 알 수 있다
하지만, useMemo를 사용하기 전 알아야 할 것이 있다

우선, 계산 결과가 메모되어 있으므로 계산에 대한 입력이 변경되지 않으면 업데이트되지 않는다

즉, useMemo는 계산에 대한 입력이 일정하게 유지되는 경우에만 유용하다

또한 useMemo는 메모된 결과에 액세스할 때마다 계속 연산을 수행해야 하기 때문에 잦은 변동이 있는 경우 오히려 성능이 악화될 수 있다

또한, useMemo는 값을 재활용하기 위해 따로 메모리를 사용하기 때문에 불필요한 값까지 메모이제이션 해버리면 오히려 메모리를 낭비할 수 있다

연산이 매우 복잡한 계산식이 아닌곳에도 useMemo를 남발한다면, 성능상의 이점 보다는 오히려 코드를 복잡하게 만들어 유지보수를 어렵게 할 위험도 있다

useMemo는 React에서 계산 비용이 많이 드는 작업을 최적화하기 위한 훌륭한 도구이지만, 적절하게 사용해야만 성능을 향상시키고 코드의 복잡성을 줄이는 데 도움이 될 수 있다
따라서 프로젝트에 사용하기 전에 useMemo의 제한 사항을 이해하는 것이 중요하다

UseCallback

메모이제이션된 함수는 콜백 함수의 의존성이 변경되었을 때에만 변경된다. 이는 불필요한 렌더링을 방지하기 위해 참조의 동일성을 보장하거나, 자식 컴포넌트에 의존적인 콜백 함수를 전달할 때 유용하다

사실 컴포넌트를 렌더링 할 때마다 함수를 새로 선언하는 것은 성능상 큰 영향을 끼치지 않는다. 따라서, 모든 함수마다 useCallback을 사용하는 것은 큰 의미가 없고, 오히려 유지 보수를 어렵게 하거나 성능을 해칠 수 있다. useCallback의 의미있는 사용법을 알기 위해서는 자바스크립트의 함수 동등성에 대해서 알아야 한다

함수 동등성

자바스크립트에서 함수는 객체로 취급이 되기때문에, 함수를 동일하게 만들어도 메모리 주소가 다르면 다른 함수로 간주한다

바로 메모리 주소에 의한 참조 비교가 일어나기 때문인데, 콘솔창에서 아래와 같이 동일한 코드의 함수를 작성하시고 === 연산자로 비교를 해보면 false가 반환된다

만약 특정 함수를 다른 함수의 인자로 넘기거나, 자식 컴포넌트의 props로 넘길 때 함수의 참조가 달라서 예상하지 못한 성능 문제가 생길 수 있다

이 경우, useCallback을 이용해 함수를 특정 조건이 변경되지 않는 이상 재생성하지 못하게 제한하여 함수 동등성을 보장할 수 있다
(만약 리액트가 함수가 동등하지 않다고 판단한다면 상황에 따라 성능이 악화되거나, 무한루프에 빠지는 등의 문제를 겪을 수 있다)

useCallback 예시

데이터를 가져오는 fetchData 함수를 만들고, useEffect에 의존성 배열로 fetchData를 추가

import React, { useState, useEffect } from 'react'

function Profile({ id }) {
  const [data, setData] = useState(null)

  const fetchData = () =>
    fetch(`https://test-api.com/data/${id}`)
      .then(response => response.json())
      .then(({ data }) => data)

  useEffect(() => {
    fetchData().then(data => setData(data))
  }, [fetchData])

  // ...
}

언뜻 보면 페이지가 마운트 되었을 때 데이터 가져오는 fetchData 함수를 호출해 데이터를 잘 가져오는 듯 보인다
하지만, 위에서 설명한듯이 함수의 동등성 문제 때문에 예상치 못한 무한루프에 빠지게 된다

fetchData는 함수이기 때문에 id 값에 관계없이 컴포넌트가 렌더링 될 때마다 새로운 참조값으로 변경이 된다

함수가 변경되었으므로, 매번 useEffect가 실행되어 다시 렌더링이 되고 무한루프에 빠지게 된다

useCallback을 사용해 함수의 동등성을 유지

import React, { useState, useEffect, useCallback } from 'react'

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = useCallback(
    () =>
      fetch(`https://your-api.com/users/${userId}`)
        .then((response) => response.json())
        .then(({ user }) => user),
    [userId]
  );

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser]);

  // ...
}

이렇게 useCallback 훅을 사용하면, 컴포넌트가 다시 렌더링 되더라도 fetchData 함수의 참조값을 동일하게 유지시킨다
따라서, useEffect에 의존성 배열 값에 있는 fetchData 함수는 id 값이 변경되지 않는 한, 재호출되지 않는다

useCallback 주의할 점

useCallback 훅으로 함수 재생성을 방지하고, 참조 동등성을 보장하여 성능을 향상시킬 순 있다
하지만 모든 함수마다 useCallback을 사용하는 것은 오히려 성능을 악화시키고 가독성을 해칠 수 있다

가끔 React 컴포넌트 내에서 선언하는 모든 함수에 useCallback를 사용하는 경우가 있다
일반적으로 소프트웨어의 성능 최적화에는 그에 상응하는 대가가 있는데, (예를 들어 코드가 복잡해지거나 메모리를 사용하거나, 유지보수가 어려워지는 등) 모든 함수에 useCallback을 사용하는 것은 오히려 성능을 악화시킬 수 있다

따라서, useCallback를 사용하기 전에 실질적으로 얻을 수 있는 성능 이점이 어느 정도인지 반드시 예상을 해보고 사용하는 것이 좋다고 한다

useMemo와 useCallback을 사용하지 말아야 할 경우

연산이 복잡하지 않은 함수에 useCallback을 사용하는 것은 메모리 낭비이므로, 간단한 일반 함수들에는 useCallback을 사용하지 않는게 좋다

특히, 단순히 함수 내부에서 setState나 dispatch 함수등을 호출하는 경우에는 useCallback을 사용하지 않는게 좋다

이미 리액트 자체에서 useState 와 useDispatch에 대한 성능 최적화가 보장되기 때문에, 렌더링이 새로 되어도 해당 함수는 재생성되지 않는다

useCallback, useMemo의 의존성 배열에 완전히 새로운 객체나 배열을 전달해서는 안된다
만약 useCallback 내부 함수나 useMemo 내부 값에서 사용하지 않는 props를 전달한다면 메모이제이션을 하는데 소용이 없다

의도적으로 매번 새로운 함수나 값을 계산해야 한다면 굳이 useCallback이나 useMemo를 사용할 필요가 없다

DOM에서 다른 컴포넌트를 렌더링하지 않는 컴포넌트 (html 태그만 렌더링하는 컴포넌트) 에서는 useMemo를 사용할 필요가 없다.

div, span, a, img와 같이 호스트 환경 (브라우저 / 모바일)에 속하는 컴포넌트에 전달하는 항목에는 useMemo와 useCallback을 사용할 필요가 없다

리액트는 해당 컴포넌트들에 함수 참조가 변경되었는지 신경쓰지 않기 때문이다 (ref는 제외)

useMemo와 useCallback을 사용해야 하는 경우

  • 연산 혹은 처리량이 매우 많아서 렌더링의 문제가 되는 경우, 리렌더시 비용 절감을 위해서 useMemo를 사용

  • 자식 컴포넌트에서 useEffect가 반복적으로 트리거 되거나, 무한 루프에 빠질 위험이 있을 때 useMemo, useCallback을 사용

  • 자식 컴포넌트에 함수를 props로 넘길 때, 불필요한 렌더링이 일어난다고 판단된다면 useCallback으로 함수 동등성을 유지

  • 함수 자체가 매우 복잡하거나, 다시 계산하는데 비용이 많이 드는 경우에 useCallback을 사용

  • 사용자의 입력값이 map 혹은 filter 등을 사용하여 이후 렌더링에서도 동일한 참조를 사용할 가능성이 높을 경우 useMemo를 사용해서 메모이제이션을 적용

  • 리액트 상위 트리에서, 부모가 리렌더링 될 때 자식 컴포넌트까지의 렌더링 전파를 막고 싶을 때 useMemo를 사용
    (자식 컴포넌트가 useMemo로 메모이제이션 컴포넌트일 경우, 메모이제이션된 props를 사용해 필요한 부분만 리렌더링 할 수 있다)

  • ref 함수를 부수작용(side effect)와 함께 전달하거나, ref로 wrapper 함수를 만들 때 useMemo를 사용
    (리액트는 ref 함수가 변경될 때 마다 과거 값을 null로 호출하고 새로운 함수를 호출하기 때문인데, 이 경우 ref 함수의 이벤트 리스터가 변경되는 등의 불필요한 작업이 일어날 수 있다)

profile
즐겁게 살자

0개의 댓글