LHJ

I'm a FE developer.

최적화

04 Oct 2020 » node_webpack2

최적화

코드가 많아지면 번들링된 결과물도 커지기 마련이다.
거의 메가바이트 단위로 커질수도 있는데 이는 브라우저 성능에 영향을 줄 수 있다.
다운로드 시간이 오래걸릴 것이기 때문이다.
파일을 다운로드하는데 시간이 많이 걸리기 때문이다.
이번 섹션에서는 번들링한 결과물을 어떻게 최적화할 수 있는지 몇 가지 방법에 대해 알아보겠다.

어떻게 웹팩으로 번들 결과를 압축하고 또 상황에 따라서는 작은파일 여러개로 분리할 수 있는지 알아보도록 하겠다.
우선 가장 쉬운 방법인 webpack.config.js 파일의 mode 값을 사용하는 방법이다.

production 모드

웹팩에 내장되어 있는 최적화 방법 중 mode 값을 설정하는 방식이 가장 기본이다.
세 가지 값이 올 수 있는데 지금까지 설정한 “development”는 디버깅 편의를 위해 아래 두 개 플러그인을 사용한다.

  1. NamedChunksPlugin
  2. NamedModulesPlugin

DefinePlugin을 사용한다면 process.env.NODE_ENV 값이 “development”로 설정되어 어플리케이션에 전역변수로 주입된다.

반면 mode를 “production”으로 설정하면 자바스크립트 결과물을 최소화하기 위해 다음 일곱개 플러그인을 사용한다.

  1. FlagDependencyUsagePlugin
  2. FlagIncludedChunksPlugin
  3. ModuleConcatenationPlugin
  4. NoEmitOnErrorsPlugin
  5. OccurrenceOrderPlugin
  6. SideEffectsFlagPlugin
  7. TerserPlugin

DefinePlugin을 사용한다면 process.env.NODE_ENV 값이 “production”으로 설정되어 어플리케이션 전역변수로 주입된다.

그동안 우리는 공부하면서 mode 값을 development로 두고 실습했다.

// webpack.config.js
module.exports = {
   mode: "development"
}

그런데 development가 아닌 production으로 설정해야 운영환경에 적합한 번들결과를 만들어낼 수 있다.

그럼 노드 환경변수 NODE_ENV 값에 따라 모드를 설정하도록 웹팩 설정 코드를 다음과 같이 추가할 수 있다.

// webpack.config.js
const mode = process.env.NODE_ENV || 'development'; // 기본값을 development로 설정
module.exports = {
    mode,
}

process.env.NODE_ENV가 없으면 mode는 기본값인 development로 설정된다.
위 코드는 키와 값의 변수명이 같기 때문에 mode라고만 적어주면된다.
빌드시에 이를 운영 모드로 설정하여 실행하도록 package.jsonscripts 부분에 npm 스크립트를 아래와 같이 추가한다.

package.json

{
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "build": "NODE_ENV=production webpack --progress",
        "lint": "eslint src --fix",
        "start": "webpack-dev-server --progress"
    }
}

“build” 부분에 NODE_ENV=production을 추가했다.
npm start로 실행될 때는 노드 환경변수 값을 전달하지 않았다.
이 경우엔 mode는 기본값인 development로 실행될 것이다.

위와 같은 오류가 나는 경우가 있다.
이런 경우엔

npm i -g win-node-env

명령어로 win-node-env를 설치해주면 해결된다.

npm run build

파일이 난독화되었다.
이것은 production 모드에서 사용하는 플러그인들에 의해서 이렇게 난독화된 것이다.

CSS는 그대로인 것을 볼 수 있다.
그럼 이전과 비교하기 위해서 이번엔 development로 두고 빌드해보자.

package.json

{
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "build": "NODE_ENV=development webpack --progress",
        "lint": "eslint src --fix",
        "start": "webpack-dev-server --progress"
    }
}

그럼 이렇게 코드를 읽기 쉽게 해서 번들링한다.
modeproduction은 1차적으로 코드를 최적화할 수 있는 방법이다.

optimization 속성으로 최적화

빌드 과정을 커스터마이징할 수 있는 여지를 제공하는데 그것이 바로 optimization 속성이다.

HtmlWebpackPlugin이 html 파일을 압축한 것 처럼

// webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
    plugins: [
        new HtmlWebpackPlugin({
            template: "./src/index.html",
            templateParameters: {
                env: process.env.NODE_ENV === "development" ? "(개발용)" : "",
            },
            minify:
                process.env.NODE_ENV === "production"
                    ? {
                          collapseWhitespace: true,
                          removeComments: true,
                      }
                    : false,
        }),
    ]
}

css 파일도 빈칸을 없애는 압축을 하려면 어떻게 해야할까?
optimize-css-assets-webpack-plugin이 바로 그것이다.

npm i -D optimize-css-assets-webpack-plugin

이 플러그인은 일반 플러그인과 설정방법이 조금 다르다.
일반 플러그인은 plugins 키에 설정을 했는데, 이것은 optimization 키에서 설정한다.

// webpack.config.js
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
    optimization: {
        minimizer: mode === 'production' ? [
            new OptimizeCSSAssetsPlugin(),
        ] : [],
    }
}

optimizationminimizer 부분엔 여러개 플러그인을 설정할 수 있기 때문에 [] 배열형태로 넣는다.

npm run build

위와 같이 CSS 파일도 압축된 것을 확인할 수 있다.

이것 뿐만아니라 mode=production일 경우 사용되는 7가지 플러그인 중 TerserWebpackPlugin은 자바스크립트 코드를 난독화하고 debugger 구문을 제거한다.
기본 설정 외에도 콘솔 로그를 제거하는 옵션도 있는데 배포 버전에는 로그를 감추는 것이 좋을 수도 있기 때문이다.
운영에 나가는 결과물에 console.log가 찍히고 그러는 것은 보기에 안 좋을 수 있다. 정보노출 위험도 있고.

npm i -D terser-webpack-plugin
// webpack.config.js
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    optimization: {
        minimizer: mode === 'production' ? [
            new OptimizeCSSAssetsPlugin(),
            new TerserPlugin({
                terserOptions: {
                    compress: {
                        drop_console: true, // 콘솔 로그를 제거한다.
                    }
                }
            })
        ] : [],
    }
}

코드 스플리팅 (코드 분할)

코드를 압축하는 것 외에도 아예 결과물을 여러개로 쪼개면 좀 더 브라우저 다운로드 속도를 높일 수 있다.
큰 파일 하나를 다운로드하는 것 보다 작은 파일 여러개를 동시에 다운로드하는 것이 더 빠르기 때문이다.

가장 단순한 것은 엔트리를 여러개로 분리하는 것이다.

// webpack.config.js
module.exports = {
    entry: {
        main: "./src/app.js",
        result: "./src/result.js",
    },
}
npm run build

위와 같이 main.js, result.js 두가지 결과물이 나온다.

그리고 번들된 dist/index.html을 보면 두 결과물을 모두 로딩하는 것을 볼 수 있다.
이것은 html-webpack-plugin이 하는 역할이다.
그런데 문제는

빌드 전 파일에 위와 같이 import되어있기 때문에 결과물을 확인해보면

위와 같이 동일한 코드가 각각의 결과물에 들어간 것을 볼 수 있다.
중복되는 코드가 각각 entry 파일에 들어가는 것이다.
그래서 이와 같은 중복을 제거하는 옵션도 켜줘야된다.

// webpack.config.js
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    optimization: {
        minimizer: mode === 'production' ? [
            new OptimizeCSSAssetsPlugin(),
            new TerserPlugin({
                terserOptions: {
                    compress: {
                        drop_console: true, // 콘솔 로그를 제거한다.
                    }
                }
            })
        ] : [],
        splitChunks: {
            chunks: "all"
        },
    }
}

위와 같이 splitChunkschunks를 설정해주면 중복되는 코드를 전부 제거하고 entry 파일을 다시 빌드한다.

아까와 다르게 vendors~main~result.js란 파일이 하나 더 생겼다.

보면 아까와 다르게 axios 검색 결과가 없다.

vendors~main~result.js 파일엔 axios가 검색된다.
main.jsresult.js에서 중복된 코드는 다 vendors~main~result.js 파일로 빠지게 되어있다.

용량도 한번 살펴보도록 하자.
용량이 이전과 비교했을 때 줄어들었을 것이다.

그런데 entry 포인트로 분리하는 것은 개발자가 일일이 분리해야되기 때문에 손이 많이가는 방법이다.
그래서 뭔가 자동화하는 방법이 필요한데, 그걸 웹팩에선 다이나믹 임포트(dynamic import), 동적 임포트라고 한다.
다이나믹(동적) 임포트 사용방법도 알아보도록 하겠다.

다이나믹(동적) 임포트

import로 파일 연동할 때

// app.js
import form from "./form";
import result from "./result";
import "./app.css";

let resultEl;
let formEl;

document.addEventListener("DOMContentLoaded", async () => {
    formEl = document.createElement("div");
    formEl.innerHTML = form.render();
    document.body.appendChild(formEl);

    resultEl = document.createElement("div");
    resultEl.innerHTML = await result.render();
    document.body.appendChild(resultEl);
});

이런식이 아니라 다음과 같이 가져올 수 있다.

// app.js
import form from "./form";
// import result from "./result";
import "./app.css";

let result;
let resultEl;
let formEl;

document.addEventListener("DOMContentLoaded", async () => {
    formEl = document.createElement("div");
    formEl.innerHTML = form.render();
    document.body.appendChild(formEl);

    import(/* webpackChunkName: "result" */"./result.js").then(async m => {
        result = m.default;
        resultEl = document.createElement("div");
        resultEl.innerHTML = await result.render();
        document.body.appendChild(resultEl);
    })
});

위와 같이 설정하면 웹팩이 위 코드를 보고 알아서 result.js 모듈은 따로 번들한다.

// webpack.config.js
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    entry: {
        main: "./src/app.js",
        // result: "./src/result.js",
    },
    optimization: {
        minimizer: mode === 'production' ? [
            new OptimizeCSSAssetsPlugin(),
            new TerserPlugin({
                terserOptions: {
                    compress: {
                        drop_console: true, // 콘솔 로그를 제거한다.
                    }
                }
            })
        ] : [],
        // splitChunks: {
        //     chunks: "all"
        // },
    }
}

대신 위와 같이 entry 포인트와 splitChunks 부분은 지워줘야된다.

npm run build

이렇게 다이나믹 임포트로 지정하면 주석으로 지정한 webpackChunkName: "result"result 이름으로 result.jsvendors~result.js 파일을 만들어준다.


사실 이런식으로 코드 스플리팅하는 것은 개발 초기단계에는 불필요하다.
코드가 많아지고 번들했는데 파일이 1Mb 이상으로 커져버리게되면 그때가서 코드를 분리해도 늦지 않는다.

externals

마지막 방법으로는 애초에 번들하지 말아야할 대상들은 빌드범위 대상에서 빼버리는 것이다.
그것이 바로 externals라는 것이다.

조금만 더 생각해보면 최적회해 볼 수 있는 부분이 있다.
바로 axios 같은 써드파티 라이브러리다.
패키지로 제공될 때 이미 빌드과정을 거쳤기 때문에 빌드 프로세스에서 제외하는 것이 좋다.
웹팩 설정 중 externals가 바로 이러한 기능을 제공한다.

// webpack.config.js
module.exports = {
    externals: {
        axios: "axios",
    },
}

externals에 추가하면 웹팩은 코드에서 axios를 사용하더라도 번들에 포함하지 않고 빌드한다.
대신 이를 전역 변수로 접근하도록하는데 키로 설정한 axios가 그 이름이다.

즉, 웹팩으로 빌드할 때 axios를 모듈을 사용하는 부분이 있으면, 전역변수 axios를 사용하는 것으로 간주하라. 라는 의미이다.

// result.js
import axios from "axios";

const result = {
    async render() {
        const res = await axios.get("/api/users");

        return (res.data || [])
            .map((user) => {
                return `<div>${user.id}: ${user.name}</div>`;
            })
            .join("");
    },
};

export default result;

우리 실습 코드 중에 result.js 파일에서 axios 모듈을 사용하는데, 위와 같은 코드가 있다면 axios를 저 부분에 import하지 말고 그냥 전역변수 axios가 있는 것처럼 해서 웹팩이 빌드한다.
대신에 axios 모듈을 전역변수 axios에 담아쓰려면 가져와야겠지?
axios 모듈은 node_modules 폴더 안에 있다.

axios.min.js 파일을 html 파일에 로딩하면 된다.
로딩하기 위해선 위 파일도 우리 결과물 폴더인 dist 폴더에 있는 것이 좋겠지?
그래서 웹팩이 실행될 때 파일을 복사해야되는데, 복사하는 라이브러리가 copy-webpack-plugin이다.

npm i -D copy-webpack-plugin

그리고 webpack.config.jsplugins에 해당 플러그인을 정의해주면 된다.

// webpack.config.js
const CopyPlugin = require('copy-webpack-plugin');

module.exports = {
    plugins: [
        new CopyPlugin({
            patterns: [
                {
                    from: "./node_modules/axios/dist/axios.min.js",
                    to: "./axios.min.js",
                }
            ]
        }),
    ],
};

html 파일에도 axios 파일을 로딩하는 코드를 넣어주자.

<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Title<%= env %></title>
    <!-- 이것은 주석입니다. -->
</head>
<body>
<script src="axios.min.js"></script>
</body>
</html>
npm run build

이런식으로 웹팩이 빌드할 필요가 없는 외부 라이브러리는 externals로 빼놓는 것이 좋다.
운영환경 뿐만아니라 개발 환경에서도 이런 외부 라이브러리는 따로 빌드하지 않기 때문에, 이렇게 빼놓으면 웹팩 빌드시간을 단축시킬 수 있다.


이 부분들은 개발 초기에는 잘 안 쓰는 부분이다.
개발 후반부로가서 배포하기 전에 빌드용량이 커지면 그때가서 설정하는데 이러한 기본적인 방법을 숙지하고 있으면 좋을 거 같다.