Skip to content

코드 스플리팅으로 초기 번들 크기를 줄여 최초 로딩 속도 개선

코드 스플리팅으로 번들을 분리하여 최초 로딩 속도를 개선한 경험을 재구성해 정리한 글입니다.

Lighthouse FCP 개선 필요

개발이 약 90% 진행된 시점에 랜딩 페이지에서 처음 Lighthouse 분석을 실시했더니 성능(Performance) 부문의 점수가 유독 낮아 개선이 필요했습니다. 특히 최초 로딩 속도와 직결되는 FCP(First Contentful Paint) 개선이 시급했습니다.

React.lazy()를 활용한 코드 스플리팅

FCP 개선을 위해 가장 먼저 떠올린 방법은 코드 스플리팅이었습니다. 코드 스플리팅을 통해 하나의 거대한 JavaScript 번들을 여러 개의 청크로 나누면, 최초 진입 시점에 브라우저가 다운로드, 파싱, 실행할 JavaScript가 줄어들어 브라우저가 DOM 렌더링에 집중할 수 있는 시간이 빨라질 것으로 기대했습니다.

가장 먼저 코드 스플리팅을 적용한 곳은 페이지 라우터였습니다. 모든 사용자가 모든 페이지를 이용하는 것은 아니기 때문에, 페이지 라우터에서 라우트별 페이지 컴포넌트들을 동적으로 import하도록 바꿨습니다. 비필수 페이지 코드를 처음부터 받지 않게 되어 FCP뿐 아니라 LCP(Largest Contentful Paint)와 TTI(Time to Interactive)도 함께 개선될 것이라 봤습니다.

  • 페이지 라우터 main.tsx 예시
tsx
import { lazy, StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
import App from "./App.tsx";

// ...

// 페이지 컴포넌트 lazy loading
const Sample1Page = lazy(() => import("./pages/Sample1Page.tsx"));
const Sample2Page = lazy(() => import("./pages/Sample2Page.tsx"));
const Sample3Page = lazy(() => import("./pages/Sample3Page.tsx"));

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    {/* ... */}
    {/* 모든 페이지 컴포넌트가 로딩 중일 때 표시할 fallback UI 전달 */}
    <Suspense fallback={<div>Loading...</div>}>
      <BrowserRouter>
        <Routes>
          <Route index element={<App />} />
          <Route path="/sample1" element={<Sample1Page />} />
          <Route path="/sample2" element={<Sample2Page />} />
          <Route path="/sample3" element={<Sample3Page />} />
        </Routes>
      </BrowserRouter>
    </Suspense>
    {/* ... */}
  </StrictMode>
);

번들러(Webpack)는 위와 같이 동적 import를 기준으로 번들을 나누고, React.lazy() 메서드로 이 동적 임포트를 감싼 Promise를 활용해 컴포넌트를 비동기로 로딩했습니다.

무거운 컴포넌트를 초기 번들에서 분리하기

또한 렌더링 비용이 큰 컴포넌트에도 코드 스플리팅을 적용했습니다. 대시보드 페이지의 차트 컴포넌트와 3D 렌더링용 캔버스 컴포넌트를 로딩할 때 실제로 필요해지는 시점 직전에 로드하고, Suspense를 활용해 로딩 시 fallback UI를 렌더링하여 사용자 경험을 개선했습니다.

  • Suspense Wrapper 컴포넌트 LazyComponent.tsx 예시
tsx
import { Suspense } from "react";
import type { ComponentType, LazyExoticComponent } from "react";

interface LazyComponentProps {
  component: LazyExoticComponent<ComponentType>;
  fallback?: React.ReactNode;
}

export default function LazyComponent({ component: Component, fallback = <div>Loading...</div> }: LazyComponentProps) {
  return (
    <Suspense fallback={fallback}>
      <Component />
    </Suspense>
  );
}
  • 사용 예시
tsx
import { lazy, useState } from "react";
import LazyComponent from "../base/LazyComponent";

// ...

// 렌더링 비용이 큰 컴포넌트
const CanvasContainer = lazy(() => import("./CanvasContainer"));

export default function SampleComponent() {
  return (
    <div>
      {/* ... */}
      {/* Suspense Wrapper에 컴포넌트를 lazy loading하여 전달 */}
      <LazyComponent component={CanvasContainer} />
    </div>
  );
}

React Router의 prefetch로 코드 스플리팅 보완하기

코드 스플리팅을 적용하면 리소스를 필요한 때에만 로드하기 때문에 더 효율적이지만, 그만큼 사용자 입장에서는 콘텐츠가 바뀔 때마다 지연을 느낄 수 있습니다. React Router의 <Link> 컴포넌트를 사용할 때 prefetch="intent" 옵션을 설정해서 사용자가 마우스 커서를 링크에 호버할 때 리소스를 미리 받아와 실제 페이지 이동 시 체감 속도를 더 빠르게 개선할 수 있었습니다.

tsx
import { Link } from "react-router";

// ...

export default function App() {
  // ...

  return (
    <div>
      <Link to="/product" prefetch="intent">
        상품 페이지
      </Link>
      <Link to="/orders" prefetch="intent">
        주문 페이지
      </Link>
    </div>
  );
}

결과

코드 스플리팅을 적용한 뒤 빌드 결과 기준으로 초기 JS 번들 크기가 4.5MB에서 2.6MB로 약 40% 줄었고, 그만큼 랜딩 페이지 최초 로딩 시간도 눈에 띄게 줄어들었습니다. FCP 기준으로는 2.9초에서 2.4초로 줄어 0.5초 이상의 개선 효과가 있었습니다.