브라질 출신 풀스택 개발자인 에수 실바(Esau Silva)How to use Webpack with React: an in-depth tutorial 튜토리얼을 번역한 글이다. 리액트에 익숙한 경험자를 대상으로 웹팩(webpack)과 바벨(babel)을 사용해 리액트 개발 환경을 만드는 과정을 소개한다. 완성된 코드는 react-starter-boilerplate-hmr에서 확인할 수 있다.


리액트 라우터(React Router), 핫 모듈 리플레이스먼트(HMR, Hot Module Replacement), 경로(Route)와 벤더(Vendor)로 코드 분할 및 배포 설정 구성까지, 웹팩(Webpack)과 리액트(React) 사용법의 모든 것을 알아보자.

아래는 앞으로 사용할 라이브러리 목록이다.

  • React 16
  • React Router 4
  • Semantic UI : CSS 프레임워크
  • Hot Module Replacement (HMR)
  • CSS Autoprefixer
  • CSS Modules
  • Stage 1 Preset
  • Webpack 4
  • Route과 Vendor로 코드 분할
  • Webpack Bundle Analyzer

준비 사항

  • yarnNode.js 설치가 되어 있는지 확인한다.
  • 리액트(React)와 리액트 라우터(React Router) 기초 지식이 필요하다.
  • 역자: 리액트 기초지식이 없다면, 이 튜토리얼을 멈추고 create-react-app으로 간단한 리액트 앱을 만들어보는 것을 추천한다. create-react-app은 리액트 개발 도구와 환경 설정이 이미 세팅되어 있기 때문에 애플리케이션 구현에만 신경쓰면 된다. 리액트 도움닫기 - create-react-app 장부터 읽는 것을 추천한다.

📌 yarn 대신 npm을 사용할 수 있으나 명령어가 다를 수 있으니 유의하길 바란다.

1. 의존성 초기화

제일 먼저 새 디렉터리를 만들고 그 안에 package.json 파일을 만든다.

mkdir webpack-for-react && cd $_
yarn init -y

package.json 파일은 아래와 같을 것이다.

{
  "name": "webpack-for-react",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

초기 프로덕션 의존성(production dependencies) 과 개발 의존성(development dependencies)을 설치한다. 개발 의존성은 개발 단계에서만 사용되는 의존 라이브러리이며, 프로덕션 의존성은 배포 단계에서 사용되는 라이브러리를 말한다.

yarn add react react-dom react-prop-types react-router-dom semantic-ui-react

yarn add babel-core babel-loader babel-preset-env babel-preset-react babel-preset-stage-1 css-loader style-loader html-webpack-plugin webpack webpack-dev-server webpack-cli -D

📌 볼드체는 변경된 코드를 뜻한다. 의존성 버전은 다를 수 있다.


{
  "name": "webpack-for-react",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
    
  "dependencies": {
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-prop-types": "^0.4.0",
    "react-router-dom": "^4.2.2",
    "semantic-ui-react": "^0.77.1"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.3",
    "babel-preset-env": "^1.6.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-1": "^6.24.1",
    "css-loader": "^0.28.10",
    "html-webpack-plugin": "^3.0.4",
    "style-loader": "^0.19.1",
    "webpack": "^4.0.0",
    "webpack-cli": "^2.0.14",
    "webpack-dev-server": "^3.0.0"
  } 
}

설치된 라이브러리를 간략히 알아보자.

  • react: 리액트
  • react-dom: 브라우저 DOM 메서드를 제공한다.
  • react-prop-types: React props 타입을 체크한다.
  • react-router-dom: Provides routing capabilities to React for the browser
  • semantic-ui-react: CSS 프레임워크
  • babel-core: Babel 핵심 의존성 라이브러리이다. Babel(바벨)은 자바스크립트 ES6를 ES5로 컴파일하여 현재 브라우저가 이해할 수 있도록 변환하는 도구다.
  • babel-loader: babel과 webpack을 사용해 자바스크립트 파일을 컴파일한다.
  • babel-preset-env: ES2015, ES2016, ES2017 버전을 지정하지 않아도 바벨이 자동으로 탐지해 컴파일한다.
  • babel-preset-react: 리액트를 사용한다는 것을 바벨에게 말해준다.
  • babel-preset-stage-1: TC39에서 검토 중인 Stage 1 스펙을 사용한다. (stage-0부터 3까지는 EcmaScript 스펙 중에서 비공식 실험적인 기술들을 사용할 수 있게 해주는 프리셋으로 Stage 2와 Stage 3도 사용 가능하다.)
  • css-loader: import/require()처럼 @importurl() 해석한다.
  • html-webpack-plugin: 애플리케이션을위한 HTML 파일을 생성하거나 템플릿을 제공한다.
  • style-loader: <style> 태그를 삽입하여 CSS에 DOM을 추가한다.
  • webpack: 모듈 번들러(Module bundler)
  • webpack-cli: Webpack 4.0.1 이상에서 필요한 커맨드라인 인터페이스다.
  • webpack-dev-server: 애플리케이션 개발 서버를 제공한다.

2. Babel 설정

최상위 디렉터리 webpack-for-react에 바벨 설정 파일을 만든다.

touch .babelrc

.babelrc 파일을 열어 아래 코드를 추가한다.

{
  "presets": ["env", "react", "stage-1"]
}

바벨이 프리셋(preset) 플러그인을 사용할 수 있게 됐다. 나중에 Webpack에서 babel-loader를 호출할 때 어떤 역할을 하는지 이해하게 될 것이다.

3. Webpack 설정

지금부터 본격적으로 시작해보자. Webpack 설정 파일을 만들어보자.
터미널에서 아래 명령어를 입력해 webpack.config.js을 만든다.

touch webpack.config.js

webpack.config.js 파일을 열고 아래 코드를 작성한다.

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const port = process.env.PORT || 3000;

module.exports = {
  // webpack 설정 부분
};

웹팩 기본 설정을 마쳤다. 다음으로 webpackhtml-webpack-plugin이 필요하다. 환경 변수 PORT가 없으면 기본 포트를 제공하고 모듈을 내보내는 일을 한다.

webpack.config.js 파일을 다시 열어 아래 코드를 추가한다.

...
module.exports = {
  mode: 'development',
};

설정 사항이 개발 환경(development)인지 프로덕션(production)인지를 알려줬다.

"개발 모드는 속도와 개발자 경험에 최적화되어 있다. 프로덕션 모드는 애플리케이션을 배포와 관련된 유용한 집합을 제공한다." 웹팩 4: mode and optimization 에서 발췌

...
module.exports = {
  ...
  entry: './src/index.js',
  output: {
    filename: 'bundle.[hash].js'
  },
};

웹팩 인스턴스를 실행하기 위해 entry, output, filename, devtool, module, rules 값을 설정해보자.

  • entry - 애플리케이션의 진입점(entry point)이다. 리액트 앱이 있는 위치와 번들링 프로세스가 시작되는 지점이다. (웹팩 공식 문서 - entry point)

웹팩4에서는 웹팩3과 반대로 entry를 생략할 수 있다. entry가 없으면 웹팩은 시작점이 ./src 디렉토리 아래에 있다고 가정한다. 그러나 이 튜토리얼에서는 entry를 설정해 시작점을 분명하게 표시하기로 한다. 나중에 이 부분을 삭제해도 된다.

  • output - 컴파일된 파일을 저장할 경로를 알려준다.
  • filename - 번들된 파일 이름을 말한다. [hash]는 애플리케이션이 수정되어 다시 컴파일 될 때마다 웹팩에서 생성된 해시로 변경해주어 캐싱에 도움이 된다.
...
module.exports = {
  ...
  devtool: 'inline-source-map',
};

devtool소스 맵(source maps)을 생성해 애플리케이션 디버깅을 도와준다. 소스 맵에는 여러 가지 유형이 있으며 그 중 inline-source-map은 은 개발시에만 사용된다. (이외 옵션은 공식 문서를 참고한다.)

...
module.exports = {
  ...
  module: {
    rules: [

      // 첫 번째 룰
      {
        test: /\.(js)$/,
        exclude: /node_modules/,
        use: ['babel-loader']
      },

      // 두 번째 룰
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader',
            options: {
              modules: true,
              camelCase: true,
              sourceMap: true
            }
          }
        ]
      }
    ]
  },
};
  • module - 애플리케이션 내 포함되는 모듈을 정의한다. 우리의 경우 ESNext(바벨), CSS 모듈에 해당한다.
  • rules - 각 모듈을 처리하는 방법을 설정한다.

첫 번째 룰

node_modules 디렉터리를 제외한 자바스크립트 파일을 찾은 다음 babel-loader를 통해 바벨을 사용해 바닐라 자바스크립트로 변환한다. 바벨은 .babelrc 파일에서 설정 내용을 읽는다.

두 번째 룰

css 파일을 찾고 style-loadercss-loader로 css를 처리한다. 그 다음 css-loader에게 CSS 모듈, 카멜 케이스(camel case), 소스 맵을 사용할 것을 지시한다.

CSS 모듈과 카멜 케이스

이제 import Styles from ‘./styles.css 또는 import { style1, style2 } from './styles.css'와 같이 구조 해체 문법으로 스타일 정의를 할 수 있다.

...
<div className={Style.style1}>Hello World</div>
// 또는 구조해체 문법을 사용할 수 있다.
<div className={style1}>Hello World</div>
...

아래와 같은 CSS 클래스가 있다면,

.home-button {...}

아래 코드와 같이 카멜 케이스를 정의해 CSS 클래스를 가져온다.

import { homeButton } from './styles.css'

이제 플러그인을 구성해보자.

html-webpack-plugin은 다른 옵션을 가진 객체를 받는다. HTML 템플릿과 favicon을 지정한다. 이후 Bundle Analyzer 및 HMR 용 플러그인을 추가할 것이다.

📌웹팩 공식 문서 - 플러그인 설정

webpack.config.js 파일에 아래 코드를 추가하자.

module.exports = {
  ...
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      favicon: 'public/favicon.ico'
    })
  ],
};

마지막으로 개발 서버를 설정한다.

...
module.exports = {
  ...
  devServer: {
    host: 'localhost',
    port: port,
    historyApiFallback: true,
    open: true
  }
};

host는 localhost로, port는 기본 port로 할당했다. 현재 기본 port 번호는 3000이다. historyApiFallbacktrue로, opentrue로 설정한다. 서버를 실행하면 브라우저가 자동으로 열리고 http://localhost:3000 에서 애플리케이션이 자동으로 실행된다.

📌 웹팩 공식 문서 - 개발 서버 설정

완성된 웹팩 설정 코드는 아래와 같다.

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const port = process.env.PORT || 3000;

module.exports = {
  mode: 'development',  
  entry: './src/index.js',
  output: {
    filename: 'bundle.[hash].js'
  },
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.(js)$/,
        exclude: /node_modules/,
        use: ['babel-loader']
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader',
            options: {
              modules: true,
              camelCase: true,
              sourceMap: true
            }
          }
        ]
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      favicon: 'public/favicon.ico'
    })
  ],
  devServer: {
    host: 'localhost',
    port: port,
    historyApiFallback: true,
    open: true
  }
};

3. 리액트 애플리케이션 만들기

간단한 리액트 애플리케이션을 만들어보자. home, page not found, dynamic 각 세 웹 페이지 경로를 만들고 비동기로 로딩하게 만들 것이다.

📌리액트와 리액트 라우터 기초 지식이 있다고 생각하고 설명한다.

현재 프로젝트 구조는 아래와 같다.

|-- node_modules
|-- .babelrc
|-- package.json
|-- webpack.config.js
|-- yarn.lock

터미널을 열고 public 폴더를 만들고 index.html을 만든다.

mkdir public && cd $_
touch index.html

public 디렉터리 안에 favicon도 추가한다. 이 곳에서 기본 리액트 favicon 샘플 이미지 파일을 다운받을 수 있다.

index.html 파일을 열고 아래 코드를 붙여 넣는다.

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.13/semantic.min.css"></link>
  <title>webpack-for-react</title>
</head>

<body>
  <div id="root"></div>
</body>

</html>

간단한 HTML 템플릿이다. Semantic UI 스타일시트 URL를 추가하고 div도 추가했다. 이 부분이 리액트 앱이 렌더링되는 곳이다.

다시 터미널로 돌아와 명령어로 index.js 파일을 만든다.

cd ..
mkdir src && cd $_
touch index.js

index.js 파일을 열고 아래 코드를 넣는다.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';

ReactDOM.render(<App />, document.getElementById('root'));

터미널에서 명령어로 리액트 컴포넌트 파일을 만든다.

mkdir components && cd $_
touch App.js Layout.js Layout.css Home.js DynamicPage.js NoMatch.js

프로젝트 구조가 아래와 같을 것이다.

|-- node_modules
|-- public
    |-- index.html
    |-- favicon.ico
|-- src
    |-- components
        |-- App.js
        |-- DynamicPage.js
        |-- Home.js
        |-- Layout.css
        |-- Layout.js
        |-- NoMatch.js
    |-- index.js
|-- .babelrc
|-- package.json
|-- webpack.config.js
|-- yarn.lock

리액트 라우터로 페이지 경로와 컴포넌트를 연결할 차례다.
App.js 파일을 열고 아래와 같이 수정한다.

import React from 'react';
import { Switch, BrowserRouter as Router, Route } from 'react-router-dom';

import Home from './Home';
import DynamicPage from './DynamicPage';
import NoMatch from './NoMatch';

const App = () => {
  return (
    <Router>
      <div>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route exact path="/dynamic" component={DynamicPage} />
          <Route component={NoMatch} />
        </Switch>
      </div>
    </Router>
  );
};

export default App;

이제 Layout 컴포넌트 스타일과 컴포넌트를 정의하자.

Layout.css 파일을 열고 아래 코드를 넣는다.

.pull-right {
  display: flex;
  justify-content: flex-end;
}

.h1 {
  margin-top: 10px !important;
  margin-bottom: 20px !important;
}

Layout.js 파일을 열고 아래 코드를 작성한다.

import React from 'react';
import { Link } from 'react-router-dom';
import { Header, Container, Divider, Icon } from 'semantic-ui-react';

import { pullRight, h1 } from './layout.css';

const Layout = ({ children }) => {
  return (
    <Container>
      <Link to="/">
        <Header as="h1" className={h1}>
          webpack-for-react
        </Header>
      </Link>
      {children}
      <Divider />
      <p className={pullRight}>
        Made with <Icon name="heart" color="red" /> by Esau Silva
      </p>
    </Container>
  );
};

export default Layout;

웹 사이트의 레이아웃을 만들었다. CSS Modules를 통해 layout.css에서 pullRight, h1 두 CSS 룰을 가져왔다. 카멜 케이스로 가져와야 한다.

Home.js를 열고 아래 코드를 작성한다.

import React from 'react';
import { Link } from 'react-router-dom';

import Layout from './Layout';

const Home = () => {
  return (
    <Layout>
      <p>Hello World of React and Webpack!</p>
      <p>
        <Link to="/dynamic">Navigate to Dynamic Page</Link>
      </p>
    </Layout>
  );
};

export default Home;

DynamicPage.js를 열고 아래 코드를 작성한다.

import React from 'react';
import { Header } from 'semantic-ui-react';

import Layout from './Layout';

const DynamicPage = () => {
  return (
    <Layout>
      <Header as="h2">Dynamic Page</Header>
      <p>This page was loaded asynchronously!!!</p>
    </Layout>
  );
};

export default DynamicPage;

NoMatch.js를 열고 아래 코드를 작성한다.

import React from 'react';
import { Icon, Header } from 'semantic-ui-react';

import Layout from './Layout';

const NoMatch = () => {
  return (
    <Layout>
      <Icon name="minus circle" size="big" />
      <strong>Page not found!</strong>
    </Layout>
  );
};

export default NoMatch;

필요한 모든 컴포넌트를 만들었다. 마지막으로 애플리케이션을 실행하는 스크립트를 package.json에 정의한다.


{
  "name": "webpack-for-react",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
   "scripts": {
    "start": "webpack-dev-server"
   },
  "dependencies": {
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-prop-types": "^0.4.0",
    "react-router-dom": "^4.2.2",
    "semantic-ui-react": "^0.77.1"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.3",
    "babel-preset-env": "^1.6.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-1": "^6.24.1",
    "css-loader": "^0.28.10",
    "html-webpack-plugin": "^3.0.4",
    "style-loader": "^0.19.1",
    "webpack": "^4.0.0",
    "webpack-cli": "^2.0.14",
    "webpack-dev-server": "^3.0.0"
  }
}

scriptsstart 키(key)를 추가했다. 이제 웹팩 개발 서버와 리액트가 함께 실행된다. 환경 설정 파일을 지정하지 않으면 webpack-dev-serverwebpack.config.js 파일을 최상단(루트) 디렉터리 기본 구성 항목으로 찾는다.

터미널에서 직접 확인해보자! 프로젝트 최상단 디렉터리로 가서 yarn 명령어로 서버를 실행한다.

yarn start

이제 리액트 앱은 웹팩으로 작동한다. 번들된 자바스크립트 해쉬인 bundle.d505bbab002262a9bc07.js로 파일명이 표시되는 것을 확인할 수 있다.

4. Hot Module Replacement (HMR) 설정

터미널로 돌아가, react-hot-loader 라이브러리를 개발 의존성으로 설치한다.

yarn add react-hot-loader -D

.babelrc 파일을 열고 볼드체로 된 부분을 추가한다. 2번째 줄 끝에 (,)를 추가하는 것도 잊지 말자.


{
  "presets": [["env", { "modules": false }], "react", "stage-1"],
  "plugins": ["react-hot-loader/babel"]
}


코드를 간결하게 만들기 위해 기존에 입력한 코드는 생략했다.

...
module.exports = {
  entry: './src/index.js',
  output: {
    ...
    publicPath: '/'
  },
  ...
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    ...
  ],
  devServer: {
    ...
    hot: true
  }
};

추가한 부분을 살펴보자.

  • publicPath: ‘/’ — Hot reloading 은 중첩된 경로에서 동작하지 않는다.
  • webpack.HotModuleReplacementPlugin — HMR 업데이트시 브라우저 터미널에 표시해 알아보기 쉽게 한다.
  • hot: true — 서버에 HMR 작동을 허락한다.

index.js 파일을 열고 <AppContainer>으로 애플리케이션 전체 컴포넌트를 감싼 다음module.hot을 체크하도록 설정한다.

import { AppContainer } from 'react-hot-loader';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';

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

render(App);

// Webpack Hot Module Replacement API 부분
if (module.hot) module.hot.accept('./components/App', () => render(App));

이제 HMR을 테스트해보자! 터미널로 다시 돌아와 서버를 재 실행해보자.

yarn start

새로고침 없이 앱이 업데이트 되는 것을 볼 수 있다.

브라우저에서 변경 사항을 표시하려면 크롬 개발자 도구(Chrome DevTools)에서 Rendering -> Paint flashing 을 선택 후 변경된 페이지마다 녹색 부분으로 강조 표시되는 것을 볼 수 있다. 터미널에서도 웹팩이 브라우저에 전송한 변경 사항을 확인할 수 있다.

5. 코드 분할

코드 분할(code splitting)을 사용하면 큰 번들 대신에 비동기 또는 병렬로 로드되는 여러 개의 번들을 만들 수 있다. 또한 Vendor 코드를 분리시켜 앱에서 로딩 시간을 줄일 수 있게 된다.

경로 지정

경로 별로 코드 분할을 수행할 수 있는 방법은 많다. 이 중 우리는 react-imported-component를 사용할 것이다.

사용자가 다른 경로로 이동할 때 로드 스피너를 보여주어 새 페이지가 로드될 때까지 대기하는 동안 사용자가 빈 화면을 보지 않게 만들어보자. 이를 위해 로딩 컴포넌트(Loading component)를 만들 것이다.

그러나 새 페이지가 실제로 아주 빨리 로딩되는 경우, 사용자는 몇 밀리 초 동안 깜빡이는 로드 스피너를 보지 못하게 되므로 300 밀리 초 정도 로딩되는 컴포넌트를 지연시키고자 한다. 이를 위해 react-delay-render 라이브러리를 사용할 것이다.

먼저 터미널에서 아래 명령어를 입력해 두 의존성을 추가하자.

yarn add react-imported-component react-delay-render

이제 Loading 컴포넌트를 만들어보자.

touch ./src/components/Loading.js

Loading.js 파일을 열고 아래 코드를 입력한다.

import React from 'react';
import { Loader } from 'semantic-ui-react';
import ReactDelayRender from 'react-delay-render';

const Loading = () => <Loader active size="massive" />;

export default ReactDelayRender({ delay: 300 })(Loading);

이제 Loading 컴포넌트를 만들었으니, App.js 파일을 열어 아래와 같이 수정한다.

import React from 'react';
import { Switch, BrowserRouter as Router, Route } from 'react-router-dom';
import importedComponent from 'react-imported-component';

import Home from './Home';
import Loading from './Loading';

const AsyncDynamicPAge = importedComponent(
  () => import(/* webpackChunkName:'DynamicPage' */ './DynamicPage'),
  {
    LoadingComponent: Loading
  }
);
const AsyncNoMatch = importedComponent(
  () => import(/* webpackChunkName:'NoMatch' */ './NoMatch'),
  {
    LoadingComponent: Loading
  }
);

const App = () => {
  return (
    <Router>
      <div>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route exact path="/dynamic" component={AsyncDynamicPAge} />
          <Route component={AsyncNoMatch} />
        </Switch>
      </div>
    </Router>
  );
};

export default App;

이로서 DynamicPage 컴포넌트, NoMatch 컴포넌트, 기본 애플리케이션, 각각 하나의 번들 또는 청크(chunck)가 만들어진다.

이제 번들 파일 이름을 변경해보자. webpack.config.js를 열고 아래와 같이 수정한다.

...
module.exports = {
  ...
  output: {
    filename: '[name].[hash].js',
    ...
  },
}

이제 앱을 실행해 경로를 기준으로 코드 분할이 잘 실행되는지 확인해보자.

yarn start

위 이미지 파일에 웹팩으로 생성된 세 가지 청크(chunk) 파일을 확인해보자. 앱이 실행되면 주요 번들만 로드된다는 것을 알 수 있다. 마지막으로 동적 페이지로 이동을 클릭하면 이 페이지에 해당하는 번들이 비동기적으로 로딩되는 것을 확인할 수 있다.

404 not found: 페이지를 찾을 수 없음 페이지는 번들이 로드되지 않기 때문에 사용자 대역폭(user bandwith)을 절약할 수 있게 된다.

vendor

vendor로 애플리케이션 코드를 쪼개보자. webpack.config.js 파일을 열고 아래와 같이 파일을 변경한다.

...
module.exports = {
  entry: {
    vendor: ['semantic-ui-react'],
    app: './src/index.js'
  },
  ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'initial',
          test: 'vendor',
          name: 'vendor',
          enforce: true
        }
      }
    }
  },
  ...
};

각 설정 부분을 살펴보자.

  • entry.vendor: [‘semantic-ui-react’] — 메인 앱에서 특정 라이브러리를 빼내어 vendor로 만든다.
  • optimization — 이 부분을 생략하면 Webpack은 vendor로 에플리케이션을 분할할 것이다. 이 부분을 추가하면 큰 번들 용량이 대폭 줄어든다. (참고: CommonsChunkPlugin -> Initial vendor chunk)

📌 웹팩3에서는 CommonsChunkPlugin을 사용해 vendor와 common으로 코드 분할을 했지만, 웹팩4에서는 삭제된 기능이다. 따라서 CommonsChunkPlugin을 제거했다. 캐싱 제어가 필요할 수도 있기에 optimization.splitChunks를 추가했다. 자세한 내용은 이 곳을 참고하길 바란다.

터미널에서 다시 앱을 실행하자.

yarn start

터미널에서 이전 세 청크와 더해진 새 vendor 청크를 표시했다. HTML을 보면 vendor와 앱 청크 둘다 모두 로드 되었음을 알 수 있다.

지금까지 웹팩 구성을 업데이트한 webpack.config.js 코드는 아래와 같다.

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const port = process.env.PORT || 3000;
module.exports = {
  mode: 'development',
  entry: {
    vendor: ['semantic-ui-react'],
    app: './src/index.js'
  },
  output: {
    filename: '[name].[hash].js'
  },
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.(js)$/,
        exclude: /node_modules/,
        use: ['babel-loader']
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader',
            options: {
              modules: true,
              camelCase: true,
              sourceMap: true
            }
          }
        ]
      }
    ]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'initial',
          test: 'vendor',
          name: 'vendor',
          enforce: true
        }
      }
    }
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      favicon: 'public/favicon.ico'
    })
  ],
  devServer: {
    host: 'localhost',
    port: port,
    historyApiFallback: true,
    open: true,
    hot: true
  }
};

배포 설정

webpack.config.js 파일 이름을 webpack.config.development.js로 바꾸고 webpack.config.production.js 파일을 새로 만든다.

mv webpack.config.js webpack.config.development.js
cp webpack.config.development.js webpack.config.production.js

새로 개발 의존성 라이브러리인 Extract Text Plugin(텍스트 추출 플러그인)을 설치한다. 공식 문서에서 "엔트리 청크(entry chunks)의 모든 필수 *.css 모듈을 별도 CSS 파일로 이동시킨다. 스타일은 JS에 번들링되지 않고 별도의 CSS 파일(styles.css)로 인라인된다. 스타일 시트 파일 용량이 크더라도 CSS 번들이 JS 번들과 병렬로 로드되기 때문에 더 빠르게 로드되는 장점이 있다."라고 말하고 있다.

yarn add extract-text-webpack-plugin@next -D

📌 웹팩4 사용을 위해 extract-text-webpack-plugin 선 배포판을 설치해야 한다.

webpack.config.production.js 파일을 열고 아래와 같이 변경한다.

const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
  mode: 'production',
  entry: {
    vendor: ['semantic-ui-react'],
    app: './src/index.js'
  },
  output: {
    // 'static' 디렉터리 안에 자바스크립트 번들을 생성한다.
    filename: 'static/[name].[hash].js',
    // 출력 디렉터리는 'dist' 이다.
    // '__dirname' 은 현재 디렉터리의 절대 경로를 제공하는 Node 변수다.
    // 'path.resolve'로 디렉터리를 합친다.
    // 웹팩4는 출력 경로를 './dist'로 가정하기 때문에 이부분을 그대로 둘 수 있다.
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/'
  },
  // 프로덕션 소스맵
  devtool: 'source-map',
  module: {
    rules: [
      {
        test: /\.(js)$/,
        exclude: /node_modules/,
        use: ['babel-loader']
      },
      {
        test: /\.css$/,
        // 'Extract Text Plugin' 구성한다.
        use: ExtractTextPlugin.extract({
          // CSS가 추출되지 않으면 loader가 실행된다.
          fallback: 'style-loader',
          use: [
            {
              loader: 'css-loader',
              options: {
                modules: true,
                // @import(ed) 리소스에 css-loader를 적용하기 전 로더를 구성한다.
                importLoaders: 1,
                camelCase: true,
                // CSS 파일을 위해 소스 맵을 생성한다.
                sourceMap: true
              }
            },
            {
              // css-loader 전에 PostCSS이 실행되어 압축(minify)하고 CSS 룰을 적용하고 
              // 자동 전처리(autoprefixer)를 실행한다.
              // 자동 전처리 단계에서 최신 브라우저 2 사양을 사용한다.
              loader: 'postcss-loader',
              options: {
                config: {
                  ctx: {
                    autoprefixer: {
                      browsers: 'last 2 versions'
                    }
                  }
                }
              }
            }
          ]
        })
      }
    ]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'initial',
          test: 'vendor',
          name: 'vendor',
          enforce: true
        }
      }
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      favicon: 'public/favicon.ico'
    }),
    
    // 'styles' 디렉터리 내 스타일시트를 생성한다.
    new ExtractTextPlugin({
      filename: 'styles/styles.[hash].css',
      allChunks: true
    })
  ]
};

port 변수, HMR 및 devServer와 관련된 플러그인을 제거했다. 또한 프러덕션 구성에 PostCSS를 추가했다. postcss-loader를 설치하고 새 설정 파일을 만든다.

yarn add postcss-loader -D
touch postcss.config.js

postcss.config.js 파일을 열고 아래 코드를 입력한다.

module.exports = {
  plugins: [require('autoprefixer')]
};

Postfix가 autoprefixer 플러그인을 사용하도록 했다. (자세한 옵션은 공식 문서를 참고한다.)

배포 빌드하기 전 마지막으로 package.jsonbuild 스크립트를 추가해야 한다.

파일을 열고 scripts 부분에 아래 내용을 추가한다.

...
"scripts": {
  "dev":"webpack-dev-server --config webpack.config.development.js",
  "prebuild": "rimraf dist",
  "build": "cross-env NODE_ENV=production webpack -p --config webpack.config.production.js"
},
...

스크립트 명령어에서 startdev 로 수정했고, prebuildbuild 두 항목을 추가했다.

마지막으로 개발 및 배포 시 어떤 설정 구성을 따라야 하는지를 알려줬다.

  • prebuild — rimraf 라이브러리를 사용해 build 스크립트 전에 실행하여 마지막 프로덕션 빌드로 생성된 dist 디렉토리를 삭제한다.

  • build - 먼저 윈도우 환경에서 cross-env 라이브러리를 사용하고 NODE_ENV으로 환경 변수를 설정한다. 그 다음 웹팩을 -p 플래그로 호출해 프로덕션을 위해 빌드 최적화를 지시하고 마지막으로 프로덕션 구성을 지정한다.

package.json에 명시된 두 의존성을 추가하자.

yarn add rimraf cross-env -D

프로덕션 빌드 생성 전, 새 프로젝트 구조를 살펴보자.

|-- node_modules
|-- public
    |-- index.html
    |-- favicon.ico
|-- src
    |-- components
        |-- App.js
        |-- DynamicPage.js
        |-- Home.js
        |-- Layout.css
        |-- Layout.js
        |-- Loading.js
        |-- NoMatch.js
    |-- index.js
|-- .babelrc
|-- package.json
|-- postcss.config.js
|-- webpack.config.development.js
|-- webpack.config.production.js
|-- yarn.lock

빌드 명령어로 프로덕션 번들을 생성할 수 있다.

yarn build

위 이미지에서 확인할 수 있듯이, build 스크립트가 실행 후 웹팩은 프로덕션이 준비된 애플리케이션이 들어있는 dist 디렉터리를 만든다. 그리고 생성된 파일을 검사해 압축된 파일인지를 확인하고 원본 소스가 있는지 확인한다. PostCSS는 CSS 파일에 자동 접두어(autoprefixing)를 추가한다.

이제 프로덕션 파일을 가져와 노드 서버를 실행해 서비스되는 화면을 볼 수 있다.

📌프로덕션 파일 실행을 위해 quick-node-server을 사용했다.

개발 구성 웹팩과 프로덕션 구성 웹팩 두 가지가 있다. 그러나 두 파일 설정 항목이 비슷하여 서로 공유 가능하게 만들어보자. 이를 위해 다음 단계에서 웹팩 구성을 다시 수정해보자.

6. 웹팩 구성

이 부분은 션 라킨(Sean Larkin)의 웹팩 아카데미(Webpack Academy)을 바탕으로 작성됐다. 웹팩 아카데미에서 리액트와 웹팩에 대해 자세히 배우기를 권한다.

webpack-mergeChalk를 개발 의존성으로 설치하자.

yarn add webpack-merge chalk -D

build-utils/addons 디렉터리를 만들고 새 파일을 추가한다.

mkdir -p build-utils/addons
cd build-utils
touch build-validations.js common-paths.js webpack.common.js webpack.dev.js webpack.prod.js

현재 프로젝트 폴더 구조는 아래와 같을 것이다.

|-- build-utils
    |-- addons
    |-- build-validations.js
    |-- common-paths.js
    |-- webpack.common.js
    |-- webpack.dev.js
    |-- webpack.prod.js
|-- node_modules
|-- public
    |-- index.html
    |-- favicon.ico
|-- src
    |-- components
        |-- App.js
        |-- DynamicPage.js
        |-- Home.js
        |-- Layout.css
        |-- Layout.js
        |-- Loading.js
        |-- NoMatch.js
    |-- index.js
|-- .babelrc
|-- package.json
|-- postcss.config.js
|-- webpack.config.development.js
|-- webpack.config.production.js
|-- yarn.lock

common-paths.js 파일을 열고 아래 코드를 작성한다.

const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '../');

module.exports = {
  projectRoot: PROJECT_ROOT,
  outputPath: path.join(PROJECT_ROOT, 'dist'),
  appEntry: path.join(PROJECT_ROOT, 'src')
};

파일명에서 유추할 수 있듯이 웹팩 구성의 공통 경로를 정의했다. PROJECT_ROOTbuild-utils 디렉터리 (프로젝트의 실제 최상단(root) 경로에서 한 레벨 아래에 위치) 내에서만 작업하는 것과 같이 단일 디렉터리만 확인해야 한다.

build-validations.js 파일을 열고 아래 코드를 작성한다.

const chalk = require('chalk');
const ERR_NO_ENV_FLAG = chalk.red(
  `You must pass an --env.env flag into your build for webpack to work!`
);

module.exports = {
  ERR_NO_ENV_FLAG
};

앞으로 package.json을 수정할 때 스크립트에 -env.env플래그가 필요하다. 플래그가 있는지를 유효성 판단한다. 플래그가 없으면, 오류를 발생시킨다.

다음으로 웹팩 환경 설정 파일을 개발과 프로덕션 공통, 개발, 프로덕션 세 파일로 나눠보자.

먼저 webpack.common.js 파일을 열고 아래 코드를 입력한다.

const commonPaths = require('./common-paths');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = {
  entry: {
    vendor: ['semantic-ui-react']
  },
  output: {
    path: commonPaths.outputPath,
    publicPath: '/'
  },
  module: {
    rules: [
      {
        test: /\.(js)$/,
        exclude: /node_modules/,
        use: ['babel-loader']
      }
    ]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'initial',
          test: 'vendor',
          name: 'vendor',
          enforce: true
        }
      }
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      favicon: 'public/favicon.ico'
    })
  ]
};
module.exports = config;

webpack.config.development.jswebpack.config.production.js에서 공통 항목을 빼내서 common-paths.js 파일을 만들었다. 이를 위해 output.path 경로를 common-paths.js로 설정했다.

webpack.dev.js 파일을 열고 개발 설정 내용을 떼어 옮긴다.

 commonPaths = require('./common-paths');
const webpack = require('webpack');
const port = process.env.PORT || 3000;
const config = {
  mode: 'development',
  entry: {
    app: `${commonPaths.appEntry}/index.js`
  },
  output: {
    filename: '[name].[hash].js'
  },
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader',
            options: {
              modules: true,
              camelCase: true,
              sourceMap: true
            }
          }
        ]
      }
    ]
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  devServer: {
    host: 'localhost',
    port: port,
    historyApiFallback: true,
    hot: true,
    open: true
  }
};
module.exports = config;

webpack.prod.js 파일을 열고 프로덕션 설정 내용을 떼어 옮긴다.

const commonPaths = require('./common-paths');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const config = {
  mode: 'production',
  entry: {
    app: [`${commonPaths.appEntry}/index.js`]
  },
  output: {
    filename: 'static/[name].[hash].js'
  },
  devtool: 'source-map',
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: [
            {
              loader: 'css-loader',
              options: {
                modules: true,
                importLoaders: 1,
                camelCase: true,
                sourceMap: true
              }
            },
            {
              loader: 'postcss-loader',
              options: {
                config: {
                  ctx: {
                    autoprefixer: {
                      browsers: 'last 2 versions'
                    }
                  }
                }
              }
            }
          ]
        })
      }
    ]
  },
  plugins: [
    new ExtractTextPlugin({
      filename: 'styles/styles.[hash].css',
      allChunks: true
    })
  ]
};
module.exports = config;

이제 모든 것을 통합할 차례다. 프로젝트 루트 디렉터리에서 기존 웹팩 구성을 삭제하고 새로운 웹팩 구성을 만든다. 새 파일인 webpack.config.js로 만든다.

cd ..
rm webpack.config.development.js webpack.config.production.js
touch webpack.config.js

webpack.config.js을 설정하기 전에, package.json 파일을 열어 scripts 부분을 업데이트한다.

...
"scripts": {
  "dev": "webpack-dev-server --env.env=dev",
  "prebuild": "rimraf dist",
  "build": "cross-env NODE_ENV=production webpack -p --env.env=prod"
},
...

-config 플래그를 제거했기 때문에 웹팩은 webpack.config.js에서 기본 구성을 찾는다. 이제 -env 플래그를 사용해 웹팩 환경 변수를 전달하고 개발 환경에서는 env=dev를, 프로덕션 환경에서는 env = prod를 전달한다.

webpack.config.js 파일을 열고 아래 내용을 입력한다.

const buildValidations = require('./build-utils/build-validations');
const commonConfig = require('./build-utils/webpack.common');

const webpackMerge = require('webpack-merge');

// 애드온(addon)으로 웹팩 플러그인을 추가할 수 있다. 
// 개발할 때마다 실행할 필요가 없다.
// '번들 분석기(Bundle Analyzer)'를 설치할 때가 대표적인 예다.
const addons = (/* string | string[] */ addonsArg) => {
  
  // 애드온(addon) 목록을 노멀라이즈(Normalized) 한다.
  let addons = [...[addonsArg]] 
    .filter(Boolean); // If addons is undefined, filter it out

  return addons.map(addonName =>
    require(`./build-utils/addons/webpack.${addonName}.js`)
  );
};

// 'env'는 'package.json' 내 'scripts'의 환경 변수를 포함한다.
// console.log(env); => { env: 'dev' }
module.exports = env => {

  // 'buildValidations'를 사용해 'env' 플래그를 확인한다.
  if (!env) {
    throw new Error(buildValidations.ERR_NO_ENV_FLAG);
  }

  // 개발 또는 프로덕션 모드 중 사용할 웹팩 구성을 선택한다.
  // console.log(env.env); => dev
  const envConfig = require(`./build-utils/webpack.${env.env}.js`);
  
  // 'webpack-merge'는 공유된 구성 설정, 특정 환경 설정, 애드온을 합친다.
  const mergedConfig = webpackMerge(
    commonConfig,
    envConfig,
    ...addons(env.addons)
  );

  // 웹팩 최종 구성을 반환한다.
  return mergedConfig;
};

리액트 개발 환경 구축을 위해 설정할 내용이 너무 많아보이지만 장기적으로는 매우 유용한 방법이다.

애플리케이션을 실행해 프로덕션 파일이 빌드되면서 모든 기능이 예상대로 잘 작동되는지 확인하자.

yarn dev
yarn build

6. 보너스: 웹팩 번들 분석기 (Webpack Bundle Analyzer) 설치하기

웹팩 번들 분석기(Webpack Bundle Analyzer)를 꼭 설치할 필요는 없지만 손쉽게 번들을 최적화할 수 있어 편리하다.

webpack-bundle-analyzer를 설치하고 설정 파일을 만든다.

yarn add webpack-bundle-analyzer -D
touch build-utils/addons/webpack.bundleanalyzer.js

webpack.bundleanalyzer.js 파일을 열고 아래 코드를 입력한다.

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
  .BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'server'
    })
  ]
};

플러그인 부분을 뺴서 웹팩 번들 분석기에 추가했다. webpack-merge이 플러그인을 결합하여 마지막으로 웹팩 구성 설정인 webpack.config.js로 전달한다.

마지막으로 package.json 파일을 열어 새 스크립트를 추가한다.


"scripts": {
  "dev": "webpack-dev-server --env.env=dev",
  "dev:bundleanalyzer": "yarn dev --env.addons=bundleanalyzer",
  "prebuild": "rimraf dist",
  "build": "cross-env NODE_ENV=production webpack -p --env.env=prod",
  "build:bundleanalyzer": "yarn build --env.addons=bundleanalyzer"
},

  • dev:bundleanalyzer — dev 스크립트를 불러 새 환경 변수인 addons=bundleanalyzer를 전달한다.
  • build:bundleanalyzer — build 스크립트를 불러 새 환경 변수인 addons=bundleanalyzer를 전달한다.

이제 웹팩 번들 분석기를 실행해보자.

yarn dev:bundleanalyzer

웹팩 번들 분석기 외에도 많은 플러그인이 있다. 여러 플러그인을 웹팩 구성 애드온(addon)에 추가하여 유용하게 사용할 수 있다.

맺는 말

여기까지 오느라 정말 수고했다. 그리고 여러분은 모두 해냈다! 🎉 리액트 개발을 위한 웹팩 설정 기초에 대해 배웠다. 앞으로 심화된 테크닉과 기술들을 탐험하고 사용할 수 있을 것이다. 이 튜토리얼 코드는 깃허브에서 다운받을 수 있다.

이 튜토리얼을 읽으면서 재미를 느꼈길 바란다. 질문이나 제안은 언제나 환영한다. 미디엄, 트위터, 깃허브로 언제든지 연락하길 바란다.