React 18 에 추가된 useDeferredValue, useTransition 을 써 보자

녹차괴물·2022년 7월 13일
60
post-thumbnail

react 18에서는 많은 기능이 추가되었다. 그중 가장 돋보이는 변화는 concurrent rendering 기능이다. 기존에 디바운싱, 스로틀링과 같이 미묘한 처리로 우회하던 문제를 react hooks api 로 직접 해결할 수 있게 되었다.

디바운싱, 스로틀링

자바스크립트에서는 짧은 시간에 유저 입력 등의 이벤트가 많이 일어나는 경우 화면이 끊길 수 있다. 그래서 이런 경우 특별한 최적화가 필요하다.

대표적인 예가 바로 자동완성이다. 구글에 검색어를 입력하면 그 아래로 추천 검색어를 쭉 띄워줘야 한다. 한 글자가 바뀔 때마다 실시간으로 업데이트하면서.

구글의 자동완성 기능

만약 유저 입력 한 번에 추천 검색어 업데이트 한 번이 일어난다면 불필요한 재랜더링이 초당 수 번씩 필요할 것이다. 그래서 정작 필요한 '진짜 검색어' 에 대한 자동완성이 느려질 수 있다. 이런 걸 방지하기 위해 디바운싱이라는 처리를 한다.

디바운싱은 특정 함수의 호출이 너무 자주 일어나지 않도록 일정 시간 동안 함수 실행을 지연시키는 것을 말한다. 다음과 같이 훅으로 작성해볼 수 있다.

const useDebouncedEffect = (func, delay, deps) => {
  const callback = useCallback(func, deps);

  useEffect(() => {
    const timer = setTimeout(() => {
      callback();
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [callback, delay]);
};

작동은 useEffect 훅과 유사하지만, 함수를 실행하기 전 delay 시간만큼 기다리며, 그 전에 다시 업데이트될 경우 기존 함수를 실행하지 않도록 되어 있다.

스로틀링 또한 비슷한 문제를 해결하기 위한 기법이다. 지정된 시간 동안 함수가 최대 한 번 호출되도록 제한한다. 댓글 알림 같은 걸 띄우려면 주기적으로 네트워크 요청이 필요한데, 스로틀링을 이용해서 적당한 주기를 설정할 수 있다.

정말 모든 문제가 해결되었을까?

디바운싱과 스로틀링에서 적절한 딜레이를 선택하는 것은 꽤 어렵다. 딜레이가 너무 길면 유저 입력에 대한 반응도 그만큼 느려지지만, 너무 짧아도 적용하는 의미가 없어진다.

또한 cpu 부하가 큰 작업에는 적용해도 거의 의미가 없다. 유저가 숫자를 입력하면 100,000개의 배수를 띄워 주는 페이지를 생각해 보자. 대충 생각해도 컴퓨터가 버벅일 것 같다.

유저가 빠르게 입력할 경우, 분명히 그걸 전부 처리하는 것은 비효율적이다. 그러나 디바운싱을 적용한다면 delay 를 얼마로 주어야 할까? 그건 컴퓨터 사양에 따라 다를 것이다.

게다가 디바운싱을 적용한다고 해도, 일단 계산을 시작하면 메인 스레드가 거기서 블록되기 때문에 다음 입력을 처리하지 못한다. 한 순간 입력을 멈추면 다음 입력을 받지 못할 정도로 프레임이 저하된다.

사실, 디바운싱과 스로틀링은 문제를 근본적으로 해결하는 방법은 아니다. 위 시나리오에서 배수를 띄워주는 속도는 빠를수록 좋다. 다만 그걸 계산하는 동안 메인 스레드가 블록돼서 상호작용이 불가능해지는 상태가 되는 게 문제인 것이다.

이 문제를 근본적으로 해결하려면 반응 속도는 최대한 빠르게 하되, 사용자의 상호작용이 있으면 그걸 우선적으로 처리하여 화면이 멈춘 것처럼 보이지 않게 해야 한다. 무거운 연산은 메인 스레드가 놀고 있을 때만 처리하고, 유저 입력이 들어오면 다시 거기에 집중하는 것이다.

즉, 이벤트의 우선 순위를 나누고, 우선 순위가 높은 이벤트가 발생하면 context switch 하여 그 작업을 먼저 핸들링하면 된다. 근데 자바스크립트에는 병렬 스레드나 스케줄러가 없다.

리액트, 도와줘!

react 18 은 fiber 라는 엔진을 개선하여 자체적인 스케줄러를 가지게 되었다. 마치 OS 처럼 작업의 우선순위를 정하고, 우선순위 높은 작업이 들어오면 먼저 처리하는 기능이 구현되었다. 무겁고 유저 경험에 중요하지 않은 작업은 우선순위를 낮춰 프레임률을 유지할 수 있다.

useTransition

상태 변화의 우선순위를 지정하기 위해 useTransition 훅이 새로 도입되었다. [isPending, startTransition] 을 반환하는데, isPending 은 작업이 지연되고 있음을 알리는 boolean 이며, startTransition 은 낮은 우선순위로 실행할 함수를 인자로 받는다. 다음과 같이 사용할 수 있다.

function App() {
  const [isPending, startTransition] = useTransition();
  const [count, setCount] = useState(0);
  
  function handleClick() {
    startTransition(() => {
      setCount(c => c + 1);
    })
  }
  

  return (
    <div>
      {isPending && <Spinner />}
      <button onClick={handleClick}>{count}</button>
    </div>
  );
}

클릭할 때마다 일어나는 count 에 대한 상태 업데이트를 낮은 우선순위로 실행한다. 그래서 더 중요한 이벤트가 있는 경우 count의 업데이트를 지연시키고 대신 이전의 값을 보여준다. isPending을 이용하여 업데이트가 지연된 동안 스피너를 보여줄 수도 있다.

useTransition 을 통해 앞서 나온 100,000 개 배수를 보여주는 페이지를 다음과 같이 개선할 수 있다.

useDeferredValue

이 훅 또한 useTransition 과 유사하게 낮은 우선순위를 지정하기 위한 훅이다. 차이점이라면 useTransition은 함수 실행의 우선순위를 지정하는 반면, useDeferredValue는 값의 업데이트 우선순위를 지정한다. 우선순위가 높은 작업을 실행하는 동안 useMemo와 유사하게 이전 값을 계속 들고 있으면서 업데이트를 지연시킨다.

이 훅은 useMemo와 함께 사용하면 더 효과가 좋다. 종속된 값들을 memoize 시키면 불필요한 재 랜더링을 막으면서 하위 컴포넌트나 상태의 업데이트를 지연시킬 수 있다.

아래는 검색 자동완성을 처리하는 코드이다. 사용자 입력 query의 업데이트에 대해 추천 검색어인 suggestions를 띄워 준다. 추천 검색어의 업데이트는 우선순위 높은 작업이 없을 때만 실행되기 때문에 디바운싱을 적용하지 않아도 좋은 유저 경험을 줄 수 있다.

function Typeahead() {
  const query = useSearchQuery('');
  const deferredQuery = useDeferredValue(query);

  // Memoizing tells React to only re-render when deferredQuery changes,
  // not when query changes.
  const suggestions = useMemo(() =>
    <SearchSuggestions query={deferredQuery} />,
    [deferredQuery]
  );

  return (
    <>
      <SearchInput query={query} />
      <Suspense fallback="Loading results...">
        {suggestions}
      </Suspense>
    </>
  );
}

참고문헌

리액트 공식 가이드

react 18 working group

기타 블로그

3개의 댓글

comment-user-thumbnail
2022년 10월 5일

좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2023년 4월 18일

유익한글 감사합니다. 참고하여 프로젝트에 적용했습니다~

답글 달기
comment-user-thumbnail
2023년 10월 5일

깔끔하게 정리해주셔서 감사합니다!

답글 달기