코드 스플리팅은 webpack의 가장 매력적인 기능 중 하나입니다. 이 기능을 사용하여 코드를 다양한 번들로 분할하고, 요청에 따라 로드하거나 병렬로 로드할 수 있습니다. 더 작은 번들을 만들고 리소스 우선순위를 올바르게 제어하기 위해서 사용하며, 잘 활용하면 로드 시간에 큰 영향을 끼칠 수 있습니다.
일반적으로 코드 스플리팅은 세 가지 방식으로 접근할 수 있습니다.
entry 설정을 사용하여 코드를 수동으로 분할합니다.SplitChunksPlugin을 사용하여 중복 청크를 제거하고 청크를 분할합니다.코드를 분할하는 가장 쉽고 직관적인 방법입니다. 그러나 다른 방법에 비해 수동적이며, 같이 살펴볼 몇 가지 함정이 있습니다. 메인 번들에서 다른 모듈을 어떻게 분리하는지 알아보겠습니다.
project
webpack-demo |- package.json |- package-lock.json |- webpack.config.js |- /dist |- /src |- index.js + |- another-module.js |- /node_modules another-module.js
import _ from 'lodash'; console.log(_.join(['Another', 'module', 'loaded!'], ' ')); webpack.config.js
const path = require('path'); module.exports = { - entry: './src/index.js', + mode: 'development', + entry: { + index: './src/index.js', + another: './src/another-module.js', + }, output: { - filename: 'main.js', + filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), }, }; 다음과 같은 빌드 결과가 생성됩니다.
... [webpack-cli] Compilation finished asset index.bundle.js 553 KiB [emitted] (name: index) asset another.bundle.js 553 KiB [emitted] (name: another) runtime modules 2.49 KiB 12 modules cacheable modules 530 KiB ./src/index.js 257 bytes [built] [code generated] ./src/another-module.js 84 bytes [built] [code generated] ./node_modules/lodash/lodash.js 530 KiB [built] [code generated] webpack 5.4.0 compiled successfully in 245 ms 언급했듯이 이 접근 방식에는 몇 가지 함정이 있습니다.
이 중 첫 번째 항목을 통해 지금 예제의 문제를 알 수 있습니다. 왜냐하면 ./src/index.js에서도 lodash를 가져오므로 양쪽 번들에서 중복으로 포함되기 때문입니다. 다음 섹션에서 중복된 것을 제거하겠습니다.
dependOn 옵션을 사용하면 청크간 모듈을 공유할 수 있습니다.
webpack.config.js
const path = require('path'); module.exports = { mode: 'development', entry: { - index: './src/index.js', - another: './src/another-module.js', + index: { + import: './src/index.js', + dependOn: 'shared', + }, + another: { + import: './src/another-module.js', + dependOn: 'shared', + }, + shared: 'lodash', }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), }, }; 단일 HTML 페이지에서 여러 엔트리 포인트를 사용하는 경우 optimization.runtimeChunk: 'single'도 필요합니다. 그렇지 않으면 여기에서 설명하는 문제가 발생할 수 있습니다.
webpack.config.js
const path = require('path'); module.exports = { mode: 'development', entry: { index: { import: './src/index.js', dependOn: 'shared', }, another: { import: './src/another-module.js', dependOn: 'shared', }, shared: 'lodash', }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), }, + optimization: { + runtimeChunk: 'single', + }, }; 다음은 빌드 결과입니다.
... [webpack-cli] Compilation finished asset shared.bundle.js 549 KiB [compared for emit] (name: shared) asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime) asset index.bundle.js 1.77 KiB [compared for emit] (name: index) asset another.bundle.js 1.65 KiB [compared for emit] (name: another) Entrypoint index 1.77 KiB = index.bundle.js Entrypoint another 1.65 KiB = another.bundle.js Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB runtime modules 3.76 KiB 7 modules cacheable modules 530 KiB ./node_modules/lodash/lodash.js 530 KiB [built] [code generated] ./src/another-module.js 84 bytes [built] [code generated] ./src/index.js 257 bytes [built] [code generated] webpack 5.4.0 compiled successfully in 249 ms 보시다시피 shared.bundle.js, index.bundle.js 및 another.bundle.js 외에 또 다른 runtime.bundle.js 파일이 생성됩니다.
webpack은 하나의 페이지에 여러 엔트리 포인트를 허용하지만, 가능하다면 entry: { page: ['./analytics', './app'] }처럼 여러 개의 import가 포함된 엔트리 포인트 사용을 피해야 합니다. 이는 async 스크립트 태그를 사용할 때 최적화에 용이하며 일관된 순서로 실행할 수 있도록 합니다.
SplitChunksPlugin을 사용하면 기존 엔트리 청크 또는 완전히 새로운 청크로 공통 의존성을 추출할 수 있습니다. 이를 활용하여 이전 예제의 lodash 중복을 제거해 보겠습니다.
webpack.config.js
const path = require('path'); module.exports = { mode: 'development', entry: { index: './src/index.js', another: './src/another-module.js', }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), }, + optimization: { + splitChunks: { + chunks: 'all', + }, + }, }; optimization.splitChunks 설정 옵션을 적용하면 index.bundle.js와 another.bundle.js에서 중복 의존성이 제거된 것을 확인 할 수 있습니다. 플러그인은 lodash를 별도의 청크로 분리하고 메인 번들에서도 제거된 것을 알 수 있습니다. 그러나 공통 의존성은 webpack에서 지정한 크기 임계값을 충족하는 경우에만 별도의 청크로 추출된다는 점에 유의해야 합니다.
... [webpack-cli] Compilation finished asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors) asset index.bundle.js 8.92 KiB [compared for emit] (name: index) asset another.bundle.js 8.8 KiB [compared for emit] (name: another) Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB runtime modules 7.64 KiB 14 modules cacheable modules 530 KiB ./src/index.js 257 bytes [built] [code generated] ./src/another-module.js 84 bytes [built] [code generated] ./node_modules/lodash/lodash.js 530 KiB [built] [code generated] webpack 5.4.0 compiled successfully in 241 ms 다음은 코드 스플리팅을 위해 커뮤니티에서 제공하는 다른 유용한 플러그인과 로더입니다.
-mini-css-extract-plugin : 메인 애플리케이션에서 CSS를 분리하는데 유용합니다.
webpack은 동적 코드 스플리팅에 두 가지 유사한 기술을 지원합니다. 첫 번째이자 권장하는 접근 방식은 ECMAScript 제안을 준수하는 import()구문을 사용하는 방식입니다. 기존의 webpack 전용 방식은 require.ensure를 사용하는 것입니다. 이 두 가지 중 첫 번째를 사용해 보겠습니다.
시작하기 전에 위 예제의 설정에서 추가 entry 및 optimization.splitChunks를 제거하겠습니다. 다음 데모에는 필요하지 않습니다.
webpack.config.js
const path = require('path'); module.exports = { mode: 'development', entry: { index: './src/index.js', - another: './src/another-module.js', }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), }, - optimization: { - splitChunks: { - chunks: 'all', - }, - }, }; 또한 현재 사용하지 않는 파일을 프로젝트에서 제거하겠습니다.
project
webpack-demo |- package.json |- package-lock.json |- webpack.config.js |- /dist |- /src |- index.js - |- another-module.js |- /node_modules 이제 정적으로 가져오던 lodash를 동적으로 가져와서 청크를 분리해보겠습니다.
src/index.js
-import _ from 'lodash'; - -function component() { +function getComponent() { - const element = document.createElement('div'); - // Lodash, now imported by this script - element.innerHTML = _.join(['Hello', 'webpack'], ' '); + return import('lodash') + .then(({ default: _ }) => { + const element = document.createElement('div'); + + element.innerHTML = _.join(['Hello', 'webpack'], ' '); - return element; + return element; + }) + .catch((error) => 'An error occurred while loading the component'); } -document.body.appendChild(component()); +getComponent().then((component) => { + document.body.appendChild(component); +}); default가 필요한 이유는 webpack 4 이후로 CommonJS 모듈을 가져올 때 더 이상 module.exports 값 으로 해석되지 않으며 대신 CommonJS 모듈에 대한 인공 네임 스페이스 객체를 생성하기 때문입니다. 그 이유에 대한 자세한 내용은 webpack 4: import() 및 CommonJs를 참고하세요.
webpack을 실행하여 lodash가 별도의 번들로 분리되어 있는지 살펴보겠습니다.
... [webpack-cli] Compilation finished asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors) asset index.bundle.js 13.5 KiB [compared for emit] (name: index) runtime modules 7.37 KiB 11 modules cacheable modules 530 KiB ./src/index.js 434 bytes [built] [code generated] ./node_modules/lodash/lodash.js 530 KiB [built] [code generated] webpack 5.4.0 compiled successfully in 268 ms import()는 promise를 반환하므로 async 함수와 함께 사용할 수 있습니다. 코드를 단순화하는 방법은 다음과 같습니다.
src/index.js
-function getComponent() { +async function getComponent() { + const element = document.createElement('div'); + const { default: _ } = await import('lodash'); - return import('lodash') - .then(({ default: _ }) => { - const element = document.createElement('div'); + element.innerHTML = _.join(['Hello', 'webpack'], ' '); - element.innerHTML = _.join(['Hello', 'webpack'], ' '); - - return element; - }) - .catch((error) => 'An error occurred while loading the component'); + return element; } getComponent().then((component) => { document.body.appendChild(component); }); Webpack 4.6.0+에서 프리페치 및 프리로드에 대한 지원이 추가되었습니다.
모듈을 가져올 때 인라인 지시문을 사용하면 webpack이 브라우저에 아래와 같은 "리소스 힌트"를 줄 수 있습니다.
간단한 프리페치의 예제를 들어보겠습니다. HomePage 컴포넌트에서 LoginButton 컴포넌트를 렌더링하고, 이 컴포넌트를 클릭하면 LoginModal 컴포넌트를 요청하여 로드하는 경우입니다.
LoginButton.js
//... import(/* webpackPrefetch: true */ './path/to/LoginModal.js'); 이는 페이지 head에 <link rel="prefetch" href="login-modal-chunk.js">를 추가하고 브라우저에 login-modal-chunk.js를 유휴 시간에 미리 가져오도록 지시합니다.
프리로드 지시문은 프리페치와 비교했을 때 여러 가지 차이점이 있습니다.
간단한 프리로드의 예로는, 별도의 청크에 있어야 하는 큰 라이브러리에 항상 의존하는 Component를 생각해 볼 수 있습니다.
거대한 ChartingLibrary가 필요한 ChartComponent를 상상해 봅시다. 렌더링 될 때 LoadingIndicator를 표시하고 즉시 ChartingLibrary를 요청하여 불러옵니다.
ChartComponent.js
//... import(/* webpackPreload: true */ 'ChartingLibrary'); ChartComponent를 사용하는 페이지를 요청할 때 <link rel="preload">를 통해 charting-library-chunk도 요청됩니다. page-chunk가 더 작고 더 빨리 완료된다고 가정하면 이미 요청된 charting-library-chunk가 완료될 때까지 페이지에는 LoadingIndicator가 표시됩니다. 두 번이 아닌 한 번의 라운드 트립이 필요하므로 대기 시간이 긴 환경에서 로드 시간이 증가할 수 있습니다.
때로는 프리로드에 대한 자신만의 제어가 필요합니다. 예를 들어 모든 동적 import의 프리로드는 비동기 스크립트를 통해 수행할 수 있습니다. 이는 서버사이드 랜더링을 스트리밍할 때 유용합니다.
const lazyComp = () => import('DynamicComponent').catch((error) => { // 에러가 있는 작업을 수행합니다. // 예를 들어, 모든 네트워크 에러가 발생할 경우 요청을 재시도할 수 있습니다. }); Webpack이 해당 스크립트의 자체 로드를 시작하기 전에 스크립트 로드가 실패하면(Webpack은 해당 스크립트가 페이지에 없는 경우 해당 코드를 로드하기 위해 스크립트 태그를 생성함), 해당 catch 핸들러는 chunkLoadTimeout에 전달되지 않습니다. 이 동작은 예기치 않은 것일 수 있습니다. 하지만 설명 가능합니다. Webpack은 해당 스크립트가 실패했다는 것을 모르기 때문에 에러를 발생시킬 수 없습니다. Webpack은 에러가 발생한 후 즉시 onerror 핸들러를 스크립트에 추가합니다.
이러한 문제를 방지하기 위해, 에러 발생 시 스크립트를 제거하는 자체 onerror 핸들러를 추가할 수 있습니다.
<script src="https://example.com/dist/dynamicComponent.js" async onerror="this.remove()" ></script> 이 경우 에러가 있는 스크립트는 제거됩니다. Webpack은 자체 스크립트를 생성하고 모든 에러는 시간 초과 없이 처리됩니다.
코드 스플리팅을 시작하면 출력을 분석하여 어디서 모듈이 종료되었는지 확인하는 데 유용합니다. 공식 분석 도구부터 시작하는 것이 좋습니다. 커뮤니티에서 지원하는 다른 옵션도 있습니다.
실제 애플리케이션에서 어떻게 import()를 사용하는지 더 구체적으로 알고 싶다면 Lazy Loading의 예제를 확인하세요. 더 효율적인 코드 스플리팅 방법은 Caching을 참고하세요.