
번들러: Webpack, Rollup, Vite가 하는 일의 본질 (대규모 업데이트)
수천 개의 JS 파일을 하나로 합치는 마법. Tree Shaking, HMR, Code Splitting의 원리와 차세대 번들러(Vite, Turbopack)의 등장 배경.

수천 개의 JS 파일을 하나로 합치는 마법. Tree Shaking, HMR, Code Splitting의 원리와 차세대 번들러(Vite, Turbopack)의 등장 배경.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

<script> 태그의 지옥과 번들러의 탄생2010년 이전, 우리가 '프론트엔드 개발'이라는 용어조차 희미하게 쓰던 시절, 웹사이트에 자바스크립트를 추가하는 유일한 방법은 <script> 태그였습니다.
jQuery가 전 세상을 지배하던 그 시절의 코드는 이랬습니다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Old School Web</title>
<!-- 의존성 순서 관리의 지옥 -->
<script src="lib/jquery.min.js"></script>
<script src="lib/jquery-ui.min.js"></script>
<script src="lib/bootstrap.min.js"></script>
<!-- 내 코드 -->
<script src="src/utils.js"></script>
<script src="src/main.js"></script>
</head>
<body>
<h1>Welcome to Dependency Hell</h1>
</body>
</html>
얼핏 보면 좋아 보이지만, 이 방식은 치명적인 "의존성 지옥(Dependency Hell)"을 야기했습니다.
main.js가 utils.js보다 먼저 로드되면? Uncaught ReferenceError: utils is not defined가 발생합니다. 개발자는 수십 개의 파일 로딩 순서를 머릿속으로 관리해야 했습니다.window 객체를 공유했습니다. utils.js에서 var x = 1을 선언하면, main.js의 x와 충돌할 수 있었습니다. 변수명 충돌을 피하기 위해 MyDailyApp_Utils_Function 처럼 긴 이름을 써야만 했죠.이 지옥에서 우리를 구원해준 것이 바로 번들러(Bundler)입니다. 처음 Webpack 설정을 보고 "뭐야 이게? 왜 이렇게 복잡해?"라고 생각했지만, 결국 이게 현대 웹 개발의 기반이 되었다는 것을 이해했습니다.
Webpack 같은 현대 번들러는 단순한 "파일 병합기" 그 이상입니다. 거대한 애플리케이션 빌드 파이프라인의 총지휘자 역할을 한다는 걸 실제로 정리해본다면, 세 가지 핵심 단계로 나뉩니다.
JavaScript는 ES6(ES2015)가 나오기 전까지 공식적인 모듈 시스템이 없었습니다. Node.js는 CommonJS(require)를 썼지만 브라우저는 이를 이해하지 못했죠.
번들러의 첫 번째 임무는 진입점(Entry Point)부터 시작해서, 스파이더처럼 코드를 기어 다니며 모든 import / require 문을 찾아내는 것입니다.
이를 통해 파일 간의 관계도인 의존성 그래프(Dependency Graph)를 메모리에 그립니다. "누가 누구를 필요로 하는지" 완벽하게 파악하는 것이죠.
웹팩에는 "모든 것은 모듈이다"라는 철학이 있습니다.
js)뿐만 아니라css, scss)png, svg)woff2)이 모든 것을 import 구문으로 가져올 수 있게 만듭니다.
이 마법을 부리는 것이 바로 로더(Loader)입니다.
| Loader | 역할 | 비유 |
|---|---|---|
babel-loader | ES6+ 문법을 ES5로 변환 | 현대 한국어를 조선시대 말로 번역 |
css-loader | CSS 파일을 JS 모듈로 변환 | 옷감을 재단해서 포장 |
style-loader | 변환된 CSS를 <style> 태그로 주입 | 포장된 옷을 마네킹에 입힘 |
file-loader | 파일을 복사하고 URL 반환 | 창고지기 |
// webpack.config.js 예시
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'], // 오른쪽(css-loader)부터 실행됨
},
{
test: /\.(png|jpg)$/,
type: 'asset/resource',
},
],
},
};
그래프를 다 그린 후, 번들러는 결과물을 최적화합니다.
a, b처럼 한 글자로 줄입니다. (Ex: TerserPlugin)"나무를 흔들어서 죽은 잎을 떨어뜨린다." Tree Shaking은 모던 웹 성능 최적화의 핵심입니다. 하지만 단순히 쓰지 않는 함수라고 무조건 지우면 문제가 생길 수 있습니다. 이를 사이드 이펙트(Side Effect)라고 합니다.
// A.js
import './polyfill'; // 아무 변수도 export 안 함!
import { func } from './B.js';
func();
polyfill.js는 Array.prototype.map 등을 수정하는 사이드 이펙트를 발생시키는 파일입니다.
만약 번들러가 "어? A.js에서 polyfill로부터 아무것도 import 안 하네? 이거 안 쓰는구나!" 하고 지워버리면?
오래된 브라우저에서 앱이 터져버립니다.
그래서 package.json에 "sideEffects": ["*.css", "./polyfill.js"]와 같이 명시하여, "이 파일들은 지우지 마세요"라고 번들러에게 알려줘야 안전하게 Tree Shaking을 할 수 있습니다. 처음엔 이 개념이 헷갈렸는데, 실제로 번들 사이즈가 절반으로 줄어드는 걸 보고 나서야 와닿았습니다.
Tree Shaking은 정적 분석(Static Analysis)이 가능한 ES Modules(import/export) 환경에서만 작동합니다.
그런데 Babel이 코드를 CommonJS(require)로 바꿔버리면? Tree Shaking이 불가능해집니다.
반드시 Babel 설정에서 modules: false 옵션을 켜서, import 문을 그대로 남겨둬야 웹팩이 이를 보고 가지치기를 할 수 있습니다.
<script type="module">)을 사용합니다. 브라우저가 import를 만나면 서버에 파일을 요청하고, Vite 서버는 그 파일 하나만 쏙 줘버립니다.graph TD
A[Source Code] -->|Webpack| B[Bundle Entire App]
B --> C[Dev Server]
C --> D[Browser]
E[Source Code] -->|Vite| F[Native ESM Request]
F -->|On Demand| G[Compile Single File]
G --> H[Browser]
style B fill:#f9f,stroke:#333
style G fill:#9f9,stroke:#333
Webpack은 전체 번들링 후 서빙, Vite는 요청 시 개별 컴파일 후 서빙한다.
많은 분들이 묻습니다. "이제 Webpack 버리고 Vite 써야 하나요?" 결론부터 말하면 "새 프로젝트면 무조건 Vite"입니다. 하지만 레거시는 함부로 못 바꿉니다.
Webpack은 Node.js(Javascript)로 만들어졌습니다. JS는 싱글 스레드입니다. 프로젝트 파일이 1,000개가 넘어가면, 이 파일들을 다 읽고, AST 만들고, 변환하고, 합치는 과정이 CPU 하나에서 낑낑대며 돌아갑니다. 그래서 "서버 시작" 버튼 누르고 커피 한 잔 마시고 와야 하는 것입니다. (Cold Start 30초~1분).
Vite는 두 가지 무기를 씁니다.
import를 요청하면 서버가 "그때그때" 변환해서 줍니다.
그래서 프로젝트가 아무리 커져도 서버 시작 속도가 0.1초입니다. O(1)의 속도죠.하지만 Webpack의 생태계(Loaders, Plugins)가 워낙 방대하고, 세밀한 커스텀 설정(SplitChunksPlugin 등)이 가능해서, 복잡한 엔터프라이즈급 빌드에는 여전히 Webpack이 필요할 수 있습니다. 이 부분은 실제 프로젝트 마이그레이션을 해보면서 깊이 받아들였습니다.
Webpack 5에서 등장한 게임 체인저입니다. 기존에는 여러 팀(쇼핑몰 팀, 결제 팀, 회원 팀)이 작업한 코드를 합치려면, npm 패키지로 배포하고 다시 빌드하는 긴 과정이 필요했습니다.
Module Federation을 쓰면, 서로 다른 호스트(도메인)에 있는 자바스크립트 파일을 런타임에 원격으로 import 해서 실행할 수 있습니다. 마치 마이크로 서비스(Backend Microservices)처럼 프론트엔드도 독립적으로 배포하고 런타임에 조합하는 것이 가능해진 것입니다.
// 런타임에 http://checkout.com/remoteEntry.js를 로드해서 사용
const PaymentButton = React.lazy(() => import('checkout/PaymentButton'));
이것은 대규모 엔터프라이즈 프론트엔드 아키텍처의 패러다임을 완전히 바꾸어 놓았습니다.
"IE11에서도 돌아가게 해주세요." 이 요청을 받으면 우리는 Babel과 Polyfill을 설정해야 합니다. 하지만 둘은 다릅니다.
() => {}를 function() {}으로 바꿉니다.Promise나 Array.prototype.includes가 아예 없습니다.if (!Array.prototype.includes) { ... } 처럼, 브라우저에 없는 기능을 JS로 직접 구현해서 채워(Fill) 넣습니다.async/await는 단순 변환으로 안 되고, 제너레이터(Generator)라는 복잡한 상태 머신으로 구현해야 합니다. 이 런타임이 그 역할을 합니다.useBuiltIns: 'usage'babel.config.js에서 이 옵션을 켜면, "내가 코드에서 실제 쓴 기능만" 찾아서 폴리필을 import 해줍니다. 번들 사이즈를 획기적으로 줄일 수 있습니다. 이 방식의 장점은 번들 사이즈를 줄이면서도 호환성을 보장한다는 점이 와닿았습니다.
번들러의 원리는 의외로 간단합니다. 한번 정리해본다면:
acorn, babel/parser).ImportDeclaration을 찾습니다. "이 파일은 utils.js가 필요하구나"를 알아냅니다.// 번들러가 만드는 최종 코드 구조 (단순화)
(function(modules) {
var installedModules = {};
function require(moduleId) {
if(installedModules[moduleId]) return installedModules[moduleId].exports;
var module = installedModules[moduleId] = {
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, require);
return module.exports;
}
return require(0); // Entry Point 실행
})([
/* 0 */
function(module, exports, require) {
var utils = require(1);
utils.hello();
},
/* 1 */
function(module, exports, require) {
exports.hello = function() { console.log("Hello"); };
}
]);
이 코드를 보면 결국 번들러가 하는 일이 "모든 모듈을 함수로 감싸고, 커스텀 require 시스템을 만드는 것"이라는 게 이해가 됩니다.
A: 역할이 다릅니다. Babel은 번역기(Transpiler)입니다. 최신 JS 문법을 구형 문법으로 바꿔줍니다. 하지만 파일들을 합쳐주지는 못합니다. Webpack은 통합 관리자(Bundler)입니다. 파일들을 합치고 최적화합니다. Webpack 빌드 과정 중에 Babel을 로더(babel-loader)로 실행하여 "변환 후 합치기"를 수행하는 것이 표준입니다.
A: Code Splitting은 번들러가 파일을 쪼개는 행위(기술) 자체를 말하고, Lazy Loading은 쪼개진 파일을 실제로 필요할 때 가져오는 전략(패턴)을 말합니다. React의 Suspense와 lazy가 대표적인 Lazy Loading 구현체입니다.
A: HMR 런타임(클라이언트 스크립트)이 개발 서버와 웹소켓으로 연결되어 있습니다. 파일이 변경되면 서버가 변경된 모듈 정보(JSON)를 보냅니다. 런타임은 이를 받아 DOM을 업데이트하거나(CSS), React의 경우 컴포넌트 인스턴스의 상태를 유지한 채 render 함수만 교체하는 방식으로 갱신합니다.
A: Loader는 파일 단위로 작동합니다(CSS를 JS로 변환, TS를 JS로 변환). Plugin은 번들 전체 과정에 개입합니다(번들 결과물 압축, HTML 파일 생성, 환경 변수 주입). Loader는 변환기, Plugin은 후처리기라고 보면 됩니다.
<script> 의존성 지옥 -> 번들러(Webpack)의 등장.Back in 2010, frontend development was the Wild West.
We managed dependencies manually.
If main.js depended on jquery.js, you had to manually place the <script src="jquery.js"> tag before <script src="main.js">.
As Single Page Applications (SPAs) grew, we encountered:
var ended up on window. Collisions were frequent and hard to debug.Enter the Bundler. A tool to stitch all your messy files into one neat bundle.js.
A bundler like Webpack is essentially a graph compiler.
src/index.js). The bundler parses this file first.import App from './App', goes to App.js, finds its imports, and recursively builds a map of the entire application.dist folder.Bundlers natively understand only JavaScript and JSON. But web apps need CSS, Images, Fonts, Typescript. Loaders transform these files into modules that JavaScript can understand.
style-loader: Injects CSS strings into the DOM <style> tag.file-loader: Emits the file to the output directory and returns its URL.ts-loader: Compiles Typescript to JavaScript.Plugins hook into the build lifecycle to perform tasks that loaders can't.
HtmlWebpackPlugin: Generates an index.html file that automatically includes your hashed bundle (<script src="bundle.a1b2c3.js">).DefinePlugin: Replaces variables like process.env.NODE_ENV with string literals ("production") at build time."Tree Shaking" creates a mental image of shaking a tree so dead leaves fall off. In programming, it means removing unused code from the final bundle.
It relies on the static structure of ES Modules (ESM).
Because import and export must be at the top level (unlike require, which can be dynamic), the bundler can statically analyze which functions are imported and which are not.
Example:
// math.js
export function add(a, b) { return a+b; }
export function sub(a, b) { return a-b; }
// main.js
import { add } from './math';
console.log(add(1, 2));
Webpack detects that sub is never imported.
In the final output, the code for sub is completely removed.
This significantly reduces the file size of libraries like lodash (if used correctly like lodash-es).
HMR is magic. You edit a CSS file, and the color changes instantly without a page reload. How does this work?
Type: update, Module: styles.cssFor React components, HMR goes even deeper. When you edit a component's JSX, the bundler doesn't just reload the module - it uses React Fast Refresh (previously React Hot Loader). This technology does something incredible: it preserves the component state while swapping out the render function.
// Before: Counter.jsx
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
// After editing (count is still 5 after HMR!)
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>;
}
The counter doesn't reset to zero. The state (count = 5) persists.
This is possible because React Fast Refresh creates a "proxy" component that wraps your real component. When the code changes, it swaps the inner implementation while keeping the outer wrapper (and its state) intact.
Without HMR, every code change would reset your app to the initial state - imagine having to log in again, navigate to the page, fill out a form, and reproduce a bug every time you change a single line of CSS. HMR eliminates that pain.
Webpack bundles the entire app before serving it. For a large app with 5,000 modules, this initial build can take 60 seconds. Vite flips the script.
Vite serves files as Native ES Modules.
When the browser requests GET /src/main.js, Vite serves it.
If main.js has import App from './App.js', the browser fires a request for GET /src/App.js.
Vite transforms (compiles TS to JS) files on demand.
It doesn't bundle the whole app. It only compiles what you are looking at.
This makes the dev server start time O(1) (Constant), regardless of app size.
Vite uses Esbuild (written in Go) for pre-bundling dependencies. Go is compiled to machine code and utilizes parallelism, making it 10-100x faster than JS-based bundlers like Webpack (Node.js).
Vercel (creators of Next.js) introduced Turbopack, claiming to be the successor to Webpack. Written in Rust, it features:
Rust is a systems programming language with zero-cost abstractions and no garbage collector. Unlike JavaScript (which runs in V8 and has GC pauses), Rust compiles directly to native machine code. For a bundler processing millions of AST nodes per second, this performance difference is exponential.
Traditional bundlers cache at the file level. If you change one line in App.tsx, the entire file gets reprocessed.
Turbopack caches at the function call level.
It breaks the build pipeline into tiny incremental units (functions). Each function's output is cached based on its inputs (like a memoization system).
// Before (Webpack): Change one line → Reprocess entire file
// After (Turbopack): Change one line → Only reprocess affected functions
function transformTSX(code) { ... } // Cached separately
function minify(code) { ... } // Cached separately
function bundleChunk(modules) { ... } // Cached separately
When you edit a file, Turbopack checks "which functions need to be re-executed?" and skips the rest. This is why it can achieve 700x faster updates than Webpack in large apps.
The cache survives server restarts.
Turbopack stores the cache on disk (similar to how node_modules/.cache works).
On the next dev server start, it reads the cache and skips work that hasn't changed since yesterday.
This means the second startup is almost instant, even for massive codebases.
Like Vite, Turbopack only bundles code that is needed for the current page you're viewing.
If you have 50 routes in your app, but you're only testing /dashboard, Turbopack doesn't touch the other 49 routes.
This keeps memory usage low and speeds up iteration.
The combination of Rust's raw speed, function-level caching, and lazy bundling makes Turbopack feel like developing with a handful of files, even when you have thousands.
After years of working with bundlers, I've seen (and made) these mistakes repeatedly. Here are the most painful ones and how to fix them.
sideEffects in package.jsonIf you publish a library and don't specify "sideEffects": false, bundlers will be too conservative with tree shaking.
// package.json
{
"name": "my-library",
"sideEffects": false // "Go ahead, shake aggressively!"
}
If your library has CSS imports or modifies global objects (like polyfills), you must list them:
{
"sideEffects": ["*.css", "./src/polyfill.js"]
}
Without this, users of your library will bundle all your code, even if they only import one function.
This is a classic beginner mistake:
// BAD: Bundles all of lodash (~70KB)
import _ from 'lodash';
_.debounce(fn, 300);
// GOOD: Only bundles debounce (~2KB)
import debounce from 'lodash-es/debounce';
debounce(fn, 300);
Even better, use lodash-es (the ESM version) instead of lodash (CommonJS). Only ESM supports tree shaking.
If you have a large app with multiple pages, loading everything upfront kills your First Contentful Paint (FCP).
// BAD: All routes loaded on initial page load
import Home from './Home';
import Dashboard from './Dashboard';
import Settings from './Settings';
// GOOD: Each route loaded only when accessed
const Home = lazy(() => import('./Home'));
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
In Next.js, this is automatic. In React Router, you need to wrap routes with Suspense.
This single change can reduce your initial bundle from 500KB to 100KB, drastically improving load time.
Barrel exports (index.js files that re-export everything) are convenient but dangerous:
// components/index.js (BAD)
export { Button } from './Button';
export { Modal } from './Modal';
export { Table } from './Table';
// app.js
import { Button } from './components'; // Oops! Also bundles Modal and Table
Because bundlers see import from './components/index.js', they process the entire index file.
Even if you only use Button, the bundler might include references to Modal and Table (depending on your bundler's optimization level).
Solution: Import directly from the source file:
import { Button } from './components/Button';
Or use tools like babel-plugin-import to automatically transform barrel imports.
A: Webpack is primarily for Applications. It handles assets, heavy splitting, and complex HMR well. Rollup is primarily for Libraries. It produces cleaner, flatter code and follows ESM standards strictly. Vite uses Rollup internally for its production build command.
node_modules so huge?A: Node's dependency resolution algorithm is nested. If A depends on B(v1) and C depends on B(v2), both versions of B are installed. This recursive structure leads to massive duplication and file counts. pnpm solves this using Hard Links and a content-addressable store.
A: It's the process of splitting code into smaller bundles.
react, lodash into vendor.js so they can be cached long-term (since they change rarely).home.js, dashboard.js. The user only downloads the code for the page they are on, improving First Contentful Paint (FCP).dependencies and devDependencies?A: dependencies are required for the application to run in production (e.g., react, axios). devDependencies are only needed during development or build (e.g., webpack, jest, eslint). The bundler needs devDependencies to build the app, but the final bundle (static files) doesn't "use" them at runtime.
A: A source map is a JSON file that maps locations in the minified/transpiled code back to the original source code. It allows you to debug Hello.tsx in Chrome DevTools even though the browser is actually running something like main.a1b2.js. It works by storing a list of mappings: MinifiedLine:Column -> OriginalFile:Line:Column.