[프로솔브 리팩터링] Webpack 빌드 속도 및 번들 크기 개선

fgStudy·2023년 3월 4일
2

프로솔브

목록 보기
3/3
post-thumbnail

이번에 프로솔브에 새로운 기능을 추가하면서 현재 Webpack 환경설정이 빌드 속도가 느리다는 생각이 들어 리팩터링을 하였습니다.

기존 Webpack 환경설정은 development와 production 모드 설정을 제대로 분리하지 않았어요.
development일 때는 빠른 빌드 속도가 중요하기에 파일 압축 등의 최적화 작업이 필요 없으나, 기존 Webpack 환경설정은 development와 production 두 모드에서 사용했었어요.

또한 기존 설정은 번들 크기 최적화가 충분히 이루어지지 않았었어요.

그래서 이번에 production 모드일때만 번들 크기 최적화 작업을 진행하고 추가적인 최적화를 진행하였습니다.

이러한 개선 작업을 통해 development 환경 빌드 속도 34.36% 감소(콜드 스타트 기준) 및 production 환경 번들 크기 27.7% 감소를 하게 되었습니다!

이 글에서 이전 설정과 개선된 설정을 비교한 다음, 빌드 속도와 번들 크기를 비교해보고자 합니다.


이전 Webpack 설정

// webpack.common.js
const path = require('path');	
const getAbsolutePath = pathDir => path.resolve(__dirname, pathDir);	
const getHtmlPlugins = chunks => {	
  return chunks.map(	
    ({ chunk, title }) =>	
      new HtmlPlugin({	
        title: `${title}`,	
        filename: `${chunk}.html`,	
        chunks: [chunk],	
      }),	
  );	
};	

const Dotenv = require('dotenv-webpack');	
const { ESBuildMinifyPlugin } = require('esbuild-loader');	
const { CleanWebpackPlugin } = require('clean-webpack-plugin');	
const CopyPlugin = require('copy-webpack-plugin');	
const HtmlPlugin = require('html-webpack-plugin');	

module.exports = {	
  entry: {	
    popup: getAbsolutePath('src/pages/popup/index.tsx'),	
    background: getAbsolutePath('src/pages/background/index.ts'),	
    testContent: getAbsolutePath('src/pages/content/testPage.tsx'),	
    solutionContent: getAbsolutePath('src/pages/content/solutionPage.tsx'),
    problemContent: getAbsolutePath('src/pages/content/problemPage.tsx'),
    solutionTab: getAbsolutePath('src/pages/newTab/solution/index.tsx'),
    profileTab: getAbsolutePath('src/pages/newTab/profile/index.tsx'),	
    memoTab: APP_PATH('src/pages/newTab/memo/index.tsx'),
  },	
  output: {	
    filename: 'script/[name].js',	
    path: getAbsolutePath('dist'),	
  },	
  cache: {	
    type: 'filesystem',	
    buildDependencies: {	
      config: [__filename],	
    },	
  },
  module: {	
    rules: [	
      {	
        test: /\.ts(x?)$/,	
        exclude: /node_modules/,	
        loader: 'esbuild-loader',	
        options: {	
          loader: 'tsx',	
          target: 'esnext',	
          tsconfigRaw: require('./tsconfig.json'),	
        },	
      },	
      {	
        test: /\.css$/i,	
        use: ['style-loader', 'css-loader'],	
      },	
      {	
        test: /\.(jpe?g|png|gif)$/i,	
        use: [	
          {	
            loader: 'url-loader',	
            options: {	
              limit: 10000,	
              fallback: 'file-loader',	
            },	
          },	
        ],
      },
      {	
        test: /\.svg$/i,	
        use: ['@svgr/webpack'],	
      },	
      {	
        test: /\.(woff|woff2|eot|ttf|otf)$/i,	
        use: [	
          {	
            loader: 'url-loader',	
            options: {	
              limit: 10000,	
              fallback: 'file-loader',	
            },	
          },	
        ],	
      },	
    ],	
  },	
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],	
    alias: {	
      '@src': getAbsolutePath('./src'),	
      '@assets': getAbsolutePath('./assets'),	
    },	
  },	
  optimization: {	
    minimizer: [	
      new ESBuildMinifyPlugin({	
        target: 'es2015',	
      }),	
    ],	
  },	
  performance: {	
    hints: false,	
    maxEntrypointSize: 512000,	
    maxAssetSize: 512000,	
  },	
  plugins: [	
    new Dotenv({	
      path: '.env',	
    }),	
    new CleanWebpackPlugin({	
      cleanOnceBeforeBuildPatterns: ['**/*', path.resolve(process.cwd(), 'dist/**/*')],	
    }),	
    new CopyPlugin({	
      patterns: [	
        {	
          from: getAbsolutePath('src/static'),	
          to: getAbsolutePath('dist'),	
        },	
      ],	
    }),	
    ...getHtmlPlugins([	
      { chunk: 'popup', title: '프로솔브 - PopUp 페이지' },	
      { chunk: 'solutionTab', title: '프로솔브 - 문제 풀이 페이지' },	
      { chunk: 'profileTab', title: '프로솔브 - 나의 풀이 페이지' },	
      { chunk: 'memoTab', title: '프로솔브 - 문제 아카이빙 페이지' },
    ]),	
  ],	
};	
// webpack.dev.js
const { merge } = require('webpack-merge');	
const common = require('./webpack.common.js');	

module.exports = merge(common, {	
  mode: 'development',	
  devtool: 'cheap-module-source-map',
});	
// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'production',
  devtool: 'source-map',
});

common.js는 development와 production 두 모드에서 공통적으로 사용하는 Webpack 설정이에요. 그리고 해당 파일을 dev와 prod 파일에서 merge해 사용하고 있고요.

webpack merge는 웹팩 설정들을 병합하는 역할을 하는데요.
즉 development일 때와 production일 때 모두 동일한 설정에 mode와 devtool만 다르게 해둔 상태였습니다.

development일 때는 빠른 빌드 속도가 중요하기에 파일 압축 등의 최적화 작업이 필요 없고, production에서는 번들 크기를 최대한 줄여야 하기 때문에 최적화 작업이 필요합니다.

그래서 저는 4가지 개선점이 필요하다고 느끼고 리팩터링을 하고자 했습니다.

  • devtool 보안 문제
  • cache 객체 수정
  • production에서만 optimization
  • 파일 로더(url-loader, file-loader)를 asset module로 변경 및 파일 압축

개선점 1. devtool 보안 문제

// webpack.dev.js
const { merge } = require('webpack-merge');	
const common = require('./webpack.common.js');	

module.exports = merge(common, {	
  mode: 'development',	
  devtool: 'cheap-module-source-map',	
});

// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'production',
  devtool: 'source-map',
});

기존 코드를 보면 development일 때는 'cheap-module-source-map', production일 때는 'source-map'을 이용하고 있어요.

development 때는 디버깅하기 편한 devtool을, production 때는 압축 및 보안에 유리한 devtool을 사용해야 합니다. 사용자가 소스 코드 원본을 확인할 수 없게끔 해야하기 때문입니다.

그렇다면 production 때 source-map은 좋은 선택지가 아닙니다.
source-map은 번들링된 자바스크립트 파일과 원본 소스 코드 간의 매핑 정보를 제공합니다. source-map은 일반적으로 암호화가 되지 않은 상태로 생성하기에 보안에 취약합니다.

그래서 저는 production일 때 "hidden-source-map"을 이용했습니다. hidden-source-map은 원본 소스 매핑 정보를 관계자만 확인할 수 있습니다. 사용자는 원본 소스 코드를 확인할 수 없기에 보안상 유리합니다.

// webpack.dev.js
const { merge } = require('webpack-merge');	
const common = require('./webpack.common.js');	

module.exports = merge(common, {	
  mode: 'development',	
  devtool: 'cheap-module-source-map',	
});

// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'production',
  devtool: 'hidden-source-map',
});

참고: https://webpack.js.org/configuration/devtool/


개선점 2. cache 객체 수정

// 기존 webpack 설정
cache: {
  type: 'filesystem',	
  buildDependencies: {	
    config: [__filename],	
  },	
},

cache type을 development와 production 모두 filesystem으로 정의해둔 상황이었습니다.
그리고 buildDependencies 옵션을 이용해 웹팩 설정 파일이 변경될 때마다 캐시를 업데이트함으로써 웹팩이 빌드할 때 모든 의존성을 매번 다시 빌드하는 문제를 방지했습니다.

filesystem cache 타입의 경우 캐시 데이터를 파일 시스템에 저장하기에 IO 작업을 수행합니다. 반면 memory cache 타입의 경우 캐시 데이터를 메모리에 저장하기에 filesystem보다 빠르게 빌딩할 수 있습니다.

// 리팩터링한 webpack 설정
cache: {
  type: env.dev ? 'memory' : 'filesystem',
  buildDependencies: {
    config: [__filename],
  },
  idleTimeout: 2000,
},

그래서 저는 development 모드에서는 memory type을, production 모드에서는 filesystem type을 이용하는 방식으로 리팩터링을 했습니다!

추가로 idleTimeout을 이용해 캐시 유효 기간을 설정해 캐시 메모리 사용량을 줄이고 캐시에 저장된 데이터의 일관성을 유지하게끔 해주었습니다.


개선점 3. production에서만 optimization

// 기존 webpack.common.js
optimization: {
  minimizer: [
    new ESBuildMinifyPlugin({
      target: 'es2015',
    }),
  ],
},

저는 JavaScript 최적화를 위해 ESBuildMinifyPlugin를 이용했습니다.

// 리팩터링한 코드 - build/webpack.prod.js
optimization: {
  minimizer: [
    new ESBuildMinifyPlugin({
      target: 'es2015',
      css: true
    }),
  ],
},

마찬가지로 Development일 때는 최적화를 할 필요가 없기에 해당 코드를 webpack.prod.js로 이동시켰습니다. 추가로 css 최적화를 위해 css: true 속성을 주었습니다.


개선점 4. 파일 로더(url-loader, file-loader)를 asset module로 변경 및 파일 압축

{	
  test: /\.(jpe?g|png|gif)$/i,	
    use: [	
      {	
        loader: 'url-loader',	
        options: {	
          limit: 10000,	
          fallback: 'file-loader',	
        },	
      },	
    ],
},

기존에는 이미지 로더로 url-loader와 file-loader를 이용했습니다.
url-loader는 파일 크기가 limit 값보다 작은 경우, 해당 파일을 Base64로 인코딩해 번들 파일 안에 직접 포함합니다. file-loader는 따로 파일로 추출해 디스크에 저장하는 방식입니다.

위 설정은 10KB 이하면 url-loader를 이용하고 이상일 때는 file-loader를 이용합니다.

이처럼 정적파일을 처리하는 방식은 Webpack4 방식이고, 최신 버전인 Webpack5에서는 asset modules를 사용하게끔 권장합니다. asset/inline과 asset/resource는 파일의 크기에 따라 자동으로 인라인 또는 파일 처리를 결정할 수 있기 때문입니다.

그래서 저는 다음과 같이 asset modules를 이용하는 방식으로 수정했습니다.
아래 설정을 보면 maxSize로 10KB를 지정했습니다. 이렇게 하면 Webpack은 10KB 이하인 파일은 inline 모듈로 처리하고, 그 이상은 resource 모듈로 처리합니다.

참고: https://webpack.kr/guides/asset-modules/

{
  test: /\.(jpe?g|png|gif)$/i,
  type: 'asset',
  parser: {
    dataUrlCondition: {
      maxSize: 10 * 1024, // 10KB
    },
  },
  use: [
    {
      loader: 'image-webpack-loader',
      options: {
        mozjpeg: {
          progressive: true,
          quality: 65,
        },
        optipng: {
          enabled: false,
        },
        pngquant: {
          quality: [0.65, 0.9],
          speed: 4,
        },
        gifsicle: {
          interlaced: false,
        },
        webp: {
          quality: 75,
        },
      },
    },
  ],
},

추가로 'image-webpack-loader' loader를 이용해 이미지를 압축해 번들 크기를 줄이고자 했습니다.


리팩터링한 Webpack 설정

리팩터링한 Webpack 코드는 다음과 같습니다.

build/webpack.common.js

// build/webpack.common.js
const path = require('path');

const APP_PATH = require('./app-paths');
const HTML_PLUGIN = chunks => {
  return chunks.map(
    ({ chunk, title }) =>
      new HtmlPlugin({
        title: `${title}`,
        filename: `${chunk}.html`,
        chunks: [chunk],
      }),
  );
};

const Dotenv = require('dotenv-webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const HtmlPlugin = require('html-webpack-plugin');
const { env } = require('process');

module.exports = {
  entry: {
    popup: APP_PATH('src/pages/popup/index.tsx'),
    background: APP_PATH('src/pages/background/index.ts'),
    testContent: APP_PATH('src/pages/content/testPage.tsx'),
    solutionContent: APP_PATH('src/pages/content/solutionPage.tsx'),
    problemContent: APP_PATH('src/pages/content/problemPage.tsx'),
    solutionTab: APP_PATH('src/pages/newTab/solution/index.tsx'),
    profileTab: APP_PATH('src/pages/newTab/profile/index.tsx'),
    memoTab: APP_PATH('src/pages/newTab/memo/index.tsx'),
  },
  output: {
    filename: 'script/[name].js',
    publicPath: './',
    path: APP_PATH('dist'),
  },
  cache: {
    type: env.dev ? 'memory' : 'filesystem',
    buildDependencies: {
      config: [__filename],
    },
    idleTimeout: 2000,
  },
  module: {
    rules: [
      {
        test: /\.ts(x?)$/,
        exclude: /node_modules/,
        loader: 'esbuild-loader',
        options: {
          loader: 'tsx',
          target: 'esnext',
          tsconfigRaw: require('../tsconfig.json'),
        },
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.svg$/i,
        use: ['@svgr/webpack'],
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],
    alias: {
      '@src': APP_PATH('./src'),
      '@assets': APP_PATH('./assets'),
    },
  },
  plugins: [
    new Dotenv({
      path: '.env',
    }),
    new CopyPlugin({
      patterns: [
        {
          from: APP_PATH('src/static'),
          to: APP_PATH('dist'),
        },
      ],
    }),
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ['**/*', path.resolve(process.cwd(), 'dist/**/*')],
    }),
    ...HTML_PLUGIN([
      { chunk: 'popup', title: '프로솔브 - PopUp 페이지' },
      { chunk: 'solutionTab', title: '프로솔브 - 문제 풀이 페이지' },
      { chunk: 'profileTab', title: '프로솔브 - 나의 풀이 페이지' },
      { chunk: 'memoTab', title: '프로솔브 - 문제 아카이빙 페이지' },
    ]),
  ],
};

webpack.common.js 파일은 development, production 모드 두 곳에서 사용하는 설정들을 담은 webpack 설정입니다.


build/webpack.dev.js

// build/webpack.dev.js
const { HotModuleReplacementPlugin } = require('webpack');

module.exports = {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  plugins: [new HotModuleReplacementPlugin()],
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/i,
        type: 'asset/inline',
      },
    ],
  },
};

webpack.dev.js 파일은 development 시에 사용할 webpack 설정입니다.

디버깅을 용이하게 하기 위해 devtool로 'cheap-module-source-map'옵션을 주었습니다.

이미지 처리를 할 때는 빠른 디버깅을 위해 asset modules 중 'asset/inline'을 이용했습니다. asset/inline은 Base64 인코딩된 URL을 번들 파일에 포함하기에 HTTP 요청이 줄어들어 번들을 다운로드 하는 속도가 빠릅니다.


build/webpack.prod.js

// build/webpack.prod.js
const { ESBuildMinifyPlugin } = require('esbuild-loader');

module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/i,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 10 * 1024, // 10KB
          },
        },
        use: [
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
                quality: 65,
              },
              optipng: {
                enabled: false,
              },
              pngquant: {
                quality: [0.65, 0.9],
                speed: 4,
              },
              gifsicle: {
                interlaced: false,
              },
              webp: {
                quality: 75,
              },
            },
          },
        ],
      },
    ],
  },
  optimization: {
    minimize: true,
    usedExports: true,
    minimizer: [
      new ESBuildMinifyPlugin({
        target: 'es2015',
        css: true,
      }),
    ],
  },
};

webpack.prod.js 파일은 production 시에 사용할 파일입니다.

앞서 말했듯이 보안 문제를 완화하기 위해 devtool로 'hidden-source-map' 옵션을 주었습니다.

또한 이미지 처리에는 asset modules를 이용했고, 이미지 압축을 위해 image-webpack-loader를 이용했습니다.

추가로 ESBuildMinifyPlugin 플러그인을 이용해 JavaScript와 CSS를 압축했고, usedExports 속성을 주어 트리 쉐이킹을 진행했습니다!


build/optionalPlugin/webpack.bundleAnalyzer.js

// build/optionalPlugin/webpack.bundleAnalyzer.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 분석된 결과를 파일로 저장
      reportFilename: `bundle-size.html.html`, // 분석 결과 파일명
      openAnalyzer: true, // 웹팩 빌드 후 보고서 파일을 자동으로 열지 여부
    }),
  ],
};

webpack.bundleAnalyzer.js 파일은 번들 크기를 분석하는 BundleAnalyzerPlugin 플러그인이 있습니다.


webpack.config.js

// webpack.config.js
const { merge } = require('webpack-merge');
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const commonConfig = require('./build/webpack.common.js');

const optionalPlugin = pluginsArg => {
  const optionalPlugin = [pluginsArg].filter(Boolean);

  return optionalPlugin.map(pluginsArg =>
    require(`./build/optionalPlugin/webpack.${pluginsArg}.js`),
  );
};

module.exports = env => {
  const envConfig = require(`./build/webpack.${env.env}.js`);
  const smp = new SpeedMeasurePlugin();

  const mergedConfig = smp.wrap(
    merge(commonConfig, envConfig, ...optionalPlugin(env.optionalPlugin)),
  );

  return mergedConfig;
};

webpack.config.js는 각 개발 모드에 따라 선택할 webpack 설정입니다.

기본 설정 파일인 webpack.common.js 각 모드에 따른 설정 파일(webpack.dev.js or webpack.prod.js)을 merge합니다.

선택적 플러그인(ex. webpack.bundleAnalyzer)이 제공될 경우 해당 플러그인도 merge합니다.

추가로 저는 빌드 속도를 측정하기 위해 SpeedMeasurePlugin 플러그인을 사용했습니다.


빌드 속도와 번들 크기 비교

지금부터 이전 설정과 리팩터링한 설정의 빌드 속도와 번들 크기를 비교하고자 합니다.
development일 때는 번들 크기 비교가 중요하지 않기에 제외하고자 합니다.

빌드 속도는 콜드 스타트와 웜 스타트 두 가지를 비교하고자 합니다.

콜드 스타트는 이전 빌드 결과가 캐시 되지 않은 상태, 즉 초기 빌드 때를 의미합니다.
웜 스타트는 캐시가 있는 경우를 의미합니다.
(콜드 스타트에서는 모든 로더와 플러그인이 실행되는 반면, 웜 스타트에서는 적은 수의 로더만 실행됩니다.)


이전 Webpack 설정

Development

빌드 속도

  • 콜드 스타트 34.89 sec
  • 웜 스타트 9.21 sec

Production

빌드 속도: 콜드 30.09 sec -> 웜 7.36 sec
번들 크기(Gzipped 기준): 1.13 MB


리팩터링한 Webpack 설정

Development

빌드 속도

  • 콜드 스타트 22.91 sec
  • 웜 스타트 6.67 sec

Production

빌드 속도

  • 콜드 스타트 38.55 sec
  • 웜 스타트 6.83 sec

번들 크기(Gzipped 기준): 817.58 KB


정리

Development 빌드 속도

Development 빌드 속도에서 유의미한 개선이 있었습니다!

  • 이전 설정: (콜드) 34.89 (웜) 9.21
  • 리팩터링 설정: (콜드) 22.91 (웜) 6.54

콜드 스타트는 34.36%, 웜 스타트는 20.07% 감소했습니다.


Production 빌드 속도

반면 Production 콜드스타트 빌드 속도는 조금 느려지게 되었습니다.
그 이유는 image-webpack-loader를 이용한 이미지 압축화와 트리쉐이킹이 추가되었기 때문입니다.

  • 이전 설정: (콜드) 30.09 (웜) 7.36
  • 리팩터링 설정: (콜드) 38.55 (웜) 6.83

Production 번들 크기

이미지 최적화 작업과 트리 쉐이킹을 추가한 결과 번들 크기를 유의미하게 최적화했습니다!

  • 이전 설정: 1.13 MB
  • 리팩터링 설정: 817.58 KB

번들 크기가 1.13MB에서 817.58KB로 줄어 약 27.7% 감소했습니다!


리팩터링 후기

아직은 프로젝트 규모가 작아 유의미한 번들 크기를 계산하지 못했습니다. 그 점이 아쉬워 V1.1.0이 배포될 때 다시 계산을 해보려고 합니다.

이전에 Webpack으로 환경 설정을 할 때는 각 기능이 어떤 역할을 하는지 잘 이해하지 못한 상태에서 사용을 했었습니다. 하지만 이번 리팩터링을 하면서 각각이 어떤 역할을 하는지를 잘 이해하게 되었습니다.

또한 빌드 속도와 번들 크기에는 trade-off가 있다는 것을 느껴, 프로젝트 스펙에 따라 어떤 설정을 선택할지에 대한 고민이 필요하다는 것을 느꼈습니다.

profile
지식은 누가 origin인지 중요하지 않다.

2개의 댓글

comment-user-thumbnail
2023년 3월 7일

요새 slip 왜 안오세여 h s y 님

1개의 답글