Create React App에서의 코드 분할

코드 분할은 React 앱을 빌드하는 데 필수적인 단계는 아닙니다. 하지만 코드 분할이 무엇인지, 그리고 더 큰 규모의 React 앱에 어떻게 도움이 되는지 궁금하다면 계속 읽어보세요.

코드 분할

React.js 싱글 페이지 앱을 개발하다 보면 앱이 상당히 커지는 경향이 있습니다. 앱의 특정 섹션(또는 라우트)은 처음 로드할 때 필요하지 않은 많은 컴포넌트를 불러올 수 있습니다. 이는 앱의 초기 로딩 시간을 늦추는 원인이 됩니다.

Create React App으로 앱을 빌드할 때 하나의 큰 .js 파일이 생성되는 것을 확인했을 것입니다. 이 파일에는 앱이 필요로 하는 모든 자바스크립트 코드가 포함되어 있습니다. 하지만 사용자가 로그인 페이지만 로드하려고 한다면, 나머지 앱을 함께 로드하는 것은 비효율적입니다. 앱이 작을 때는 문제가 되지 않지만, 앱이 커지면 이는 중요한 문제가 됩니다. 이를 해결하기 위해 Create React App은 코드를 분할하는 간단한 내장 기능을 제공합니다. 이 기능은 당연히 코드 분할(Code Splitting)이라고 불립니다.

Create React App(1.0 버전부터)은 import() 제안을 사용해 앱의 일부를 동적으로 불러올 수 있도록 지원합니다. 이에 대해 더 자세히 알아보려면 여기를 참고하세요.

동적 import()는 React 앱의 어떤 컴포넌트에든 사용할 수 있지만, React Router와 함께 사용할 때 특히 효과적입니다. React Router는 경로에 따라 어떤 컴포넌트를 로드할지 결정하기 때문에, 해당 컴포넌트를 네비게이션할 때만 동적으로 불러오는 것이 합리적입니다.

코드 분할과 React Router v5

React Router를 사용해 앱의 라우팅을 설정할 때 일반적으로 다음과 같은 구조를 사용합니다.

/* 컴포넌트를 불러옵니다 */
import Home from "./containers/Home";
import Posts from "./containers/Posts";
import NotFound from "./containers/NotFound";

/* 컴포넌트를 사용해 라우트를 정의합니다 */
export default () => (
  <Switch>
    <Route path="/" exact component={Home} />
    <Route path="/posts/:id" exact component={Posts} />
    <Route component={NotFound} />
  </Switch>
);

먼저 라우트에 응답할 컴포넌트를 불러옵니다. 그리고 이를 사용해 라우트를 정의합니다. Switch 컴포넌트는 경로와 일치하는 라우트를 렌더링합니다.

하지만 위 예제에서는 모든 컴포넌트를 정적으로 상단에서 불러옵니다. 이는 어떤 라우트가 일치하든 상관없이 모든 컴포넌트가 로드된다는 의미입니다. 여기에 코드 분할을 적용하려면 일치하는 라우트에 해당하는 컴포넌트만 로드하도록 해야 합니다.

비동기 컴포넌트 생성하기

이를 위해 필요한 컴포넌트를 동적으로 불러오는 방법을 사용할 것입니다.

Change indicator src/components/AsyncComponent.js에 다음 내용을 추가하세요.

import React, { Component } from "react";

export default function asyncComponent(importComponent) {
  class AsyncComponent extends Component {
    constructor(props) {
      super(props);

      this.state = {
        component: null,
      };
    }

    async componentDidMount() {
      const { default: component } = await importComponent();

      this.setState({
        component: component,
      });
    }

    render() {
      const C = this.state.component;

      return C ? <C {...this.props} /> : null;
    }
  }

  return AsyncComponent;
}

여기서 몇 가지 작업을 수행하고 있습니다:

  1. asyncComponent 함수는 인자로 함수(importComponent)를 받습니다. 이 함수는 호출될 때 주어진 컴포넌트를 동적으로 불러옵니다. 이는 아래에서 asyncComponent를 사용할 때 더 명확해질 것입니다.
  2. componentDidMount에서 전달된 importComponent 함수를 호출합니다. 그리고 동적으로 로드된 컴포넌트를 상태에 저장합니다.
  3. 마지막으로, 컴포넌트가 로딩을 완료하면 조건부로 렌더링합니다. 아직 로딩이 완료되지 않았다면 null을 렌더링합니다. 하지만 null 대신 로딩 스피너를 렌더링할 수도 있습니다. 이렇게 하면 앱의 일부가 아직 로딩 중일 때 사용자에게 피드백을 제공할 수 있습니다.

Async 컴포넌트 사용하기

이제 이 컴포넌트를 라우트에서 사용해 보겠습니다. 정적으로 컴포넌트를 임포트하는 대신에,

import Home from "./containers/Home";

asyncComponent를 사용하여 원하는 컴포넌트를 동적으로 임포트할 것입니다.

const AsyncHome = asyncComponent(() => import("./containers/Home"));

여기서 중요한 점은 실제로 임포트를 하는 것이 아니라, AsyncHome 컴포넌트가 생성될 때 동적으로 import()를 수행할 함수를 asyncComponent에 전달한다는 것입니다.

또한, 여기서 함수를 전달하는 것이 이상하게 보일 수 있습니다. 왜 그냥 문자열(예: ./containers/Home)을 전달하고 AsyncComponent 내부에서 동적 import()를 하지 않는 걸까요? 이는 동적으로 임포트할 컴포넌트를 명시적으로 지정하기 위함입니다. Webpack은 이러한 임포트를 보고 필요한 부분(또는 청크)을 생성합니다. 이는 @wSokra@dan_abramov가 지적한 바 있습니다.

그런 다음 AsyncHome 컴포넌트를 라우트에서 사용합니다. React Router는 라우트가 매치될 때 AsyncHome 컴포넌트를 생성하고, 이 컴포넌트는 Home 컴포넌트를 동적으로 임포트하여 이전과 동일하게 동작합니다.

<Route path="/" exact component={AsyncHome} />

이제 Notes 프로젝트로 돌아가서 이러한 변경 사항을 적용해 보겠습니다.

Change indicator 변경 후 src/Routes.js는 다음과 같아야 합니다.

import React from "react";
import { Route, Switch } from "react-router-dom";
import asyncComponent from "./components/AsyncComponent";
import AppliedRoute from "./components/AppliedRoute";
import AuthenticatedRoute from "./components/AuthenticatedRoute";
import UnauthenticatedRoute from "./components/UnauthenticatedRoute";

const AsyncHome = asyncComponent(() => import("./containers/Home"));
const AsyncLogin = asyncComponent(() => import("./containers/Login"));
const AsyncNotes = asyncComponent(() => import("./containers/Notes"));
const AsyncSignup = asyncComponent(() => import("./containers/Signup"));
const AsyncNewNote = asyncComponent(() => import("./containers/NewNote"));
const AsyncNotFound = asyncComponent(() => import("./containers/NotFound"));

export default ({ childProps }) => (
  <Switch>
    <AppliedRoute path="/" exact component={AsyncHome} props={childProps} />
    <UnauthenticatedRoute
      path="/login"
      exact
      component={AsyncLogin}
      props={childProps}
    />
    <UnauthenticatedRoute
      path="/signup"
      exact
      component={AsyncSignup}
      props={childProps}
    />
    <AuthenticatedRoute
      path="/notes/new"
      exact
      component={AsyncNewNote}
      props={childProps}
    />
    <AuthenticatedRoute
      path="/notes/:id"
      exact
      component={AsyncNotes}
      props={childProps}
    />
    {/* 마지막으로, 매치되지 않는 모든 라우트를 잡습니다 */}
    <Route component={AsyncNotFound} />
  </Switch>
);

몇 가지 변경만으로도 앱이 코드 분할을 사용할 준비가 되었다는 점이 매우 멋집니다. 그리고 복잡성을 크게 증가시키지 않았습니다! 이전의 src/Routes.js는 다음과 같았습니다.

import React from "react";
import { Route, Switch } from "react-router-dom";
import AppliedRoute from "./components/AppliedRoute";
import AuthenticatedRoute from "./components/AuthenticatedRoute";
import UnauthenticatedRoute from "./components/UnauthenticatedRoute";

import Home from "./containers/Home";
import Login from "./containers/Login";
import Notes from "./containers/Notes";
import Signup from "./containers/Signup";
import NewNote from "./containers/NewNote";
import NotFound from "./containers/NotFound";

export default ({ childProps }) => (
  <Switch>
    <AppliedRoute path="/" exact component={Home} props={childProps} />
    <UnauthenticatedRoute
      path="/login"
      exact
      component={Login}
      props={childProps}
    />
    <UnauthenticatedRoute
      path="/signup"
      exact
      component={Signup}
      props={childProps}
    />
    <AuthenticatedRoute
      path="/notes/new"
      exact
      component={NewNote}
      props={childProps}
    />
    <AuthenticatedRoute
      path="/notes/:id"
      exact
      component={Notes}
      props={childProps}
    />
    {/* 마지막으로, 매치되지 않는 모든 라우트를 잡습니다 */}
    <Route component={NotFound} />
  </Switch>
);

모든 컨테이너를 정적으로 임포트하는 대신, 필요한 경우 동적으로 임포트할 함수를 생성하고 있습니다.

이제 npm run build를 사용하여 앱을 빌드하면 코드 분할이 동작하는 것을 볼 수 있습니다.

Create React App Code Splitting build screenshot

.chunk.js 파일은 우리가 동적으로 import()를 호출한 부분입니다. 물론, 우리 앱은 매우 작고 분할된 부분도 크게 중요하지 않습니다. 그러나 노트를 편집하는 페이지에 리치 텍스트 에디터가 포함되어 있다면, 그 크기가 얼마나 커질지 상상할 수 있습니다. 그리고 이는 앱의 초기 로딩 시간에 영향을 미칠 것입니다.

이제 npx sst deploy를 사용하여 앱을 배포하면, 데모에서 브라우저가 다른 청크를 필요에 따라 로드하는 것을 볼 수 있습니다.

Create React App loading Code Splitting screenshot

이제 끝났습니다! 몇 가지 간단한 변경만으로도 앱이 Create React App의 코드 분할 기능을 완전히 사용할 준비가 되었습니다.

다음 단계

이제 구현이 정말 쉬워 보이지만, 새로운 컴포넌트를 가져오는 요청이 너무 오래 걸리거나 실패할 경우 어떻게 될지 궁금할 수 있습니다. 또는 특정 컴포넌트를 미리 로드하고 싶을 수도 있습니다. 예를 들어, 사용자가 로그인 페이지에 있고 곧 홈페이지로 이동할 것 같을 때 홈페이지를 미리 로드하고 싶을 수 있습니다.

위에서 언급했듯이, 가져오기가 진행 중일 때 로딩 스피너를 추가할 수 있습니다. 하지만 이를 한 단계 더 나아가서 이러한 예외 상황을 처리할 수 있습니다. 이를 잘 처리하는 훌륭한 고차 컴포넌트가 있는데, 바로 react-loadable입니다.

이를 사용하려면 먼저 설치해야 합니다.

$ npm install --save react-loadable

그리고 위에서 사용했던 asyncComponent 대신 이 라이브러리를 사용합니다.

const AsyncHome = Loadable({
  loader: () => import("./containers/Home"),
  loading: MyLoadingComponent,
});

AsyncHome은 이전과 동일하게 사용됩니다. 여기서 MyLoadingComponent는 다음과 같이 생겼을 것입니다.

const MyLoadingComponent = ({ isLoading, error }) => {
  // 로딩 상태 처리
  if (isLoading) {
    return <div>Loading...</div>;
  }
  // 에러 상태 처리
  else if (error) {
    return <div>Sorry, there was a problem loading the page.</div>;
  } else {
    return null;
  }
};

이 간단한 컴포넌트는 다양한 예외 상황을 우아하게 처리합니다.

미리 로드 기능을 추가하거나 더 커스터마이징하려면 react-loadable의 다른 옵션과 기능을 확인해 보세요. 그리고 코드 분할을 즐겁게 해보세요!