React 성능 최적화를 위한 useMemo 와 useCallback

2022. 3. 9. 22:34React

728x90

이번 포스트에서는 리액트 메모이제이션 훅으로 사용되고 있는 useMemo와 useCallback에 대해 알아보자.

 

위키피디아에 따르면 메모이제이션(memoization)이란

 

동일한 계산을 반복할 때, 이전에 계산한 값을 메모리에 저장해서 동일한 계산의 반복을 제거하면서 프로그램 실행 속도를 높이는 기술이다

 

 

리액트에서 메모이제이션이 필요한 이유

이 포스트를 읽고 있을 독자들은 어떨지 모르겠지만 필자는 리액트를 처음 접했을 때, 관련 컴포넌트가 아닌데도 불구하고 부모 컴포넌트가 변경되었다는 이유로 해당 컴포넌트 전체가 재정의/실행되어야 한다는 점이 좀 마음에 걸렸다.

 

이때 메모이제이션을 이용하면 이전 상태에서 변화가 없다면 메모리에 저장해두었던 값/함수를 사용하기 때문에 컴포넌트 전체를 다시 계산해서 그릴 필요가 없게 된다.

 

작은 규모의 프로젝트에서 메모이제이션 기법을 사용하지 않았다고 해서 앱이 현저히 느려지거나 또 사용했다고 해서 눈에 띄게 빨라지는 걸 경험 하진 못할 것이다. 하지만 프로젝트의 규모가 커지거나 앱 특성상 컴포넌트 리 랜더링 횟수가 높다면 앞서 말한 상황이 마음에 걸리는 걸로 그치진 않기 때문에 그때를 대비해서라도 알고 있으면 좋을 거라고 생각한다.

 

이미 해당 내용을 다루는 자료들은 수두룩하기 때문에 해당 포스트에서는 필자가 해당 내용을 처음 접했을 때

의문이 갔었던 부분들 위주로 설명을 해보겠다.

 

 

 

 둘 다 메모이제이션을 위해 사용된다고 했는데 왜 굳이 두 개의 훅이 존재하나?

각각 어떤 상황에 사용하면 좋은가?

 성능 최적화를 위해서 무조건 쓰면 되는 것인가?

 

 


 

 

 둘 다 메모이제이션을 위해 사용된다고 했는데 왜 굳이 두개의 훅이 존재하나?

 

둘다 메모리의 값을 저장해두기 위해 사용하지만 어떤 값을 저장하느냐에 차이가 있다. 

useCallback 은 함수 자체를 기억하고(참조 동일성) useMemo는 주로 함수 실행을 통해 계산된 값을 기억한다 (함수 자체를 기억할 수도 있음). 

 

calculatePrice라고 하는 값비싼 연산을 요구하는 함수가 있다고 가정을 해보자.

 

const calculatePrice = () => {
	// 가격을 구하는 계산 생략
	return price
} 
const memoizedFn = useCallback(calculatePrice, [dep]) // useCallback
const memoizedFnReturn = useMemo(calculatePrice, [dep]) // useMemo

 

useCallback의 경우 calculatePrice라는 함수를 기억하고 있기 때문에 dependency array에 변경이 없는 한 컴포넌트 리 랜더링이 일어나도 매번 같은 참조를 갖고 있다. memoizedFn이라는 함수를 실행할 때 calculatePrice의 가격을 구하는 계산 로직이 실행된다.

 

useMemo의 경우 dependency array에 변경이 일어날 때마다 calculatePrice가 실행되고 실행결과가 memoizedFnReturn에 저장된다. 

 

결과적으로 아래 두 훅은 아래와 같이 작성 시 동일하게 동작한다

useCallback(calculatePrice, deps)
useMemo(() => calculatePrice, deps)

 

각각 어떤 상황에 사용하면 좋은가?

 

필자는 컴포넌트 내부에 값비싼 연산을 요구하는 함수가 정의되어 있고 번번이 리 랜더링이 일어난다면 useCallback을 사용해 함수를 memoize 해주는 편이다. 물론 useMemo를 사용해도 상관없긴 하지만 useCallback은 온전히 함수를 기억하기 위해 만든 훅이기 때문에 용도에 맞게, 추후 유지보수를 위해서라도 사용하고 있다. 

 

useMemo는 앞서 말했듯이 계산된 값을 기억해야 할 때 사용을 하는데, 필자는 주로 원시 값보다는 객체의 값을 기억할 때 사용한다.

 

현재 컴포넌트 상태에 따라 테마 스타일이 재정의되는 아래 예제를 한번 참고해보자. 

 

const App = () => {
  const [dark, setDark] = useState(true);

  const themeStyles = {
    backgroundColor: dark ? "#000" : "#fff",
    color: dark ? "#fff" : "#000",
  };

  useEffect(() => {
    alert(`지금 테마 스타일은 ${dark ? "다크" : "라이트"}`);
  }, [themeStyles, dark]);

  return (
    <div style={themeStyles}>
      <button onClick={() => setDark(!dark)}>테마 변경</button>
    </div>
  );
};

export default App;

 

버튼을 클릭하면 테마 스타일 객체가 변경되어서 스타일이 적용되고 현재 적용된 테마가 뭔지 alert으로 띄어준다. 

 

문제는 이 상태로 코드를 실행하면 themeStyle은 필드 값이 동일하다 해도 참조값이 다르기 때문에 매번 다른 값으로 간주되어 alert이 반복적으로 실행이 된다.

 

 

 

 

 

이를 해결해주기 위해선 useMemo를 사용해주면 된다.

 

  const themeStyles = useMemo(() => {
    return {
      backgroundColor: dark ? "#000" : "#fff",
      color: dark ? "#fff" : "#000",
    };
  }, [dark]);

 

위와 같이 useMemo로 감싸주게 되면 themeStyle에 변경이 없을 경우

기존의 값을 참조하기 때문에 전에 있었던 이슈가 해결된다.

 

물론 위 예제는 useMemo를 무조건 사용해야 하는 경우도 아니고 완벽한 예시도 아니다.

그저 이런 식으로 사용할 수 있음을 보여주고 싶었다.

 

성능 최적화를 위해서 무조건 쓰면 되는 것인가?

어처피 성능을 좋게 하려고 하는 건데 그냥 아무 때나 막 써도 되는 거 아닌가라는 의문점을 가졌을 수도 있을 것이다. 

이에 대한 해답은 useCallback, useMemo 훅을 사용한 성능 최적화는 공짜가 아니라는 점에 있다.

때로는 메모이제이션을 위해 추가적으로 사용되는 메모리의 값이 새로 정의하는 값보다 비싸고

코드 가독성 또한 떨어져 유지보수가 더욱 힘들어질 수도 있기 때문에 적절히 사용하는게 좋다. 

 

적절히라는말은 참 애매하지만 그 애매한 적절함을 알아가는것도 개발자의 역량이라고 생각한다.

 

 


참고자료: 

 

What's the difference between useCallback and useMemo in practice?

Maybe I misunderstood something, but useCallback Hook runs everytime when re-render happens. I passed inputs - as a second argument to useCallback - non-ever-changeable constants - but returned me...

stackoverflow.com