프론트엔드/성능 최적화

로딩 성능 최적화 - 코드 스플릿, 레이지 로딩

정현우12 2023. 4. 28. 17:53

Code Split & Lazy Loading

dynamic importreact-lazy를 사용해서 chunk를 여러 개로 나눠 불필요한 코드, 중복 코드 없이 적절한 크기의 코드가 적절한 타이밍에 로드될 수 있도록 하는 것이다.

하나의 큰 JS파일을 다운로드 하는것 보다 여러개의 작은 chunk(해당 페이지에서 필요한 chunk들만)를 다운로드하는 것이 더 빠르기 때문에 코드 스플릿을 통해 초기 로딩 속도를 개선할 수 있다.

크게 2가지 패턴이 있다.

  • 페이지 별로 (라우팅 단위)
  • 모듈 별로 (컴포넌트 단위)

페이지별로 코드 스플릿

번들 파일 분석하기

코드스플릿을 하기에 앞서 webpack-bundle-analyzer를 활용해서 번들된 파일들의 크기를 확인한다.

보면 refractor라는 모듈이 chunk의 절반을 사용하고 있다.

package.lock을 통해 해당 모듈이 어디서 사용되는지 확인해본다.

react-syntax-highlighter라는 모듈에서 사용하고 있음을 알 수 있다.

해당 모듈은 md의 코드 블럭에서 코드들을 강조처리 하는 역할을 한다.

이 모듈은 상세조회 페이지에서만 사용되기 때문에 목록화면에서는 필요가 없다.

코드 스플릿 하기

페이지 별로 (라우트 기반) 코드 스플릿을 한다.

Suspense, lazy를 사용한다.

Suspense는 해당 페이지에 필요한 파일을 다운로드할 동안 fallback에 제공된 컴포넌트를 보여주고, 다 다운로드하면 원래 보여줘야할 페이지를 보여준다.

lazy는 런타임 중에 필요한 컴포넌트를 레이지 로딩하게 해준다.

import React, {Suspense, lazy} from 'react'
import { Switch, Route } from 'react-router-dom'
import './App.css'
// import ListPage from './pages/ListPage/index'
// import ViewPage from './pages/ViewPage/index'

const ListPage = lazy(() => import('./pages/ListPage/index'))
const ViewPage = lazy(() => import('./pages/ViewPage/index'))

function App() {
  return (
    <div className="App">
      <Suspense fallback={<div>Loading...</div>}>

        <Switch>
          <Route path="/" component={ListPage} exact />
          <Route path="/view/:id" component={ViewPage} exact />
        </Switch>
      </Suspense>
    </div>
  )
}

export default App

결과

  • 코드 스플릿 전
  • 코드 스플릿 후

퍼포먼스 탭을 통해 검사해보면, 목록 페이지 로딩이 184.40ms -> 108.27ms로 기존보다 훨씬 빨라진 것을 확인할 수 있다.

번들된 파일을 검사해보면, chunk 가 여러개로 나뉘어져 있는 것을 확인할 수 있다.

각 chunk의 이름이 해시값이라 뭔지 알아보기가 힘들다.

chunk파일 이름 정해주기

웹팩 config에서 chunk파일의 이름을 정해줄 수 있다.

 output: {
    filename: 'static/js/[name].[contenthash:8].js',
    path: path.resolve(__dirname, '../../dist'),
    publicPath: './',
    chunkFilename: 'static/js/[name].[contenthash:8].chunk.js',
  },

chunkFilename에 규칙을 정해줌으로써 각 청크가 무슨 페이지인지 알 수 있다.

컴포넌트 코드 스플릿

번들 파일 분석하기

이미지 모달에서만 사용되는 이미지 갤러리 라이브러리와 이미지모달이 각각 메인 chunk들에 포함되어 있다.

이미지 모달을 눌렀을 때만 사용되므로 해당 컴포넌트를 레이지 로딩하면 chunk의 크기를 줄일 수 있다.

최적화하기

// import ImageModal from './components/ImageModal'

const ImageModal = lazy(() => import('./components/ImageModal'))

결과

이미지 모달을 클릭했을 때 관련 청크들을 불러온다.

번들에서도 코드가 분리된 것을 확인할 수 있다.

이미지 Lazy loading

사이트 분석

이미지를 먼저 다운로드하고 그 후에 배너 비디오를 다운로드한다.

문제점: 배너비디오가 사용자에게 먼저 보여지는데, 다운로드가 늦어서 사용자가 배너 비디오를 늦게 보게 된다.

해결방안:

1) 이미지를 빠르게 다운로드 - 근본적 해결책은 아님
2) 이미지를 나중에 다운로드 (이미지 Lazy loading)

이미지 로드 시점

이미지를 나중에 다운로드한다면 어느 시점에 다운로드해야 될까?

해당 이미지 요소가 보여질 때

IntersectionObserver를 활용해서 구현할 수 있다.
또는 react-lazyload 활용 가능하다.

구현

이미지 주소를 dataset-src에 저장해놨다가 IntersectionObserver를 활용해 해당 이미지가 화면에 보여질 때 srcdataset-src 를 준다. 이러면 화면에 보여지는 시점에 이미지를 불러온다.

이 때 문제점이 생길 수 있다. width, height을 지정해주지 않으면 src가 없는 img태그는 0x0이라서 한꺼번에 많은 img태그가 한 화면에 있고 동시에 매우 많은 이미지를 다운로드 요청해서 부하가 걸릴 수 있다.

따라서 width,height을 주고 src가 없을 때는 알록달록한 색깔을 주는 식으로 해서 Layout shift도 줄이고 좋은 사용자 경험을 줄 수 있다. ex) Pinterest

/*
 * Image with Lazy Loading
*/
export default function Image(props) {
  const {src, width, height, ...restProps} = props
  const imgRef = useRef()

  useEffect(() => {
    const callback = (entries, observer) => {
      entries.forEach(entry => {
        if(entry.isIntersecting) {
          console.log(entry,'이미지 불러오기!')
          entry.target.src = entry.target.dataset.src
          observer.unobserve(entry.target)
        }
      })
    }
    const observer = new IntersectionObserver(callback, {})
    observer.observe(imgRef.current)
    return () => {
      observer.unobserve(imgRef.current)
    }    
  }, [])

  return <img width={width} height={height} data-src={src} {...restProps} ref={imgRef}/>
}

적용 및 결과

img태그를 Image 컴포넌트로 변경해준다.

적용시 banner-video 로드를 훨씬 빠르게 한다.