장기 캐싱 활용

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에서 webpack 구성에 optimization.splitChunks.chunks: 'all' 옵션을 추가합니다.

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

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

    webpack 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 청크가 변경됩니다.

이 문제를 해결하려면 런타임을 별도의 파일로 이동하겠습니다. webpack 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 런타임

더 나은 결과를 얻으려면 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에서 webpack 구성에 optimization.splitChunks.chunks: 'all' 옵션을 추가합니다.

// 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를 사용하여 중요하지 않은 코드 지연 로드
  • 불필요한 항목이 로드되지 않도록 경로/페이지별로 코드 분할