DEV Community

Chan
Chan

Posted on

전역 상태 업데이트로 인한 성능 병목에 관한 고찰

상황

스크롤 진행도를 측정할 일이 있어서 다음과 같이 useScrollProgress 훅을 만들었다.

function useScrollProgress() {
  const [progress, setProgress] = useState(0);

  const calculateScrollProgress = useCallback(() => {
    ...
  });

  useEffect(() => {
    window.addEventListener(calculateScrollProgress, { passive: true });

    return () => {
      window.removeEventListener(calculateScrollProgress);
    }
  });

  return progress;
}
Enter fullscreen mode Exit fullscreen mode

그리고 이 스크롤 진행도를 로깅해야 했기에 로깅하였다.

Enter fullscreen mode Exit fullscreen mode

그리고 이 hook을 여러 페이지 컴포넌트에서 사용하였다.

MainPage.tsx

function MainPage() {

  useScrollProgress();

}
Enter fullscreen mode Exit fullscreen mode

문제점

내가 처음에 생각한 문제점은 다음과 같았다.

scroll 이벤트가 발생할 때마다 현재 스크롤 높이, document 높이, window 길이를 계산하고 있으니 프레임 저하가 발생할 것이다.

실제로 크롬 개발자 도구의 퍼포먼스 탭에서 확인하니 성능 저하가 분명 있긴 했다. 하지만, 이 성능 저하가 어디서부터 오는지 정확한 확인이 필요했다. 그래서 react profiler를 통해서도 확인해보았다.

사실 문제는 다음과 같았다.

  1. 최상위 컴포넌트인 MainPage에서 스크롤 이벤트가 발생할 때마다 상태가 업데이트된다.
  2. 상태가 업데이트될 때마다 subtree에 대하여 리액트의 리렌더링 작업이 발생한다.
  3. 실제로 리렌더링되어야 할 컴포넌트가 없음에도 리렌더링 되어, 프레임 저하가 발생하게 된다.

해결 방안

  1. useScrollProgress 내에서 상태를 useState()가 아닌 useRef()를 사용하여, 렌더링을 막을 수 있다.
function useScrollProgress() {
  const ref = useRef(0);

  const caculateScrollProgress = () => {
    ref.current = next;
  }

  return ref.current;
}
Enter fullscreen mode Exit fullscreen mode
  1. hook 대신 컴포넌트로 만들어서 막을 수도 있다.
function ScrollProgressTracker() {
  useScrollProgress();

  return null;
}
Enter fullscreen mode Exit fullscreen mode

MainPage.tsx

function MainPage() {
  return <>
    <ScrollProgressTracker />
    <Banner/>
    <Main/>
    <Footer/>
  </>
}
Enter fullscreen mode Exit fullscreen mode
  1. 자식 컴포넌트를 모두 React.memo()로 감싸서, 상태가 업데이트될 때 리렌더링되는 것을 막을 수 있다.

MainPage.tsx

import { memo } from 'react';

export const Banner = memo(function() {
  return <></>
});

export const Main = memo(function() {
  return <></>
});

export const Banner = memo(function() {
  return <></>
});

function MainPage() {
  return <>
    <ScrollProgressTracker />
    <Banner/>
    <Main/>
    <Footer/>
  </>
}
Enter fullscreen mode Exit fullscreen mode

개인적인 최선

(3)의 경우 모든 하위 컴포넌트를 React.memo()로 감싸야 하기에 리소스가 가장 많이 들어서 적합한 선택지가 아니라고 보인다. 변경점이 여러 곳으로 늘어나기 때문이다.

선호도에 따라 (1) 또는 (2)를 사용할 수 있겠는데, 이는 문제 상황에 따라서 선택하면 될 것이다. 굳이 반환값이 없이 effect만 실행해야 하는 경우에는 (2)의 컴포넌트 인터페이스가 더 잘 들어맞을 것이고, hook에서 값을 꺼내와야 하는 경우에는 (1)의 hook 인터페이스가 더 잘 들어맞을 것으로 보인다.

TL;DR

  1. 트리의 최상위 레벨에 있는 컴포넌트에 상태가 있고, 이 상태가 자주 바뀌는 경우 리렌더링 시간이 오래 걸릴 수 있다.
  2. 이를 해결하기 위해서는 상태를 ref로 바꾸거나, 상태를 사용하는 컴포넌트 트리를 분리해버리거나, React.memo()를 사용할 수 있다.
  3. React.memo()는 최후의 수단이므로 다른 방법으로 개선할 수 없을 때 고민해보자. 개발 리소스가 가장 많이 드는 방법이다.

Top comments (0)