간단하게 적용해 볼 수 있는 React 경량화 방법들

December 22, 2021

SPA는 무거워지기 쉽습니다.

프론트엔드 라이브러리로 리액트가 많이 사용되면서 SPA(Single Page Application)이 모던웹에서 많이 사용되고 있습니다. SPA 를 사용하면 하나의 자바스크립트로 하여금 html파일에 필요한 domd을 그리게 할 수 있고, 라우팅 또는 유저 인터랙션을 하나의 자바스크립트 파일안에서 모두 해결할 수 있습니다.

이렇게 되면 관리가 쉽고, 좀 더 자연스러운 유저경험을 제공할 수 있지만 (마치 앱과 비슷하게) 빠르게 첫 페이지를 보여줘야 하는 페이지 혹은 네트워크 상황이 좋지 않다면 이런 점은 어느 정도 장애물이 될 수 있는데요.

이런 점을 해결하기 위해서 자주 사용되는 번들러 webpack은 코드를 분할하는 방법을 제공하고 있고, 리액트 라이브러리에서 또한 Dynamic Import를 통한 code splitting을 지원합니다.

code-splitting
from https://jeroenmols.com/blog/2017/11/28/coveragproblem/

node_modules 분리하기

onefile
하나의 js파일로 실행되는 SPA
bundle
하나의 번들안의 포함되어 있는 파일들

node_modules는 보통 vendor라는 이름으로 분리 됩니다. vendor를 분리 하기 전 SPA은 위 사진처럼 하나의 js로 실행됩니다.

하지만 이렇게 webpack설정을 바꿔 무거운 js파일을 나눌 수 있습니다. 링크

optimization: {
    splitChunks: {
        cacheGroups: {
            vendor: {
                test: /[\\/]node_modules[\\/](react|react-dom|lodash-es|lottie-web|date-fns)[\\/]/,
                name: 'vendor',
                chunks: 'all',
                filename: `[name].[chunkhash].js`,
            },
        },
    },
},

정규식을 통해 따로 분리 할 번들파일을 별도로 생성했습니다.

two-bundle
두개의 파일로 나뉘어진 번들파일

이렇게 될 경우 무거운 파일을 분리할 수 있다는 장점 외에 이점이 있습니다. 위에서 vendor 번들파일을 분리 할때 [name].[chunkhash].js 라는 이름으로 분리 했는데요.

여기서 [chunkhash]는 node_modules이 바뀌지 않은 이상. 즉, 새로운 패키지를 설치하거나 업데이트 하지 않는 이상 빌드할 때마다 같은 이름으로 유지됩니다. 이렇게 되면 브라우저 캐시의 도움을 얻어 매 배포시마다 전체파일을 다운받지 않고 변경되는 main번들만 다운 받을 수 있습니다.

Route별로 Dynamic import 적용하기

큰 사이드 이팩트 없이 적용해 볼 수 있는 방법이 하나 더 있습니다.

리액트에서는 다음과 같이 모듈이 실제로 필요한 부분에서 로드 될수 있도록 코드 분할 방법을 제공하는데요

  • Before

    import { add } from './math';
    console.log(add(16, 26));
  • After

    import("./math").then(math => {
        console.log(math.add(16, 26));
    });
  • Before

    import OtherComponent from './OtherComponent';
  • After

    const OtherComponent = React.lazy(() => import('./OtherComponent'));

아래처럼 기존의 Route 구현방식을(react-router v6)

  • Before
import Home from './pages/Home'
import About from './pages/About'
import Posts from './pages/Posts'


<Routes>
    <Route path="/" element={<Home />} />
    <Route path="posts" element={<Posts />} />
    <Route path="about" element={<About />} />
</Routes>

다음처럼 변경하고 어떤 차이점이 발생했는지 살펴보겠습니다.

  • After
const Home = React.lazy(() => import('./pages/Home'))
const About = React.lazy(() => import('./pages/About'))
const Posts = React.lazy(() => import('./pages/Posts'))

<Suspense fallback={<div>loading!</div>}>
    <Routes>
        <Route path="/" element={<Home />} />
        <Route path="posts" element={<Posts />} />
        <Route path="about" element={<About />} />
    </Routes>
</Suspense>

컴포넌트를 레이지 로딩 하지 않았을 때 vendor를 분리한 청크파일은 이렇게 생성됩니다.

 asset bundle.6db81df76b61896aca9a.js 727 KiB [emitted] [immutable] (name: main)

하지만 위에서 원하는 결과대로 빌드가 되면 다음처럼 여러개의 js파일이 생성됩니다

asset bundle.8a2af5e79c6a5a895b15.js 393 KiB [emitted] [immutable] (name: main)
asset src_pages_About_js.bundle.8a2af5e79c6a5a895b15.js 82.4 KiB [emitted] [immutable]
asset src_pages_Posts_js.bundle.8a2af5e79c6a5a895b15.js 3.69 KiB [emitted] [immutable]
asset src_pages_Home_js.bundle.8a2af5e79c6a5a895b15.js 2.6 KiB [emitted] [immutable]

이렇게 된다면 SPA에서 가지고 있는 모든 페이지 소스를 첫 로드시 가져오지 않고 각 페이지가 실제로 route될때 js파일을 다운받습니다.

파일을 비동기적으로 불러오기 때문에 컴포넌트가 렌더링 되기 전에 그려져야 할 Fallback을 처리해주기 위해 Suspense로 분리 되어야 할 페이지 또는 컴포넌트를 감싸줍니다.

<Suspense fallback={<LoadingPage />}>
    {...}
</Suspense>

FYI: SSR 을 지원하기 위해서 Loadable Components를 사용할 수 있습니다.

결론

번들사이즈는 웹 성능에 많은 영향을 미칩니다. 단순히 청크를 나누는 일로도 웹 성능 개선에 영향을 미칠 수 있지만 번들 자체 용량을 줄이거나 작게 유지 하는것이 더 좋은 방향이겠죠? :D

번들을 나누기 전에 아래와 같이 번들을 줄일 수 있는 여지가 있는지 먼저 체크 해보시길 바랍니다.

  • 대체가능한 더 가벼운 라이브러리를 사용한다 (moment.js => date-fns)
  • import _ from "lodash" 와 같이 tree-shaking을 하지 않고 해당 라이브러리를 통쨰로 불러오고 있다. 참고: 트리 쉐이킹으로 자바스크립트 페이로드 줄이기-TOAST UI
  • 프로젝트에 사용되지 않는 불필요한 파일들이 같이 번들링 되고 있지 않는지 확인한다. (i18n과 함께 안쓰는 번역값들이 같이 들어 있다던지 등등)
  • webpack production build, TerserWebpackPlugin, UglifyJS 와 같은 최적화 도구를 적절하게 사용해서 번들사이즈를 최소화 시키고 있는지 확인한다.
이전 - Markup guide