0. 들어가며: 왜 파일을 그냥 올리면 안 될까?

프로젝트를 시작하면 우리는 자연스럽게 webpack이나 vite 같은 빌드 도구를 설정한다.
너무 당연하게 쓰다 보니, 막상 누군가 근본적인 질문을 던지면 대답이 애매해질 때가 있다.
“왜 파일을 그냥 올리면 안 되고, webpack을 써서 ‘번들링’을 거쳐야 하나요?”
이번 시간에는 webpack의 수 많은 기능 중에서도 ‘번들링(Bundling)’을 코드와 함께 살펴보겠다.
이 글을 읽고나서 webpack의 번들링이 “왜 필요하고” → “내부에서 어떻게 작동하며” → “번들링 결과물의 형태를 이해”할 수 있을 것이다.
💡 핵심 요약
1. webpack은 브라우저가 모르는 모듈 시스템(import/require)을 이해시켜준다.
2. webpack은 의존성 그래프를 기반으로 안 쓰는 코드는 버리고 중복은 합친다.
3. webpack은 수천 개의 파일을 보내는 대신 적당한 덩어리로 묶어 네트워크 오버헤드를 줄인다.
1. 왜 번들링이 필요할까?
우리는 JS 파일을 역할에 따라 여러 개로 분리하고, 각 파일을 import/require()를 사용해 호출한다.
하지만 이 문법은 브라우저가 “태생부터” 이해하던 문법이 아니다.
그래서 이 문법을 이해하기 위해 개발자와 브라우저 사이에 통역 역할이 필요했고, 그 역할을 번들러가 맡았다.
물론 요즘 브라우저는 import/export를 네이티브로 지원하고, <script type="module">만 써도 ESM을 그대로 실행할 수 있다.
그럼 이렇게 생각할 수 있을 것이다.
❓ 번들러가 import/require 문법을 해석해주는 역할이라면, 요즘에는 필요없지 않나?
그럼에도 번들러가 “여전히” 필요한 이유는, 번들러의 역할이 문법 해석에 그치지 않기 때문이다.
- 번들러는 구형 브라우저/특수 런타임 호환을 지원한다.
- 브라우저가 기본으로 해주지 않는 node_modules 해석(bare specifier), 경로 별칭, 조건부 exports를 처리한다.
- TS/JSX 변환, 폴리필 전략, 그리고 여러 최적화(트리셰이킹/중복 제거/청크/캐시)를 한다.
1) 번들링이 필요한 3가지 이유
(1) 모듈 통역: 브라우저가 못 알아듣는 문법을 ‘실행 가능한 코드’로 바꾼다
과거 브라우저의 실행 모델은 단순했다.
HTML에 적힌 <script> 태그를 위에서 아래로 실행할 뿐, 파일 내부에서 다른 파일을 가져오는 능력은 없었다.
- 개발자는 “이 파일은 저 파일이 필요해”라며
import를 작성한다. - 하지만 (특히 구형 환경에서는) 브라우저가 이 관계를 이해하지 못한다.
- 번들러는 모듈 간 연결 관계를 읽고, 브라우저가 실행 가능한 형태로 변환/결합해 결과물을 만든다.
즉, 번들러는 개발자가 쓰는 “모듈 언어”를 브라우저의 “실행 언어”로 바꿔주는 통역기다.
(2) 스코프 보호: 전역 오염을 막고 각 파일의 공간을 만든다
번들링 없이 파일 100개를 <script>로 연결하면, 변수들은 어떻게 될까?
대부분 window라는 전역 공간에 모이면서 충돌한다.
- A 파일의
const name과 B 파일의const name이 같은 전역에서 만나면 - 예상치 못한 덮어쓰기/충돌이 발생하며 전역 오염(Global pollution)이 발생한다.
번들러는 이 문제를 구조적으로 막는다.
- 모듈을 함수 스코프(Function Scope)로 감싸,
- 파일별로 독립된 실행 컨텍스트(각자의 공간)를 만들어준다.
- 그래서 변수명이 겹쳐도 안전하고, 코드 분리/재사용이 가능해진다.
(3) 전달 최적화: ‘전체를 보고’ 가볍고 빠르게 보낸다
번들러는 엔트리부터 시작해 프로젝트 전체를 순회하며 모듈 간 의존 관계를 의존성 그래프(Dependency Graph) 형태로 구성한다.
이 그래프는 하나의 빌드 실행 단위인 Compilation마다 생성된다.
번들러가 배포 단계에서 수행하는 대부분의 최적화는 이 의존성 그래프를 기반으로 이루어진다.
즉, 프로젝트 전체 구조를 담은 “전체 지도” 가 있어야 어떤 코드를 제거하고, 어디를 분리하고, 무엇을 공유할지 판단할 수 있다.
2) 번들링은 어떤 최적화를 할까?
(1) 그래프 기반 최적화: Tree-shaking / Deduplication
1-1. Tree-shaking: 안 쓰는 코드는 제거한다
예를 들어 A 라이브러리에서 “캐릭터가 춤추는 기능” 하나만 쓴다고 해보자.
- 번들러가 없다면: 기능 하나 쓰려고 A 라이브러리 전체를 내려받는다.
- 번들러가 있다면: 그래프를 보고 “이 기능만 쓰네?”라고 판단해 필요한 코드만 남긴다.
그래서 이를 “나무를 흔들어 마른 잎을 떨군다”는 비유로 Tree-shaking이라고 부른다.
다만 트리 셰이킹은 의존성 그래프가 있다고 해서 자동으로 적용되는 기능은 아니다.
안전한 코드 제거를 위해서는 아래 조건들이 함께 충족되어야 한다.
조건1. ESM(import / export) 기반일수록 코드 제거가 유리하다
// ❌ Tree-shaking이 어려운 경우 (CommonJS)
const { funcA } =require('./utils');
CommonJS는
require()가 실행 시점(runtime) 에 평가된다exports객체의 구조도 런타임에 결정되므로번들러는 “어떤 함수가 실제로 쓰이는지”를 빌드 타임에 확정할 수 없다
반면 ESM은 다르다.
// ✅ Tree-shaking이 유리한 경우 (ESM)
import { funcA }from'./utils';
// utils.js에서 funcB가 사용되지 않았다면 제거 가능
- ESM은 import/export 구조가 정적으로 고정되어 있고
- 번들러가 “이 파일에서는 funcA만 쓰인다”는 사실을 빌드 타임에 분석할 수 있다
- 그래서 Tree-shaking은 ESM 기반일수록 정확하게 동작한다
조건 2. production 모드에서 usedExports 분석 + minimizer가 함께 동작해야 한다
- Tree-shaking은 보통 2단계로 이루어진다.
1. usedExports 분석
- “어떤 export가 실제로 사용되었는지” 표시만 해둔다
- 이 단계만으로는 코드가 실제로 삭제되지는 않는다
2. minimizer(Terser 등)
- “사용되지 않는 export”를 실제 코드에서 제거한다- 그래서 개발 모드에서는 코드가 남아 있고, production 빌드에서만 코드 제거가 일어나는 경우가 많다.
조건 3. 모듈에 부수 효과(side effect)가 많을수록 제거 판단이 어려워진다
- 여기서 부수 효과(side effect) 란, 전역 상태를 변경하거나 외부 환경에 영향을 주는 코드를 의미한다.
- 예를 들면 다음과 같다.
// 부수 효과가 있는 코드
console.log('loaded');
window.__CONFIG__ = { mode:'prod' };
- 이 파일은 import되기만 해도 콘솔 출력이 발생하고 전역 값이 변경된다
- 즉, “사용하지 않더라도 실행 자체가 의미를 가진다”
- 이런 경우 번들러는 “혹시 이 코드가 실행되어야 하는 건 아닐까?” 라고 판단해 안전하게 제거하지 못한다.
- 반대로 아래 코드는 다르다.
// 부수 효과가 없는 코드
export function add(a, b) {
return a + b;
}
- 함수가 호출되지 않으면 아무 일도 일어나지 않는다
- 그래서 사용되지 않는다면 안전하게 제거할 수 있다
1-2. Deduplication: 같은 모듈은 한 번만 포함한다
여러 파일이 동일한 모듈 B를 필요로 할 때,
- 번들러가 없다면: 중복 포함이 생겨 사용자는 같은 코드를 여러 번 받는다.
- 번들러가 있다면: ‘B는 한 번만 포함하고, 공통 청크(Shared chunk)로 분리하게’ 된다.
(2) 네트워크 관점 최적화: 택배 1,000개 vs 큰 상자 5개
실무에서 번들링이 필요한 이유 중 하나는 네트워크 오버헤드(Overhead) 때문이다.
요청/응답에는 실제 콘텐츠 외에도 생각보다 많은 고정 비용이 함께 따라온다.
❓ 왜 파일을 잘게 나누면 느려질까?
파일 요청이 늘어날수록 다음과 같은 비용이 누적된다.
핸드셰이크(Handshake)
요청마다 연결 및 협상 과정이 발생한다. 파일이 1,000개라면, 이 과정도 1,000번 반복된다.
동시 연결 제한(Concurrency limit)
브라우저는 한 번에 처리할 수 있는 요청 수가 제한되어 있다.
이로 인해 요청이 순차적으로 쌓이는 Waterfall 현상이 발생한다.헤더 오버헤드(Header overhead)
파일이 작을수록, 실제 데이터 대비 요청·응답 헤더가 차지하는 비중이 상대적으로 커진다.
HTTP/2면 이런 걱정은 안 해도 되지 않나?
HTTP/2의 멀티플렉싱 덕분에 동시 요청 처리와 병목은 크게 완화되었다.
(멀티플렉싱은 하나의 연결 위에서 여러 요청과 응답을 동시에 주고받는 방식임)
하지만 이는 “번들링이 더 이상 필요 없다”는 뜻은 아니다.
- 각 요청에는 여전히 라운드트립 및 처리 오버헤드가 존재한다
- 파일을 내려받은 이후에도 브라우저는 Parse → Compile → Execute 과정을 모두 수행해야 한다
- 작은 파일이 지나치게 많아지면 경우에 따라 압축 효율이 오히려 떨어질 수 있다
결국 결론은 하나다. 너무 잘게 쪼개도 문제고, 전부 합쳐도 문제다.
그래서 번들링으로 “적당히 묶는 게 중요”하다.
3) 웹팩이 정의한 ‘적당히’ 묶는 단위는?
웹팩은 아무 기준 없이 파일을 묶지 않는다.
SplitChunksPlugin 의 기본 설정을 보면, 웹팩이 생각하는 “적당함”의 기준이 드러난다.
(1) 웹팩이 청크를 나누는 기준

https://webpack.kr/plugins/split-chunks-plugin/
- 용량 기준 (
minSize)- 너무 작은 파일은 분리할수록 네트워크 오버헤드가 커진다.
- 일정 크기(예: 20KB) 미만의 조각은 굳이 나누지 않고 합친다.
- 개수 제한 (
maxRequests)- 한 화면에서 동시에 로드되는 파일 수가 과도해지지 않도록
- 초기 로딩 및 비동기 요청 개수에 상한을 둔다.
- 재사용성 (Common Module)
- 여러 페이지에서 공통으로 사용되는 코드는 하나의 청크로 분리해
- 중복 다운로드를 방지하고 캐싱 효율을 높인다.
- 변경 빈도 (Vendor 분리)
- 자주 변경되는 애플리케이션 코드와
- 거의 변하지 않는 라이브러리(
node_modules)를 분리해 캐시 무효화 범위를 최소화한다.
📌 그럼 우리 서비스의 청크 단위가 적절한지 어떻게 알 수 있을까? 4가지를 체크해보면 알 수 있다
- 초기 진입 페이지에서 “지금 안 쓰는 코드”가 같이 내려오지 않는가?
- 공통 라이브러리가 페이지마다 중복 다운로드되지 않는가?
- 청크가 너무 잘게 쪼개져서 로딩 딜레이가 체감되지는 않는가?
- 내 코드 변경이 라이브러리(Vendor) 캐시까지 무효화시키지는 않는가?
웹팩은 이처럼 여러 최적화를 위해 파일을 묶는다.
그렇다면 웹팩은 내부적으로 어떤 순서와 구조로 이 결정을 수행할까? 다음 챕터에서 알아보자!
2. 빌드 파이프라인: ‘지도를 그리고, 짐을 싸서, 내보내기’
앞서 우리는 번들링이 왜 필요한지를 살펴봤다.
- 모듈을 해석하기 위해서
- 스코프를 안전하게 보호하기 위해서
- 그리고 네트워크 관점에서 효율적인 전송을 위해서.
이번 챕터에서는 웹팩이 entry를 입력으로 받아 dist에 결과물을 만들기까지,
어떤 단계와 사고 과정을 거치는지 빌드 파이프라인 관점에서 정리해본다.
웹팩의 빌드 과정은 크게 4단계로 나눌 수 있다.
- 전체 흐름: Entry → ModuleGraph → ChunkGraph → Emit
- 핵심 설계: 먼저 “파일 간 연결 관계(논리)”를 확정하고, 그 다음 “배포 단위(물리)”를 만든다.
- 유연성: “사실(코드 구조)”과 “전략(배포 방식)”을 분리했기에, 코드를 수정하지 않고 설정만으로 번들링·코드 분할·캐싱 전략을 자유롭게 바꿀 수 있다.
1) 먼저, 웹팩 안에서 어떤 객체들이 일을 할까?
빌드 파이프라인을 이해할 때 가장 헷갈리는 지점 중 하나는
우리가 흔히 “웹팩이 뭔가를 한다” 라고 뭉뚱그려 말한다는 점이다.
실제로는 하나의 주체가 모든 일을 처리하는 것이 아니라, 여러 객체들이 빌드를 완성한다.
아래는 이번 챕터에서 등장하는 주요 객체들과 그 역할을 정리한 표다.
| 분류 | 역할 |
|---|---|
| Compiler | 웹팩 실행부터 종료까지를 총괄하는 상위 관리자. 전체 프로세스에서 단 하나만 존재 |
| Compilation | “이번 빌드 1회”의 실무 책임자. 빌드(또는 리빌드)마다 새로 생성됨 |
| ModuleGraph | 모듈 간 의존 관계, 즉 논리적 구조를 기록하는 그래프 |
| ChunkGraph | 어떤 모듈을 어떤 청크에 담을지 결정하는 배포 단위 그래프 |
| Chunk | 최종적으로 생성될 파일 단위에 대응되는 배포 컨테이너 |
여기서 꼭 짚고 가야 할 핵심은 하나다.
Compilation은 이번 빌드에서 생성되는 모든 데이터의 중심이다.즉, “그래프를 만든다”, “청크를 나눈다”, “파일을 만든다”라는 말은
대부분
Compilation내부 상태를 채워나가는 과정을 의미한다.
이제 이 객체들이 어떤 순서로 생성되고, 어떤 정보를 주고받는지 빌드 단계별로 살펴보자.
2) 번들링의 4단계 흐름
(1) Entry: “어디서부터 출발할지”를 고정한다
Entry는 Webpack에게 프로젝트의 시작점을 알려주는 단계다.
entry: "./src/index.js"설정은 “이 파일을 기준으로, 연결된 모든 모듈을 추적하라”는 의미다.중요한 점은 이 단계가 파일을 합치기 시작하는 단계가 아니라
의존성 추적을 시작할 기준점을 정의하는 단계라는 것이다.
(2) ModuleGraph: “누가 누구를 필요로 하는지”를 기록한다 (논리 지도)
웹팩은 엔트리부터 소스 파일을 읽어가며 import / require 등을 분석해 모듈 간 관계도를 만든다.
이 과정은 대략 다음 순서로 진행된다.
- Parsing : 소스 코드를 분석해 AST(Abstract Syntax Tree)로 변환한다.
- Dependency 추출 : 어떤 모듈이 무엇을 참조하는지 의존성을 수집한다.
- ModuleGraph 완성 : “모듈 노드 + 의존성 엣지”로 구성된 논리 그래프를 만든다.
아직 이 단계에서는 “파일을 몇 개로 나눌지” 같은 배포 전략은 전혀 결정하지 않는다.
ModuleGraph는 오직 코드 구조가 어떻게 생겼는지만 담는다.
(3) ChunkGraph: “어떻게 묶어서 배포할지”를 결정한다 (포장 전략)
여기서부터 전략(Strategy) 이 개입된다.
- 웹팩은 ModuleGraph를 바탕으로 “청크(Chunk)라는 배포 상자”를 만들고
- 각 모듈을 어느 청크에 담을지에 대한 매핑을 구성한다.
ChunkGraph는 단순한 “청크 목록”이 아니라, 다음과 같은 정보들을 함께 가진 구조다.
- 모듈 → 청크 매핑 (N:M) : 하나의 모듈이 여러 청크에 포함될 수 있다 (공유 청크, 코드 스플리팅)
- 청크 → 모듈 매핑 : 특정 파일(청크)에 어떤 모듈들이 들어가는지
- 청크 간 관계 : 부모/자식 구조, 특히
import()로 생성되는 async 청크 연결
(4) Emit: “결정된 배치(ChunkGraph)를 실제 파일로 출력한다” (출고)
마지막으로 웹팩은 메모리 안에 있던 결과를 실제 파일로 찍어낸다.
dist/main.js,dist/1.js같은 파일이 이 단계에서 만들어진다.- 프로덕션 빌드에서는
main.[contenthash].js형태로 해시가 붙어 출력된다.
3) ModuleGraph → ChunkGraph 빌드 중간 산출물 살펴보기
앞에서 살펴본 개념을 실제 빌드 중간 산출물의 형태로 연결해보자.
말로만 설명하면 추상적으로 느껴질 수 있기에, 이번에는 단순화한 JSON 데이터로
ModuleGraph와 ChunkGraph가 각각 무엇을 담는지 확인해본다.
(1) 기본 예시로 살펴보기
예시 프로젝트 구조
src/
index.js// entry
App.js
util.js1-1. ModuleGraph: “관계 데이터”만 담긴다
ModuleGraph는 모듈 간 논리적 관계만을 기록한다.
아직 배포 단위나 파일 개수에 대한 정보는 없다. 오직 다음 정보만 담는다.
- 어떤 모듈이 존재하는지
- 어떤 모듈이 어떤 모듈을 참조하는지
- 그 참조가 동기인지 / 비동기인지
{
"modules": [
// ModuleGraph에서 index.js를 가리키는 모듈 노드
{ "id": "M0", "resource": "/src/index.js" },
{ "id": "M1", "resource": "/src/App.js" },
{ "id": "M2", "resource": "/src/util.js" }
],
"dependencies": [
// index.js(M0)가 App.js(M1)를 동기적으로 import/require 함
{ "from": "M0", "to": "M1", "request": "./App", "async": false },
{ "from": "M1", "to": "M2", "request": "./util", "async": false }
],
// ModuleGraph 탐색을 시작하는 진입 모듈(entry)
"entryModules": ["M0"]
}
1-2. ChunkGraph: “물리 배치 + 매핑”이 추가된다
이제 ModuleGraph를 바탕으로 배포 단위(Chunk) 가 만들어진다.
{
// 생성된 배포 단위(청크) 목록 — 여기서는 main 청크 하나만 존재
"chunks": [
{ "id": "C0", "name": "main", "initial": true }
],
// 각 청크에 어떤 모듈들이 포함되는지에 대한 매핑
"chunkToModules": {
"C0": ["M0", "M1", "M2"]
},
// 각 모듈이 어느 청크(파일)에 속해 있는지에 대한 역방향 매핑
"moduleToChunks": {
"M0": ["C0"],
"M1": ["C0"],
"M2": ["C0"]
},
// 청크 간 부모·자식 관계 — async 청크가 없으므로 비어 있음
"chunkRelations": []
}
- 모든 모듈이 하나의
main청크에 묶였고 - 어떤 모듈이 어떤 파일로 배포되는지가 명확해졌다
(2) import()가 등장한 경우 예시 살펴보기
이번에는 entry에서 App을 동적 import로 바꿔보자.
// index.js
import("./App").then(({ default: App }) =>App())
2-1. ModuleGraph 변화: 비동기 의존성만 추가된다
{
"dependencies": [
// M0(index.js)가 M1(App.js)를 비동기 import()로 참조함
{ "from": "M0", "to": "M1", "request": "./App", "async": true }
]
}
ModuleGraph의 변화는 단순하다. “이 의존성은 async다”라는 사실 하나만 추가된다.
2-2. ChunkGraph 변화: 청크가 분리되고 관계가 생긴다
{
"chunks": [
// 초기 진입 시 바로 로드되는 main 청크
{ "id": "C0", "name": "main", "initial": true },
// import()로 분리된 비동기 청크 (App 관련 코드)
{ "id": "C1", "name": "src_App_js", "initial": false, "async": true }
],
"chunkToModules": {
// main 청크에는 entry 모듈만 포함
"C0": ["M0"],
// App 모듈과 그 하위 의존성이 비동기 청크로 이동
"C1": ["M1", "M2"]
},
"chunkRelations": [
// main 실행 중 import()를 만나면 C1 청크를 로드하라는 관계 정보
{ "parent": "C0", "child": "C1", "reason": "import()" }
]
}
이제 구조가 분명해진다.
main청크에는 entry 모듈만 남고App과util은 비동기 청크로 분리된다- 두 청크 사이에는
import()로 연결된 부모–자식 관계가 기록된다
🤔 여기서 한 가지 질문이 떠오른다.
“그냥 파일을 합치면 되는데, 왜 웹팩은 ModuleGraph를 만들고 ChunkGraph를 또 만들까?”
이유는 하나다. 사실과 전략을 분리하기 위해서다.
- ModuleGraph(논리 / 사실)는 내 코드가 어떤 관계로 연결되는지
- ChunkGraph(물리 / 전략)는 이 관계를 어떤 파일 조합으로 사용자에게 전달할지가 중요하다.
이 분리 덕분에, 코드 구조를 크게 바꾸지 않고도 설정만으로 배포 전략을 바꿀 수 있다.
splitChunks.cacheGroups를 조정하면, vendor / common 분리 기준이 달라지고optimization.runtimeChunk: "single"을 켜면, runtime만 별도 파일로 분리되며import()는 코드 레벨에서, “비동기 경계”를 추가해 청크 분리를 유도한다.
즉 웹팩은 “관계는 먼저 고정하고, 그 위에서 전략을 갈아끼우는 구조”로 설계돼 있다.
4) Emit 단계: ChunkGraph가 “실제 파일 트리”로 떨어진다
지금까지 만들어진 모든 그래프와 전략의 결과는 마지막에 실제 파일 형태로 출력된다.
(1) 분리 없이 한 덩어리인 경우
dist/
main.[contenthash].js
index.html(2) import()로 async 청크가 생긴 경우
dist/
main.[contenthash].js
src_App_js.[contenthash].js
index.html(3) SplitChunks까지 적용된 경우
dist/
main.[contenthash].js
vendors-main.[contenthash].js
common-home-admin.[contenthash].js // 멀티 엔트리일 때
src_App_js.[contenthash].js // import()가 있을 때
index.html
💡 결국 웹팩은
ModuleGraph로 사실을 고정하고, ChunkGraph로 전략을 결정한 뒤,
Emit에서 그 결과를 파일로 현실화한다.
3. 마치며…
이번 시간에는 웹팩의 번들링 과정을 빌드 타임 관점에서 살펴봤다.
- 웹팩은 먼저 코드를 분석해 의존 관계를 정리하고 ModuleGraph를 만든다.
- 그 위에서 “어떻게 나눠서 전달할지”라는 배포 전략을 세워 ChunkGraph를 구성한다.
즉, 번들링은 단순한 “파일 합치기”가 아니다.
- (1) 의존 관계를 파악하는 분석 작업이고,
- (2) 전달 단위를 설계하는 전략 작업이다.
그리고 이 빌드 타임의 결정은 브라우저에서 실제로 실행될 수 있어야 비로소 완성된다.
다음 장에서는 이렇게 생성된 번들이 브라우저에서 어떻게 동작하는지 살펴보겠다.
'라이브러리 파헤치기' 카테고리의 다른 글
| 아코디언(Accordion)에 대해 알아보자 (with. 라이브러리 구조 맛보기) (2) | 2024.02.04 |
|---|
댓글