react-router :: 2장. 코드 스플리팅 (Code Splitting)


이 튜토리얼은 3개의 포스트로 나뉘어진 이어지는 강좌입니다. 목차를 확인하시려면 여기를 참고하세요.

2장. 코드 스플리팅 (Code Splitting)

싱글페이지 어플리케이션의 단점은 자바스크립트 번들 파일에 어플리케이션에 대한 모든 로직을 불러와서, 규모가 커지면 용량이 커지기 때문에, 로딩속도가 지연 될 수 있습니다. 웹 어플리케이션을 하지만 걱정하지 마세요. 이에 대한 솔루션이 있습니다. 바로 이번에 배울, 필요에 따라 번들 파일을 여러개의 파일로 분리시키는 코드 스플리팅입니다.

2장에서는, 코드 스플리팅을 하는 방법을 배워보고, 추가적으로 파일이 수정되었을 때 화면을 새로고침 하지 않고 바뀐 부분만 리로딩해주는 react-hot-loader 도 적용을 해보도록 하겠습니다.

2-1. 코드 스플리팅의 기본

코드 스플리팅의 원리는 간단합니다. 한개의 파일에서 처음부터 모든걸 불러오는게 아니라, 우리가 설정하는 대로, 라이브러리나 컴포넌트가 실제로 필요해질 때, 나중에 불러오는 것 입니다. 코드 스플리팅을 시작하기 전에, 먼저 리액트 프로젝트에서 다음 명령어를 통해 웹팩 및 바벨 환경설정을 밖으로 꺼내주세요.

$ yarn eject

자, 이제 우리가 웹팩 설정을 직접 할 수 있게 되었습니다.

2017년 5월에 업데이트된 create-react-app v1.0 이상 버전부터는 Webpack2 을 사용하고, 코드스플리팅을 위한 환경설정이 이미 어느정도 되어있기 때문에 건들여야 할 설정은 그리 많지 않습니다.

Vendor

코드 스플리팅을 하려면 먼저 Vendor 설정을 해주어야합니다. 우리는 먼저 프로젝트에서 전역적으로 사용되는 라이브러리들을 분리시켜줄건데요, 이를 Vendor 파일이라고 부릅니다. 예를들어, react, react-dom, redux, react-redux, react-router-dom, styled-components 등의 라이브러리처럼 프로젝트 전체에서 꼭 필요한 라이브러리들을 넣어주면 됩니다.

webpack.config.dev.js 파일의 entry 부분을 확인해보세요.

config/webpack.config.dev.js

  entry: [
    require.resolve('react-dev-utils/webpackHotDevClient'),
    require.resolve('./polyfills'),
    require.resolve('react-error-overlay'),
    paths.appIndexJs,
  ]

현재는 이렇게 배열형태로 되어있는데, 이렇게 배열 형태로 되어있으면 vendor 설정을 하지 못합니다. 이 부분을 다음과 같이 수정하세요:

  entry: {
    dev: 'react-error-overlay',
    vendor: [
      require.resolve('./polyfills'),
      'react',
      'react-dom',
      'react-router-dom'
    ],
    app: ['react-dev-utils/webpackHotDevClient', paths.appIndexJs]
  }

객체로 변형을 하여, 개발에만 필요한 react-error-overlay 는 dev 라는 이름으로 저장하게했고, 전역적으로 사용되는 라이브러리는 vendor 에 넣었으며, (polyfill 은 구형 브라우저에서도 Promise 등의 ES6 전용 코드가 제대로 작동하게하는 라이브러리입니다) app 부분엔 webpackHotDevClient 와 appIndexJs 를 넣었습니다.

왜 webpackHotDevClient 는 dev 쪽에 안넣었냐구요? 그 이유는 그렇게하면 코드가 수정되도 브라우저가 새로고침이 되지 않기때문입니다. 따라서 webpackHotDevClient 와 appIndexJs 를 한 파일로 저장하게 했지요.

애초에, 개발서버를 위한 웹팩 설정에서는 코드 스플리팅을 할 필요가 없습니다. 다만, 우리는 이 과정을 배워보면서 어떻게 작동하는지 알아보기 위하여 하는 것 입니다. 코드스플리팅을 이해하고 난 다음에는, 프로덕션에서만 코드스플리팅을 하도록 설정할것입니다.

프로덕션에서만 코드스플리팅을 적용시키는것은 중요합니다. 왜냐하면 우리는 이어지는 강의에서 서버사이드 렌더링도 해볼것이기 때문이죠. 서버사이드 렌더링을 할 때 또한 코드스플리팅이 불필요합니다.

entry 를 수정하셨다면, output 부분의 filename 과 chunkFilename 을 다음과 같이 변경해주세요.

    filename: 'static/js/[name].[hash].js',
    chunkFilename: 'static/js/[name].[chunkhash].chunk.js',

filename 부분에는 [hash] 값을 주었는데요, 이 해쉬 값은 앱이 빌드 될 때마다 새로운 값이 생겨납니다. 그 하단의 chunkFilename 은 [chunkhash]가 들어가있습니다. 웹팩쪽에서 미리 분리시킨 파일말고, 우리가 코드를 통해 직접 분리시킬 파일들은 chunkFile 이라고 칭합니다. 이 부분은 잠시 후 알아볼것이구요, 이렇게 파일이름에 해쉬값을 포함시켜주면 각 파일마다 고유의 이름을 가질 수 있게 되고, 코드가 업데이트 되었을때 기존 캐시를 사용하지 않고 최신 파일을 불러와서 사용하도록 할 수 있습니다.

설정을 다 하셨다면, 웹팩 개발서버를 끄고 다시 실행하세요.

그리고 브라우저를 열어서 개발자도구의 Network 를 열고 새로고침을 하면, 다음과 같이 파일이 분리되어있을거에요.

잘 된것같죠? 사실 아직 분리는 안되었습니다. 왜냐구요? 정말 분리가 되었다면.. app 의 파일사이즈가 좀 줄어야 하는데, 오히려 vendor 보다 파일사이즈가 큽니다. 프로젝트의 로직이 많다면 이럴수도 있겠지만 지금상황에서는 뭔가 좀 잘못됐죠. 지금 상태는 그저 결과물 파일이 여러개가 되었을뿐, vendor 에 들어가야 할 내용이 여전히 app 안에 들어있습니다. app 에서는 그저 react, react-dom 등의 라이브러리를 불러왔었기에 그대로 번들링을 한것이구요.

app 안에 들어있는 중첩된 vendor 내용을 없애주고싶지요? 이 부분은 웹팩의 CommonsChunkPlugin 을 통하여 해결해줄 수 있습니다. 다시 webpack.config.dev.js 파일을 열어서 plugins 쪽에 다음 코드를 삽입하세요.

config/webpack.config.dev.js – plugins

  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      // filename: 'vendor.js' // 이런식으로 파일이름을 지정해 줄 수도 있습니다. (hash 생략가능)
    }),

이제 다시 페이지를 불러와보세요.

이제 좀 제대로 됐군요!

이 작업은 더 나은 코드 스플리팅을 위한 준비 작업일뿐입니다. 다음 섹션에서는 비동기적으로 코드를 불러오는 작업을 해보도록 하겠습니다.

 

2-2. 비동기적 코드 불러오기: 청크 생성

자, 이제 코드 스플리팅의 꽃인 코드를 비동기적으로 불러오는 작업을 해봅시다. 코드를 비동기적으로 불러오면, 웹팩에서 처리를 하면서 코드를 분리시키는데요, 이를 청크(chunk) 라고 부릅니다.

자, 먼저 다음과 같이 SplitMe 라는 간단한 컴포넌트를 components 디렉토리에 만들어보세요.

src/components/SplitMe.js

import React from 'react';

const SplitMe = () => {
    return (
        <h3>
            SplitMe
        </h3>
    );
};

export default SplitMe;

코드 스플리팅 할 파일 자체에는 특별히 하는게 없습니다. 하지만 이를 불러올때는 평소와 조금 다릅니다.

비동기적으로 파일을 불러오기 위해서, import 를 최상단에서 하는것이 아니라, 특정 함수에서 불러오도록 작성합니다. LifeCycle 메소드 안에 넣을수도 있고, 우리가 따로 함수를 지정해줄 수도 있겠죠.

다음 코드는 App 컴포넌트에서 SplitMe 컴포넌트를 버튼이 눌려졌을 때 비동기적으로 불러오는 코드입니다. 먼저 한번 훑어보면서 직접 작성을 해보세요.

src/shared/App.js

import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts } from 'pages';
import Menu from 'components/Menu';

class App extends Component {

    state = {
        SplitMe: null
    }

    showSplitMe = () => {
        // 비동기적으로 코드를 불러옵니다. 함수의 결과는 Promise 를 반환합니다.
        // import() 는 모듈의 전체 네임스페이스를 불러오므로, default 를 직접 지정해주어야합니다.
        import('components/SplitMe').then(({default: Component}) => {
            // 불러오고 난 다음엔 컴포넌트를 state 에 집어넣습니다.
            this.setState({
                SplitMe: Component
            });
        });
    }

    render() {
        const { SplitMe } = this.state; // state 에 담겨있는 SplitMe 에 대한 레퍼런스를 만들고

        return (
            <div>
                <Menu/>
                { SplitMe && <SplitMe/> /* SplitMe 가 유효하면 렌더링을 해줍니다 */}
                <button onClick={this.showSplitMe}>ClickMe</button>
                <Route exact path="/" component={Home}/>
                <Route path="/posts" component={Posts}/>
                <Switch>
                    <Route path="/about/:name" component={About}/>
                    <Route path="/about" component={About}/>
                </Switch>
            </div>
        );
    }
}

export default App;

import() 은 Promise 를 반환하며, 해당 Promise 가 resolve 하는 값은 모듈의 전체 네임스페이스입니다. then() 안에 들어있는 함수의 파라미터에 ({default: Component}) 가 들어갔지요? 이는 비구조화 할당 문법의 활용법입니다. 결과값 객체 안에 있는 default 를 Component 라는 이름으로 할당해주는것입니다.

다음 코드를 읽고 브라우저의 개발자 콘솔에서 직접 실행해보신다면 이해가 바로 될거에요.

cosnt foo = { hello: 'world' };
const { hello: bar } = foo;
console.log(bar) // world

App 코드를 완성하고나면, 브라우저를 열어서 개발자도구의 Network 를 열고, 버튼을 클릭해보세요.

버튼을 누르니까 파일을 새로 불러왔지요?

자.. 그런데 코드스플리팅을 할 때마다 이렇게 상태관리를 해주는건 매우 귀찮을것입니다.

이를 간편하게 하기위하여 페이스북의 개발자 Andrew Clark (acdlite) 가 공유한 코드가 있는데, 한번 살펴볼까요?

// https://gist.github.com/acdlite/a68433004f9d6b4cbc83b5cc3990c194

function asyncComponent(getComponent) {
  return class AsyncComponent extends React.Component {
    static Component = null;
    state = { Component: AsyncComponent.Component };

    componentWillMount() {
      if (!this.state.Component) {
        getComponent().then(({default: Component}) => {
          AsyncComponent.Component = Component
          this.setState({ Component })
        })
      }
    }
    render() {
      const { Component } = this.state
      if (Component) {
        return <Component {...this.props} />
      }
      return null
    }
  }
}

const Foo = asyncComponent(() => import('./Foo'))
const Bar = asyncComponent(() => import('./Bar'))

asyncComponent 함수는 파라미터로 컴포넌트를 불러오는 함수를 받아와서 상태관리를 자동으로 해줍니다. 그리고 스플리팅할 코드를 코드의 하단에 나와있는것처럼 불러와주면 되지요.

이 코드를 살펴보면, 함수 내에서 컴포넌트를 정의하고, componentWillMount에서 불러온 컴포넌트를 static 값으로 설정합니다. 이렇게 함으로서, 한번 불러왔던 컴포넌트가 언마운트 되어도, static 으로 설정한 값은 유지되기 때문에, 나중에 컴포넌트가 마운트 될 때 초기 state 가 설정되면서 기존에 불러왔었던 컴포넌트를 사용하기 때문에 파일을 다시 새로 불러오지 않아도 계속 사용 할 수 있습니다.

자, 이제 비동기 코드로딩이 어떻게 이뤄지는지도 이해했고, 이를 쉽게 하는 방법도 알게되었습니다. 다음 섹션에서는 방금 나온 코드의 asyncComponent 를 통해 라우트들을 코드 스플리팅 하는 방법을 알아보겠습니다.

 

2-3. 라우트 코드스플리팅 하기

준비하기

우선, 우리가 이전 섹션에서 작업한 SplitMe 관련 코드는 날려주세요. 연습삼아 한번 해본것이기 때문에 더 이상 해당 코드들은 필요가 없어졌습니다.

그리고, 다음과 같이, asyncRoute 함수를 lib 디렉토리에 저장하세요.

src/lib/asyncRoute.js

import React from 'react';

export default function asyncComponent(getComponent) {
  return class AsyncComponent extends React.Component {
    static Component = null;
    state = { Component: AsyncComponent.Component };

    componentWillMount() {
      if (!this.state.Component) {
        getComponent().then(({default: Component}) => {
          AsyncComponent.Component = Component
          this.setState({ Component })
        })
      }
    }
    render() {
      const { Component } = this.state
      if (Component) {
        return <Component {...this.props} />
      }
      return null
    }
  }
}

코드스플리팅을 위한 라우트 인덱스 만들기

섹션 1에서 우리가 프로덕션에서만 코드스플리팅을 지원 할 것이라고 했던 것, 기억나시나요? 개발서버에서는 코드스플리팅을 적용하지 않고, 프로덕션에서만 적용을 하기 위하여, 우리는 코드스플리팅을 위한 라우트 인덱스를 따로 만들것입니다.

우선, index.async.js 라는 파일을 pages 디렉토리에 만들고, 다음과 같이 코드를 입력하세요.

import asyncRoute from 'lib/asyncRoute';

export const Home = asyncRoute(() => import('./Home'));
export const About = asyncRoute(() => import('./About'));
export const Post = asyncRoute(() => import('./Post'));
export const Posts = asyncRoute(() => import('./Posts'));

asyncRoute 함수를 통하여 코드를 비동기적으로 불러오는 컴포넌트를 생성해서 내보내주었습니다. 이제, App 컴포넌트에서 pagespages/index.async.js 로 치환하세요.

그 다음엔, 브라우저에서 Network 를 열고 페이지를 이동해보세요.

잘 되지요? 잘 되는것을 확인하셨으면, 다시 pages 로 원상복구 하세요. 왜냐구요? index.async.js 를 불러오는건 프로덕션에서만 불러오도록 설정을 할것이기 때문입니다.

웹팩 설정하기

자, 이제 프로덕션을 위한 웹팩설정을 해주겠습니다.

webpack.config.prod.js 파일의 entry 부분을 다음과 같이 수정하세요:

config/webpack.config.prod.js – entry

  entry: {
    vendor: [
      require.resolve('./polyfills'),
      'react',
      'react-dom',
      'react-router-dom'
    ],
    app: paths.appIndexJs
  },

output 의 경우엔, 이미 설정이 되어있기 때문에 건들일 필요가 없습니다. 그 다음엔, 프로덕션에서만 라우트를 코드스플리팅 하는 설정을 할 건데요, 원리는 간단합니다. 프로덕션 웹팩 설정에서는, 만약에 ‘pages’ 를 불러올때 이를 자동으로 ‘pages/index.async.js’ 로 치환하도록 하면 됩니다.

이 작업은 webpack 의 NormalModuleReplacementPlugin 이 해줍니다.

config/webpack.config.prod.js – plugin

설정의 plugins: 부분에 NormalModuleReplacementPlugin 을 넣으세요.

  plugins: [
    new webpack.NormalModuleReplacementPlugin(
        /^pages$/,
        'pages/index.async.js'
    ),
    // CommonsChunkPlugin 도 적용하세요.
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
    }),

vendor 가 제대로 처리되기 위하여, CommonsChunkPlugin 을 적용하는것도 잊지 마세요~

자, 이제 여러분은 코드 스플리팅을 하는 방법을 익혔습니다. 이번 2장에서 배운 코드 스플리팅을 통하여 앞으로 리액트 어플리케이션을 만들 때 유저를 위하여 더욱 최적화 된 환경을 준비해줄 수 있겠지요?

여기까지 잘 따라오셨다면, 한번 yarn build 명령어를 통해 빌드를 하고 build 디렉토리를 확인해보세요

다음 섹션에서는, 코드 스플리팅이랑은 그렇게 큰 연관은 없지만, 코드 스플리팅이 프로덕션 빌드에서만 적용되어있다는 점을 살려, 개발서버에서는 코드가 변경되었을 때 페이지를 새로고침 하지 않고 실시간으로 코드만 갈아끼워주는 모듈인 react-hot-loader 를 프로젝트에 적용하는 방법을 알아보겠습니다.

 

2-4. react-hot-loader 적용하기

react-hot-loader 는 코드가 변경되었을 때 페이지를 새로고침하지 않고 바뀐부분만 빠르게 교체해주는 라이브러리입니다. 비록, 리액트 어플리케이션을 개발 할 때 필수적인 개발도구는 아니지만, 앱의 규모가 커지면 개발서버가 수정될때마다 새로고침이 된다면 딜레이가 발생되어 개발의 흐름이 중간중간 1~6초씩 끊길 수도 있습니다. 특히, styled-components 를 사용하게 되는 경우엔, 스타일이 JS 안에 있어서, 스타일을 수정 할 때마다 새로고침이 된다는게 조금 불편할수도 있겠죠.

이렇게 자바스크립트 코드의 일부분만 교체하는 기능은 웹팩 개발서버의 기능이기 때문에, 라이브러리 없이도 코드를 조금 건들여주면 가능합니다. 하지만, 어플리케이션의 state 를 계속 유지하려면 과정이 복잡하기 때문에 라이브러리의 힘을 빌릴 필요가 있습니다.

설치하기 & 웹팩 설정

Webpack2 와 react-router v4 와 호환이 잘 되는 최신버전 [email protected] 버전을 설치하도록 하겠습니다.

$ yarn add [email protected]

이제, 개발서버를 위한 웹팩설정을 조금 건들여줘야합니다. 코드스플리팅이 제대로 작동하는걸 한번 테스팅 했으니, entry를 다시 배열형태로 되돌려주세요. 그리고, 'react-hot-loader/patch' 를 최상단에 넣으세요.

config/webpack.config.dev.js – entry

  entry: [
    'react-hot-loader/patch', 
    'react-dev-utils/webpackHotDevClient',
    'react-error-overlay',
    require.resolve('./polyfills'),
    paths.appIndexJs
  ],

그 다음엔, babel-loader 를 검색하여 해당 로더설정에 plugins 를 설정하세요.

config/webpack.config.dev.js – babel-loader 설정

      {
        test: /\.(js|jsx)$/,
        include: paths.appSrc,
        loader: require.resolve('babel-loader'),
        options: {
          cacheDirectory: true,
          plugins: [
            'react-hot-loader/babel'
          ]
        },
      }

자, 이제 서버를 한번 재시작하세요.

그 다음엔, src 디렉토리의 index.js 파일을 다음과 같이 수정하세요:

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import Root from './client/Root';
import registerServiceWorker from './registerServiceWorker';
import { AppContainer } from 'react-hot-loader';
import './index.css';

const render = Component => {
  ReactDOM.render(
    <AppContainer>
      <Component />
    </AppContainer>,
    document.getElementById('root')
  )
}

render(Root)


if (module.hot) {
  module.hot.accept('./client/Root', () => { render(Root) })
}


registerServiceWorker();

여기서 AppContainer 는 개발환경에서 모듈 리로딩 및 에러 처리를 도와주는 컴포넌트입니다. AppContainer 는 프로덕션 환경에서는 자동으로 비활성화됩니다.

그 하단에는, module.hot 관련 코드가 사용되었는데, 이 코드는 웹팩관련 코드입니다. 이 코드는 변화가 발생 했을 때, 모듈 업데이트를 허용해주고, 해당 모듈의 하위 모듈들의 업데이트도 허용해줍니다.

이제 App 에 들어가서 렌더 메소드를 마음대로 수정해보세요. 업데이트가 새로고침 없이 이뤄지나요?

수정이 되었을때 바로바로 적용이 되니, 개발이 한결 더 편해지겠지요?

자, 우리는 지금까지 클라이언트쪽 라우팅도, 코드 스플리팅도, 리액트 핫 로딩도 경험해보았습니다. 이제는 드디어, 서버사이드 렌더링을 건들여볼 차레입니다!

  • Sangtae Humphrey Ahn

    다른 것들은 다 이해하기 괜찮은데 Webpack 관련한 코드들은 이해하기 어렵네요 … 백엔드쪽 지식이 많이 부족해서 그런지 ㅠㅠ 혹시 참조할 만한 documentation같은 것들이 있을까요??

  • 염기석

    config/webpack.config.dev.js

    어떻게 생성됐는지 빠져있는거같습니다.

    혹시 동영상강의는 올라오나요?

  • 장수영

    여러번 반복해서 보고 익혀야 겠네요 한번만 봐서는 아직 잘모르겠네요
    강의 감사합니다

  • naminsik

    저도 벨로퍼트님 덕분에 많이 익혀왔는데 딱 여기서 막히네요 ㅠ
    2-1 부터 막히다니 허허..
    npm으로 하다보니 eject해도 config/webpack.config.dev.js 를 찾질 못하고 있어요~

    여러번 반복해서 읽어봐야겠습니다.

  • jaefree82

    항상 감사히 생각하고 강의를 봅니다.
    제가 아직 이해가 많이 부족한지 어렵네요. 이번 장에서 궁금한게 react-hot-loader를 개발환경에만 적용하는데 production에 적용을 안하는 이유가 따로 있는건가요?

    • react-hot-loader 는 개발을 하면서 코드가 바뀔때마다 핫모듈 리로딩을하는 기능인데, 프로덕션에서는 이미 빌드 한 상태이니, 리로딩이 필요없겠지요?

  • 준영 박

    안녕하세요 작성해주신 방법으로 너무나 잘 보고 적용했습니다.

    적용중에 이슈가 좀 있어서 질문 드립니다 ㅜㅜ

    server side rendering 적용시 생기는 이슈인데요.

    asyncRoute 적용시 js 로딩시에 무조건 re-render를 하게 되는데, 이걸 막아줘야 해서요 ㅜㅜ

    https://stackoverflow.com/questions/45398007/server-side-render-re-render-at-client#
    와 비슷한 상황입니다

    dynamic import가 비동기라 라이프사이클로도 제어가 안되네요 ㅜㅜ 제머리로는 한계가..

    혹시 해결할 방법이 없을까요??

    • 안녕하세요~

      이렇다 할 만한 솔루션을 찾기 어려워서 힘들었죠! ㅠㅠ

      이 이슈를 해결하기 위해선, route 들이 담긴 config 파일을 작성한다음에,
      react-router v4 의 matchPath 함수를 사용하여 (혹은, path-to-regexp 사용) 전달받은 URL에 어떤 컴포넌트를 보여주는지 확인하고, asyncRoute 의 getComponent 를 미리 실행하셔야 합니다

      물론, 이건 제가 삽질하면서 만든 방식이라 더 좋은 솔루션이 있을지도 모릅니다 ㅎㅎ 저는 충분히 괜찮더군요

      react-router v3 에서도 동일한 방식으로 적용 할 수 있습니다. (최근에 라프텔 https://laftel.net/ 에서 이 방식으로 서버사이드 렌더링을 구현하였습니다.

      예제 코드: https://gist.github.com/velopert/5aa9778c59702813ac817d5fbb636120

      예제 프로젝트를 만들어둔게있는데 아직 깃헙에 올리진 않았습니다. 조만간 올릴 예정이구요!
      만약에 궁금한게 더 있으시다면, 페이스북으로 메시지 주세요 🙂

      https://www.facebook.com/velopert/

      • 준영 박

        오오 참조해서 해보겠습니다!! 감사합니다!!!