목차
- 들어가며
- 번들러란 무엇인가?
- 번들러가 필요한 이유
- 번들러는 무슨 일을 하는가?
- 번들러 동작 방식
- 의존성 트리 구축 과정
- Webpack의 동작 방식
- Rollup의 동작 방식

들어가며
프론트엔드에서 성능 최적화를 고민하다 보면 자연스럽게 번들 크기나 로드 속도에 신경을 쓰게 됩니다.
최적화 방법에는 여러가지가 있지만 Core Web Vitals, ighthouse나 번들 크기를 확인하게 됩니다.
하지만 next build나 vite build처럼 단 한 줄로 실행되는 번들링 과정은, 정작 내부에서 무슨 일이 일어나는지 알기 어려웠습니다.
"코드가 어떻게 병합되고, 어떤 기준으로 필요한 코드만 남기며, 실행 시 어떤 구조로 동작하는지"
이런 궁금증을 가지고 Webpack과 Rollup이라는 대표적인 번들러의 작동 원리와 구조를 간단하게 정리해보게 되었습니다.
이 글은 번들러가 왜 필요하고, 무슨 일을 하고, 어떻게 다르게 동작하는지를 이해하는 데 초점을 맞추고 있습니다.
번들러란 무엇인가?
번들러는 여러 개의 JavaScript 모듈을 하나의 파일 또는 여러 개의 최적화된 파일로 묶어주는 도구이다.
프로젝트 규모가 커질수록 모듈 간의 의존성이 복잡해지기 때문에,
이를 효율적으로 관리하고 브라우저에서 잘 동작하도록 하기 위해 번들링 과정이 필요하다.
현재는 webpack, vite, rollup 등 다양한 번들러들이 존재하며, 실제 프로덕션 환경에서도 사용되고 있다.
번들러가 필요한 이유
1. 전역 스코프로 인한 모듈 간 충돌 가능성
과거에는 JavaScript에서 모듈을 불러올 수 있는 공식적인 방법이 없었고
여러 개의 .js 파일을 <script> 태그로 순차적으로 불러오는 방식이 일반적이었다.
하지만 이 방식에는 치명적인 단점이 있었는데,
각 스크립트가 동일한 전역 공간을 공유하기 때문에,
다른 파일에서 선언된 변수나 함수가 의도치 않게 덮어씌워지는(Override) 문제가 발생할 수 있었다.
<head>
<script src="./source/hello.js"></script>
<script src="./source/world.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module">
document.querySelector("#root").innerHTML = word;
</script>
</body>
예를 들어 script 태그를 통해 hello.js와 world.js 두 파일을 불러오고
hello.js와 world.js에서 같은 식별자인 word를 사용한다고 가정한다.
이 경우 먼저 로드된 hello.js의 word 값이 나중에 로드된 world.js가 그대로 덮어씌워진다.
이런 현상은 파일이 많아질수록 더 자주 발생하며, 버그를 추적하기도 매우 어려워진다.
2. 다수의 파일 요청으로 인한 성능 저하
모듈 단위로 파일을 나누는 것은 유지보수에 유리하지만,
브라우저가 이 많은 파일을 각각 서버에 요청해야 한다는 점은 성능상 문제로 이어진다.
요청 수가 많아지면 네트워크 지연이 발생하고,
브라우저는 필요한 리소스를 제때 받지 못해 초기 로딩 속도가 느려질 수 있다.
특히 브라우저는 일반적으로 HTML에서 명시된 순서대로 리소스를 받아 실행하기 때문에,
중요한 코드가 나중에 도착하면 사용자 경험에도 영향을 미친다.
그렇다고 모든 코드를 하나의 거대한 파일로 만드는 방식은 현실적으로 어렵다.
모든 코드가 한 공간에서 돌아가게 되면 네이밍 컨벤션, 스코프 분리, 협업 관리가 훨씬 어려워지기 때문이다.
번들러는 무슨 일을 하는가?
1. 번들러는 기본적으로 웹 애플리케이션을 구성하는 여러 파일을 하나로 묶어주는 역할을 한다.
- 이를 통해 브라우저는 각 파일마다 별도의 요청이 아닌, 한 번의 요청으로 필요한 리소스를 가져올 수 있다.
2. 모듈 간의 의존성 파악과 실행 순서를 보장한다.
- 각 파일이 어떤 다른 파일에 의존하는지를 분석해, 실행에 필요한 올바른 순서를 자동으로 구성한다.
- 이 과정 덕분에 개발자는 전역 스코프 충돌이나 로딩 순서를 직접 처리할 필요가 없다.
3. 번들러는 단순히 파일을 묶는 데 그치지 않고, 트랜스파일러나 컴파일러의 역할까지 수행하기도 한다.
예를 들어,
- Babel과 같은 컴파일러로 JS(ES6)문법을 하위 버전이나 CommonJS 방식으로 변환하고,
- esbuild처럼 빠른 트랜스파일러는 코드의 식별자를 난독화하는 uglify,
- 불필요한 공백과 줄바꿈을 제거하는 minify 옵션을 통해 번들 크기를 줄이는 작업도 지원한다.
4. 또한, 번들러는 필요한 리소스만 먼저 로드하고 나머지는 나중에 불러오는 방식도 지원한다.
- 이 때 핵심이 되는 기능이 Tree-shaking이다.
- ESM 기반의 모듈 구조에서는 실제로 사용되지 않는 export는 최종 번들 결과물에서 자동으로 제거할 수 있고,
그 결과 번들 사이즈가 줄어들어 사용자 입장에서도 더 빠른 로딩 속도를 경험할 수 있다.
번들러의 동작 방식
번들러는 크게 두 단계를 통해 JS 모듈을 하나의 파일로 합치는 작업을 수행한다.
- Entry path를 기반으로 의존성 관계에 놓인 모듈을 재귀적으로 탐색하고, 각 모듈의 절대 경로를 기반으로 의존성 트리(Dependency Graph)를 구축한다.
- 앞선 과정에서 구축한 의존성 트리를 기반으로 각 모듈을 하나의 번들링 파일에 합쳐 이를 반환한다.
의존성 트리 구축 과정
1. Entry Point 탐색
- 설정 파일(webpack.config.js, rollup.config.js 등)에 명시된 entry file부터 탐색을 시작한다.
2. AST(Abstract Syntax Tree) 생성
- 탐색하려는 모듈의 코드를 Parser(Babel/parser, Esprima, Acorn 등)를 통해 AST로 변환한다.
3. 반환된 AST 내에서 ImportDeclaration type을 가진 노드를 찾고, 내부 속성 중 source.value 값을 통해 import 경로를 찾는다.

4. 앞선 과정에서 찾아낸 import 경로를 기반으로, 실제 해당 모듈이 위치한 절대 경로를 탐색한다.
5. 탐색한 절대 경로와, 모듈 내부의 코드를 매핑하여 의존성 트리의 노드를 구축한다.
- 만약 CommonJS나 ES5 이하의 polyfill 과정이 필요하다면 별도의 트랜스파일러를 사용하여 모듈 내부의 코드를 변환한 결과를 저장한다.
Webpack의 동작 방식
- Webpack은 의존성 트리를 기반으로 각 모듈을 독립적인 함수로 감싸 스코프를 분리하고,
이를 하나의 모듈 맵(Module Map) 형태로 관리한다. - 각 모듈은 해당 파일의 경로를 키로, 해당 모듈을 실행할 함수를 값으로 하는 객체에 저장되며,
이후 런타임에서 require를 통해 참조된다.
const modules = {
'cat.js': function(exports, require) {
exports.default = function meow() {
return 'Meow!';
};
},
'dog.js': function(exports, require) {
exports.default = function bark() {
return 'Woof!';
};
},
'app.js': function(exports, require) {
const meow = require('cat.js').default;
const bark = require('dog.js').default;
console.log('Cat says:', meow());
console.log('Dog says:', bark());
}
};
- 실행 시 require 함수는 모듈 맵에서 해당 경로의 함수를 찾아 실행하고, 그 결과를 반환한다.
- 이 과정에서 모듈 캐시를 활용하여 이미 실행된 모듈은 다시 실행하지 않고 캐시된 결과를 재사용한다.
- 만약 캐싱이 없다면, 서로를 참조하는 모듈 간에 무한 재귀처럼 순환 참조가 발생할 수 있으므로,
이를 방지하기 위한 캐시 메커니즘이 필수적이다.
Rollup의 동작 방식
- Rollup은 각 모듈의 코드를 하나의 컨텍스트로 정리(호이스팅)한 뒤,
정적 분석을 통해 모듈을 병합하여 하나의 번들 파일을 생성한다. - 모든 코드가 동일한 스코프에서 실행되기 때문에,
식별자 충돌을 방지하기 위해 이름을 동적으로 변경하는 처리를 수행한다. - 또한, 의존성 그래프를 분석하여 정의 순서와 실행 순서를 올바르게 정렬해야 하기 때문에,
Rollup의 출력 과정에서는 모듈 간 선언 위치 조정이 핵심이다.
function cat$meow() {
return 'Meow!';
}
// sound/dog.js → 같은 함수명이어도 충돌 없이 이름 변경
function dog$meow() {
return 'Woof!';
}
// 실행 순서 맞추기
console.log(cat$meow()); // → Meow!
console.log(dog$meow()); // → Woof!
Webpack, Rollup의 특징 정리
Webpack
- 각 모듈을 함수로 감싸 스코프를 분리
- 실행 시점에 require()를 통해 모듈을 호출하고, 내부적으로 Module Cache를 활용해 순환 참조를 제어
- 모듈을 function으로 감싸고 __webpack_require__()로 런타임에 호출함
- 즉, 브라우저가 실행할 때 모듈을 불러오고 평가하는 구조
- → "실행 시점"에 모듈을 연결하고 실행
- 결과적으로는 안전한 실행이 가능하지만, 각 모듈을 평가하는 런타임 오버헤드가 존재
장점
- 모듈별 스코프가 완전히 격리되어 식별자 충돌 방지에 강함
- Module Cache로 순환 참조 발생 시 재평가 없이 이전 결과 재사용 가능
단점
- 모든 모듈을 함수로 감싸고 실행 시 평가하므로 런타임 오버헤드가 존재
- 결과 코드가 다소 난해하고, 가독성이 낮음
Rollup
- 각 모듈의 코드를 정적으로 분석하여 하나의 실행 컨텍스트 내에 병합
- 실행 시점이 아닌 번들 생성 시점에 모듈 간 순서와 의존성을 정렬하여 실행 흐름을 결정
- import / export 구문을 빌드 시점에 모두 정적으로 분석
- 어떤 코드가 필요하고 어떤 순서로 실행되어야 하는지를 미리 계산해서
→ 최종 번들에 불필요한 코드 없이 실행 가능한 형태로 병합 - → "번들 생성 시점"에 실행 순서를 확정짓고, 런타임 오버헤드가 거의 없음
- 결과적으로는 빠르고 간결한 실행 코드가 생성되지만, 순환 참조나 동적 import에 대한 유연성이 부족
장점
- 모듈을 정적으로 분석해 병합하므로 결과 코드가 가볍고 실행이 빠름
- 출력 코드가 원본에 가까워 가독성이 우수하고, 디버깅이 용이함
단점
- dynamic import나 순환 참조에 대한 대응이 미약
- 런타임 로딩이나 조건부 로딩이 필요한 구조에서는 유연성이 떨어짐
'Tech > SW Development' 카테고리의 다른 글
| Webpack, Esbuild, Rollup 빌드 속도 측정 & 비교 (0) | 2025.04.15 |
|---|---|
| Errorboundary가 포착할 수 없는 에러 (3) | 2024.11.26 |
| React-router-dom 뜯어보기 (2) | 2024.01.10 |
| 디자인 패턴 도입기 (0) | 2023.12.12 |
| Next13에서 Sass 사용하기 (0) | 2023.12.11 |