scent-jo 소개

React 프로젝트 번들링을 통한 FCP 개선기 (feat. Vite)

Engineering Case Study

도입

기간 내에 개발을 모두 마친 후 배포를 진행하였다. 배포를 진행하면서 감사하게도 50명의 사용자 중 몇몇 분에게 피드백을 받을 수 있었다. 그 중 좋은 도전이 되는 피드백을 받을 수 있었다.

“초기 화면이 로드되는 속도가 조금 더 빨랐으면 좋겠어요!”

그래서 최종 발표까지 시간이 있어 평소에 관심은 있었지만, 시도해보지 못했던 “성능 개선”을 경험할 수 있는 좋은 기회라고 생각하고 도전하게 되었다.

개선 전 Lighthouse 진단

성능 개선을 시작하기 전 중요한 것은 “진단” 이라고 생각했다.

아프면 병원에가서 의사 선생님께 진찰을 받듯이 현재 상태를 아는 것이 중요하다고 생각했다.

다양한 도구들이 있지만, 나는 Chrome 브라우저를 사용하여 개발을 했기 때문에 개발자 도구에 탑재 되어 있어서 쉽게 사용가능한 Lighthouse 를 통해 진단하였다.

Lighthouse에서 측정하는 기준은 다음과 같이 선택하였다.

  • 모드 : 기본값
  • 기기 : Mobile(모바일)
  • 카테고리 : Performance(성능)

나는 오로지 로딩과 렌더링에 대한 성능만 측정할 것이기 때문에 **Performance(성능)**만 선택하였고, 개발 중인 서비스는 모바일 기기를 타겟으로 만들었기 때문에 Mobile(모바일) 을 선택하여 했다.

나는 렌더링 성능에 대해서만 확인할 것이기 때문에 FCPLCP, Speed Index 3가지를 중점으로 보았다.

💡 FCP(First Contentful Paint) 정의 “화면에 텍스트, 이미지, 캔버스 등의 첫 콘텐츠가 나타나는 시점

💡 LCP(Largest Contentful Paint) 정의 “가장 큰 콘텐츠가 화면에 나타나는 시점”

💡 Speed Index 정의 “콘텐츠가 시각적으로 로딩되는 속도”

Lighthouse 측정 결과는 다음과 같았다.

  • FCP : 8.0초
  • LCP : 14.8초
  • Speed Index : 8.0초

원인 분석

Lighthouse 에서 측정 후 친절하고 자세하게 어떤 부분에서 낮은 성능을 보이는지 알려준다.

(뒤늦게 쓰다보니 해당 부분에 대해서 개선 전 캡쳐 사진을 챙겨놓지 않았다는 것을 알게되었다…ㅠㅠ)

아래는 개선 중 캡쳐한 사진으로 완전한 처음 상태는 아니다..

여기서 내가 눈여겨 본 것은 다음과 같았다.

  • “사용하지 않는 자바스크립트 줄이기”
  • “콘텐츠가 포함된 최대 페인트 이미지 미리 로드”
  • “텍스트 압축 사용”

React 를 통해 프로젝트를 구성하고 배포하면서 항상 번들링 파일이 하나로 만들어져있는 것을 알고 있었고, 프로젝트가 커지면 함께 번들 파일이 사이즈가 커지면서 불필요한 낭비가 발생할 수 있을 것이라 생각했다.

또한 프로젝트에서 주요하게 사용한 것이 Naver MAP API 였기 때문에 관련 JS 파일을 불러오는 과정에서 최적화를 할 수 있을 거라고 생각했다.

위 해당 부분에 대해서 개발자 도구범위 (Coverage) 혹은 Network 탭을 통해 위 내용에 대해서 객관적으로 분석할 수 있다.

하지만 😭 , 개선을 하던 당시에는 분석하는 부분에 대해 잘 몰랐고 기록을 남겨두지 못해서 아쉬움이 있다.

Lighthouse 측정 결과를 보고 로딩 속도를 저하시키는 것에 대한 원인을 다음과 같이 정의하였다.

  1. 불필요하게 무거운 초기 JS 번들 파일
  • 당장 사용하지 않는 파일도 함께 불러옴 → 리소스 낭비
  • 불필요한 코드가 메모리에 로드 → 메모리 낭비
  1. 이미지나 폰트, Naver MAP API 모듈의 지연된 로딩
  • Naver Map 관련 script 파일이 모두 로드 될때까지 기다려야함
  • 이미지 사이즈가 크면 다운로드 하는 시간이 오래 걸림

해결 전략

먼저 “JS 번들 사이즈를 줄이는 것”은 Vite 빌드 도구를 사용하고 있기 때문에 이를 통해 해결 할 수 있을 것이라고 생각했다. 또한 큰 이슈인 이미지와 Naver Map API를 로딩하는 것을 최적화 하는 것을 목표로 전략을 세웠고 크게 다음 4가지 방법을 선택했다.

  1. Dynamic Import, Lazy Loading을 통한 Code Splitting
  2. 이미지 및 에셋 최적화
  3. Blocking 리소스 최적화
  4. Gzip 압축 기법 적용을 통한 네트워크 최적화

참고로 내가 개발했던 환경은 React 19.0.0 에서 빌드 도구로 Vite 6.2.0 을 사용하였다.


1️⃣ JS 번들 사이즈를 줄이기

먼저 최적화 하지 않은 상태에서 빌드를 하고 배포를 진행하면 어떤 상태인지 확인해보자.

다음과 같이 하나의 JS 파일만이 빌드 결과로 나타나는 것이 보인다. 프로젝트가 커지면서 만들어지는 JS 파일의 사이즈가 약 1.1MB로 나타나는 것을 볼 수 있다.

직관적으로 생각해보면 우리 서비스에 처음 접속하면 저만큼의 파일을 받아오는 시간이 걸린다는 것이다.

다른 이미지 파일도 함께 받아오다보니 특히 네트워크 상태가 안좋은 환경에서는 화면에 로드되는데 더욱 큰 시간이 걸릴 것이다.

번들 파일의 사이즈를 최적화 하는 방법을 Code Spliting 이라고 하는데, 이를 적용하는 방법에는 2가지 방법이 있다.

  1. React의 Lazy Suspense 를 통한 Dynamic import
  2. Vite config 파일 설정을 통한 Chunk 파일 분리

아무렇게나 분할한다면 이것 또한 불필요한 네트워크 리소스 낭비가 될 수 있기 때문에 적절히 분할하는 것이 중요하다는 생각이 들었다.

그래서 어떤 기준을 가지고 분할할 것인지 고민이 필요했다.

먼저, 초기 로딩 속도를 개선하는 것이 목표이기 때문에 메인 페이지 이외에 다른 페이지가 함께 로드 되는 것은 불필요 할 수 있을 것이라고 생각했다. 그래서 1단계로 Route를 설계한 기반으로 페이지 단위로 나누는 것을 생각했다.

2단계로 이번 프로젝트에서 feature 단위로 나누도록 폴더 구조를 구성하였다. 그래서 각각의 페이지에서 사용되는 feature를 기준으로 불러올 수 있도록 feature 단위공통 컴포넌트 단위로 나누기로 결정했다.

마지막으로 Vite의 *manualChunks 설정을 통해* 무거운 라이브러리들을 별도의 Chunk 파일로 나누기로 결정하였다.

❗ Code Spliting 기준

  1. Route 기반 페이지 단위로 나누기
  2. feature 단위공통 컴포넌트 단위로 나누기
  3. 무거운 라이브러리들을 별도의 Chunk 파일로 나누기

(1단계)Route 기반 페이지 단위로 나누기

페이지 단위로 번들 파일을 나누는 방법은 간단하게 React.lazy() 함수를 사용하는 것이다. 이때 중요한 것은 React.Lazy() 로 불러온 컴포넌트는 단독으로 쓰일 수 없고, React.Suspense() 컴포넌트로 하위에서 렌더링 되어야 한다.

(참고) https://ko.react.dev/reference/react/lazy#usage

우리 팀은 React-Router 에서 createBrowserRouter 를 통해 Route를 구성하였고, 이를 별도의 rotuer.ts 파일로 나눠 관리하였다. 그래서 Route를 정의한 파일에서 각각의 페이지를 Lazy 를 통해 불러오도록 하고, RouterProvider 를 정의한 App.tsx 파일에서 Suspense 로 직접 만든 로딩화면을 불러오도록 처리해주었다.

// `src/App.tsx`
// ... 이상 생략

const App = () => {
  return (
    <Suspense fallback={<EmLoading className="w-full h-dvh" />}>
        {/* 내용 생략 */}
         <RouterProvider router={router} />
                {/* 내용 생략 */}
    </Suspense>
  )
}
// `src/routes/router.tsx`
// ... 이상 생략

const HomePage = lazy(() => import("@/pages/HomePage/HomePage"))
const MyPage = lazy(() => import("@/pages/MyPage/MyPage"))
const RecommendPage = lazy(() => import("@/pages/RecommendPage/RecommendPage"))
const EmotionReportPage = lazy(
  () => import("@/pages/EmotionReportPage/EmotionReportPage"),
)
const CalendarPage = lazy(() => import("@/pages/CalendarPage/CalendarPage"))
const PostCreatePage = lazy(
  () => import("@/pages/PostCreatePage/PostCreatePage"),
)
const MyPostListPage = lazy(() => import("@/pages/MyPage/MyPostListPage"))
const NotFoundPage = lazy(() => import("@/pages/NotFoundPage/NotFoundPage"))
const TermPage = lazy(() => import("@/pages/Term/TermPage"))
const TermsAgreementPage = lazy(
  () => import("@/pages/TermsAgreementPage/TermsAgreementPage"),
)
const LoginPage = lazy(() => import("@/pages/LoginPage/LoginPage"))
const LoginSuccessPage = lazy(
  () => import("@/pages/LoginSuccessPage/LoginSuccessPage"),
)

// router 정의
const router = createBrowserRouter([
  {
    element: <ProtectedRoute />,
    children: [
      {
        element: <MainLayout />,
        children: [
          {
            path: "/main",
            element: <HomePage />,
          },
          {
            path: "/recommend",
            element: <RecommendPage />,
          },

   // 이하 생략...

(2단계) feature, 공통 컴포넌트 단위, 무거운 라이브러리들을 별도의 Chunk 파일로 나누기

이 부분에 대해서는 Vite config 파일을 통해 처리하였다. 물론 위에서 사용한 Lazy() 를 통한 동적 임포트 방식을 사용하는 것이 조금 더 효율적일 수 있지만, 짧은 시간내에 많은 파일을 수정하기에는 번거로움이 클 것 같아 설정을 통해 Chunk 파일을 분리하는 것으로 만족하기로 하였다.

Vite에서 빌드 단계에서는 내부적으로 rollup을 사용하기 때문에 build.rollupOptions 을 통해 이를 처리할 수 있도록 되어있다.

여담으로 rollup을 사용하기 때문에 Rollup 관련 플러그인들을 Vite에서도 사용할 수 있다. Vite에 대해 추후 더 알아보기로 하자.

build.rollupOptions 에서 제공되는 함수 중 ouput.manualChunks 를 통해 원하는 파일을 Chunk 파일로 나눌 수 있다.

(참고) https://rollupjs.org/configuration-options/#output-manualchunks

위 함수의 자세한 설명은 내용이 길어 추후 따로 설명하기로 하고, 다음과 같이 정의 하였다.

// `vite.config.ts`
// ... 위 내용 생략

build: {
  chunkSizeWarningLimit: 1000, // 경고 한계치를 1000KB로 설정
  rollupOptions: {
    output: {
      assetFileNames: "assets/[name].[ext]",
      chunkFileNames: "js/[name]-[hash].js",
      manualChunks(id) {
        // node_modules를 vendor chunk로 분리
        if (id.includes("node_modules")) {
          // 큰 라이브러리들을 별도의 청크로 분리
          if (id.includes("react")) {
            return "vendor-react"
          }
          if (id.includes("react-dom")) {
            return "vendor-react-dom"
          }
          if (id.includes("axios")) {
            return "vendor-axios"
          }
          if (id.includes("tailwindcss")) {
            return "vendor-tailwind"
          }
          return "vendor"
        }

        // 공통 컴포넌트 분리
        if (id.includes("/src/components/")) {
          return "components"
        }

        // Feature 기반 코드 스플리팅
        if (id.includes("/src/features/")) {
          const featureName = id.split("/src/features/")[1].split("/")[0]
          return `feature-${featureName}`
        }
      },
    },
  },

  // 프로덕션 모드에서만 Terser 사용 (불필요한 코드 최소화)
  minify: mode === "production" ? "terser" : false,
  terserOptions: {
    compress: {
      drop_console: true, // 콘솔 로그 제거
      drop_debugger: true, // 디버거 제거
      pure_funcs: ["console.log", "console.info"], // 순수 함수 제거
      passes: 2, // 최적화 패스 수
    },
    mangle: {
      toplevel: true, // 최상위 변수 이름 축소
    },
  },
  reportCompressedSize: true, // 압축 크기 리포트 활성화
},

위에서 세운 기준을 바탕으로 프로젝트에서 사용하는 라이브러리 중 용량이 큰 라이브러리와 Feature 폴더, 공통 컴포넌트를 기준으로 각각의 Chunk 파일로 분리하였다.

추가로 콘솔로그 같은 불필요한 코드을 최소화 할 수 있는 옵션도 있어 설정해주었다.

다시 빌드를 하여 확인해보면 다음과 같이 번들 파일이 잘 나눠진 것을 확인할 수 있다.


2️⃣ 이미지 및 에셋 최적화

우리 프로젝트에서는 자주 사용되는 svg 파일과 이미지 파일이 있다. 이것을 S3를 통해 CDN 서버에 올리는 것도 초반에 고려하기는 했지만, 자주 변경되는 파일이 아니고, 영구적으로 계속 사용할 것이었기 때문에 React 앱의 assests 파일로 관리하기로 하였다.

에셋파일을 svg 이나 png을 그대로 사용하기에는 파일 자체가 용량이 큰 문제가 있어 로드 되는데 많은 시간이 걸린다. 그래서 구글에서 만들어 무료로 공개한 효율적인 이미지 포맷인 webp 로 변환하였다.

webp 포맷을 사용하면 파일의 사이즈가 PNG 파일 대비 대략 20~30% 가 작아진다고 알려져있다.

(참고) → https://developers.google.com/speed/webp/gallery2?hl=ko

이를 Vite의 플러그인 도구인 vite-plugin-imagemin 를 사용하여 빌드 시 알아서 변환 할 수 있다고 하여 사용해 보았지만, 어떤 이유에서인지 적용이 잘 되지 않아 사용할 수 없었다.

그래서 빠르게 Ezgip 이라는 사이트를 통해 직접 하나하나 변환 작업해주었다.

예시로 우리 프로젝트에서 감정을 보여줄 때 사용하던 감정 이모티콘 GIF를 webp로 변환한 모습이다.

변환 하기 전 GIF 총 용량이 약 12.3MB 에서 WebP 총 용량: 약 5.4MB 으로 56% 개선되었다.


3️⃣ Blocking 리소스 최적화

마지막으로 우리 프로젝트에서 주요하게 사용되는 Naver 지도 API 에 대한 최적화를 하려고 한다.

특별히 지도 API를 사용하면서 프론트 쪽에서 클러스터링 처리를 해주어야 하는 챌린지가 있었다. 이것을 구현하기 위해 별도의 MarkerClustering.js 을 다운받아 사용해야했다.

<script
  type="text/javascript"
  src="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=%VITE_NAVER_CLIENT_ID%&submodules=geocoder"
></script>
<script type="text/javascript" src="/js/MarkerClustering.js"></script>

이렇게 <script> 태그를 html에 직접 삽입하면 브라우저 렌더링의 과정에서 렌더 시 Blocking이 발생할 수 있다. 또한 초기 렌더링 전에 외부 스크립트를 먼저 로딩하게 되면서 로딩이 지연되는 현상이 발생하게 된다.

이 부분을 처리하기 위해 두가지 방법을 떠올렸다.

  1. HTML 파싱이 끝난 뒤 모두 끝난 후 실행되도록 defer 를 적용
  2. JS 를 통해서 동적으로(조건부로) 처리 → Lazy 처리

두 가지 방법 중 동적으로 처리하는 것이 로드 시점을 자유롭게 제어할 수 있다는 점에서 좋다고 생각이 들었지만, 어차피 첫 페이지에서 지도를 불러오고, 여러 페이지에서 지도를 사용하였기에 한번 불러오고 브라우저가 캐싱하도록 기대하는 것이 더 괜찮겠다는 생각이들어 1번의 방법을 선택하였다.

(두 가지 방법을 직접 비교해보지는 못했지만, 추후 리팩토링 시 확인해봐야겠다.)

추가로 렌더링 최적화에 사용되는 방법 중 Resource Hints 기법 중 DNS prefetch, preconnect, preload를 적용해주었다.

  • DNS prefetch : 해당 도메인의 DNS lookup을 미리 수행
  • preconnect : 외부 스크립트를 로드 할 때 DNS + TCP + TLS 연결까지 미리 수행
  • preload : 해당 스크립트를 앞서 다운로드해두도록 브라우저에게 지시 → script 태그에 의해 사용되기 전 브라우저가 캐시에 적재

위 내용을 바탕으로 아래와 같이 적용하여 주었다.

<link rel="dns-prefetch" href="https://openapi.map.naver.com" />
<link rel="preconnect" href="https://openapi.map.naver.com" crossorigin />
<link rel="preload" href="/js/MarkerClustering.js" as="script" />
<link rel="icon" href="/favicon.svg" />
<script
  defer
  type="text/javascript"
  src="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=%VITE_NAVER_CLIENT_ID%&submodules=geocoder"
></script>
<script type="text/javascript" defer src="/js/MarkerClustering.js"></script>
<script type="module" defer src="/src/main.tsx"></script>

4️⃣ Gzip 압축 기법 적용을 통한 네트워크 최적화

마지막으로 서버에서 클라이언트로 데이터를 전송할 때 네트워크 전송 용량 자체를 줄여줄 수 있는 Gzip 압축 기법을 적용하였다.

이 방법은 빌드를 실행하면서 나오는 결과에서 힌트를 얻을수 있었다. 한 눈에 대충 보기에도 파일의 사이즈의 차이가 많게는 3배 이상 작은 것을 알 수 있다.

Gzip을 적용하면 네트워크 전송량 자체가 감소하면서 모바일을 타겟으로 개발한 우리 프로젝트에서 지하철 같이 네트워크가 불안정한 환경에서도 효과적으로 적용될 수 있을 거라는 생각이 들었다.

물론 거의 모든 브라우저에서 Gzip을 적용하기에 적용하기도 고려할 문제는 없었다.

Gzip을 적용하기 위해 ViteNginx 두 군데에서 설정이 필요했다.

먼저 vite-plugin-compression2 라이브러리를 통해서 Vite 설정에서 빌드되는 파일도 Gzip으로 압축되도록 설정해주었다.

// `vite.config.ts`
//...

plugins: [
        compression({
          algorithm: "gzip",
          compressionOptions: {
            level: 9, // 최대 압축 수준
          },
          include: ["**/*.{js,css,html}"],
        }),

//...

다음으로 React 앱의 정적 파일을 서빙해주는 Nginx conf 파일에 다음과 같이 gzip을 사용한다는 설정을 넣어주었다.

gzip_static on;

    gzip_types
        text/plain
        text/css
        application/json
        application/javascript
        text/javascript
        image/svg+xml;

이렇게 적용 후 gzip이 잘 적용되었는지 확인해 보았는데, 제대로 동작을 하지 않는 문제가 발생했다.

인프라를 담당하던 팀원에게 도움을 요청하여 알아낸 결과 React 앱을 서빙하던 Nginx에서 http 1.0을 적용하고 있었고, 이 때문에 gzip 을 지원하지 않았던 것이었다.

그래서 http 1.1을 적용하여 다음과 같이 Content-Encodinggzip으로 잘 넘어오는 것을 확인할 수 있었다.

Gzip 압축을 적용한 결과 기존의 파일 사이즈보다 72.5% 나 감소한 것을 확인할 수 있었다.


결과

위의 4가지 내용을 프로젝트에 적용하고 동일한 환경으로 Lighthouse 검사를 돌려 보았다.

개선 전과 비교 하여 다음과 같은 결과를 얻을 수 있었다.

  • FCP : 8.0초 → 2.7초 (약 300% 개선)
  • LCP : 14.8초 → 13.6초 (약 110% 개선)
  • Speed Index : 8.0초 → 4.1초 (약 200% 개선)

LCP 점수에서는 여전히 아쉬운 지표를 보여주고 있지만, FCP 점수는 약 **300%**가 Speed Index 가 약 200% 개선되는 성과를 얻을 수 있었다. 직접 서비스를 이용하면서 확실하게 빨라졌다고 많이 체감이 되어서 성능 개선에 대한 뿌듯함을 느낄 수 있었다.

후기

성능 개선을 하기로 하며 계획 이외에 남는 시간에 적용한 것이다보니 하루 밖에 시간을 사용하지 못했던 것이 아쉬웠다. 진작에 개발 계획에 있었다면 팀원들과 함께 고민하며 지금보다 훨씬 더 좋은 결과를 얻을 수 있었을지도 모르겠다는 생각이 든다.

그래도 눈에 띄는 성과를 얻을 수 있었고, 실제 사용자의 피드백을 통해 도전한 성능 개선기라 그런지 **“사용자 경험 개선”**에 기여를 했다는 것을 체감하며 사용자들에게 긍정적인 효과를 줄 수 있었음에 기쁨을 느낄 수 있었다.

이 프로젝트를 기점으로 성능 개선에 많은 관심과 공부가 필요하다는 것을 느끼게 되었다. 흥미도 생긴 것 같다 ㅎㅎ 아직 띄엄띄엄 아는 성능 개선에 대한 공부를 해보려고 한다.

그리고 이것과 함께 연결하여 아직 많이 공부하지 못한 SEO 최적화접근성 최적화 에도 공부하여 정말로 사용자에게 존중의 경험을 전달할 수 있는 개발자가 되고 싶다!!

요약

Why? “초기 화면이 로드되는 속도가 조금 더 빨랐으면 좋겠어요!” 피드백을 받음

How?

  1. Dynamic Import, Lazy Loading을 통한 Code Splitting
  2. 이미지 및 에셋 최적화
  3. Blocking 리소스 최적화
  4. Gzip 압축 기법 적용을 통한 네트워크 최적화

결과

  • FCP : 8.0초 → 2.7초 약 300% 개선
  • LCP : 14.8초 → 13.6초 약 110% 개선
  • Speed Index : 8.0초 → 4.1초 약 200% 개선
  • 이미지 → webp : 파일 사이즈 56% 감소
  • Gzip 압축을 적용 → 기존의 파일 사이즈 대비 72.5% 감소

Reference

lazy – React

Configuring Vite

Rollup

[React] 코드 스플릿팅(Code Splitting)으로 최적화하기

GitHub - btd/rollup-plugin-visualizer: 📈⚖️ Visuallize your bundle

resource-hints

함께 보면 좋은 콘텐츠