Code Splitting

1. 개요

코드 스플리팅은 왜 적용해야 할까요?

리액트와 같은 SPA로 개발된 프로젝트를 빌드해 보면 하나의 JS파일로 번들링되는 것을 볼 수 있습니다.

이렇게 하나의 파일로 번들링된 결과물로 배포된 웹페이지에 진입하면 사용자는 처음 진입 시 모든 페이지에 대한 정보를 불러오게 되고, 이는 초기 로딩을 느리게 만들어 사용자 경험을 나쁘게 합니다.

위와 같은 초기 로딩이 느려지는 것은 SPA로 개발된 CSR(Client Side Rendering)의 문제점 중 하나로
이를 해결하기 위해 SSR(Server Side Rendering)과 같은 방법도 있지만, 이번 글에서는 좀더 간단히 코드 스플리팅(Code Splitting)을 통해 개선하는 방법을 알아보겠습니다.


2. import()

코드 스플리팅에 대해 알아보기전 핵심이 되는 동적 import에 대해 먼저 알아보겠습니다.

자바스크립트 버전이 ES5에서 ES6으로 넘어오면서 가장 큰 변화중 하나는 모듈화가 가능해졌다는 점입니다.
ES5까지는 전역 스코프만 가능했지만, ES6부터는 모듈 스코프가 가능해져 import와 export를 통해 필요할때만 특정 파일만 불러 개발할 수 있게 되었습니다.

import { red, blue } from "./fieldMoudle";
import { mixColor } from "./functionModule";

위와 같이 정적인 import 문법으로 선언할 수 있지만, 이렇게 선언된 스트립트는 실제로 사용되지 않더라도 전부 불러오게 됩니다.

반면 아래와 같이 import() 문법을 사용하면, 런타임시 실제로 필요할때 스트립트 파일을 불러올 수 있습니다.

async function callMixModule() {
  // 특정 함수
  const { mixColor } = await import("./functionModule");
  mixColor();

  // default
  const module = await import("./functionMoudle");
  module.default();
}

3. Code Splitting이란?

위의 예시처럼 동적 import를 이용하면 컴포넌트 또한 런타임시 불러올 수 있습니다.
또한 동적 import로 작성된 코드는 WebPack, Rollup 등 Code Splitting을 지원하는 번들러에서 번들 시 코드가 나누어져 번들링 되게 됩니다.

이렇게 코드를 나누어 번들링하고, 런타임시 필요한 모듈을 불러오게 하는 것을 코드 분할(Code Splitting)이라고 합니다.


4. Code Splitting 방법

이어서 Code Splitting 방법에 대해 알아보겠습니다. React 16.6 이전엔 state를 이용하여 런타임시 컴포넌트를 불러올 수 있었습니다.
componentDidMount() {
  import('./comp/DefaultComp').then((result) => {
    this.setState({ comp: result.default });
  });
};

handleClick = async () => {
  const result = await import('./comp/OtherComp');

  this.setState({ comp: result.default });
}

render() {
  return (
    <div>
      <button onClick={this.handleClick}>Change Component</button>
      {this.state.comp}
    </div>
  )
}

위와 같이 state를 이용할 수도 있지만, React 16.6 이후에 지원하는 React.lazy를 사용하면 선언적인 방식으로 좀더 편하고 가독성 좋은 코드를 작성할 수 있습니다.

아래에서는 React.lazy와 react-router-dom을 사용해 적용하는 방법을 알아보겠습니다.

예시코드에서 사용한 버전은 다음과 같습니다.

  • react => 18.2.0
  • react-router-dom => 6.4.3

4.1. 미적용 코드 및 실행 결과

먼저 코드 분할이 적용되지 않은 코드와 실행 결과입니다.

import React from 'react'
import { Route, Routes } from 'react-router-dom'
import Main from './comp/Main'
import Dummy1 from './comp/Dummy/Dummy1'
import Dummy2 from './comp/Dummy/Dummy2'
import Dummy3 from './comp/Dummy/Dummy3'

function App () {
  return (
    <Routes>
      <Route path="/" element={<Main />} />
      <Route path="/dummy1" element={<Dummy1 />} />
      <Route path="/dummy2" element={<Dummy2 />} />
      <Route path="/dummy3" element={<Dummy3 />} />
    </Routes>
  )
}

export default App

위와 같이 작성 후 실행해보면, '/'로 진입해 Main 컴포넌트만 사용하고 있어도 정적으로 선언된 모든 Dummy 컴포넌트들이 전부 불러와지는 것을 확인할 수 있습니다.

4.2. 적용 코드 및 실행 결과

다음은 React.lazy를 이용해 코드 분할을 적용한 코드와 실행 결과입니다.
lazy로 넘겨지는 import() 구문의 컴포넌트는 default를 export하고 있어야 합니다.

import React, { lazy } from "react";
import { Route, Routes } from "react-router-dom";
import Main from "./comp/Main";

const Dummy1 = lazy(async () => await import("./comp/Dummy/Dummy1"));
const Dummy2 = lazy(async () => await import("./comp/Dummy/Dummy2"));
const Dummy3 = lazy(async () => await import("./comp/Dummy/Dummy3"));

function App() {
  return (
    <Routes>
      <Route path="/" element={<Main />} />
      <Route path="/dummy1" element={<Dummy1 />} />
      <Route path="/dummy2" element={<Dummy2 />} />
      <Route path="/dummy3" element={<Dummy3 />} />
    </Routes>
  );
}

export default App;

코드 분할이 적용 후에는, '/'로 진입시 필요한 Main.tsx만 불러오는 것을 확인할 수 있고,

Dummy1 컴포넌트를 실제로 사용하는 '/dummy1'로 진입해야 Dummy1.tsx를 불러오는 것을 알 수 있습니다.

4.3. 번들링 결과 비교

코드 분할을 적용하여 번들링해보면, 분할되어 번들링되어 청크 파일의 개수가 늘어나는 것을 확인할 수 있습니다.

미적용 결과(274KB)

적용 결과(284KB)

코드가 분할된 만큼 index 파일에 분할된 모듈들을 import하는 등의 코드가 추가되기 때문에 파일 크기 또한 늘어나는 것을 확인할 수 있습니다.

4.4.Suspense

이렇게 페이지 이동구간 등에 코드 스플리팅을 적용할 수 있지만, 사용자의 네트워크 상태가 안좋거나 파일이 큰 경우, 오랫동안 빈화면 혹은 워터폴 현상으로 인해 사용자의 경험을 해칠 수 있습니다. 이러한 것을 방지하기 위해 Suspense와 같이 사용하는 것이 권장됩니다.

Suspense는 로딩UI를 선언적으로 작성할 수 있게 해주는 컴포넌트로, 하위에 선언된 컴포넌트들의 비동기적 활동이 완료되어 렌더링할 수 있는 상태가 될때 까지는 렌더링을 멈추고 fallback props로 넘겨진 컴포넌트를 대신하여 보여줍니다.

import React, { lazy, Suspense } from "react";
import { Route, Routes } from "react-router-dom";
import Main from "./comp/Main";
import { DummyPageSkeleton } from "./comp/Skeleton";

const Dummy1 = lazy(async () => await import("./comp/Dummy/Dummy1"));
const Dummy2 = lazy(async () => await import("./comp/Dummy/Dummy2"));
const Dummy3 = lazy(async () => await import("./comp/Dummy/Dummy3"));

function App() {
  return (
    <Routes>
      <Route path="/" element={<Main />} />
      <Route
        path="/dummy1"
        element={
          <Suspense fallback={<DummyPageSkeleton />}>
            <Dummy1 />
          </Suspense>
        }
      />
      <Route
        path="/dummy2"
        element={
          <Suspense fallback={<DummyPageSkeleton />}>
            <Dummy2 />
          </Suspense>
        }
      />
      <Route
        path="/dummy3"
        element={
          <Suspense fallback={<DummyPageSkeleton />}>
            <Dummy3 />
          </Suspense>
        }
      />
    </Routes>
  );
}

export default App;

위와 같이 Suspense와 같이 적용하면 페이지 이동시 파일이 로딩하는 순간에 보이는 빈화면을 원하는 모습으로 채울 수 있습니다.
너무 빨라서 안보인다면 아래와 같이 네트워크를 느리게해서 확인해볼 수 있습니다.

추가로 Suspense에 대해 좀더 자세히 알고 싶다면 여기를 참고하면 많은 도움이 될 것 같습니다.


추가: 마주했던 오류

CSR 환경에 코드 스플리팅을 적용한 프로젝트를 배포해서 사용하다 보니, 분할된 파일을 찾지못해 오류가 나는 것을 경험할 수 있었습니다.

이는 사용자가 이용중 재배포시 일어날 수 있는 문제로, 아래의 시나리오를 예시로 설명해보겠습니다.

  • 코드 분할이 적용된 빌드결과
  • 사용자는 처음 '/'로 진입해 성공적으로 index.[hash값].js를 불러오는데 성공했지만 아직
    '/dummy1' 페이지론 이동하지 않아 Dummy1.[첫번째hash값].js를 불러오진 않았지만 '/dummy1'로
    진입시 현재 불러온 index.[hash값].js에서는 Dummy1.[첫번째hash값].js를 찾아 불러올 준비가 되어있습니다.
// 번들링된 index 파일에 정의된 Dummy1
const Dummy1 = react.exports.lazy(
  async () =>
    await __vitePreload(
      () => import("./Dummy1.57256fcb.js"),
      true ? [] : void 0
    )
);
  • 그러나 사용자가 '/dummy1'로 이동하기 전에 프로젝트를 다시 빌드하고 배포하면 기존의 Dummy1.[첫번째hash값].js는 지워지고 새로운 hash 값을 가진 Dummy1.[두번째hash값].js로 빌드되어 배포됩니다.

  • 이 상태에서 사용자가 '/dummy1'로 진입하면 현재 불러와진 index.js를 따라 Dummy1.[첫번째hash값].js 찾아 서버에서 불러오려고 하지만 이미 없어진 뒤여서 오류가 나게 됩니다.

이러한 문제는 페이지를 새로고침하는 등의 새로운 index 파일을 가져오도록 하는 행위를 하면 해결이 되지만, 그러한 사실을 모르는 사용자는 빈 화면이라는 오류를 직면하게 됩니다.

이를 방지하기 위해 아래와 같이 try/catch를 통해 새로고침 하도록 하여 해결할 수 있었습니다.

import { lazy } from "react";

const lazyWithRetry = (componentImport) => {
  lazy(async () => {
    const pageHasAlreadyBeenForceRefreshed = JSON.parse(
      window.localStorage.getItem("page-has-been-force-refreshed") || "false"
    );

    try {
      const component = await componentImport();

      window.localStorage.setItem("page-has-been-force-refreshed", "false");

      return component;
    } catch (error) {
      if (!pageHasAlreadyBeenForceRefreshed) {
        window.localStorage.setItem("page-has-been-force-refreshed", "true");
        return window.location.reload();
      }

      throw error;
    }
  });
};

const Dummy1 = lazyWithRetry(async () => await import("./comp/Dummy/Dummy1"));

마치며

이렇게 코드 스플리팅을 적용해야하는 이유와 lazy를 이용한 사용 방법을 알아보았습니다. 예시 코드로 알 수 있듯이, 사용 방법이 간단하기 때문에 적은 비용으로도 웹 성능을 향상시킬 수 있는 방법입니다.

웹 사이트의 속도는 UX에서 빠질 수 없는 요소입니다. 코드 분할을 통해, 초기 로딩 속도를 개선해서 사용자 경험을 높여보면 좋겠습니다.



NaN

참조