프런트엔드 크기 줄이기

webpack을 사용하여 앱을 최대한 작게 만드는 방법

애플리케이션을 최적화할 때 가장 먼저 해야 할 일 중 하나는 애플리케이션을 최대한 작게 만드는 것입니다. webpack을 사용하여 이를 실행하는 방법은 다음과 같습니다.

프로덕션 모드 사용 (webpack 4만 해당)

Webpack 4에서는 새로운 mode 플래그를 도입했습니다. 이 플래그를 'development' 또는 'production'로 설정하여 특정 환경용 애플리케이션을 빌드하고 있다고 webpack에 힌트할 수 있습니다.

// webpack.config.js
module.exports = {
  mode: 'production',
};

프로덕션용 앱을 빌드할 때는 production 모드를 사용 설정해야 합니다. 이렇게 하면 webpack에서 축소, 라이브러리의 개발 전용 코드 삭제와 같은 최적화를 적용합니다. 기타

추가 자료

축소 사용 설정

축소는 공백을 삭제하고 변수 이름을 줄이는 등 코드를 압축하는 것입니다. 다음 행을 추가하면 됩니다.

// Original code
function map(array, iteratee) {
  let index = -1;
  const length = array == null ? 0 : array.length;
  const result = new Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}

// Minified code
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l}

Webpack은 코드를 축소하는 두 가지 방법인 번들 수준 축소로더별 옵션을 지원합니다. 두 가지를 동시에 사용해야 합니다.

번들 수준 축소

번들 수준 축소는 컴파일 후 전체 번들을 압축합니다. 이용 방법은 다음과 같습니다.

  1. 다음과 같이 코드를 작성합니다.

    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    
  2. Webpack은 이를 다음과 같이 컴파일합니다.

    // bundle.js (part of)
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (immutable) */ __webpack_exports__["render"] = render;
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
    __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
    
    function render(data, target) {
    console.log('Rendered!');
    }
    
  3. 축소기는 이를 다음과 같이 대략 압축합니다.

    // minified bundle.js (part of)
    "use strict";function t(e,n){console.log("Rendered!")}
    Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
    

webpack 4에서는 프로덕션 모드와 프로덕션 모드가 아닌 경우 모두 번들 수준 축소가 자동으로 사용 설정됩니다. 내부적으로 UglifyJS 최소화 도구를 사용합니다. 축소를 사용 중지해야 하는 경우 개발 모드를 사용하거나 falseoptimization.minimize 옵션에 전달하면 됩니다.

webpack 3에서는 UglifyJS 플러그인을 직접 사용해야 합니다. 이 플러그인은 webpack과 번들로 제공됩니다. 사용 설정하려면 구성의 plugins 섹션에 추가합니다.

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

로더별 옵션

코드를 축소하는 두 번째 방법은 로더별 옵션 (로더의 정의)입니다. 로더 옵션을 사용하면 최소화 도구로 최소화할 수 없는 항목을 압축할 수 있습니다. 예를 들어 css-loader를 사용하여 CSS 파일을 가져오면 파일이 문자열로 컴파일됩니다.

/* comments.css */
.comment {
  color: black;
}
// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);

이 코드는 문자열이므로 최소화 도구에서 압축할 수 없습니다. 파일 콘텐츠를 축소하려면 로더를 구성해야 합니다.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { minimize: true } },
        ],
      },
    ],
  },
};

추가 자료

NODE_ENV=production 지정

프런트엔드 크기를 줄이는 또 다른 방법은 코드에서 NODE_ENV 환경 변수production 값으로 설정하는 것입니다.

라이브러리는 NODE_ENV 변수를 읽어 개발 모드인지 프로덕션 모드인지 감지합니다. 일부 라이브러리는 이 변수에 따라 다르게 동작합니다. 예를 들어 NODE_ENVproduction로 설정되지 않으면 Vue.js는 추가 검사를 실행하고 경고를 출력합니다.

// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.');
}
// …

React도 비슷하게 작동하여 경고가 포함된 개발 빌드를 로드합니다.

// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

// react/cjs/react.development.js
// …
warning$3(
    componentClass.getDefaultProps.isReactClassApproved,
    'getDefaultProps is only used on classic React.createClass ' +
    'definitions. Use a static property named `defaultProps` instead.'
);
// …

이러한 검사 및 경고는 일반적으로 프로덕션에서는 필요하지 않지만 코드에 남아 라이브러리 크기를 늘립니다. webpack 4에서는 optimization.nodeEnv: 'production' 옵션을 추가하여 삭제합니다.

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,
  },
};

webpack 3에서는 DefinePlugin를 대신 사용합니다.

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    }),
    new webpack.optimize.UglifyJsPlugin()
  ]
};

optimization.nodeEnv 옵션과 DefinePlugin는 동일한 방식으로 작동합니다. process.env.NODE_ENV가 나오는 모든 위치를 지정된 값으로 바꿉니다. 위의 구성을 사용하면 다음과 같이 설정할 수 있습니다.

  1. Webpack은 process.env.NODE_ENV의 모든 인스턴스를 "production"로 바꿉니다.

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if (process.env.NODE_ENV !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
  2. 그러면 최소화 도구가 이러한 if 브랜치를 모두 삭제합니다. "production" !== 'production'는 항상 false이고 플러그인은 이러한 브랜치 내의 코드가 실행되지 않는다는 것을 이해하기 때문입니다.

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js (without minification)
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    }
    

추가 자료

ES 모듈 사용

프런트엔드 크기를 줄이는 다음 방법은 ES 모듈을 사용하는 것입니다.

ES 모듈을 사용하면 webpack에서 트리 셰이킹을 실행할 수 있습니다. 트리 쉐이킹은 번들러가 전체 종속 항목 트리를 탐색하고 사용되는 종속 항목을 확인한 후 사용되지 않는 항목을 삭제하는 것입니다. 따라서 ES 모듈 문법을 사용하면 webpack에서 사용되지 않는 코드를 제거할 수 있습니다.

  1. 내보내기가 여러 개 있는 파일을 작성했지만 앱에서 그중 하나만 사용합니다.

    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    
  2. Webpack은 commentRestEndpoint가 사용되지 않는다고 인식하고 번들에서 별도의 내보내기 지점을 생성하지 않습니다.

    // bundle.js (part that corresponds to comments.js)
    (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    const render = () => { return 'Rendered!'; };
    /* harmony export (immutable) */ __webpack_exports__["a"] = render;
    
    const commentRestEndpoint = '/rest/comments';
    /* unused harmony export commentRestEndpoint */
    })
    
  3. 축소 도구는 사용되지 않는 변수를 삭제합니다.

    // bundle.js (part that corresponds to comments.js)
    (function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
    

ES 모듈로 작성된 라이브러리에서도 작동합니다.

하지만 webpack의 내장 미니파이어 (UglifyJsPlugin)를 정확하게 사용할 필요는 없습니다. 불필요한 코드 삭제를 지원하는 모든 최소화 도구(예: Babel Minify 플러그인 또는 Google Closure Compiler 플러그인)를 사용하면 됩니다.

추가 자료

이미지 최적화

이미지가 페이지 크기의 절반 이상을 차지합니다. CSS는 JavaScript만큼 중요하지는 않지만 (예: 렌더링을 차단하지 않음) 대역폭의 상당 부분을 차지합니다. url-loader, svg-url-loader, image-webpack-loader를 사용하여 webpack에서 최적화합니다.

url-loader: 작은 정적 파일을 앱에 인라인 처리합니다. 구성이 없으면 전달된 파일을 가져와 컴파일된 번들 옆에 배치하고 해당 파일의 URL을 반환합니다. 하지만 limit 옵션을 지정하면 이 제한보다 작은 파일을 Base64 데이터 URL로 인코딩하고 이 URL을 반환합니다. 이렇게 하면 이미지가 JavaScript 코드에 인라인 처리되고 HTTP 요청이 저장됩니다.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  }
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`

svg-url-loader는 Base64가 아닌 URL 인코딩으로 파일을 인코딩한다는 점을 제외하고 url-loader와 동일하게 작동합니다. 이는 SVG 이미지에 유용합니다. SVG 파일은 일반 텍스트이므로 이 인코딩은 크기 측면에서 더 효과적입니다.

module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: "svg-url-loader",
        options: {
          limit: 10 * 1024,
          noquotes: true
        }
      }
    ]
  }
};

image-webpack-loader는 통과하는 이미지를 압축합니다. JPG, PNG, GIF, SVG 이미지를 지원하므로 이 모든 유형에 사용할 것입니다.

이 로더는 이미지를 앱에 삽입하지 않으므로 url-loadersvg-url-loader와 함께 작동해야 합니다. 두 규칙 (하나는 JPG/PNG/GIF 이미지용, 다른 하나는 SVG 이미지용)에 모두 복사하여 붙여넣지 않도록 하려면 이 로더를 enforce: 'pre'와 함께 별도의 규칙으로 포함합니다.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre'
      }
    ]
  }
};

로더의 기본 설정은 이미 사용 가능합니다. 하지만 추가로 구성하려면 플러그인 옵션을 참고하세요. 지정할 옵션을 선택하려면 Addy Osmani의 훌륭한 이미지 최적화 가이드를 확인하세요.

추가 자료

종속 항목 최적화

평균 JavaScript 크기의 절반 이상이 종속 항목에서 비롯되며, 그 크기 중 일부는 불필요할 수 있습니다.

예를 들어 Lodash (v4.17.4 기준)는 번들에 최소화된 코드 72KB를 추가합니다. 하지만 메서드 중 20개만 사용한다면 약 65KB의 축소된 코드는 아무것도 하지 않습니다.

또 다른 예로는 Moment.js가 있습니다. 2.19.1 버전은 축소된 코드가 223KB나 되며 이는 매우 큽니다. 페이지의 평균 JavaScript 크기는 2017년 10월 기준 452KB였습니다. 하지만 이 중 170KB는 현지화 파일입니다. Moment.js를 여러 언어로 사용하지 않으면 이러한 파일은 목적 없이 번들을 부풀립니다.

이러한 모든 종속 항목은 쉽게 최적화할 수 있습니다. 최적화 접근 방식을 GitHub 저장소에 모았습니다. 확인해 보세요.

ES 모듈에 모듈 연결 사용 설정 (범위 호이스팅이라고도 함)

번들을 빌드할 때 webpack은 각 모듈을 함수로 래핑합니다.

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// bundle.js (part  of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();
}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_exports__["a"] = render;
  function render(data, target) {
    console.log('Rendered!');
  }
})

이전에는 CommonJS/AMD 모듈을 서로 격리하는 데 이 작업이 필요했습니다. 그러나 이렇게 하면 각 모듈의 크기와 성능 오버헤드가 추가되었습니다.

Webpack 2에서는 CommonJS 및 AMD 모듈과 달리 각 모듈을 함수로 래핑하지 않고도 번들로 묶을 수 있는 ES 모듈 지원을 도입했습니다. webpack 3에서는 모듈 연결을 통해 이러한 번들링을 가능하게 했습니다. 모듈 연결은 다음과 같은 작업을 실행합니다.

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // CONCATENATED MODULE: ./comments.js
    function render(data, target) {
    console.log('Rendered!');
  }

  // CONCATENATED MODULE: ./index.js
  render();
})

차이를 확인할 수 있나요? 일반 번들에서 모듈 0은 모듈 1의 render를 요구했습니다. 모듈 연결을 사용하면 require가 필요한 함수로 간단히 대체되고 모듈 1이 삭제됩니다. 번들의 모듈 수가 줄고 모듈 오버헤드도 줄어듭니다.

이 동작을 사용 설정하려면 webpack 4에서 optimization.concatenateModules 옵션을 사용 설정하세요.

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    concatenateModules: true
  }
};

webpack 3에서는 ModuleConcatenationPlugin를 사용합니다.

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin()
  ]
};

추가 자료

webpack 코드와 webpack 이외의 코드가 모두 있는 경우 externals 사용

일부 코드는 webpack으로 컴파일되고 일부 코드는 컴파일되지 않는 대규모 프로젝트가 있을 수 있습니다. 동영상 호스팅 사이트와 같이 플레이어 위젯은 webpack으로 빌드되었지만 주변 페이지는 빌드되지 않은 경우를 예로 들 수 있습니다.

동영상 호스팅 사이트의 스크린샷
(완전히 무작위 동영상 호스팅 사이트)

두 코드에 공통 종속 항목이 있는 경우 이를 공유하여 코드를 여러 번 다운로드하지 않아도 됩니다. 이는 webpack의 externals 옵션을 사용하여 수행됩니다. 모듈을 변수 또는 기타 외부 가져오기로 대체합니다.

window에서 종속 항목을 사용할 수 있는 경우

webpack 이외의 코드가 window에서 변수로 사용할 수 있는 종속 항목을 사용하는 경우 종속 항목 이름을 변수 이름으로 별칭 지정합니다.

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  }
};

이 구성을 사용하면 webpack에서 reactreact-dom 패키지를 번들로 묶지 않습니다. 대신 다음과 같이 대체됩니다.

// bundle.js (part of)
(function(module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React;
}),
(function(module, exports) {
  // A module that exports `window.ReactDOM`. Without `externals`,
  // this module would include the whole ReactDOM bundle
  module.exports = ReactDOM;
})

종속 항목이 AMD 패키지로 로드되는 경우

webpack이 아닌 코드가 종속 항목을 window에 노출하지 않으면 더 복잡해집니다. 그러나 webpack 이외의 코드가 이러한 종속 항목을 AMD 패키지로 사용하는 경우 동일한 코드를 두 번 로드하지 않을 수 있습니다.

이렇게 하려면 webpack 코드를 AMD 번들로 컴파일하고 모듈을 라이브러리 URL에 별칭으로 지정합니다.

// webpack.config.js
module.exports = {
  output: {
    libraryTarget: 'amd'
  },
  externals: {
    'react': {
      amd: '/libraries/react.min.js'
    },
    'react-dom': {
      amd: '/libraries/react-dom.min.js'
    }
  }
};

Webpack은 번들을 define()로 래핑하고 다음 URL에 종속되도록 합니다.

// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () { … });

webpack 이외의 코드가 동일한 URL을 사용하여 종속 항목을 로드하는 경우 이러한 파일은 한 번만 로드됩니다. 추가 요청은 로더 캐시를 사용합니다.

추가 자료

요약

  • webpack 4를 사용하는 경우 프로덕션 모드 사용 설정
  • 번들 수준의 미니파이어 및 로더 옵션으로 코드 최소화
  • NODE_ENVproduction로 바꿔 개발 전용 코드를 삭제합니다.
  • ES 모듈을 사용하여 트리 셰이킹 사용 설정
  • 이미지 압축
  • 종속 항목별 최적화 적용
  • 모듈 연결 사용 설정
  • 적절한 경우 externals을 사용하세요.