Webpack 빌드 프로세스, 처음부터 끝까지 한 번에 이해하기

재영·2025년 11월 17일

Webpack

목록 보기
1/2

들어가며

Webpack을 공부하게 된 이유

프런트엔드 최적화를 다루다 보면 결국 Webpack 설정으로 돌아오곤 합니다. 캐시 전략, 이미지 최적화, 환경 변수 주입 모두 Webpack 단계에서 풀어야 하는 문제들이죠. 하지만 전체 흐름을 제대로 이해하지 못한 채 설정만 추가하다 보니, "아이콘이 왜 보이지 않지?", "배포했는데 왜 배포 전 화면이 나오지?"와 같은 이슈가 터졌을 때 빠르고 정확하게 원인을 좁히기 어려웠습니다.
'봄봄' 서비스가 커지면서 빌드 시간과 번들 크기, 런타임 성능까지 점차 병목이 드러나기 시작했습니다. 하지만 이것이 Webpack의 설정 부족 때문인지, 아니면 도구 자체를 Vite 등으로 전환해야 할 문제인지 판단이 서지 않았습니다. 그래서 이번 글에서는 Webpack의 전체 빌드 흐름을 처음부터 끝까지 재정리하고, 그 속에서 현재 서비스에 적용 가능한 개선 지점을 체계적으로 찾고자 합니다.

빌드란 무엇인가?

"개발자가 작성한 코드를 브라우저가 이해할 수 있는 언어로 변환하는 일"

웹 애플리케이션을 만들다 보면 수많은 파일이 얽히고설켜 있습니다.
JavaScript, TypeScript, CSS, 이미지 파일까지 브라우저는 TypeScript나 JSX를 직접 실행할 수 없습니다. 그래서 빌드(build) 라는 과정이 필요한데요. 빌드는 단순히 "코드를 합치는 일"이 아니라, 변환(compile) → 최적화(optimize) → 결과물 출력(output) 으로 이어지는 하나의 자동화된 파이프라인입니다.

이 글에서는 그중에서도 프런트엔드 빌드 도구의 대표격인 Webpack을 중심으로, “빌드가 실제로 어떻게 동작하는지”를 단계별로 살펴보겠습니다.

Webpack 빌드의 큰 흐름

Webpack의 빌드는 크게 여섯 단계로 구성됩니다.

엔트리(Entry) → 의존성 그래프(Dependency Graph) → 로더(Loaders) → 플러그인(Plugins) → 번들링(Bundle) → 결과 출력(Output)

이제 각 단계가 구체적으로 어떤 일을 하는지 차근히 살펴보겠습니다.

1. 엔트리 (Entry)

엔트리는 Webpack이 빌드를 시작하는 진입점(entry point) 입니다.
가장 먼저 읽는 파일로, 보통 React 프로젝트에서는 src/main.tsx가 이에 해당하죠.
이 파일을 기준으로 import된 모든 파일들을 추적하여 의존성 그래프를 만듭니다.

module.exports = {
  entry: './src/main.tsx',
};

엔트리를 여러 개 지정하면, 여러 페이지나 앱을 동시에 번들링할 수도 있습니다.

“엔트리를 여러 개 지정하면 어떻게 될까?”
예를 들어, 하나의 프로젝트 안에 사용자용 페이지(user)관리자용 페이지(admin) 가 따로 있다고 해봅시다.
이때 엔트리를 다음처럼 두 개로 지정할 수 있습니다.

module.exports = {
  entry: {
    user: './src/user/index.tsx',
   admin: './src/admin/index.tsx',
  },
};

이렇게 설정하면 Webpack은 두 개의 독립된 번들(user.js, admin.js)을 생성합니다.
각 엔트리 파일을 기준으로 별도의 의존성 그래프가 만들어지고, 두 그래프에 공통으로 포함된 모듈은 자동으로 공통 청크(common chunk) 로 분리됩니다.
즉, 서로 다른 페이지를 독립적으로 로드할 수 있고, 중복된 코드(예: React, 공용 유틸 등)는 한 번만 다운로드되므로 로드 속도와 캐싱 효율이 개선됩니다.

2. 의존성 그래프 (Dependency Graph)

Webpack은 지정된 엔트리 파일에서 시작해, 코드 안의 import 또는 require 구문을 재귀적으로 탐색하며 모듈 간의 관계를 그래프로 표현합니다. 이 그래프를 Dependency Graph(의존성 그래프)라고 부릅니다.


내부 동작 원리

1) 엔트리 탐색 (Entry Resolution)
Webpack은 설정에서 지정한 엔트리(예: src/main.tsx)를 첫 노드(root node)로 등록합니다. 이 파일을 AST(Abstract Syntax Tree)로 파싱하여 내부의 import, require 구문을 찾습니다.

2) 모듈 분석 (Module Parsing)
발견된 의존 경로를 기준으로 각 모듈을 로드합니다. Webpack은 모듈을 해석할 때 resolver를 사용하여, 실제 파일 경로를 찾고 해당 파일의 내용을 다시 파싱합니다. 이 과정에서 .ts, .css, .svg 등 JS 외의 파일은 로더(loader)에 의해 변환되어 JS 모듈처럼 다뤄집니다.

3) 재귀적 추적 (Recursive Traversal)
각 모듈에서 또 다른 import가 발견되면, 동일한 과정을 반복합니다. 이렇게 모든 모듈의 의존 관계를 따라가며 하나의 방향성 그래프(Directed Graph)가 구성됩니다. 루프(순환 참조)가 감지될 경우, Webpack은 이를 처리 가능한 형태(캐시 모듈 참조)로 관리합니다.

4) 노드 및 엣지 구성
그래프의 노드(Node)는 각각의 모듈(파일)을 의미하고, 엣지(Edge)는 한 모듈이 다른 모듈을 import하는 의존 관계를 의미합니다.


의존성 그래프가 중요한 이유
이렇게 만들어진 의존성 그래프는 Webpack의 모든 최적화의 기반이 됩니다.

  • 번들링 단계에서는 그래프를 순회하여 모듈을 하나의 실행 순서로 정렬합니다.
  • Tree Shaking은 그래프에서 실제로 참조되지 않는 노드를 제거하는 과정입니다.
  • 코드 스플리팅은 그래프를 분석하여 공통 부분(vendor 모듈)을 독립된 청크로 분리합니다.
  • HMR(Hot Module Replacement) 역시 이 그래프를 이용해, 변경된 노드와 그 영향을 받는 노드만 재컴파일합니다.

즉, 의존성 그래프는 빌드의 뼈대가 되는 단계입니다.

3. 로더 (Loaders)

브라우저는 오직 JavaScript 혹은 JSON만 이해할 수 있습니다.
그런데 우리는 TypeScript, CSS, 이미지 등 다양한 언어를 함께 사용하는데요.
로더(loader)는 이런 파일들을 Webpack이 이해할 수 있도록 JavaScript 형태로 변환(compile)하는 역할을 합니다.

module: {
  rules: [
    {
      test: /\.(ts|tsx)$/,
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: [
              '@babel/preset-env',
              '@babel/preset-react',
              '@babel/preset-typescript',
            ],
          },
        },
      ],
      exclude: /node_modules/,
    },
    {
      test: /\.css$/,
      use: ['style-loader', 'css-loader'],
    },
  ],
}

대표적인 로더들

  • babel-loader: TypeScript, JSX를 일반 JavaScript로 변환
  • css-loader: CSS 파일을 JavaScript 모듈로 변환
  • style-loader: 변환된 CSS를 DOM에 <style> 태그로 삽입

즉, 우리가 작성한 최신 React + TypeScript + CSS 코드는 브라우저가 직접 이해할 수 있는 평범한 JavaScript 함수로 변환됩니다.

로더 체인의 핵심

로더는 오른쪽에서 왼쪽으로 순차적으로 실행됩니다.
예를 들어 ['style-loader', 'css-loader']는:
1. css-loader가 먼저 CSS를 JS로 변환
2. style-loader가 그 결과를 DOM에 삽입

babel-loader의 presets 또한 같은 순서대로 변환을 하게 됩니다.
이런 체인 구조 덕분에 SASS, PostCSS 같은 로더를 추가해
점진적으로 CSS를 완성할 수 있습니다.

4. 플러그인 (Plugins)

로더가 파일 단위 변환에 집중한다면, 플러그인(plugin)빌드 전체 과정의 확장과 자동화를 담당합니다.
Webpack의 진짜 강점이 바로 이 플러그인 시스템인데요. 빌드 타임에 파일을 생성하거나, 환경 변수를 주입하고, 정적 리소스를 복사하는 등 “코드 변환” 이상의 일을 가능하게 해줍니다.
다양한 플러그인이 존재하지만, 대표적인 플러그인을 간단하게 소개드리겠습니다.


HtmlWebpackPlugin — HTML에 빌드 결과 자동 삽입

HtmlWebpackPlugin은 빌드 시점에 HTML 파일을 생성하거나, 기존 템플릿에 번들된 JS·CSS 파일을 자동으로 삽입해주는 플러그인입니다.

new HtmlWebpackPlugin({
  template: './index.html',
  filename: 'index.html',
  inject: true,
  favicon: './public/assets/logo.png',
});
  • template : 기준이 되는 HTML 템플릿을 지정
  • 빌드 결과물(main.js, style.css 등)을 <script>, <link> 태그로 자동 삽입
  • favicon 옵션을 지정하면 파비콘도 함께 포함됨

💡 예시 전후 비교

원본 템플릿

<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

빌드 후 결과

<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
    <link rel="icon" href="assets/logo.png">
  </head>
  <body>
    <div id="root"></div>
    <script src="main.5c8a.js"></script>
  </body>
</html>

빌드 후 JS 파일 이름이 main.5c8a.js처럼 해시로 바뀌어도
<script>가 자동으로 갱신되므로 HTML을 직접 수정할 필요가 없습니다.


DefinePlugin — 환경 변수 및 상수 주입

DefinePlugin은 코드 안에서 사용할 전역 상수환경 변수(process.env)를 빌드 타임에 주입하는 플러그인입니다.

new webpack.DefinePlugin({
  'process.env': JSON.stringify(process.env),
});

이렇게 정의하면 실제 코드에서는 다음처럼 접근할 수 있습니다.

const baseUrl = process.env.API_BASE_URL;

실제로는 런타임 변수처럼 보이지만, Webpack은 빌드 시점에 다음처럼 문자열로 치환합니다 👇

const baseUrl = 'https://api.example.com/v1';

이 방식 덕분에,

  • 불필요한 분기 코드는 트리쉐이킹(Tree Shaking) 으로 제거되고
  • 빌드 환경(dev, staging, prod 등)에 맞게 코드가 자동 분기됩니다.

즉, DefinePlugin은 “환경별 조건 분기”를 가능하게 하는 핵심 도구입니다.


CopyWebpackPlugin

CopyWebpackPlugin정적 파일(public 폴더 등)을 빌드 결과물(dist) 에 자동으로 복사해주는 플러그인입니다.

예를 들어, assets, robots.txt, sitemap.xml 같은 파일을 자동으로 포함시킬 수 있습니다.

new CopyWebpackPlugin({
  patterns: [
    { from: 'public/assets', to: 'assets' },
    {
      from: path.resolve(__dirname, 'public', `robots.${process.env.NODE_ENV}.txt`),
      to: path.resolve(__dirname, 'dist', 'robots.txt'),
    },
  ],
});
  • from: 복사할 원본 파일/폴더
  • to: 결과물이 저장될 위치
  • patterns 배열을 사용하면 여러 세트를 한 번에 관리 가능

💡 예를 들어,

  • public/assetsdist/assets 로 복사하여 이미지 접근 경로를 유지하고
  • 환경(prod, dev)에 따라 robots.txtsitemap.xml을 자동 교체할 수 있습니다.

이 덕분에 HTML이나 JS 코드에서 정적 파일 경로를 직접 관리할 필요 없이, assets/logo.png 형태로 접근이 가능해집니다.

5. 번들링 (Bundling)

이제 변환이 끝난 모든 모듈들을 하나로 묶는 단계입니다.
Webpack은 의존성 그래프를 따라, 각 모듈의 실행 순서와 관계를 고려해 하나의 JS 번들을 만듭니다.
이 과정에서 코드 스플리팅(splitChunks)이 적용되어 공통 모듈(vendor, react 등)을 별도 파일로 분리하기도 합니다. 즉, “필요한 코드만 효율적으로 다운로드하게 만드는” 단계예요.

6. 결과 출력 (Output)

마지막으로, 번들링된 결과물이 실제 디스크에 저장됩니다.

output: {
  filename: 'js/[name].[contenthash:8].js',
  path: path.resolve(__dirname, 'dist'),
  publicPath: '/',
},
  • filename: 생성될 파일의 이름 패턴 ([name]은 엔트리 이름, [contenthash:8]은 8자리 해시)
  • path: 빌드 결과물이 저장될 물리적 경로 (절대 경로)
  • publicPath: 브라우저에서 파일에 접근할 때 사용하는 기본 경로

여기서 filename에 포함된 [contenthash]파일 내용 기반의 고유 해시값입니다. 즉, 파일의 내용이 바뀌지 않으면 해시값도 동일하게 유지되고, 반대로 코드가 수정되면 해시값이 바뀌어 파일 이름도 달라집니다.

예를 들어 다음과 같습니다.

  • 변경 전 : main.5c8a.js
  • 변경 후 : main.ae91.js

“왜 해시값을 붙일까?”
만약 우리가 매번 같은 이름(main.js)으로 빌드 결과를 내보낸다면, 브라우저는 새로 배포된 파일을 이전 캐시와 구분하지 못합니다. 즉, 파일 내용이 변경되어도 브라우저는 여전히 캐싱된 main.js를 사용하죠.
이 때문에 최신 코드가 반영되지 않거나, 사용자가 새로고침을 여러 번 해야 최신 버전이 로드되는 문제가 발생합니다. 이 문제를 해결하기 위해 Webpack은 파일 이름에 내용 기반 해시값(contenthash) 을 붙입니다.
이제 파일이 바뀌면 자동으로 파일 이름도 달라지고, 브라우저는 새로운 리소스로 인식해 최신 버전을 받아오게 됩니다.

추가 설정들

지금까지는 Webpack의 빌드 과정을 중심으로 살펴봤습니다. 하지만 실제 프로젝트에서는 빌드 외에도 “개발 환경에서의 편의성”과 “배포용 최적화”를 고려해야 합니다. 이를 제어하는 주요 옵션이 바로 mode, resolve, devServer, optimization입니다.

1. mode

mode는 Webpack이 어떤 환경에서 실행되는지를 결정하는 옵션입니다.

module.exports = {
  mode: 'production',
};
  • development
    • 빌드 속도를 우선시
    • 코드 압축(X), 주석 유지
    • 디버깅에 유리한 source-map 자동 활성화
  • production
    • 코드 난독화 및 압축(Uglify, Terser 등)
    • Tree Shaking 활성화로 사용되지 않는 코드 제거
    • 성능 최적화 중심의 빌드

즉, mode를 전환하는 것만으로 개발 효율 ↔ 실행 성능 간의 균형을 쉽게 맞출 수 있습니다.

2. resolve

resolve모듈을 어떻게 해석할지를 정의합니다.

module.exports = {
  // ...
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '#': path.resolve(__dirname, 'public'),
    },
  },
}
  • extensions
    • 파일 import 시 확장자를 생략할 수 있게 합니다.
    • 예: import App from '@/App' → 자동으로 App.tsx, App.ts, App.js 순서로 탐색
  • alias
    • 복잡한 상대 경로(../../../) 대신 간단한 별칭으로 import 가능
    • 예: src/components/Button@/components/Button

이 설정을 잘 활용하면 코드 가독성과 유지보수성이 크게 향상됩니다.

3. devServer

webpack-dev-server는 로컬 개발 시 변경 사항을 자동으로 반영해주는 개발용 서버를 제공합니다.

module.exports = {
  // ...
  devServer: {
    static: [
      { directory: path.join(__dirname, 'dist') },
      { directory: path.join(__dirname, 'public') },
    ],
    port: 3000,
    open: true,
    hot: true,
    historyApiFallback: true,
    client: {
      overlay: true,
    },
  },
}
  • static : 정적 파일(dist, public) 제공 경로 설정
  • port : 로컬 개발 서버 포트 (localhost:3000)
  • open : 서버 실행 시 자동으로 브라우저 열기
  • hot : HMR(Hot Module Replacement) 활성화 → 새로고침 없이 즉시 반영
  • historyApiFallback : SPA 라우팅을 지원 (/about 같은 경로 새로고침 시에도 index.html로 리디렉션)
  • overlay : 에러 발생 시 브라우저 화면에 직접 표시

즉, 코드를 저장할 때마다 즉시 결과를 확인할 수 있어 개발 효율이 극대화됩니다.

4. optimization

optimization은 Webpack이 코드 크기와 로딩 속도를 최적화하는 핵심 설정입니다.

module.exports = {
  // ...
  optimization: {
    usedExports: true,
    sideEffects: false,
    minimize: isProduction,
    splitChunks: {
      chunks: 'all',
      // ...
    },
  },
}
  • usedExports: true → Tree Shaking 활성화 (사용되지 않는 export 제거)
  • sideEffects: false → 부작용 없는 모듈은 자유롭게 최적화
  • minimize: true → 코드 압축(TerserPlugin 자동 적용)
  • splitChunks → 코드 스플리팅으로 공통 모듈 분리

예를 들어 React와 다른 라이브러리를 별도 청크로 분리하면, 초기 로딩 속도가 빨라지고, 공통 모듈이 캐시되어 페이지 간 전환 시 훨씬 가볍게 동작합니다.

마무리하며

Webpack은 분명 러닝 커브가 있는 도구입니다. 하지만 그 원리를 이해하고 나면, 프런트엔드 최적화의 강력한 무기가 됩니다. Vite나 다른 도구로 전환을 고민하기 전에, 현재 Webpack 설정을 제대로 활용하고 있는지 먼저 점검해 보세요. 생각보다 많은 개선점을 발견해 보세요.

이번 글에서는 Webpack의 빌드 흐름을 처음부터 끝까지 정리하며 “왜 이렇게 동작하는가”를 중심으로 살펴봤습니다. 다음 글에서는 '봄봄' 프로젝트에 실제로 적용한 고급 최적화 기법을 구체적인 수치와 함께 공유하겠습니다.

1개의 댓글

comment-user-thumbnail
2025년 12월 3일

이것만 보면 나도 번들러 마스터?

답글 달기