React 프로젝트를 일정 규모 이상으로 운영하다 보면 Webpack 설정을 하나의 파일에 몰아넣기 어렵다. 개발 환경과 배포 환경은 목적이 완전히 다르기 때문이다.
따라서 프로젝트에서 Webpack 설정을 어떻게 구조화했는지와 각 파일이 어떤 역할을 하는지 이 글을 통해 정리해보고자 한다. 특히 webpack.prod.js에서 어떤 최적화(minify, code splitting, file hash 기반 캐싱 전략 등)가 이루어지는지 다뤄보고자 한다.
프로젝트의 Webpack 설정은 다음 네 파일로 구성되어 있다.
webpack.common.js : 환경과 무관하게 항상 필요한 설정을 담당webpack.dev.js : 개발자 경험을 개선하기 위한 설정을 포함webpack.prod.js : 성능과 캐시 전략을 중심으로 최적화를 담당webpack.config.js : 실행 환경에 따라 적절한 설정을 병합하는 역할을 한다.“공통 설정”과 “환경별 설정”을 분리하기 위해 이 구조를 선택했다. Webpack은 실행 환경에 따라 목표가 완전히 달라지기 때문이다.
개발 환경에서는 빠른 피드백과 디버깅 편의성이 가장 중요하다. 반면, 프로덕션 환경에서는 파일 크기 최소화, 캐시 전략, 네트워크 효율, 안정적인 운영이 핵심이다.
두 환경의 차이를 정리하면 다음과 같다.
| 구분 | 개발 환경 (development) | 프로덕션 환경 (production) |
|---|---|---|
| 목적 | 빠른 개발, 디버깅 | 사용자 경험 최적화 |
| 빌드 속도 | 빠름 | 상대적으로 느림 |
| 파일 크기 | 크더라도 상관 없음 | 최대한 작게 압축 |
| 코드 형태 | 읽기 쉬움 | 난독화 및 압축 |
| 소스맵 | 디버깅 중심 | 에러 추적 중심 |
| 코드 분리 | 최소화 | 적극적 분리 (code splitting) |
| 캐시 전략 | 고려하지 않음 | content hash 기반 캐싱 |
| 서버 | devServer (메모리 기반) | 정적 파일 배포 (dist 폴더) |
이처럼 두 환경은 추구하는 목표가 다르기 때문에 설정을 분리하는 것이 합리적이다.
import path from "node:path";
import { fileURLToPath } from "node:url";
import Dotenv from "dotenv-webpack";
import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin";
import HtmlWebpackPlugin from "html-webpack-plugin";
const __filename = fileURLToPath(import.meta.url);
export const __dirname = path.dirname(__filename);
export default {
entry: "./src/main.tsx",
output: {
publicPath: "/",
path: path.resolve(__dirname, "dist"),
clean: true,
},
resolve: {
extensions: [".ts", ".tsx", ".js"],
alias: {
"@shared": path.resolve(__dirname, "src/shared"),
"@assets": path.resolve(__dirname, "src/assets"),
"@domains": path.resolve(__dirname, "src/domains"),
"@": path.resolve(__dirname, "src"),
},
},
module: {
rules: [
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.(png|jpe?g|gif|svg|webp|avif)$/i,
type: "asset/resource",
generator: {
filename: "assets/[name].[contenthash][ext]",
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
new ForkTsCheckerWebpackPlugin(),
new Dotenv({ path: ".env.local", systemvars: true }),
],
};
이 파일에는 개발이든 배포든 항상 필요한 설정이 들어 있다.
entry: "./src/main.tsx"
Webpack이 번들링을 시작하는 진입점이다.
여기서부터 import된 모든 파일을 추적해서 하나의 그래프로 만든다.
output: {
publicPath: "/",
path: path.resolve(__dirname, "dist"),
clean: true,
}
publicPath는 정적 자원의 기준 경로다.dist 폴더에 생성된다.clean: true는 빌드할 때 이전 파일을 삭제한다.resolve: {
extensions: [".ts", ".tsx", ".js"],
alias: {
"@shared": path.resolve(__dirname, "src/shared"),
"@assets": path.resolve(__dirname, "src/assets"),
"@domains": path.resolve(__dirname, "src/domains"),
"@": path.resolve(__dirname, "src"),
},
}
이 설정 덕분에 다음과 같은 import가 가능해진다.
import Button from "@shared/components/Button";
상대 경로 지옥을 피할 수 있다.
파일 확장자별 처리 방식(로더 전략)을 정의하는 영역이다.
Webpack은 기본적으로 JavaScript만 이해한다.
CSS, TypeScript, 이미지 파일은 그대로는 처리할 수 없다.
따라서 특정 확장자의 파일을 만나면 → 어떤 loader로 어떻게 변환할지 정의해야 한다.
CSS
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
}
test에 적힌 정규표현식은 .css 확장자를 가진 파일에 이 규칙을 적용한다는 의미다.
중요한 점은 loader의 실행 순서가 오른쪽에서 왼쪽으로 실행된다는 것이다.
<style> 태그 생성 후 변환된 CSS 문자열을 안에 넣고 <head> 에 추가→ JS에서 CSS를 import 할 수 있으며, 모듈 기반 개발(CSS Modules 등)이 가능해진다.
TypeScript
{
test: /\.(ts|tsx)$/, // .ts 혹은 .tsx 확장자를 가진 파일에 규칙 적용
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
}
여기서 중요한 점은 ts-loader가 아니라 babel-loader를 사용했다는 것이다.
ts-loader는 TypeScript 컴파일러(tsc)를 직접 사용한다. 즉, 타입 체크 + 변환을 동시에 수행한다.
반면 현재 설정은 다음과 같이 역할을 분리했다.
역할 분리의 이유는 빌드 속도 때문이다. 타입 체크는 무겁다. Babel은 단순 변환만 수행하기 때문에 훨씬 빠르다.
ForkTsCheckerWebpackPlugin은 별도 프로세스(OS가 관리하는 실행 단위)에서 타입 체크를 수행한다 이로써 메인 빌드 프로세스는 빠르게 돌아가며, 타입 에러는 따로 검사되어 결과적으로 개발 경험이 개선된다.
외부 라이브러리는 이미 빌드된 코드로, 굳이 다시 변환할 필요가 없어서 exclude를 통해서 node_modules는 제외했다.
이미지
{
test: /\.(png|jpe?g|gif|svg|webp|avif)$/i,
type: "asset/resource",
}
Webpack 5부터는 file-loader 없이도 자산 처리가 가능하다.
| 타입 | 동작 방식 |
|---|---|
asset/resource | 파일을 dist에 복사하고 URL을 export |
asset/inline | 파일을 base64 Data URI로 변환하여 JS에 포함 |
asset/source | 파일 내용을 문자열로 export |
asset | 파일 크기에 따라 inline 또는 resource 자동 선택 |
이미지(사진 종류)는 asset/resource, 아이콘처럼 작은 파일은 asset 또는 inline SVG 마크업을 직접 다룰 때는 asset/source를 사용해보면 좋을 것 같다.
asset/resource는 이미지를 output 경로에 복사하고, 파일명에 해시를 붙인다. 파일 내용이 바뀌면(production 빌드를 다시 돌릴 때 계산된다.) 해시가 바뀌는 캐시 전략을 사용한다. → 해시가 바뀌면 브라우저는 새 파일을 다시 다운로드한다.
HtmlWebpackPlugin
new HtmlWebpackPlugin({
template: "./public/index.html",
})
빌드 결과물에 맞춰 index.html을 자동 생성하고 번들된 JS 파일을 자동으로 <script> 태그에 주입한다.
→ 매 빌드마다 파일명이 바뀌기 때문에(해시) HTML에 <script src="main.js"></script>와 같이 작성 할 수 없기 때문에 해당 플러그인을 사용한다.
ForkTsCheckerWebpackPlugin
위에서 살펴보았듯이, 타입 체크를 별도 프로세스에서 수행한다. 이렇게 하면 빌드 속도가 빨라진다.
dotenv-webpack
new Dotenv({ path: ".env.local", systemvars: true }),
Node 환경에서는 process.env.DB_HOST와 같이 환경 변수를 읽을 수 있지만, 브라우저는 process객체가 없기때문에, 환경 변수를 읽으려고 하면 번들링 과정에서 에러가 발생한다.
따라서 dotenv-webpack을 활용해 빌드 시점에 .env.local 파일을 읽어 해당 값을 문자열로 치환한 후 환경 변수를 주입한다.
모든 값이 번들에 포함되는 만큼, 사용자에게 그대로 노출 될 수 있으므로 민감한 정보는 서버에만 두는 것이 올바르다.
import path from "node:path";
import { __dirname } from "./webpack.common.js";
export default {
mode: "development",
devServer: {
static: {
directory: path.resolve(__dirname, "dist"),
publicPath: "/",
},
port: 3000,
open: true,
hot: true,
historyApiFallback: true,
},
};
개발 환경 설정의 핵심은 번들 최적화가 아니라 개발 생산성(피드백 루프 단축)이다. → 실행 속도보다 “빌드 속도 와 디버깅 편의성”을 우선한다.
"scripts": {
"dev": "webpack serve --env development",
"build": "webpack --env production"
},
package.json에서 dev 부분에 webpack serve라는 명령어를 사용중인데, 이 serve 기능은 webpack-dev-server 패키지를 실행한다.
해당 패키지는 webpack을 감싼 개발용 서버로, 번들 결과가 메모리에만 존재하기 때문에 빠르다.
mode: "development" // mode = development | production | none
mode는 Webpack의 내장 최적화 프리셋 스위치다.
mode: "development"를 주면:
process.env.NODE_ENV = "development" 로 주입optimization.minimize = falsedevServer: {
port: 3000, // 개발 서버를 3000번 포트에서 실행
open: true, // 서버 실행 시 브라우저 자동 실행 (개발 편의)
hot: true, // 코드 변경 시 새로고침 없이 교체 (파일 변경된 모듈만 교체)
historyApiFallback: true,
}
devServer 옵션은 webpack 코어 기능이 아니다. webpack-dev-server패키지의 설정이다.
historyApiFallback은 React Router 사용 시 필수인데, http://localhost:3000/dashboard 경로를 브라우저에서 직접 새로고침하면 브라우저는 서버에 /dashboard 요청을 하지만, 번들된 진입점(index.html)이 /dashboard 경로로 제공되도록 설정되어 있지 않아서 404에러가 발생한다.
historyApiFallback: true 설정 시 모든 404 요청을 index.html 로 fallback 시킨다. 그 후 React Router가 클라이언트에서 라우팅 처리한다.
import TerserPlugin from "terser-webpack-plugin";
export default {
mode: "production",
output: {
filename: "[name].[contenthash].js",
chunkFilename: "[name].[contenthash].js",
},
optimization: {
minimize: true,
moduleIds: "deterministic",
runtimeChunk: "single",
splitChunks: {
maxInitialRequests: 25,
// 초기 로딩 시(동기 import 기준) 동시에 요청할 수 있는 최대 chunk 수
// 이 값을 초과하면 Webpack이 chunk 병합을 시도함
maxAsyncRequests: 25,
// 동적 import(비동기 로딩) 시 동시에 요청할 수 있는 최대 chunk 수
// 과도한 네트워크 병렬 요청을 방지하기 위한 제한값
maxSize: 200_000,
// 하나의 chunk가 200KB를 초과하면 Webpack이 자동으로 분할을 시도함
// 너무 큰 단일 번들은 초기 다운로드 병목을 유발할 수 있기 때문
chunks: "all",
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: "react",
priority: 30,
},
emotion: {
test: /[\\/]node_modules[\\/](@emotion|emotion)[\\/]/,
name: "emotion",
priority: 30,
},
tanstackQuery: {
test: /[\\/]node_modules[\\/](@tanstack)[\\/]/,
name: "tanstack-query",
priority: 30,
},
reactRouter: {
test: /[\\/]node_modules[\\/]react-router[\\/]/,
name: "react-router",
priority: 30,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
priority: 20,
},
common: {
name: "common",
minChunks: 2,
reuseExistingChunk: true,
priority: 10,
},
},
},
minimizer: [
new TerserPlugin({
terserOptions: {
format: {
comments: false,
},
compress: {
drop_console: true,
},
},
extractComments: false,
}),
],
},
};
배포 환경에 대한 설정은 개발 환경과는 달리, 번들 크기 최소화와 초기 로딩 속도 개선에 초점을 맞췄다.
mode: "production" // 기본값
이 한 줄만으로도 Webpack은 자동으로 다음을 수행한다.
process.env.NODE_ENV = "production" 치환하지만 여기서는 추가적인 최적화를 더 적용했다.
output: {
filename: "[name].[contenthash].js",
chunkFilename: "[name].[contenthash].js", // 동적 import나 코드 스플리팅으로 생성된 추가 chunk 파일 이름
}
예를 들어 빌드 결과가 이렇게 나온다.
main.a83hd92.js
react.239as8.js
파일 내용이 바뀌고 다시 빌드를 실행하면 해당 파일의 해시도 바뀐다. (Webpack의 output 단계에서 수행된다.)
브라우저는 기본적으로 JS 파일을 장기적으로 캐시해서 해시가 바뀌지 않으면 다시 다운로드하지 않기때문에, 위와 같은 설정이 필요하다.
이로써 코드가 바뀐 파일만 다시 받아 사용자 로딩 속도가 빨라진다
splitChunks: {
chunks: "all", // 동기, 비동기 import 모두 분석
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: "react",
priority: 30,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
priority: 20,
},
common: {
name: "common",
minChunks: 2, // 2개 이상에서 사용되는 내부 모듈을 분리.
reuseExistingChunk: true, //중복 chunk 생성 방지
priority: 10,
},
},
}
하나의 거대한 JS 파일을 여러 개로 나누는 전략이다.
main.js (3MB)
예를 들어 나누지 않으면 이렇게 되어 앱 코드가 조금만 변경되고 다시 빌드를 하게 되면 3MB 전체를 다시 받는다.
파일을 여러개로 나누게 되면 아래와 같이 나눌 수 있다.
react.js
vendors.js
main.js
common.js
라이브러리는 거의 바뀌지 않고 우리 애플리케이션 코드만 자주 바뀐다.
react를 따로 분리해두면
결과적으로 네트워크 비용이 줄어든다.
Webpack은 모듈 그래프를 만든 뒤, 각 모듈을 그룹에 매칭한다.
react: {
test: /node_modules\/(react|react-dom)/,
priority: 30,
}
react, react-dom을 하나의 청크로 묶음priority는 한 모듈이 여러 그룹에 매칭될 수 있기 때문에 그룹 충돌 시 우선순위를 정한다.
node_modules/react-router
이때 priority가 높은 그룹이 선택된다.
Webpack 런타임 코드를 별도 파일로 분리한다.
앱 코드가 조금만 바뀌어도 runtime 코드도 함께 바뀌면서 모든 파일 해시가 연쇄적으로 바뀌는 것을 방지한다.
모듈 ID를 예측 가능하게 유지한다.
Webpack은 내부적으로 각 모듈에 ID를 부여한다.
기본값은 빌드 순서 기반일 수 있다.
문제:
deterministic은:
new TerserPlugin({
terserOptions: {
format: { comments: false },
compress: { drop_console: true },
},
extractComments: false, // 라이선스 주석을 별도 파일로 생성하지 않음
})
production 모드면 압축은 자동이지만, 압축 방식을 세밀하게 제어하려면 TerserPlugin을 직접 설정해야 한다.
format: { comments: false } 을 입력하지 않으면 기본적으로 Terser는 일부 주석(라이선스 주석)을 유지할 수 있는데, 해당 설정을 입력함으로써 모든 주석을 제거 할 수 있다.
compress: { drop_console: false } 는 코드 최적화 옵션인데, 그 안의 drop_console 은 console.* 호출을 제거할지 여부를 결정한다.
→ 보통 운영 환경에서는 true로 두지만, 로그 수집이나 디버깅이 필요하면 false로 둘 수 있다.
import { merge } from "webpack-merge";
import common from "./webpack.common.js";
import devConfig from "./webpack.dev.js";
import prodConfig from "./webpack.prod.js";
export default (env = {}) => {
if (env.production) {
return merge(common, prodConfig);
}
return merge(common, devConfig);
};
Webpack CLI를 실행하면 webpack.config.js라는 파일명을 기본적으로 찾는다.
이 파일을 통해 개발 환경에서는 webpack.dev.js를, 프로덕션 환경에서는 webpack.prod.js를 분기적으로 실행 할 수 있다.
"scripts": {
"dev": "webpack serve --env development",
"build": "webpack --env production"
},
Webpack이 실행될 때 --env로 넘긴 값이 env 객체로 들어온다.
merge(common, prodConfig) 또는 merge(common, devConfig)는
공통 설정인 common에 각각의 환경별 설정을 합친 새로운 Webpack 설정 객체를 반환한다.
Webpack은 이 “합쳐진 최종 객체”를 실제 설정으로 사용해서 동작한다.

코드 스플리팅을 적용하지 않은 상황에서는 main.js의 크기가 668kB로 네트워크 탭에서 측정할 수 있었다.

webpack-bundle-analyzer를 통해 네트워크 탭에서 본 것 처럼 main.js의 크기(Gzipped size)가 662.91kB로 유사하게 측정되는 것을 확인 할 수 있었다.
Stat size: 6.77 MB ← Webpack이 처음 읽은 원본 소스 크기
Parsed size: 2.09 MB ← minify 후 실제 파일 크기
Gzipped size: 662.91 KB ← br/gzip 압축 후 전송 크기

위에서 설명했듯 webpack.prod.js에 splitChunks를 적용한 후, 번들 결과를 분석하니 이미지에서 볼 수 있듯이 많은 파일로 분리되었다.

네트워크탭에서 코드 스플리팅 된 기존의 main.js 크기를 계산해보니 772kB로 기존의 668kB에 대비해 오히려 늘어났음을 확인 할 수 있었다.
이는 애플리케이션 코드가 실제로 늘어난 것이 아니라, 청크를 과도하게 분리하면서 발생한 구조적 오버헤드 때문이다.
청크가 많아질수록 각 파일에는 Webpack의 런타임 코드, 모듈 ID 매핑 정보, 로딩 부트스트랩 코드가 포함되고, 이 메타데이터가 반복되면서 총 번들 크기가 증가한다.
특히 maxSize처럼 강제 분할 옵션을 사용하면 작은 청크가 다수 생성되어 이러한 오버헤드가 더 커질 수 있다.
또한 파일이 지나치게 잘게 쪼개지면 압축 효율도 떨어진다. 하나의 큰 파일에서는 반복 문자열이 많아 압축률이 높아지지만, 여러 개의 작은 파일로 분리되면 압축 단위가 쪼개지면서 전체 전송량이 증가할 수 있다. 결과적으로 “많이 나누면 항상 좋다”는 가정은 성립하지 않는다.
물론 vendor.js(react, lodash 등과 같은 외부 라이브러리)는 변경 빈도가 낮기 때문에 해시가 유지되면 장기 캐싱 측면에서 큰 장점이 있다. 다음 배포 시 애플리케이션 코드(main)만 다시 다운로드하면 되므로 재방문 성능은 개선된다. 그러나 초기 로딩 관점에서는 분리 전략이 과하면 오히려 전송량이 증가할 수 있다.
그래서 splitChunks의 세부 설정(cacheGroups, maxSize 등)을 제거하고 기본 전략만 사용하도록 단순화했다.
optimization: {
minimize: true,
runtimeChunk: "single",
moduleIds: "deterministic",
splitChunks: {
chunks: "all",
},
minimizer: [
new TerserPlugin({
terserOptions: {
format: {
comments: false,
},
compress: {
drop_console: true,
},
},
extractComments: false,
}),
],
},
splitChunks를 { chunks: "all" }만 남겨두면, Webpack의 기본 휴리스틱에 따라 적절한 공통 모듈만 분리되고, 과도한 청크 생성을 방지할 수 있다. 그 결과 생성되는 파일 수가 줄어들었고, 불필요한 런타임 오버헤드도 감소했다.

여기에 lazy import까지 적용해 실제로 초기 화면에서 필요하지 않은 페이지 단위 코드는 비동기 청크로 분리했다. 즉, 단순히 “많이 쪼개는” 방식이 아니라:
이라는 방향으로 재설계함으로써, 최종적으로 초기 로딩 전송량은 줄고, 재방문 캐시 효율과 코드 유지보수성까지 균형 있게 확보할 수 있었다.
이전 구조는 main.js 안에 모든 라우트 코드와 모든 페이지 컴포넌트, 일부 무거운 라이브러리가 첫 화면 진입 시 전부 다운로드 되었다.

재설계 이후에는 splitChunks는 기본 전략만 사용하고 lazy import로 라우트 단위 분리, vendor 캐싱 전략 적용을 통해 현재 페이지에 필요한 코드만 main에 포함되고 공통 라이브러리는 별도 chunk, 초기 로딩 시 불필요한 코드 미다운로드로 인해 첫 진입 시 실제 전송량이 줄어들게 된 것이다.
처음에는 splitChunks의 cacheGroups를 세밀하게 설정하고 maxSize로 강제 분할까지 적용하면 번들이 작아질 것이라 기대했다.
하지만 실제로는 오히려 전송량이 늘었다. 청크가 잘게 쪼개질수록 각 파일마다 Webpack 런타임 오버헤드가 반복되고, 압축 효율도 떨어지기 때문이다.
결국 "많이 나누면 항상 좋다"는 가정은 성립하지 않으며 최적화는 상황에 맞게 전략적으로 접근해야 한다는 점을 배울 수 있었다.