본문 바로가기

업무

requestIdleCallback을 활용한 렌더링 성능 개선 경험

웹 개발에서 성능 개선은 여러 기준이 있지만 가장 중요한 것은 사용자 경험이다.

사용자 경험을 최대한 끌어올리기 위해선 성능이 중요한데

제한된 디바이스 환경에서 사용자에게 당장 필요한 요소를 먼저 처리해줘야 사용자에게 좋은 경험을 줄 수 있다.

 

그러기 위해 사용자에게 당장 필요하지 않은 요소를 후순위로 미루게 된다면

자연스럽게 높은 사용자 경험을 달성할 수 있게 되는 것인데

 

그것을 도와주는 브라우저 API 중 하나가 requestIdleCallback이다.

아직 완벽하게 표준이 된 API는 아니지만 사용이 권장되고 있다.

requestIdleCallback

window.requestIdleCallback(callback);

 

브라우저가 idle 상태일 때 큐에서 콜백을 꺼내서 실행하는 API이다.

따라서 당장 필요하지 않은 요소를 콜백으로 묶어서 전달하면 알아서 브라우저가 처리할 여유가 있을 때 꺼내서 처리해주는 개념이다.

 

 

업무에 활용하기

 

1. 무거운 컴포넌트

가장 먼저 활용해본 것은 사용자에게 PDF 약관 화면을 보여주는 컴포넌트 부분이다.

해당 컴포넌트는 react-pdf 라는 외부 라이브러리에 의존하는데 SSR을 지원하지 않고 꽤 무겁다.

 

 

 

 

사용자가 [동의하고 계속하기] 버튼을 누르면 바로 약관 PDF를 보여줘야 하기에

사용자가 처음 들어오는 화면 안에 미리 렌더링하게 되어 있는데

 

 

const PdfViewer = dynamic(() => import('./components/PdfViewer'), { ssr: false });

 

next.js의 dynamic을 이용하는데 next/dynmaic은 내부적으로 React.lazy를 wrapper 하는 구조이다. 해당 컴포넌트가 SSR을 지원하지 않을 때 SSR을 꺼서 클라이언트 사이드에서만 렌더링이 되게 할 수 있어서 편하다. (이 경우 서버에서는 로딩 컴포넌트까지만 렌더링해준다.)

 

 

 

렌더링 화면을 분석한 결과 첫 화면이 보이기 전에 당장 보여지지 않아도 되는 PDF 컴포넌트의 스크립트를 평가하고 레이아웃을 재계산 하느라 많은 시간이 소요되는 것을 확인했다.

 

const dynamicIdle = (factory, option) =>
  dynamic(
    () =>
      new Promise((resolve) => requestIdleCallback(() => resolve(factory()), { timeout: 3000 })),
    option,
  );
const PdfViewer = dynamicIdle(() => import('./components/PdfViewer'), { ssr: false });

return (
  <PdfViewer />
)

 

 

next/dynamic을 한 번 더 wrapper 하여 requestIdleCallback 안에서 호출되도록 수정했다. 

 

 

 

퍼포먼스를 측정한 결과 LCP 이후에 스크립트가 평가되어 원하던 개선 효과를 이뤘다.

첫 화면을 필요한 것만 빠르게 로드하면서 남는 시간에 사용자의 다음 행동에 필요한 요소들을 미리 가져오게 할 수 있었다.

FCP 기준으로 성능을 약 27% 개선했다.

 

2. 전역 스크립트 개선하기

 

 

우리 서비스에서는 카카오 관련 모듈을 많이 사용하기 때문에 프로젝트 첫 진입 시에 kakao.js 스크립트를 비롯해 로그 추적 용으로 많은 스크립트를 로드하고 있다.

 

 

 

 

물론 중요한 스크립트이지만 모든 서비스에서 첫 진입 시에 항상 최우선으로 받아지기에 정작 가장 중요한 번들이 늦게 받아지는 것을 볼 수 있다.

 

 

이 경우도 requestIdleCallback을 이용해볼 수 있는데 가장 중요한 번들들을 먼저 로드한 뒤에 여력이 남을 때 로드하도록 할 수 있다.

Next.js 11 버전부터 제공하고 있는 next/script 패키지를 이용하면 쉽게 구현할 수 있는데 strategy에 lazyOnload를 주게 되면 내부적으로 requestIdleCallback을 호출하게 되어있다.

 

next/script lazyOnload

 

 

 

이전보다 필요한 스크립트를 먼저 가져오는 것을 볼 수 있다. 물론 당장 이렇게 적용하기에는 다른 모듈에서는 문제가 있을 수 있기 때문에 섣부르게 이렇게 바꾸는 것보다  팀원들과 의논할 필요가 있다. 그래도 이런 식으로 개선해볼 수 있다는 것을 생각할 수 있었다.

 

3.  화면에 보이는 요소를 조절할 때

 

좀 더 일반적으로 사용을 해보자면 평소 리액트 코드를 작성하면 state 값에 따라 화면에 보이는 요소들을 조절하는 경우가 아주 많은데 예를 들면 특정 조건이 만족되었을 때 보여져야 하는 컴포넌트이거나 스크롤을 내리다가 뷰포트 범위에 왔을 때 보여져야 하는 요소들을 생각할 수 있다.

 

requestIdleCallback은 프레임 단위로 여력이 남을 때 idle callback을 실행하는 것을 생각하면 이런 상황에서 활용해볼 수 있는데

 

아래 useIntersectionObserver를 사용하는 커스텀 훅을 보자.

 

export function useIntersection<T extends Element>({
  rootRef,
  rootMargin,
  disabled,
}: UseIntersection): [(element: T | null) => void, boolean, () => void] {
  const isDisabled: boolean = disabled || !hasIntersectionObserver

  const [visible, setVisible] = useState(false)
  const [element, setElement] = useState<T | null>(null)

  useEffect(() => {
    if (hasIntersectionObserver) {
      if (isDisabled || visible) return

      if (element && element.tagName) {
        const unobserve = observe(
          element,
          (isVisible) => isVisible && setVisible(isVisible),
          { root: rootRef?.current, rootMargin }
        )

        return unobserve
      }
    } else {
      if (!visible) {
        const idleCallback = requestIdleCallback(() => setVisible(true))
        return () => cancelIdleCallback(idleCallback)
      }
    }
  }, [element, isDisabled, rootMargin, rootRef, visible])

  const resetVisible = useCallback(() => {
    setVisible(false)
  }, [])

  return [setElement, visible, resetVisible]
}

 

useIntersectionObserver는 요소가 뷰포트 이내에 들어왔는지 확인하는 작업에 성능상 아주 큰 이점을 주는 브라우저 API이다. 위 useIntersection 훅은 뷰포트 이내에 들어왔을 때 visible 상태를 true로 변경하여 요소가 화면에 보여지도록 하는데 이 때 requestIdleCallback을 사용하여 조절한다. 뷰포트에 들어와서 visible을 true로 바꿔야 할 때 바로 변경을 주는 것이 아니라 조금 여유를 주게 바꿈으로써 브라우저의 다른 작업에 영향을 주지 않고 화면을 갱신할 때 사용할 수 있는 것이다.

 

만약 requestIdleCallback 을 사용하지 않고 바로 setVisible을 했다면 불필요한 리렌더링이 발생할 수 있는데, 아직 TTI(time to interactive)가 끝나지 않았는데 화면이 리렌더링되어 전체 TTI가 길어질 수 있기 때문이다. requestIdleCallback으로 그런 문제를 해결할 수 있었다.

 

사실 위 코드는 next.js에서 실제로 사용되는 훅 코드인데 next/image, next/link 등에 사용되고 있다.

 

    prefetch(route: string): Promise<void> {
      // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
      // License: Apache 2.0
      let cn
      if ((cn = (navigator as any).connection)) {
        // Don't prefetch if using 2G or if Save-Data is enabled.
        if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
      }
      return getFilesForRoute(assetPrefix, route)
        .then((output) =>
          Promise.all(
            canPrefetch
              ? output.scripts.map((script) =>
                  prefetchViaDom(script.toString(), 'script')
                )
              : []
          )
        )
        .then(() => {
          requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
        })
        .catch(
          // swallow prefetch errors
          () => {}
        )
    },
  }

 

그 외에 간단한 네트워크 요청에서도 사용될 수 있는데 next.js의 router 패키지에 있는 prefetch 기능은 서비스를 SPA처럼 보여지게 해주는 강력한 기능인데 위에서도 스크립트를 미리 prefetch 할 때도 requestIdleCallback을 이용하는 것을 볼 수 있다.

 

 

나도 필요한 리소스들을 미리 가져와서 브라우저 캐시를 이용해 저장해두고 나중에 리소스가 필요한 순간에 로드하는 경우에 활용하고 있다. 이전 페이지에서 prefetch 하지만 사실은 당장 필요한 리소스가 아니기 때문에 우선순위가 낮다는 것을 알리기 위해 requestIdleCallback에 담아서 요청하고 있다. 이렇게 하면 현재 페이지 로딩에 방해하지 않고 prefetch를 이용할 수 있었다.

 

 

지원하지 않는 브라우저

이렇게 다루기 쉽고 효과가 확실한 API이지만 아직 표준은 아니기 때문에 모든 브라우저에서 사용되는 것은 아니다.

대부분의 최신 브라우저에서 지원되지만 사파리에서는 지원되지 않는다.

 

 

setTimeout을 이용한 polyfill

export const requestIdleCallback =
  (typeof self !== 'undefined' &&
    self.requestIdleCallback &&
    self.requestIdleCallback.bind(window)) ||
  function (cb: IdleRequestCallback): number {
    let start = Date.now()
    return setTimeout(function () {
      cb({
        didTimeout: false,
        timeRemaining: function () {
          return Math.max(0, 50 - (Date.now() - start))
        },
      })
    }, 1) as unknown as number
  }

export const cancelIdleCallback =
  (typeof self !== 'undefined' &&
    self.cancelIdleCallback &&
    self.cancelIdleCallback.bind(window)) ||
  function (id: number) {
    return clearTimeout(id)
  }

 

만약 지원되지 않는 경우에 사용한다면 위와 같은 폴리필 코드를 사용할 수 있다. setTimeout을 이용해 유사하게 구현하는데 엄밀히 말해서 동작이 같지는 않지만 점진적 향상(https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement) 사고 방식에 따라 이렇게 사용했다.

 

아직 많이 공부중이지만 사용하기도 쉽고 효과가 강력하기 때문에 앞으로 서비스를 개발할 때 많이 고려해볼 것 같다.

 

 

참고

https://developer.chrome.com/blog/using-requestidlecallback/

https://engineering.linecorp.com/ko/blog/line-securities-frontend-4/