[React] CRA 프로젝트를 vite로 마이그레이션하기 (3) - 성능 최적화

Gyuhan Park·2024년 1월 16일
3

react

목록 보기
3/4

💭 TMI

CRA에서 vite로 마이그레이션하여 빌드속도를 약 75% 개선하였다. 빌드 속도 개선를 통해 UX와 DX를 향상시켰으며, 빌드했을 때 번들 크기를 분석하였다. 하지만 번들 사이즈가 너무 커서 경고 메세지를 받고 초기 로딩 속도가 느리다는 문제점을 파악하였다. 이러한 문제를 해결하기 위해 code spliting 을 진행하기로 하였다.

📘 code spliting

코드를 나누어 번들링하고 런타임에 필요한 모듈을 동적으로 불러오는 것

SPA로 개발된 프로젝트를 빌드하면 하나의 JS 파일로 번들링된다. 이럴 경우 JS 파일이 다 불러올 때까지 흰 화면을 보고 있어야 한다.
하지만 초기 화면에서 모든 모듈이 필요하지 않으므로 필요한 모듈만 번들링하여 코드를 분리할 수 있고, 런타임에 필요한 모듈을 동적으로 불러올 수 있다. 이를 code spliting 이라고 한다.

이렇게 JS 파일을 한번에 불러오므로 초기 로딩 속도가 느린 것이 CSR의 문제점 중 하나이다. 그래서 이러한 단점을 개선하기 위해 code spliting을 진행해보기로 한다.

✅ import

import문을 사용하여 동적으로 컴포넌트를 가져올 수 있다.

import { lazy } from 'react';

const HomePage = lazy(() => import('./pages/home/HomePage'));

export const PageRouter: React.FC = () => (
    <BrowserRouter>
      <Switch>
        <AuthRouter>
          <Route path={PAGE_URL.Home} component={HomePage} />
          ...
        </AuthRouter>
      </Switch>
    </BrowserRouter>
);

typescript 또는 vite 설정들로 인해 모듈을 찾을 수 없거나 dynamic import가 되지 않았다고 뜬다. 내 프로젝트 경우 index 파일을 이용하여 export하는 구조였는데, 아래와 같이 default를 named로 내보내줘야 import문에서 인식할 수 있었다.

export { default as HomePage } from './HomePage';

✅ Suspense

Suspense는 리액트 내장 컴포넌트로, 코드 스플리팅 된 컴포넌트를 로딩하도록 발동시킬 수 있고, 로딩이 끝나지 않았을 때 보여줄 UI를 설정해 줄 수 있다.

fallback : 로딩이 완료되지 않은 경우에 실제 UI 대신 렌더링할 대체 UI

Suspense는 children이 일시 중단되면 자동으로 fallback으로 전환되고, 데이터가 준비되면 다시 children으로 전환된다. 렌더링 중에 fallback이 일시 중단되면 가장 가까운 상위 Suspense 경계가 활성화된다.

dynamic import 를 사용할 경우 컴포넌트를 렌더링하는 로딩시간 이 발생하기 때문에, 로딩시간동안 보여줄 UI를 설정하기 위해 Suspense 를 사용한다.

import { lazy } from 'react';

const HomePage = lazy(() => import('./pages/home/HomePage'));

export const PageRouter: React.FC = () => (
  <Suspense fallback={<Loading />}>
	...
	<Route path={PAGE_URL.Home} component={HomePage} />    
	...
</Suspense>
);

가장 가까운 상위 Suspense를 활성화하기 때문에 fallback 컴포넌트를 분리할 수도 있다.

🏁 중간 점검

하나의 JS 파일이 분리된 것을 확인할 수 있다 (995.74kB -> 661.01kB)
꽤 많이 분리했는데도 500kB가 넘는 번들이 존재한다.

npx vite-bundle-visualizer 로 번들 크기를 확인했을 때 PostEditor에서 사용되는 react-quill 다음으로 @mui 가 가장 컸다. 이를 수동으로 분리해보자.

✅ vite.config.ts

node_modules 중 @mui 만 수동으로 chunk를 분리하는 작업을 진행한다.

export default defineConfig({
  build: {
    assetsDir: 'static',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: id => {
          if (id.includes('node_modules')) {
            const module = id.split('node_modules/').pop().split('/')[0];
            if (module === '@mui') return `${module}`;
          }
        },
      },
    },
  },
  ...
})

😎 성공 (code spliting)

최종적인 코드 스플리팅 결과다. index 기준으로 995.74kB -> 320.45kB 로 약 67% 번들 사이즈가 감소하였다. 내가 맡은 board와 circle 페이지만 진행하였지만 많은 양의 번들이 분리되었다.

📘 배포

빌드한 파일로 코드를 실행해야 번들링의 영향을 알 수 있기 때문에 배포를 해보기로 하였다. 하지만 오류가 발생할 수 있기 때문에 따로 브랜치를 파서 netlify 로 배포를 진행하였다.

❌ Minified React error #227

ReactDOM was loaded before React. Make sure you load the React package before loading ReactDOM.

처음에는 모든 모듈을 spliting 했더니 위와 같은 에러가 발생하였다. react-dom이 react보다 빨리 로드되었다는 오류인데 번들링된 모듈을 제대로 못찾는 거라고 파악했다. 이후 컴포넌트별로 code spliting을 진행하였고 @mui만 수동으로 분리하였다.

❌ Site Not Found

빌드 성공하고 배포한 사이트도 잘 열려서 lighthouse 한번 돌려보자 했는데 다음과 같은 오류가 발생했다. lighthouse 뿐만 아니라 새로고침하면 페이지를 찾지 못한다.

public/_redirects 를 추가하면 정상적으로 동작한다.

/*  /index.html  200

😎 성능 분석 (code spliting)

code spliting 적용 시 성능이 눈에 띄게 개선된 것을 확인할 수 있다.

FCP : 2.9s → 2.4s (17.24% 개선)
TTI : 3.5s → 2.4s (31.43% 개선)

📘 lighthouse 웹사이트 최적화

lighthouse 분석 결과 확실히 이전보다 개선되었다. 하지만 FCP 가 1.8초 이내여야 한다는 자료를 읽기도 했고, 나머지 지표들도 추가적으로 개선해보려고 한다.

❌ Eliminate render-blocking resources

웹폰트를 다운받는동안 렌더링이 blocking 되기 때문에 FCP(First Contentful Paint)LCP(Largest Contentful Paint) 에 영향을 주었다.

아래 블로그처럼 subset 처리와 렌더링 차단방식은 크게 고려하지 않고 일단 link 태그의 preload 옵션을 채택하였다.
font 파일을 의도적으로 css 파일보다 먼저 다운로드를 시작하여 렌더링이 더 빠르게 끝난다. 따라서 렌더링 차단 시간 역시 줄어든다.

<link rel="preload" as="font" href="https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap" />
<link rel="preload" as="font" href="https://fonts.googleapis.com/icon?family=Material+Icons" />

❌ Reduce unused JavaScript

코드 스플리팅을 거쳤음에도 index 파일과 @mui 파일의 chunk size가 여전히 크게 인식된다. 다운로드 받아야하는 JS 용량이 크다는 뜻이므로 초기 로딩속도에 영향을 준다.

나머지 라우팅 컴포넌트도 모두 코드 스플리팅을 진행하여 index chunk 크기를 줄였다. 또한 @mui 모듈은 에러가 날 것을 우려하여 기존의 나눠져있는 번들을 하나로 묶었었는데, 패키지대로 3개를 각각의 번들로 묶어 다운로드 받아야할 JS 파일을 최소화하였다.

vite.config.ts

export default defineConfig({
  build: {
    assetsDir: 'static',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: id => {
          if (id.includes('@mui/base')) {
            return `@mui/base`;
          } else if (id.includes('@mui/material')) {
            return `@mui/material`;
          } else if (id.includes('@mui/icons-material')) {
            return `@mui/icons-material`;
          }
        },
      },
    },
  },
  ...
})

추가로 mui default import 시 속도를 향상시킬 수 있다는 공식 문서를 읽고 mui 모듈의 import 방식을 수정하였다.

// ❌
import { Button, TextField } from '@mui/material';

// ✅
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';

Accessibility : 이미지의 alt 속성에 이미지 설명을 추가하여 개선

<img src="/images/logo.png" alt="uniform_logo" />

SEO : index.html에 meta tag를 추가하여 개선

<meta name="description" content="...">

리소스가 preload를 사용하여 미리 로드되었지만, window의 load 이벤트 이후 몇 초 동안 사용되지 않았다는 경고 메세지 발생
font를 우선적으로 불러오기 위해 preload 속성을 적용했는데 제대로 인식이 안되었다. 해당 리소스에 적절한 as 값을 설정해주었더니 경고 메세지가 사라진 것을 확인하였다.

<!-- ❌ -->
<link rel="preload" as="font" href="https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap" />

<!-- ✅ -->
<link rel="stylesheet preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap" />

preload가 적용되면서 우선순위가 높음에서 가장 높음으로 바뀐 것을 확인할 수 있고, 폰트로 인해 블로킹되던 시간이 감소하여 스크립트가 처리되고 난 후인 DOMContentLoaded가 평균 750ms -> 600ms 로 약 20% 개선되었다.

😎 최종 성능 분석

FCP : 2.9초 → 1.8초 (37.93% 개선)
TTI : 3.5초 → 2.0초 (42.96% 개선)
LCP : 2.9초 -> 2.4초 (17.24% 개선)
TBT : 280ms -> 100ms (64.29% 개선)

before

after

react에서 code spliting 적용하기
react 공식문서 Suspense
netlify build 404 error
google 웹사이트 성능 중요 지표
[웹 최적화] Lighthouse CLI 성능 지표
font preload
웹폰트 최적화하는법 naver D2
link 태그 preload 적용하기
MUI 모듈 크기 줄이기

profile
단단한 프론트엔드 개발자가 되고 싶은

0개의 댓글