본문 바로가기

프론트엔드/React

React 18 useDeferredValue로 성능 최적화하기

반응형
  • 프로젝트: tracking map
  • 키워드: react v18, useDeferredValue, interruptible rendering, rendering blocking
  • 상황
    1. react 18에서 새로 나온 useDeferredValue hook을 사용해보고 장점을 알아본다.
    2. 실제 프로젝트에 적용하여 렌더링 성능 최적화를 확인한다.
  • 해결 과정 1
    1. 먼저 useDeferredValue hook을 체험해보기 위해 간단한 코드를 작성한다.
      아래 코드는 input element에 value를 입력할 때마다, boxes를 새로 연산하여 렌더링하는 코드이다.
        // Test.tsx
      import React, { useState, useDeferredValue, useMemo } from 'react';
      
      function Test() {
        const [value, setValue] = useState('');
      
        const boxes = useMemo(() => {
          return (
            <div style={{ display: 'flex', flexWrap: 'wrap' }}>
              {new Array(10000).fill(null).map(() => {
                const x = Math.floor(Math.random() * 256);
                const y = Math.floor(Math.random() * 256);
                const z = Math.floor(Math.random() * 256);
                const backgroundColor = 'rgb(' + x + ',' + y + ',' + z + ')';
                return <div style={{ width: 100, height: 100, backgroundColor }} />;
              })}
            </div>
          );
        }, [value]);
      
        return (
          <div>
            <input onChange={(e) => setValue(e.target.value)} style={{ height: 100 }} />
            {boxes}
          </div>
        );
      }
      export default Test;
      실행 결과는 아래 사진과 같다.
      input 결과
      input을 입력할 때마다(value가 변할 때마다) boxes의 배경색깔을 모두 계산한다. box의 수가 10000개이기 때문에 연산 시간이 꽤 필요하다. 따라서 input을 입력하면 브라우저에 렉이 걸리게 된다.
      input을 느리게 입력한다면 괜찮을 수도 있지만, 일반 유저의 타자 속도일 경우 반드시 렌더링 성능 저하를 유발하는 코드이다.(React 문서에서 이 현상을 '렌더링 차단(blocking)'이라 부른다.)
    2. 지금까지는 이러한 상황에서 debounce/throttle 기법을 이용해서 해결했다. 하지만 debounce와 throttle이 완벽한 UX를 보장해주지 않는다는 것은 모두가 알고 있는 사실이다. (debounce delay가 500ms이면 유저의 의사와 상관없이 500ms를 기다려야한다.)
    3. 이러한 상황을 해결하기 위해 나온 것이 React 18의 Concurrent Mode이며, useDeferredValue hook을 이용하여 deferredValue의 값을 지연시킬 수 있다. 이해가 어렵기 때문에 코드를 통해 설명하기로 한다.
        const [value, setValue] = useState('');
      const deferredValue = useDeferredValue(value);  // 추가
      
      const boxes = useMemo(() => {
        return (
          ...
        );
      }, [deferredValue]); // 변경
      useDeferredValue hook을 이용하여 기존 코드에서 단 2줄을 수정하였다.
      이제 "value"를 빠른 속도로 타이핑해보자. input에 있는 value는 빠르게 변하지만 deferredValue의 값을 바로 변경되지 않는다. deferredValue와 deferredValue에 의존하는 boxes 연산은 value보다 urgent하지 않기 때문에 value 값만 먼저 업데이트하고 deferredValue 값은 지연하기 때문이다.
      즉, deferredValue에 의해 boxes 연산이 진행되는 와중에 value가 업데이트된다면 boxes 렌더링을 interrupt하고 value만 먼저 렌더링할 수 있다.
    4. 이제 input tag의 value는 즉각적으로 변하지만, deferredValue와 boxes는 그보다 낮은 빈도수로 업데이트되는 것을 확인할 수 있다.
      이제 렌더링 차단은 일어나지 않는다!
      테스트를 입력했을 때 value와 deferredValue의 변화
  • 해결 과정 2
    1. 배운 내용을 실제 프로젝트에 적용해보기로 한다.
      Range Input의 경우 마우스나 터치 이벤트로 인해 아주 빠른 속도로 값이 변하는 UI 이다.
      range input
      위의 사진은 range input이 가리키는 '시각'에 따라서 naver map Polyline을 그리는 UI이다.

    2. 특정 시각에 따라 naver map Polyline을 그리는 것은 어느 정도의 연산을 필요로하기 때문에 얼마든지 렌더링 차단이 일어날 수 있다. 실제로 range input을 마우스나 터치로 조작하면 화면이 뚝뚝 끊기며 렌더링되는 것을 확인했다.
        import { mutate } from 'swr';
      
      ...
      
      const onChangeStaticTime = (e: ChangeEvent<HTMLInputElement>) => {
        mutate(STATIC_TIME_KEY, Number(e.target.value));
      };
      
      
      return (
        <Range
          type="range"
          ...
          value={time}
          onChange={onChangeStaticTime}
        />
      );
      기존 로직은 range input에 onChange event가 발생하면 바로 전역 상태인 STATIC_TME_KEY의 값을 업데이트하는 방식이다. 그 후 STATIC_TIME 값에 따라 Polyline을 계산하여 그리게 된다.

    3. 이제 useDeferredValue를 추가하여 코드를 수정한다.
        const [staticTime, setStaticTime] = useState<number | null>(null);
      const deferredStaticTime = useDeferredValue(staticTime); // 추가
      
      useEffect(() => { // 추가
        if (deferredStaticTime !== null) {
          mutate(STATIC_TIME_KEY, deferredStaticTime);
        }
      }, [deferredStaticTime]);
      
      const onChangeStaticTime = (e: ChangeEvent<HTMLInputElement>) => {
        setStaticTime(Number(e.target.value)); // 변경
      };
      
      return (
        <Range
          type="range"
          ...
          value={time}
          onChange={onChangeStaticTime}
        />
      );
      생략된 세부 로직이 있지만, STATIC_TIME_KEY의 상태가 mutate될 때마다 Polyline을 새로 그리는 것은 동일하다.
       onChange event에서 staticTime state를 업데이트하지만, 전역 상태 STATIC_TIME을 mutate 하는 것은 deferredStaticTime에 의해 지연되기 때문에 렌더링 차단이 더이상 일어나지 않는다. (복잡한 연산을 해야하는 렌더링은 staticTime 상태에 의해 interrupt 되면서 지연된다.)

  • 의의
    1. 지금까지 사용한 debounce, throttle도 렌더링 성능을 향상하는 좋은 기법이지만, 이번 React 18의 interruptible rendering 덕분에 UX가 더욱 향상되었다.
    2. 간편한 hook으로 기능이 제공되기 때문에, 기존 로직에 아주 쉽게 적용할 수 있었다.
    3. 아직 React 18에 대해 정확하게 알지 못하는 것이 많아서 더 자세히 알아보아야겠다.(transition 등등)
반응형