🎁 Module bundler/📖 Webpack

CRA 없이 webpack으로 Custom React 프로젝트 구성하기

DarrenKwonDev 2020. 9. 28. 02:01

react 생성

npm i react react-dom

 

public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

 

./src/index.js 에서 react-dom의 ReactDom을 통해 render를 돌려줍시다.

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { ThemeProvider } from "styled-components";
import theme from "./Style/theme";

ReactDOM.render(
    <ThemeProvider theme={theme}>
      <App />
    </ThemeProvider>,
  document.getElementById("root")
);

 

 

babel 세팅

npm i @babel/core @babel/preset-env @babel/preset-react -D

 

./package.json 에서 babel 세팅

babeljs.io/docs/en/configuration#docsNav

  ...중략
  "babel": {
    "presets": [
      "@babel/preset-react",
      "@babel/preset-env"
    ]
  }
}

 

자. 위 까지는 준비운동이었고 webpack 세팅이 진짜배기입니다.

 

⭐ webpack 세팅 1차 (mode, entry, output)

darrengwon.tistory.com/625?category=899262

 npm i webpack webpack-cli -D

 

우선 가장 기본적인 webpack 형태로 만들어보겠습니다. loader도 없고 mode, entry, output만 설정한 상태입니다.

여기에 Webpack concepts의 Loaders, Plugins, Browser Compatibility, Environment를 덧씌워 웹팩 설정을 해주는 것이 우리의 목표입니다.

https://webpack.js.org/concepts/

 

./webpack.config.js

const dotenv = require("dotenv");
const path = require("path");
dotenv.config();

const webpackEnv = process.env.NODE_ENV;

module.exports = (webpackEnv) => {
  const isProd = NODE_ENV === "production";
  
  return {
    mode: webpackEnv,
    entry: "./src/index.js",
    output: {
      filename: isProd
        ? "static/js/[name].[chunkhash:8].js"
        : "static/js/bundle.js",
      path: path.join(__dirname, "build"), // ./build에 넣습니다.
    },
  };
};

 

여기서 단순히 설정만하면 되는 mode, entry와 다르게 ouput은 옵션이 굉장히 많습니다.

 

auxiliaryComment, chunkLoadTimeout, chunkLoadingGlobal 등 부분은 추후 CRA를 분해해보면서 공부해보도록하고 우선은 filename, path, publicPath 만 이해하고 넘어갑시다. 

 

 

⭐ webpack 세팅 2차 (Loader)

 

- loader에 대한 컨셉

 

Out of the box, webpack only understands JavaScript and JSON files. Loaders allow webpack to process other types of files and convert them into valid modules that can be consumed by your application and added to the dependency graph. - webpack.js.org/concepts/#loaders

 

  1. The test property identifies which file or files should be transformed.
  2. The use property indicates which loader should be used to do the transforming.
module.exports = {
  output: {
    filename: 'my-first-webpack.bundle.js'
  },
  module: {
    rules: [
      { test: /\.txt$/, use: 'raw-loader' }
    ]
  }
};

 

그렇다면 실제로 loader를 사용해봅시다. webpack.js.org/loaders/

 

 

위와 같은 이와 같은 기본 세팅 외에 es+6의 js를 이해하기 위한 babel-loader

내부 파일 경로를 상대값으로 이용하기 위한 file-loader

sass를 위한 sass-loader, css를 위한 css-loader 등 필요에 따라 적절한 loader를 설치해야 합니다. (styled-components를 사용할 거라면 굳이 설치할 필요는 없습니다.)

 

 

babel-loader

npm i babel-loader -D 

webpack.js.org/loaders/babel-loader/

 

다음과 같이 설정해주면 됩니다. 

여기서 options를 살펴보자면 (위 링크에서 확인할 수 있습니다.)

 

cacheDirectory : 기본값이 false인데 true로 설정하면 매번 빌드 시 바벨 트랜스파일을 처음부터 하는 것이 아니라 node_modules./.cache/babel-loader 에 저장된 캐시를 이용해 바벨 트랜스파일을 합니다. 아... 이건 왜 true가 기본값이 아닌지 모르겠군요. true로 바꿔줍시다.

  • Default false. When set, the given directory will be used to cache the results of the loader. Future webpack builds will attempt to read from the cache to avoid needing to run the potentially expensive Babel recompilation process on each run. If the value is set to true in options ({cacheDirectory: true}), the loader will use the default cache directory in node_modules/.cache/babel-loader or fallback to the default OS temporary file directory if no node_modules folder could be found in any root directory.

cacheCompression : true면 gzip으로 합축하여 변환됩니다. 캐시 압축을 하고싶지 않다면 false로 설정하면 됩니다. false로 하면 좋은 점이 뭐냐구요? 많은 파일을 변환해야하는 경우 이점이 있다고 합니다. 

  • Default true. When set, each Babel transform output will be compressed with Gzip. If you want to opt-out of cache compression, set it to false -- your project may benefit from this if it transpiles thousands of files.

CRA에서 설정한 babel-loader에서는 option을 다음과 같이 주었습니다. 다음과 같습니다.

cacheDirectory: true,

cacheCompression: false,

 

왜 캐쉬 컴프레션을 해제 했는지에 대해서는 CRA 측에서는 github.com/facebook/create-react-app/issues/6846 를 참고하라고 하였습니다. 내용 요약을하자면 다음과 같군요. 음... 저도 꺼야겠습니다.

 

  1. Having it disabled in dev and enabled in prod means caching won't be shared between dev and prod. If a dev and prod build happens on the same machine (not unlikely), this actually increases disk space usage.
  2. A cursory look at a few of my react projects tell me that the space savings are inconsistent at best. My largest project shaves off ~35%; it's lots and lots of tiny little files: the cached chunks are very small (usually referencing single components) and don't benefit from what you'd intuitively think would compress very well if it were all in one file.
  3. Most projects do not actually benefit from cache compression at all, especially in production with builds most often happening in a CI environment, where memory is precious and disk space inexpensive.
 module: {
  rules: [
    {
      test: /\.(js|mjs|jsx)$/,
      use: {
        loader: "babel-loader",
        options: {
          presets: ["@babel/preset-react", "@babel/preset-env"],
          cacheDirectory: true,
          cacheCompression: false,
        },
      },
      include: appSrc, // ./src 아래 포함된 js만 변환합니다.
      exclude: /(node_modules|bower_components)/,
   },
 ]
}

 

 

file-loader

 

공식 문서에서 보면 test 부분이 png|jpe?g|gif 로 되어 있습니다. 이미지를 불러와 사용하는데 필요한 로더죠. 

 

npm i -D file-loader 

webpack.js.org/loaders/file-loader/

 

CRA에서는 다음과 같이 loader의 최하단 부에 file-loader를 위치시켰습니다. test 부분을 명시하지 않았는데 이는 exclude에서 제외한 모든 확장자를 핸들링하기 위해서입니다. 

제외한 항목에는 js, html, json이 있습니다. 이는 js는 웹팩 자체 로더를 사용해서 처리해야 할 내용이지 이미지 파일을 처리하기 위한 것이 아니기 때문입니다.

 

또, Make sure to add the new loader(s) before the "file" loader. 라는 문구에 집중합시다. 이러한 방식으로 file-loader를 작동시키려면 로더의 순서가 중요하므로 지켜줍시다.

 

잠깐 웹팩 핸드북에서 loader가 적용되는 순서에 대해서 가져와보았습니다.

로더가 적용되는 순서에 주의해야 합니다. 로더는 기본적으로 오른쪽에서 왼쪽 순으로 적용됩니다.

CSS의 확장 문법인 SCSS 파일에 로더를 적용하는 예시를 보겠습니다.

module: {
  rules: [
    {
      test: /\.scss$/,
      use: ['css-loader', 'sass-loader']
    }
  ]
}

 

CRA는 아래와 같이 구성했습니다.

// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
  loader: require.resolve("file-loader"),
  // Exclude `js` files to keep "css" loader working as it injects
  // its runtime that would otherwise be processed through "file" loader.
  // Also exclude `html` and `json` extensions so they get processed
  // by webpacks internal loaders.
  exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
  options: {
    name: "static/media/[name].[hash:8].[ext]",
  },
},

// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.

 

 

url-loader

npm i -D url-loader

작은 용량의 이미지 등은 file-loader가 아니라 url-loader를 사용합니다. 즉, DataURL를 이용하여 이미지를 Base64로 인코딩하여 문자열 형태로 소스코드에 넣는 처리를 해줍니다. Base64는 Quill을 사용하면서 고생한 기억이 있습니다만 적절하게 사용할 경우 편리합니다.

 

더 편리한 점은 해당 url-loader 용량 내에 걸리지 않으면 자동으로 file-loader로 fallback 되게끔 Oneof 내의 file-loader의 상단 부분에 위치시키도록합시다.

 

webpack.js.org/loaders/url-loader/#root

npm i -D url-loader

 

CRA는 다음과 같이 구성했습니다. 

{
   test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
   loader: require.resolve("url-loader"),
   options: {
     limit: imageInlineSizeLimit,
     name: "static/media/[name].[hash:8].[ext]",
   },
},

 

저는 다음과 같이 구성했습니다.

{
  test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
  loader: "url-loader",
  options: {
    limit: 10000, // 기준은 Byte입니다. 10000이면 10KB
    outputPath: "static/media",
    name: "[name].[hash:8].[ext]",
  },
},

 

webpack module의 rules 배열 내부에 oneOf 에 넣었습니다. 이렇게 되면 둘 중 하나에 걸려서 처리하겠죠 ㅎ

{
   oneOf: [
     {
        test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
        loader: "url-loader",
        options: {
          limit: 10000, // 기준은 Byte입니다. 10000이면 10KB
          outputPath: "static/media",
          name: "[name].[hash:8].[ext]",
        },
      },
      {
        loader: "file-loader",
        exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
        options: {
           outputPath: "static/media",
           name: "[name].[hash:8].[ext]",
           esModule: true, // commonJS 쓰실거잖아요. true로 (Default: true. this is explicit)
       },
   },
 ],
},

 

 

eslint-loader

=> deprecated라니! 대신 eslint-webpack-plugin 형태로 plugin으로 들어가 있습니다. 이걸 이용하도록합시다.

github.com/webpack-contrib/eslint-webpack-plugin

 

 

scss-loader

 

전에 webpack을 다뤘었을 때 공부한 적이 있습니다. 그대로 따라하면 됩니다. 단, 저는 styled-components를 선호하므로 따로 설정은 하지 않았습니다.

darrengwon.tistory.com/170?category=899262

 

 

⭐ webpack 세팅 3차 (Plugin)

 

1️⃣ html-webpack-plugin

npm i -D html-webpack-plugin

 

SPA 이므로 html을 딱 하나만 쓰는데 이 html을 build에 포함시켜주는 플러그인입니다.

 

const appHtml = path.resolve(__dirname, "public", "index.html");

const webpackEnv = process.env.NODE_ENV; // 따로 env 설정을 하지 않아도 NODE_ENV는 잡아줌

dotenv.config();

module.exports = (webpackEnv) => {
  const isProd = webpackEnv === "production";

  return {
    mode: webpackEnv, // development, production, none 존재
    plugins: [new HtmlWebpackPlugin({ template: appHtml })],
    ... 생략

 

2️⃣ DefinePlugin를 통해 환경 변수를 전역 변수로 사용하기

 

따로 설치는 없고 webpack 내장이므로 webpack에서 빼다 쓰면 됩니다.

webpack.js.org/plugins/define-plugin/

 

음... 일반적으로 .env를 설치하고 사용하면 잘 됩니다.

console.log(process.env.PORT); // 3000은 출력 잘 된다.

module.exports = (webpackEnv) => {

   ... 중략
      after: function (_, _, _) {
        console.log(
          `http://localhost:${process.env.PORT}` // 사용도 잘 된다.
  
};

 

그런데 이 녀석을 편하게 사용하기 위해 변수에 담으면 undefined가 됩니다. what the...?

const PORT = process.env.PORT;


console.log(process.env.PORT); // 3000
console.log(PORT); // undefined

 

이럴 경우 webpack.DefinePlugin 에서 json 형태로 사용할 환경 변수를 전역변수화하여 접근할 수 있게 만들면 정상적으로 작동합니다.

 plugins: [
      new webpack.DefinePlugin({ PORT: process.env.PORT }),
 ],

 

전역변수가 되었으므로 프로젝트 어디에서나 PORT라는 이름의 변수를 사용할 수 있습니다!

 

그런데 해당 변수가 많아지면 일일히 저렇게 타이핑하는 것을 효율적이지 못하겠죠. 그래서 CRA에서는 REACT_APP라는 접두사가 붙은 환경 변수만 가져와 전역 변수로 넣어주는 작업을 해줍니다. 우리도 따라해보죠

 

function getClientEnv(nodeEnv) {
  return {
    "process.env": JSON.stringify(
      Object.keys(process.env)
        .filter((key) => /^REACT_APP/i.test(key))
        .reduce(
          (env, key) => {
            env[key] = process.env[key];
            return env;
          },
          { NODE_ENV: nodeEnv }
        )
    ),
  };
}

module.exports = (webpackEnv) => {
  const isProd = webpackEnv === "production";
  const clientEnv = getClientEnv(nodeEnv); // process.env 객체에 REACT_APP 담겨있음
  const PORT = process.env.REACT_APP_PORT; // webpack 내에서만 사용할 수 있음

  return {
    ...
    plugins: [
      ...
      new webpack.DefinePlugin(clientEnv),
    ],

 

위와 같이 설정하면 코드 전역에서 process.env 를 통해 환경 변수에 접근할 수 있게 됩니다.

 

 

3️⃣ webpack-manifest-plugin

 

npm i -D webpack-manifest-plugin 

 

브라우저 캐싱을 (우리가 흔히 아는 브라우저 상에서 html, css, js를 캐싱해서 빠르게 응답시켜주는 그것) 사용하고 싶다면 manifest

 

CRA에서도 프로덕션 빌드를 실행하면 "static/js/[name].[contenthash:8].js" 라는 이름으로 chunk 파일명이 지정되는데 매번 해시값이 바뀌므로 contenthash 부분은 계속 바뀝니다. webpack 문서에 따르면 

webpack provides a method of templating the filenames using bracketed strings called substitutions. The [contenthash] substitution will add a unique hash based on the content of an asset. When the asset's content changes, [contenthash] will change as well.

라고 하니까요. 따라서 빌드할 때 마다 변경되므로 캐싱이 무효화됩니다. 여기가 Caching에 대해서 알아야 하는 지점입니다. webpack.js.org/guides/caching/

 

따라서 우리는 빌드 결과 Manifest 를 json으로 추출할 필요가 있습니다. 웹팩은 번들 결과 추출될 내용을 이미 알고 있고 이를 추출할 수도 있습니다.  WebpackManifestPlugin 를 활용해서 이를 추출해봅시다.

 

 

You might be wondering how webpack and its plugins seem to "know" what files are being generated. The answer is in the manifest that webpack keeps to track how all the modules map to the output bundles. The manifest data can be extracted into a json file for easy consumption using the WebpackManifestPlugin.

 

이 부분에 있어 CRA는 다음과 같이 작성했습니다. 

이 부분을 fileName과 publicPath만 제외하고 그대로 복사해서 사용했습니다.

      // Generate an asset manifest file with the following content:
      // - "files" key: Mapping of all asset filenames to their corresponding
      //   output file so that tools can pick it up without having to parse
      //   `index.html`
      // - "entrypoints" key: Array of files which are included in `index.html`,
      //   can be used to reconstruct the HTML if necessary
      new ManifestPlugin({
        fileName: "asset-manifest.json",
        publicPath: paths.publicUrlOrPath,
        generate: (seed, files, entrypoints) => {
          const manifestFiles = files.reduce((manifest, file) => {
            manifest[file.name] = file.path;
            return manifest;
          }, seed);
          const entrypointFiles = entrypoints.main.filter(
            (fileName) => !fileName.endsWith(".map")
          );

          return {
            files: manifestFiles,
            entrypoints: entrypointFiles,
          };
        },
      }),

 

빌드 결과물에는 manifest.json을 살펴보면 다음과 같이 빌드된 것을 확인할 수 있습니다.

{
  "files": {
    "main.js": "static/js/bundle.js",
    "index.html": "index.html",
    "static/media/favicon.ico": "static/media/favicon.d4f16676.ico",
    "static/media/image.jpg": "static/media/image.8ffce704.jpg"
  },
  "entrypoints": [
    "static/js/bundle.js"
  ]
}

 

이 manifest.json을 html에서 참고하기 위해서 manifest 부분을 link 태그로 연결해줍시다.

<head>
  <link rel="manifest" href="./manifest.json" />
</head>

 

 

4️⃣ webpack-bundle-analyzer 

 

Next에서 한 번 써봤던 것인데 이걸 React에서 사용해보고자 합니다.

npm i -D webpack-bundle-analyzer

github.com/webpack-contrib/webpack-bundle-analyzer

 

사용법 자체는 정말 쉽군요. 다른 옵션을 사용하고 싶다면 위의 링크에 들어가서 옵션을 확인해봅시다.

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

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

 

빌드 결과물도 나왔습니다. 으... 코드 스플리팅을 안해서 그런가 용량이 좀 크군요

 

⭐ webpack 세팅 4차 (etc)

1️⃣ cache : webpack.js.org/configuration/other-options/#cache

 

plugin에 대한 캐쉬를 지원합니다. 간단히 cache 항목만 넣어주면 됩니다.

cache: {
  type: isProd ? "filesystem" : "memory", // 개발시 memory, 배포시 filesystem 캐쉬
},

 

2️⃣ dev-server : webpack.js.org/configuration/dev-server/

 

port : 웹 서버가 실행될 PORT 지정
contentBase : 개발 환경에서 정적 파일을 제공하려는 경우 필요합니다.
open : 번들 작업이 끝나면 자동으로 브라우저를 열어주는지
historyApiFallback : 개발 환경에서 localhost:3000/subpage 등 URL로 직접 접근하였을 경우, cannot get /subpage 대신에 index.html로 보내줍니다.
overlay : 컴파일러 오류 또는 경고가있을 때 브라우저에 전체 화면 오버레이를 표시
stats : 컴파일(트랜스파일) 시 보여주는 항목 설정

 

npm i -D webpack-dev-server
devServer: {
      // npm i -D webpack-dev-server 후 이용 가능
      host: "localhost",
      port: PORT,
      contentBase: appPublic,
      open: true,
      hot: true,
      historyApiFallback: true,
      overlay: true,
      stats: "errors-only",
      after: function (_, _, _) {
        console.log(
          `============☆★ Ringle & trunchat team분들 안녕하세요! ★☆============ \n본 프로젝트는 다음 주소에서 동작합니다: http://localhost:${process.env.PORT}\n====================================================================`
        );
      },
    },

 

 

3️⃣ stats : webpack.js.org/configuration/stats/

 

번들 후 어떤 결과를 출력할 지 결정하는 겁니다...만 저는 모든 것을 default로 두기로 했습니다.

 

4️⃣ require.resolve('...') 의 방식으로 loader를 불러오자.

 

npm script!

 

Cannot find module 'webpack-cli/bin/config-yargs' 오류가 뜨길래 우선 급한대로 webpack serve 로 빌드하고 제출했습니다. 추후 조사를 해보니 webpack-dev-server v4 부터는 webpack-dev-server 가 아니라 webpack serve 로 invoke한다고 합니다. 결과적으로는 맞는 거였음 ㅋㅋ

webpack && webpack serve

 

 

* 추가적으로 보완할 것들

 

perfectacle.github.io/2017/04/18/webpack2-optimize/

 

(Webpack 2) 최적화하기

들어가기에 앞서이 포스트들에서 말하는 내용들은 전부 배포용 파일에 적합한 작업이다.이런 압축 작업을 개발용 버전에서 매번 빌드할 때마다 실행하면 빌드 시간이 매우 느려지기 때문이다.

perfectacle.github.io

 

MinChunkSizePlugin 플러그인을 통해 일정 크기 이하의 chunk는 코드 스플리팅을 하지 않고 그대로 통합시켜야 한다. 자잘한 chunk가 많아지면 속도가 빨라지긴 커녕 네트워크 통신하는데 비용이 더 들어갈 수 있다.

 

compression-webpack-plugin 플러그인으로 gzip으로 압축을 해주자. next에서는 해줬는데 여기서는 안했다. 파일 크기가 너무 작으면 압축을 해제하는데 더 큰 시간을 소모하므로 threshold를 잘 지정해주자.

 

 

결과물

 

아래 프로젝트에서 webpack.config.js를 계속 실험해보고 있습니다!

github.com/DarrenKwonDev/awesomeForm/blob/master/webpack.config.js

 

DarrenKwonDev/awesomeForm

turnchat tech interview. Contribute to DarrenKwonDev/awesomeForm development by creating an account on GitHub.

github.com