Navigation

Webpack Setup Decisions

2022년 말에 webpack 5 기준으로 React 프로젝트의 초기 구성을 여덟 장으로 정리하고 2024년 초 팀 위키에 올렸습니다. 다시 읽어 보니 설치 명령과 설정 파일을 순서대로 적어 두었을 뿐 왜 그 값을 골랐는지는 적혀 있지 않았고 몇 군데는 틀린 채로 남아 있었습니다. 이 글은 그 설정을 변환기, 자원 처리, 환경 분리, 검사 시점 네 가지 결정으로 다시 묶고 각 결정이 틀렸을 때 어떤 증상으로 드러나는지 적습니다. 옵션 이름은 2026-10-23에 나온 webpack 5.112.2 문서와 각 도구의 현재 문서로 다시 확인했습니다.

번들러는 모듈 그래프 위에서 네 가지 결정을 실행합니다

webpack은 entry에서 출발해 import 관계를 따라 모듈 그래프를 만들고 로더로 JavaScript가 아닌 파일을 모듈로 바꾼 뒤 플러그인으로 번들 전체에 걸친 작업을 한 다음 output에 씁니다. 당시에는 CRA의 eject나 craco로 숨은 설정을 덮어쓰는 대신 설정을 처음부터 소유하는 쪽을 골랐습니다. 설정을 소유한다는 말은 아래 네 가지를 직접 정한다는 뜻입니다.

결정정하는 것틀렸을 때의 증상
변환기TypeScript와 JSX를 누가 바꾸고 타입 검사는 어디서 하는지타입 오류가 번들에 그대로 실리거나 오래된 기기에서만 나는 런타임 오류
자원 처리CSS의 범위와 추출, 이미지와 글꼴을 파일로 둘지 인라인할지클래스 이름 충돌, 수백 KB 글꼴이 번들 안에 base64로 들어감
환경 분리어떤 값이 빌드 시점에 박히고 소스맵이 어디까지 공개되는지운영 번들에 개발 주소가 남거나 소스맵으로 원본이 공개됨
검사 시점lint와 타입 검사를 편집기, 커밋, 빌드, CI 중 어디서 막는지편집기만 빨갛고 CI는 통과하거나 반대로 저장마다 빌드가 느려짐

각 결정에는 로더 체인의 실행 순서라는 공통 전제가 있습니다. use 배열의 로더는 오른쪽에서 왼쪽으로, 아래에서 위로 실행되고 마지막에 실행되는 로더가 모듈 타입에 맞는 결과를 돌려줘야 합니다. 2022년 설정은 이 순서를 한 곳에서 틀렸고 그 이야기는 자원 처리 절에 있습니다. Loaders

변환기는 타입 검사를 번들에서 떼는 결정입니다

TypeScript와 JSX를 JavaScript로 바꾸는 후보는 셋입니다. 셋 다 결과물은 비슷하지만 타입 검사를 누가 하는지가 다릅니다.

변환기타입 검사판단
babel-loader + @babel/preset-typescript하지 않음. 타입 표기를 지우기만 함폴리필과 JSX 런타임까지 한 설정에서 다룸
ts-loader기본으로 함. transpileOnly로 끌 수 있음타입 검사가 빌드 시간에 들어감
swc-loader하지 않음jsc.parser.syntax: 'typescript'와 jsc.transform.react.runtime: 'automatic'로 같은 일을 더 빠르게 함

당시에는 babel-loader를 골랐습니다. TypeScript 공식 문서도 “Babel로 변환하고 tsc로 타입 검사”를 하나의 선택지로 두면서 Babel은 타입을 검사하지 않으므로 편집기에서 놓친 타입 오류가 운영 코드에 섞여 들어갈 수 있다고 경고합니다. 그래서 tsconfig.json에는 noEmit과 isolatedModules를 켭니다. noEmit은 tsc를 출력 없는 검사기로 만들고 isolatedModules는 파일 하나만 보는 변환기가 처리할 수 없는 코드 세 가지(타입만 가리키는 이름의 재export, 모듈이 아닌 파일의 namespace, 다른 파일 const enum 멤버 참조)를 오류로 알려 줍니다. Using Babel with TypeScript, isolatedModules

타입 검사를 어디서 돌릴지는 따로 정합니다. 당시에는 tsc를 어디서도 부르지 않아 편집기가 유일한 검사 지점이었습니다. 빌드 안에서 보고 싶다면 fork-ts-checker-webpack-plugin이 별도 프로세스에서 검사하고 개발 모드에서는 async가 기본으로 켜져 컴파일을 막지 않습니다. CI에서는 tsc --noEmit을 그대로 돌리는 편이 단순합니다.

babel.config.js, 타겟은 browserslist에 맡기고 개발에서만 Fast Refreshjs
export default (api) => {
  const isDevelopment = api.env('development')
  return {
    presets: [
      // targets를 적지 않으면 browserslist 설정을 읽는다
      '@babel/preset-env',
      ['@babel/preset-react', { runtime: 'automatic' }],
      '@babel/preset-typescript',
    ],
    plugins: [
      // 파일마다 복사되던 헬퍼를 @babel/runtime에서 가져온다. @babel/runtime은 dependencies
      '@babel/plugin-transform-runtime',
      isDevelopment && 'react-refresh/babel',
    ].filter(Boolean),
  }
}

타겟은 한곳에만 둡니다. @babel/preset-env는 targets가 없으면 browserslist 설정을 읽고 postcss-preset-env의 Autoprefixer도 같은 설정을 읽습니다. 2022년 설정은 postcss.config.js에만 browsers: '> 5% in KR, defaults, not IE < 11'을 적었습니다. 그러면 CSS 접두사는 한국 점유율 기준으로 붙고 JavaScript는 Babel 7의 기본값인 모든 브라우저 기준으로 변환돼 두 산출물이 서로 다른 브라우저를 겨냥합니다. package.json의 browserslist 키나 .browserslistrc 하나로 옮기면 두 도구와 lint 플러그인이 같은 목록을 봅니다. Browserslist, postcss-preset-env

폴리필은 문법 변환과 다른 문제입니다. 화살표 함수는 변환되지만 Array.prototype.at 같은 내장 API는 실행 환경에 없으면 그대로 오류가 납니다. Babel 7에서는 useBuiltIns: 'usage'와 core-js로 파일마다 쓰는 API의 폴리필을 넣었습니다. 2026년 6월 16일에 나온 Babel 8은 이 옵션을 없애고 babel-plugin-polyfill-corejs3를 직접 쓰게 했으며 기본 타겟을 browserslist의 defaults로 바꿨습니다. 증상은 똑같습니다. 개발자 기기에서는 나지 않고 오래된 기기에서만 나는 undefined is not a function입니다. Babel 8 migration

2022년 설정에 있던 플러그인 중 둘은 지금 필요 없습니다. @babel/plugin-syntax-dynamic-import는 @babel/core 7.8.0부터 빼도 되고 클래스 필드 변환은 ES2022로 @babel/preset-env에 들어 있습니다. modules 옵션은 기본값 auto로 둡니다. babel-loader가 호출자 정보를 넘겨 ES 모듈 문법을 그대로 두므로 트리 셰이킹이 유지됩니다. commonjs로 바꾸는 순간 webpack은 어떤 export가 안 쓰이는지 알 수 없게 됩니다. preset-env modules, Tree Shaking

자원 처리는 CSS의 범위와 파일의 크기를 정하는 결정입니다

SCSS 한 파일은 로더 네 개를 거칩니다. 2022년 설정은 [MiniCssExtractPlugin.loader, 'css-loader', 'resolve-url-loader', 'sass-loader', 'postcss-loader'] 순서였습니다. 실행은 오른쪽부터이므로 PostCSS가 Sass보다 먼저 SCSS 문법을 받았습니다. 순서가 틀려도 빌드가 멈추지 않는 조합이 있어서 늦게 발견됩니다. Sass가 먼저 CSS를 만들고 PostCSS가 그 CSS에 접두사를 붙이려면 postcss-loader는 sass-loader보다 왼쪽에 있어야 합니다. resolve-url-loader는 css-loader와 sass-loader 사이에 두고 앞선 로더가 소스맵을 내도록 sass-loader에 sourceMap: true를 줍니다. resolve-url-loader

flowchart RL
    S["x.module.scss"] --> Sass["sass-loader<br/>SCSS를 CSS로"]
    Sass --> Url["resolve-url-loader<br/>url() 경로 복원"]
    Url --> Post["postcss-loader<br/>접두사, 최신 문법 변환"]
    Post --> Css["css-loader<br/>CSS Modules, @import"]
    Css --> Out["style-loader 또는<br/>MiniCssExtractPlugin.loader"]
webpack.common.js, 스타일과 자원 규칙js
import { fileURLToPath } from 'node:url'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'

const isProduction = process.env.NODE_ENV === 'production'

export default {
  module: {
    rules: [
      {
        test: /\.s?css$/i,
        use: [
          // 같은 규칙에 둘을 함께 두지 않는다
          isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
          {
            loader: 'css-loader',
            options: {
              // *.module.scss만 CSS Modules. 개발에서는 읽을 수 있는 이름, 운영에서는 해시
              modules: {
                namedExport: false,
                localIdentName: isProduction ? '[hash:base64]' : '[path][name]__[local]',
              },
              importLoaders: 3, // @import 뒤의 파일도 아래 세 로더를 거친다
            },
          },
          'postcss-loader',
          'resolve-url-loader',
          { loader: 'sass-loader', options: { sourceMap: true } },
        ],
      },
      // 작은 파일만 인라인. 기본 기준은 8kb
      { test: /\.(png|jpe?g|gif|webp|avif)$/i, type: 'asset', parser: { dataUrlCondition: { maxSize: 4 * 1024 } } },
      { test: /\.(woff2?|ttf|otf|eot)$/i, type: 'asset/resource' },
      // CSS에서 쓰는 SVG는 URL, JS에서 ?url 없이 가져오는 SVG는 컴포넌트
      { test: /\.svg$/i, type: 'asset', resourceQuery: /url/ },
      { test: /\.svg$/i, issuer: /\.[jt]sx?$/, resourceQuery: { not: [/url/] }, use: ['@svgr/webpack'] },
    ],
  },
}

CSS Modules는 파일 이름으로 범위를 정합니다. css-loader는 modules를 비워 두면 .module.css 같은 이름만 모듈로 다루고 나머지는 전역입니다. 클래스 범위와 스타일 소유권은 Style Naming and Layers가 다루고 여기서는 켜는 방법만 둡니다. localIdentName은 문서가 권하는 대로 개발에서는 [path][name]__[local]로 어느 파일의 클래스인지 보이게 하고 운영에서는 [hash:base64]로 줄입니다. 기본값이 해시라 개발 중 DevTools에서 클래스 이름만 보고 파일을 찾지 못하는 증상은 이 옵션 하나로 사라집니다. namedExport는 esModule이 켜져 있으면 기본으로 켜지므로 import styles from으로 객체를 받는 코드라면 false를 명시합니다. css-loader modules

추출은 환경으로 가릅니다. 개발은 style-loader가 <style>로 넣어 바꿀 때마다 빠르게 반영하고 운영은 mini-css-extract-plugin이 CSS 파일로 뽑아 JavaScript와 나란히 내려받게 합니다. 플러그인 문서는 두 로더를 같은 규칙에 함께 두지 말라고 적습니다. 운영 파일 이름에는 [name].[contenthash].css를 써서 내용이 바뀐 파일만 캐시가 깨지게 합니다. mini-css-extract-plugin

이미지와 글꼴은 Asset Modules가 맡고 webpack 4의 file-loader, url-loader, raw-loader는 필요 없습니다. 당시 설정은 이미지를 asset/resource로, 글꼴과 SVG를 asset/inline으로 두었습니다. asset/inline은 크기와 상관없이 data URI로 번들에 넣으므로 글꼴 하나가 수백 KB면 그대로 CSS나 JavaScript 안에 base64로 들어갑니다. 크기로 자동 선택하는 asset 타입은 parser.dataUrlCondition.maxSize로 기준을 정하고 기본값은 8kb입니다. SVG는 CSS의 url()에서는 파일로, React 코드에서는 컴포넌트로 쓰고 싶을 때가 많아 issuer와 resourceQuery로 두 규칙을 나눕니다. Asset Modules, @svgr/webpack

이 절의 로더 결정은 webpack 5.109.0부터 전제가 바뀌었습니다. experiments.css의 기본값이 'auto'가 돼 .css를 맡는 로더 규칙이 없으면 webpack이 CSS를 직접 파싱하고 CSS Modules와 추출, HMR까지 처리합니다. css-loader와 mini-css-extract-plugin의 README는 둘 다 자신을 더 이상 필요 없는 도구로 표시합니다. Sass와 PostCSS 로더는 그대로 두고 규칙의 type을 'css/auto'로 적으면 됩니다. 아직 실험 기능이고 webpack 6에서 기본이 될 예정이라 기존 로더 설정은 그대로 동작합니다. Native CSS

환경 분리는 값이 언제 박히는지를 정하는 결정입니다

설정 파일은 공통, 개발, 운영 셋으로 나누고 webpack-merge로 합칩니다. 조건문 하나로 가를 수도 있지만 파일을 나누면 운영 빌드에 개발 플러그인이 섞였는지 파일만 열어 확인할 수 있습니다. merge는 배열을 이어 붙이므로 같은 test의 규칙을 공통과 환경 파일에 모두 두면 로더가 두 번 붙습니다. 2022년 설정은 스타일 규칙을 환경 파일에만 두어 이 문제를 피했고 규칙 단위로 합치려면 mergeWithRules를 씁니다. webpack-merge

webpack.config.js, --env env=prod로 고르는 환경js
import { merge } from 'webpack-merge'
import common from './webpack.common.js'

export default async ({ env = 'dev' }) => {
  const { default: envConfig } = await import(`./webpack.${env}.js`)
  return merge(common, envConfig)
}

mode는 process.env.NODE_ENV를 DefinePlugin으로 정의하고 운영에서는 압축과 결정적 모듈 id를 켭니다. DefinePlugin은 컴파일 시점의 텍스트 치환입니다. 문자열 값은 따옴표까지 포함해야 해서 JSON.stringify로 감싸고 치환된 값은 번들 안에 그대로 남습니다. 그래서 .env 파일에서 읽어 넣는 값은 브라우저에 공개돼도 되는 것만 둬야 하고 서버 비밀 키는 애초에 이 통로로 흐르면 안 됩니다. 배포마다 달라지는 API 주소를 빌드 시점에 박으면 환경마다 다시 빌드해야 하므로 그런 값은 실행 시점에 읽는 설정 파일로 뺍니다. DefinePlugin, Mode

webpack.prod.js, 운영은 숨긴 소스맵과 내용 해시js
import webpack from 'webpack'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'

export default {
  mode: 'production',
  // 소스맵은 만들되 번들에 참조 주석을 넣지 않는다. 오류 추적 도구에만 올리고 배포물에서는 뺀다
  devtool: 'hidden-source-map',
  output: {
    filename: '[name].[contenthash].js',
    clean: true, // 5.20.0부터 clean-webpack-plugin 대신
  },
  optimization: {
    runtimeChunk: 'single',
    moduleIds: 'deterministic',
    splitChunks: { chunks: 'all' },
  },
  plugins: [
    new webpack.DefinePlugin({ 'process.env.PUBLIC_API_BASE': JSON.stringify(process.env.PUBLIC_API_BASE) }),
    new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' }),
  ],
}

소스맵은 환경마다 다른 답입니다. 개발은 다시 빌드가 빠른 eval-cheap-module-source-map이면 충분합니다. 당시에는 운영에 source-map을 두었는데 이 값은 번들 끝에 .map 참조 주석을 넣고 파일도 함께 배포하므로 문서가 서버에서 일반 사용자의 접근을 막으라고 경고합니다. 원본 코드와 주석, 내부 API 경로가 그대로 복원됩니다. hidden-source-map은 소스맵을 만들되 참조를 넣지 않아 오류 추적 도구에 올리는 용도이고 배포물에서는 지웁니다. 그 뒤 순서는 Sentry Releases and Source Maps에 있습니다. devtool

파일 이름의 [contenthash]는 내용이 바뀐 파일만 새 이름을 받게 합니다. 다만 런타임과 매니페스트가 entry 청크에 들어 있어 소스를 안 바꿔도 해시가 바뀔 수 있고 모듈 id가 순서대로 매겨지면 모듈 하나를 더했을 때 vendor 해시까지 바뀝니다. 캐싱 가이드는 runtimeChunk: 'single'로 런타임을 떼고 moduleIds: 'deterministic'을 명시하라고 안내합니다. Caching

개발 서버에서는 두 가지가 운영과 다릅니다. SPA 라우트는 historyApiFallback이 없으면 /orders/3에서 새로고침할 때 404가 납니다. proxy는 webpack-dev-server 5부터 배열 형식이고 /api를 백엔드로 넘겨 개발 중 CORS를 숨겨 줍니다. 운영에서는 같은 일을 리버스 프록시나 서버의 CORS 응답이 해야 하므로 개발에서만 되는 요청이 있다면 이 차이를 먼저 의심합니다. DevServer

webpack.dev.js, Fast Refresh는 개발 설정에만js
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'

export default {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    historyApiFallback: true,
    proxy: [{ context: ['/api'], target: 'http://localhost:3000', changeOrigin: true }],
  },
  plugins: [new ReactRefreshWebpackPlugin()],
}

React Fast Refresh는 플러그인과 Babel 변환 react-refresh/babel을 개발에서만 켜야 하고 HMR 위에서 동작합니다. devServer.hot은 v4부터 기본으로 켜져 있습니다. 상태가 남는 조건도 알아 둬야 증상을 오해하지 않습니다. 함수 컴포넌트와 훅의 상태는 훅의 순서와 인자가 그대로일 때만 남고 클래스 컴포넌트는 초기화되며 컴포넌트가 아닌 값을 함께 export한 파일은 그 파일과 가져온 파일을 다시 실행합니다. React가 아닌 모듈이 가져오는 파일을 고치면 전체 새로고침으로 떨어집니다. 공용 상수를 컴포넌트 파일에서 분리하라는 안내는 여기서 나옵니다. react-refresh-webpack-plugin, Fast Refresh

검사 시점은 어디서 실패를 보게 할지 정하는 결정입니다

ESLint와 Prettier의 역할을 먼저 가릅니다. 2022년 설정은 plugin:prettier/recommended로 Prettier를 ESLint 규칙으로 돌렸습니다. 그러면 들여쓰기 하나가 어긋나도 편집기가 빨갛게 되고 lint가 느려집니다. Prettier 문서는 이 방식을 “일반적으로 권하지 않는다”고 적고 충돌하는 ESLint 규칙만 끄는 eslint-config-prettier를 안내합니다. 포맷은 Prettier가 저장 시점과 prettier --check로 보고 ESLint는 코드 품질만 봅니다. Integrating with Linters

설정 형식도 바뀌었습니다. 2022년의 .eslintrc는 ESLint 10부터 지원되지 않고 eslint.config.js의 flat config만 남았습니다. typescript-eslint 8과 eslint-plugin-react-hooks는 모두 flat config용 진입점을 제공합니다. Migrate to v10.0.0

eslint.config.mjs, Prettier 충돌 규칙은 마지막에 끈다js
import js from '@eslint/js'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import eslintConfigPrettier from 'eslint-config-prettier/flat'

export default defineConfig([
  { files: ['**/*.{js,ts,tsx}'], extends: [js.configs.recommended, tseslint.configs.recommended] },
  reactHooks.configs.flat.recommended,
  eslintConfigPrettier,
])

커밋 전 검사는 husky와 lint-staged가 맡습니다. 2022년에는 husky 4라 package.json의 husky.hooks에 적었지만 지금 husky 9는 .husky/pre-commit 셸 파일에 명령을 두고 prepare 스크립트로 설치합니다. lint-staged는 스테이징된 파일에만 명령을 돌리고 고친 결과를 커밋에 다시 넣습니다. 전체 프로젝트를 검사하면 커밋마다 수십 초가 걸려 결국 --no-verify로 건너뛰게 되므로 범위를 바뀐 파일로 좁히는 일이 훅을 살리는 조건입니다. husky, lint-staged

package.json, 바뀐 파일만 검사json
{
  "scripts": { "prepare": "husky" },
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,scss,md,json}": "prettier --write"
  }
}

훅은 빠른 피드백이고 합칠지 정하는 지점은 CI입니다. 훅은 HUSKY=0이나 --no-verify로 건너뛸 수 있고 동료의 환경마다 설치 여부가 다릅니다. CI는 전체 파일에 eslint, prettier --check, tsc --noEmit을 다시 돌려 통과한 것만 합칩니다. 같은 검사를 두 번 하는 것이 아니라 범위와 역할이 다릅니다.

빌드 안에서 lint를 돌릴지는 따로 정합니다. eslint-webpack-plugin은 stylelint 플러그인과 합쳐져 diagnostics-webpack-plugin 1.0.0이 됐고 webpack 5.106.0과 Node.js 22.12.0 이상이 필요합니다. 개발 빌드에 넣으면 저장마다 lint가 돌아 HMR이 느려지고 편집기와 같은 경고를 두 번 보게 됩니다. 편집기, 훅, CI 세 지점이 있으면 빌드 안의 lint는 빼고 넣더라도 lintOnStart를 끄고 경고는 빌드를 막지 않게 둡니다. diagnostics-webpack-plugin

성능 옵션은 측정한 뒤에 켭니다

2022년 설정의 마지막 장은 많은 사용자가 추천한다는 플러그인 목록이었습니다. 효과를 재지 않고 켠 옵션은 나중에 왜 있는지 아무도 모릅니다. 켜기 전에 무엇을 바꾸는지와 어떻게 확인하는지만 적어 둡니다.

옵션바꾸는 것확인 방법
splitChunks.chunks: 'all'기본값 'async'는 import() 청크만 나눔. 'all'은 초기 청크와 비동기 청크 사이에서도 공유빌드 결과의 청크 목록과 HTML에 들어가는 스크립트 수
cache: { type: 'filesystem' }개발 기본은 메모리 캐시, 운영 기본은 없음. 디스크 캐시는 명시해야 켜짐두 번째 빌드 시간. buildDependencies.config: [__filename]으로 설정 변경 시 무효화
resolve.extensions기본 배열을 덮어씀. '...'로 기본값을 남김확장자 없는 import가 해석되는지
resolve.alias경로 별칭. tsconfig paths와 따로 두면 두 곳을 맞춰야 함tsconfig-paths-webpack-plugin으로 한 원본에서 읽기
webpack-bundle-analyzerstat(변환 전), parsed(압축 후), gzip 크기를 트리맵으로analyzerMode: 'static'으로 CI 산출물에 report.html 남기기

splitChunks의 기본값은 공유되거나 node_modules에서 온 모듈이 운영 기준 20kb를 넘고 병렬 요청이 30개 이하일 때만 나눕니다. 기본값이 이미 경험적 기준이라 cacheGroups에 vendor 규칙을 더하기 전에 지금 나뉘는 청크부터 봅니다. SplitChunksPlugin, Cache

경로 별칭은 세 곳이 알아야 합니다. tsconfig의 paths, webpack의 resolve.alias, ESLint의 import 해석기입니다. 당시 설정은 Babel의 module-resolver와 tsconfig-paths-webpack-plugin을 함께 넣고 import/no-unresolved를 끄는 것으로 끝냈습니다. 규칙을 끈 자리는 해석기가 서로 맞지 않는다는 증상이었습니다. tsconfig를 원본으로 두고 webpack은 tsconfig-paths-webpack-plugin, ESLint는 eslint-import-resolver-typescript로 같은 파일을 읽게 하면 끌 규칙이 없습니다.

트리 셰이킹은 옵션이 아니라 전제 조건입니다. ES 모듈 문법을 유지하고 package.json에 sideEffects를 적고 운영 모드로 빌드해야 합니다. 부수 효과 때문에 import한 CSS 파일은 sideEffects 배열에 넣지 않으면 운영 빌드에서 떨어져 나갑니다. 개발 모드에서는 제대로 동작하지 않으므로 운영 빌드의 번들 크기로만 확인합니다. Tree Shaking

확인 방법과 한계

결정마다 확인 명령이 하나씩 있습니다. 타겟은 npx browserslist로 실제 목록을 보고 변환기는 tsc --noEmit이 CI에서 도는지 봅니다. 자원 처리는 운영 빌드 결과에서 글꼴 파일이 따로 나오는지와 base64가 CSS에 들어가 있는지 찾습니다. 환경 분리는 배포된 주소에서 .map 요청이 404인지와 번들 끝에 sourceMappingURL 주석이 없는지, 그리고 중첩 라우트에서 새로고침이 되는지 봅니다. 검사 시점은 --no-verify로 커밋한 변경이 CI에서 막히는지로 확인합니다.

원문은 2022년 말의 webpack 5, Babel 7, ESLint 8의 .eslintrc, husky 4 기준이었습니다. 이 글의 예시는 webpack 5.112.2, webpack-dev-server 6.1.0, Babel 8, ESLint 10, husky 9.1.7 문서를 기준으로 다시 쓴 것이고 당시 프로젝트 설정을 그대로 옮기지 않았습니다. 빌드 시간이나 번들 크기의 변화는 재지 않아 수치를 적지 않았습니다. 지금 새 프로젝트라면 Rolldown을 단일 번들러로 쓰는 Vite 8(2026-03)이나 커뮤니티 로더 대부분과 상위 플러그인의 85% 이상을 그대로 쓸 수 있다고 밝히는 Rspack이 먼저 후보입니다. webpack을 고를 이유는 이미 가진 설정과 플러그인 자산입니다. 그 경우에도 네 가지 결정과 증상은 도구가 바뀌어도 같은 자리에 남습니다. Vite 8, Rspack

참고 자료