장기 캐싱 활용

webpack이 애셋 캐싱에 도움이 되는 방식

다음으로 할 일은 (앱 크기를 최적화하여 앱 로드 시간을 향상시킨 후) 캐싱입니다. 이 파일을 사용하여 앱의 일부를 클라이언트에 유지하고 매번 다시 다운로드하지 않도록 합니다.

번들 버전 관리 및 캐시 헤더 사용

캐싱을 수행하는 일반적인 접근 방식은 다음과 같습니다.

  1. 매우 오랜 기간 (예: 1년) 동안 파일을 캐시하도록 브라우저에 지시합니다.

    # Server header
    Cache-Control: max-age=31536000
    

    Cache-Control의 기능에 대해 잘 모른다면 제이크 아치볼드의 캐싱 권장사항에 관한 우수한 게시물을 참고하세요.

  2. 파일이 변경되면 강제로 다시 다운로드되도록 파일 이름을 변경합니다.

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

이 접근 방식은 브라우저가 JS 파일을 다운로드하고 캐시한 후 캐시된 사본을 사용하도록 지시합니다. 브라우저는 파일 이름이 변경되거나 1년이 지난 경우에만 네트워크에 접속합니다.

webpack을 사용해도 같은 작업을 수행하지만 버전 번호 대신 파일 해시를 지정합니다. 파일 이름에 해시를 포함하려면 [chunkhash]를 사용합니다.

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

클라이언트로 전송할 파일 이름이 필요하면 HtmlWebpackPlugin 또는 WebpackManifestPlugin를 사용하세요.

HtmlWebpackPlugin는 단순하지만 덜 유연한 접근 방식입니다. 컴파일하는 동안 이 플러그인은 컴파일된 모든 리소스를 포함하는 HTML 파일을 생성합니다. 서버 로직이 복잡하지 않으면 그것으로 충분합니다.

<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

WebpackManifestPlugin는 좀 더 유연한 접근 방식이며, 복잡한 서버 부분이 있는 경우에 유용합니다. 빌드 중에는 해시가 없는 파일 이름과 해시가 있는 파일 이름 간에 매핑되는 JSON 파일이 생성됩니다. 서버에서 이 JSON을 사용하여 작업할 파일을 확인합니다.

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}

추가 자료

종속 항목과 런타임을 별도의 파일로 추출

종속 항목

앱 종속 항목은 실제 앱 코드보다 자주 변경되는 경향이 있습니다. 이러한 파일을 별도의 파일로 이동하면 브라우저에서 별도로 캐시할 수 있으므로 앱 코드가 변경될 때마다 다시 다운로드하지 않습니다.

종속 항목을 별도의 청크로 추출하려면 다음 세 단계를 따르세요.

  1. 출력 파일 이름을 [name].[chunkname].js로 바꿉니다.

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    webpack이 앱을 빌드할 때 [name]를 청크 이름으로 바꿉니다. [name] 부분을 추가하지 않으면 해시를 사용하여 청크를 구분해야 합니다. 이 작업은 매우 어렵습니다.

  2. entry 필드를 객체로 변환합니다.

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    이 스니펫에서 'main'은 청크의 이름입니다. 1단계의 [name] 대신 이 이름이 사용됩니다.

    이제 앱을 빌드하면 이 단계를 실행하지 않은 것처럼 이 청크에 전체 앱 코드가 포함됩니다. 하지만 이것은 곧 바뀔 것입니다.

  3. webpack 4에서 optimization.splitChunks.chunks: 'all' 옵션을 webpack 구성에 추가합니다.

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    이 옵션은 스마트 코드 분할을 사용 설정합니다. 이를 통해 webpack은 크기가 30KB를 초과하면 (축소 및 gzip 실행 전) 공급업체 코드를 추출합니다. 또한 공통 코드도 추출합니다. 이 기능은 빌드가 여러 번들을 생성하는 경우 (예: 앱을 경로로 분할한 경우) 유용합니다.

    웹팩 3에서 CommonsChunkPlugin를 추가합니다.

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
        // A name of the chunk that will include the dependencies.
        // This name is substituted in place of [name] from step 1
        name: 'vendor',
    
        // A function that determines which modules to include into this chunk
        minChunks: module => module.context && module.context.includes('node_modules'),
        })
      ]
    };
    

    이 플러그인은 node_modules가 포함된 모든 모듈을 가져와 vendor.[chunkhash].js라는 별도의 파일로 이동합니다.

이러한 변경사항이 적용되면 각 빌드에서 하나가 아닌 두 개의 파일, 즉 main.[chunkhash].jsvendor.[chunkhash].js (webpack 4의 경우 vendors~main.[chunkhash].js)를 생성합니다. webpack 4의 경우 종속 항목이 작으면 공급업체 번들이 생성되지 않을 수 있지만 괜찮습니다.

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                        Asset      Size  Chunks             Chunk Names
 ./main.00bab6fd3100008a42b0.js   82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

브라우저는 이러한 파일들을 별도로 캐시하고 변경되는 코드만 다시 다운로드합니다.

Webpack 런타임 코드

안타깝게도 공급업체 코드만 추출하는 것만으로는 충분하지 않습니다. 앱 코드에서 무엇인가를 변경하려고 하는 경우:

// index.js
…
…

// E.g. add this:
console.log('Wat');

vendor 해시도 변경됩니다.

                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor

이는 모듈 코드를 제외하고 webpack 번들에 모듈 실행을 관리하는 작은 코드 조각인 런타임이 있기 때문입니다. 코드를 여러 파일로 분할하면 이 코드가 청크 ID와 해당 파일 간의 매핑을 포함하기 시작합니다.

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

Webpack은 이 런타임을 마지막으로 생성된 청크(여기서는 vendor)에 포함합니다. 청크가 변경될 때마다 이 코드 부분도 변경되므로 전체 vendor 청크가 변경됩니다.

이 문제를 해결하기 위해 런타임을 별도의 파일로 이동하겠습니다. 웹팩 4에서는 optimization.runtimeChunk 옵션을 사용 설정하면 됩니다.

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

webpack 3에서 다음과 같이 CommonsChunkPlugin로 빈 청크를 추가로 만듭니다.

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => module.context && module.context.includes('node_modules')
    }),
    // This plugin must come after the vendor one (because webpack
    // includes runtime into the last chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      // minChunks: Infinity means that no app modules
      // will be included into this chunk
      minChunks: Infinity
    })
  ]
};

이러한 변경사항이 적용되면 각 빌드에서 세 개의 파일을 생성합니다.

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

index.html에 역순으로 포함하면 됩니다.

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

추가 자료

추가 HTTP 요청을 저장하는 인라인 웹팩 런타임

더 나은 결과를 얻으려면 webpack 런타임을 HTML 응답에 인라인 처리해보세요. 즉, 다음을 수행합니다.

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>

수행할 작업:

<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>

런타임은 작으므로 HTTP 요청을 저장하는 데 도움이 됩니다 (HTTP/1에서는 꽤 중요하고, HTTP/2에서는 덜 중요하지만 영향을 미칠 수 있음).

방법은 여기를 참조하세요.

HTMLWebpackPlugin을 사용하여 HTML을 생성하는 경우

HtmlWebpackPlugin을 사용하여 HTML 파일을 생성하는 경우 InlineSourcePlugin만 있으면 됩니다.

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

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

맞춤 서버 로직을 사용하여 HTML을 생성하는 경우

webpack 4의 경우

  1. 생성된 런타임 청크 이름을 알 수 있도록 WebpackManifestPlugin를 추가합니다.

    // webpack.config.js (for webpack 4)
    const ManifestPlugin = require('webpack-manifest-plugin');
    
    module.exports = {
      plugins: [
        new ManifestPlugin()
      ]
    };
    

    이 플러그인을 사용하는 빌드는 다음과 같은 파일을 만듭니다.

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. 편리한 방법으로 런타임 청크의 콘텐츠를 인라인으로 추가합니다. Node.js 및 Express를 예로 들 수 있습니다.

    // server.js
    const fs = require('fs');
    const manifest = require('./manifest.json');
    const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

또는 webpack 3의 경우:

  1. filename를 지정하여 런타임 이름을 정적으로 만듭니다.

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. 편리한 방법으로 runtime.js 콘텐츠를 인라인으로 추가합니다. Node.js 및 Express를 예로 들 수 있습니다.

    // server.js
    const fs = require('fs');
    const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

지금 필요하지 않은 코드 지연 로드

때로는 페이지에 다음과 같이 더 중요한 부분이 있거나 덜 중요한 부분이 있습니다.

  • YouTube에서 동영상 페이지를 로드할 때는 댓글보다는 동영상에 더 관심이 많습니다. 이 부분에서는 댓글보다 동영상이 더 중요합니다.
  • 뉴스 사이트에서 기사를 열 때는 광고보다 기사의 텍스트가 더 중요합니다. 여기서는 광고보다 텍스트가 더 중요합니다.

이 경우 가장 중요한 항목만 먼저 다운로드하고 나머지 부분을 지연 로드하여 초기 로드 성능을 개선하세요. 이를 위해 import() 함수코드 분할을 사용합니다.

// videoPlayer.js
export function renderVideoPlayer() { … }

// comments.js
export function renderComments() { … }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

import()는 특정 모듈을 동적으로 로드하려고 함을 지정합니다. webpack에 import('./module.js')가 표시되면 이 모듈을 별도의 청크로 이동합니다.

$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

실행이 import() 함수에 도달할 때만 다운로드합니다.

이렇게 하면 main 번들이 작아져 초기 로드 시간이 개선됩니다. 또한 캐싱이 개선됩니다. 기본 청크의 코드를 변경해도 주석 청크는 영향을 받지 않습니다.

추가 자료

코드를 경로 및 페이지로 분할

앱에 경로나 페이지가 여러 개 있지만 코드가 포함된 JS 파일이 하나만 있는 경우 (단일 main 청크) 각 요청에 추가 바이트를 제공할 가능성이 높습니다. 예를 들어 사용자가 사이트의 홈페이지를 방문하면

WebFundamentals 홈페이지

다른 페이지에 있는 기사를 렌더링하는 코드를 로드할 필요는 없습니다. 또한 사용자가 항상 홈페이지만 방문하고 기사 코드를 변경하면 webpack이 전체 번들을 무효화하므로 사용자는 전체 앱을 다시 다운로드해야 합니다.

앱을 페이지 (또는 단일 페이지 앱인 경우 경로)로 분할하면 사용자는 관련 코드만 다운로드합니다. 또한 브라우저가 앱 코드를 더 잘 캐시합니다. 홈페이지 코드를 변경하면 webpack이 해당하는 청크만 무효화합니다.

단일 페이지 앱의 경우

경로로 단일 페이지 앱을 분할하려면 import()를 사용합니다 ('지금은 필요하지 않은 지연 로드 코드' 섹션 참고). 프레임워크를 사용하는 경우 이를 위한 기존 솔루션이 있을 수 있습니다.

기존 다중 페이지 앱의 경우

기존 앱을 페이지별로 분할하려면 webpack의 진입점을 사용하세요. 앱에 홈페이지, 기사 페이지, 사용자 계정 페이지라는 세 가지 종류의 페이지가 있는 경우 다음 세 가지 항목이 있어야 합니다.

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

각 항목 파일에 대해 webpack은 별도의 종속 항목 트리를 빌드하고 해당 항목에서 사용하는 모듈만 포함된 번들을 생성합니다.

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

따라서 기사 페이지에서만 Lodash를 사용하는 경우 homeprofile 번들에는 Lodash가 포함되지 않습니다. 따라서 사용자는 홈페이지를 방문할 때 이 라이브러리를 다운로드할 필요가 없습니다.

하지만 별도의 종속 항목 트리에는 단점이 있습니다. 두 진입점에서 Lodash를 사용하고 종속 항목을 공급업체 번들로 이동하지 않은 경우 두 진입점에 Lodash 사본이 포함됩니다. 이 문제를 해결하려면 webpack 4에서 optimization.splitChunks.chunks: 'all' 옵션을 webpack 구성에 추가합니다.

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

이 옵션은 스마트 코드 분할을 사용 설정합니다. 이 옵션을 사용하면 webpack이 자동으로 공통 코드를 찾아 별도의 파일로 추출합니다.

또는 webpack 3에서 CommonsChunkPlugin를 사용하면 일반적인 종속 항목을 지정된 새 파일로 이동합니다.

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

minChunks 값을 자유롭게 사용하여 가장 적합한 값을 찾습니다. 일반적으로 크기를 작게 유지하되 청크 수가 증가하면 증가시킵니다. 예를 들어 청크가 3개인 경우 minChunks가 2개이지만 30개 청크에서는 8개가 될 수 있습니다. 2로 유지하면 공통 파일에 너무 많은 모듈이 들어가서 크기가 너무 커지기 때문입니다.

추가 자료

모듈 ID를 더 안정적으로 만들기

코드를 빌드할 때 webpack은 각 모듈에 ID를 할당합니다. 나중에 이러한 ID는 번들 내의 require()에서 사용됩니다. 일반적으로 빌드 출력에서 모듈 경로 바로 앞에 ID가 표시됩니다.

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

기본적으로 ID는 카운터를 사용하여 계산됩니다. 즉, 첫 번째 모듈의 ID는 0이고 두 번째 모듈의 ID는 1인 것과 같습니다. 여기서 문제는 새 모듈을 추가하면 모듈 목록 중간에 표시되어 다음 모듈의 ID가 모두 변경될 수 있다는 점입니다.

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ 새로운 모듈이 추가되었습니다...

[4] ./webPlayer.js 24 kB {1} [built]

↓ 자, 이제 어떻게 됐는지 봐봐! 이제 comments.js의 ID가 4가 아닌 5입니다.

[5] ./comments.js 58 kB {0} [built]

ads.js의 ID가 이제 5가 아닌 6입니다.

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

이렇게 하면 실제 코드가 변경되지 않았더라도 ID가 변경된 모듈을 포함하거나 모듈에 종속된 모든 청크가 무효화됩니다. 여기서는 0 청크 (comments.js가 있는 청크)와 main 청크 (다른 앱 코드가 있는 청크)는 무효화되지만, main만 무효화되어야 합니다.

이 문제를 해결하려면 HashedModuleIdsPlugin을 사용하여 모듈 ID가 계산되는 방식을 변경합니다. 카운터 기반 ID를 모듈 경로의 해시로 바꿉니다.

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

이 접근 방식을 사용하면 모듈의 이름을 변경하거나 모듈을 이동하는 경우에만 모듈의 ID가 변경됩니다. 새 모듈은 다른 모듈의 ID에 영향을 주지 않습니다.

플러그인을 사용 설정하려면 구성의 plugins 섹션에 추가합니다.

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

추가 자료

요약

  • 번들을 캐시하고 번들 이름을 변경하여 버전 구분
  • 번들을 앱 코드, 공급업체 코드, 런타임으로 분할
  • HTTP 요청을 저장하기 위해 런타임을 인라인 처리
  • import를 사용하여 중요하지 않은 코드를 지연 로드
  • 불필요한 항목이 로드되지 않도록 경로/페이지별로 코드를 분할합니다.