본문 바로가기

FrontEnd

Intersection Observer와 React.lazy로 성능 개선하기

반응형

원문 : https://itnext.io/infinite-component-scrolling-with-react-lazy-and-intersectionobserver-7774c03b08f2

 

Infinite component scrolling with React.lazy and IntersectionObserver

In a previous post, I wrote briefly about how the IntersectionObserver API could be used for things such as analytics and dynamic module…

itnext.io

서론

오늘날의 프론트엔드에는 성능이 필요합니다.
그러나 성능은 다양한 영역을 포괄합니다.
 
이 기사에서는 React와 컴포넌트 렌더링의 관점에서 성능을 고려할 것입니다.
가장 널리 사용되는 UI 컴포넌트 라이브러리이기 때문입니다.
 
SSR과 SSG은 복잡하고 Hydration에 대한 이해가 필요하기 때문에
CSR을 가정합니다.
 
다른 시간에 SSR과 SSG의 성능에 대해 언급하고 싶습니다. 그리고 Lighthouse가 성능을 측정한다고 가정합니다.

브라우저와 자바스크립트

먼저 JavaScript가 어떻게 실행되는지 간단히 살펴보겠습니다.
예를 들어 구글 크롬의 자바스크립트 엔진 V8 은 다음과 같이 코드를 실행할 수 있습니다.

단순화하면 다음과 같습니다.

분명 스크립트를 가져오는 것이 모든 것의 시작입니다

성능 개선 지침

JavaScript의 실행은 소스 코드를 가져오는 것으로 시작됩니다.

이 가져오기가 적시에 수행되었는지 확인하는 것이 중요합니다.

 

스크립트 태그에는 async 및 defer(지연)과 같은 속성이 있습니다.

이러한 속성은 성능 향상에 기여합니다. 그러나 이것으로 충분하지 않을 때가 있습니다.


index.html

<!DOCTYPE html>
<html lang="en">
  <body>
    <script src="/heavy.js"></script>
  </body>
</html>

heavy.js

console.log('Long words ...')

Heavy.js가 매우 큰 실행 파일이라고 가정해 보겠습니다.

html이 로드되는 즉시 heavy.js가 실행됩니다.

 

이 때는 async, defer를 사용해도 tbt(Total Blocking Time)의 증가를 막을 수 없습니다.

 


사용자 상호 작용과 Lazy 전략

Heavy.js가 내 TBT 점수를 악화시키고 있습니다.

이를 개선하기 위해 우리는 무엇을 해야 할까요?

heavy.js가 종속성이 없는 독립 스크립트이고 언제든지 실행할 수 있는 경우 지연 로딩할 수 있습니다.

 

가장 간단한 방법은 setTimeout을 사용하는 것입니다.


index.html

<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module">
      window.addEventListener(
        'load',
        () => {
          setTimeout(() => {
            const script = document.createElement('script')
            script.src = '/heavy.js'
            script.async = true

            const body = document.querySelector('body')
            body.appendChild(script)
          }, 3000)
        },
        {
          once: true
        }
      )
    </script>
  </body>
</html>

예에서 setTimeout은 리소스 로드가 완료된 후 3초 후에 스크립트가 가져오기를 시작하도록 합니다.
해당 방법을 통해 Ligthouse의 측정 범위에서 벗어날 수 있습니다.
 
혹은 사용자 상호 작용(Events that are triggered by user actions, such as scrolling or clicking events)
이 발생할 때까지 지연할 수 있습니다.

측정에서 도망치기

이 방법을 사용하면 말 그대로 Lighthouse의 측정을 피할 수 있습니다.

그러나 Loading JavaScript After User Interaction #11904에서는

이 방법이 문제를 연기할 수 있을 뿐이라고 지적했습니다.

예를 들어 사용자 상호 작용이 있을 때까지 모든 스크립트를 지연하면 상호 작용이 발생하는 즉시 많은 수의 스크립트를 가져와 실행합니다. 즉, 이것은 측정 시점을 이동시킬 뿐 본질적으로 문제를 해결하지 못합니다.

Intersection Observers(교차지점 관찰자)와 지연 로딩 전략

좋은 방법 중 하나는 필요할 때까지 아무것도 하지 않는 것입니다.

사용자의 입장에서 생각해보면 알 수 있는 것들이 있습니다.

예를 들어 DOM을 생각해 봅시다. 리소스 로드가 완료되면 사용자가 볼 수 있는 한 DOM을 빌드하기만 하면 됩니다.
전체 DOM을 구축할 필요가 없습니다.
사용자가 해당 지점에서 나가면 결과적으로 추가 대역폭을 소비하기 때문입니다.
즉, 사용자에게 뷰포트 외부의 DOM은 기본적으로 필요하지 않습니다.
또한 뷰포트에 있지만 현재 표시되지 않는 대화 상자, 오버레이 및 기타 항목도 필요하지 않습니다.
사용자는 보이는 것만 필요합니다.
대화 상자와 오버레이는 버튼 클릭과 같은 사용자 상호 작용 후에 종종 나타납니다.
또한 스크롤과 같은 사용자 상호 작용 후에 뷰포트 외부의 DOM이 나타납니다.
여기서 Intersection Observer가 뷰포트가 교차하는 위치에 대한 보다 정확한 그림을 제공합니다.
 
또한 React에서의 지연 로딩을 위해 React.lazy 함수를 사용할 수 있습니다. 

React.lazy 사용법

React.lazy 사용법을 간단히 살펴보겠습니다. 많은 분들이 알고 계실 거라 생각합니다.
기본적으로 파일을 분할하고 컴포넌트를 래핑합니다.

Dialog.tsx

import type { FC } from 'react'
const Dialog: FC<{ open: boolean }> = ({ open }) => <dialog {...{ open }}>...</dialog>

export default Dialog

다른 파일

import { lazy, useState } from 'react'
import type { FC } from 'react'

const Dialog = lazy(() => import('./Dialog.tsx'))

const Index: FC = () => {
  const [isShow, chagneShow ] = useState(false)
  return {isShow && <Dialog open={isShow} /> }
}

import 함수를 React.lazy 함수에 전달합니다.
이것은 지연 로딩 컴포넌트로 만듭니다.
 
지연 로딩 컴포넌트는 필요할 때까지 가져오기 자체를 지연시킵니다.
위의 예에서 지연 뢰딩 컴포넌트는 isShow가 true가 될 때까지 가져오지 않습니다.
이렇게 하면 사용자가 필요할 때까지 로딩을 지연할 수 있습니다.
 
자연 로딩은 transition과 함께 수행될 수도 있습니다.
 
예를 들어, 이 블로그의 전체 텍스트 검색 기능은 전체 화면 대화 상자와 함께 제공됩니다.
transion 및 지연 로딩 컴포넌트를 사용하면 고성능의 자연스러운 UI를 얻을 수 있습니다.
 
그건 그렇고, 모든 컴포넌트를 React.lazy에 래핑하는 것은 그다지 의미가 없습니다.
오히려 CLS(Cumulative Layout Shift)를 유발하여 UX를 저하시킬 수 있습니다.

Intersection Observer component

컴포넌트가 뷰포트에 들어갈 때까지 컴포넌트의 렌더링을 지연합니다.

교차 관찰자가 있는 컴포넌트는 다음과 같습니다.


Intersection.tsx

import { useRef, useState, useEffect, createElement } from 'react'
import type {
  FC,
  ReactNode,
  ReactHTML,
  DetailedHTMLProps,
  HTMLAttributes
} from 'react'

const Intersection: FC<
  {
    children: ReactNode
    as?: keyof ReactHTML
    keepRender?: boolean
  } & IntersectionObserverInit &
    DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
> = ({
  children,
  as = 'div',
  keepRender = true,
  root,
  rootMargin,
  threshold,
  ...props
}) => {
  const [isShow, setShow] = useState(false)
  const ref = useRef<HTMLElement>(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry], obs) => {
        if (entry.isIntersecting) {
          setShow(true)
          if (keepRender && ref.current) {
            obs.unobserve(ref.current)
          }
        } else {
          setShow(false)
        }
      },
      { root, rootMargin, threshold }
    )

    if (ref.current) {
      observer.observe(ref.current)
    }

    return () => observer.disconnect()
  }, [keepRender, root, rootMargin, threshold])

  return (
    <>
      {createElement(as, { ref, ...props })}
      {isShow && children}
    </>
  )
}

export default Intersection

children이 뷰포트와 교차할 때 isShow가 true가 되도록 IntersectionObserver를 등록합니다.

다음과 같이 사용합니다.


import Intersection from 'path/to/Intersection.tsx'

import { lazy } from 'react'
const LazyComponent = lazy(() => import('path/to/Lazy.tsx'))

const Index = () => {
  return (
    <>
      ...
      <Intersection>
        <LazyComponent />
      </Intersection>
      ...
    </>
  )
}

뷰포트 외부에 있는 경우 마크업은 다음과 같이 표시됩니다.


<body>
  ...
  <div></div>
  ...
</body>

div 태그는 교차 감지에 사용됩니다. div 태그가 뷰포트와 교차하면 자식이 렌더링됩니다.


<body>
  ...
  <div></div>
  <children />
  ...
</body>

또한 교차 관찰자 옵션을 props으로 허용하므로
교차 조건을 조정할 수 있습니다.
예를 들어 교차점의 마진이 100px이 되도록 하려면 다음을 사용할 수 있습니다.

return (
  <>
    <Intersection rootMargin="100px">
      <LazyComponent />
    </Intersection>
  </>
)

과도한 DOM 크기 피하기

Lighthouse 감사에는 "과도한 DOM 크기 피하기"라는 섹션이 있습니다. 다음과 같은 경우 알림이 표시됩니다.
  • 총 1500개 이상의 노드가 있습니다.
  • 깊이가 32 노드보다 깊습니다.
  • 60개 이상의 자식 노드가 있는 부모 노드가 있습니다.

Interaction Observer 컴포넌트를 사용하는 경우 교차할 때까지 렌더링되지 않으므로 이 경고가 표시되지 않습니다.

그러나 DOM 트리가 커질수록 리플로우가 더 오래 걸린다는 사실을 알아야 합니다.

 

교차점을 지난 후의 DOM을 생각해 봅시다.

교차 중에 렌더링된 구성 요소가 다시 뷰포트를 벗어나면 어떻게 해야 합니까?

 
 
두 가지 옵션이 있습니다
  • DOM 트리에서 제거
  • 아무 것도 하지 않고 Intersection Observer를 연결 해제하십시오.
어느 것이 더 낫다고 말하기는 어렵습니다만,

대부분의 경우 DOM을 조작하는 데 비용이 많이 들기 때문에 아무것도 하지 않는 것이 좋습니다.

Interaction Observer 컴포넌트에는 이 동작을 선택할 수 있는 keepRender props가 있습니다.


컴포넌트 내부 최적화

React에서 성능 향상은 종종 컴포넌트의 내부에 중점을 둡니다.

React는 immutable한 접근 방식을 사용합니다. 상태 변경은 객체 ID 검사에 의해 감지됩니다.

덕분에 데이터 업데이트 흐름이 매우 간단합니다. 이것이 React가 단순하다고 여겨지는 이유 중 하나입니다.

 

반면에 상태가 업데이트될 때마다 해당 컴포넌트를 다시 계산해야 합니다.

React는 아키텍처로 인해 컴포넌트 내부에서 비교적 쉽게 성능 저하가 발생하기 쉽습니다.

 

메모이제이션은 이를 보완하는 방법입니다.

memo, useMemo, useCallback 등을 통해 렌더링 성능을 향상시킵니다.

 

결론 및 참고

SSR과 SSG의 경우 점진적 수화와 같은 다른 접근이 필요합니다. 

NPM에는 비슷한 라이브러리들이 있으니, 해당 기능이 필요할 경우 라이브러리를 사용하세요

 

intersection observer 더 알아보기

 

 

반응형