번들러의 존재 이유
이 글에서는 번들러가 왜 등장했는지, 의존성 그래프란 무엇인지, Vite는 왜 다른 접근을 택했는지 정리해보았습니다.
이 글에서는 번들러가 왜 등장했는지, 의존성 그래프란 무엇인지, Vite는 왜 다른 접근을 택했는지 정리해보았습니다.
국내 빅테크 면접에서 번들러에 대한 질문을 받은 적이 있습니다. 저는 "여러 모듈을 묶어주는 도구"라고 답변했습니다. 하지만 말하면서도 "그걸 물어보려고 질문한 걸까?", "왜 이걸 물어봤을까?" 의문이 들었습니다.
면접 후 여러 문서를 참고해 학습했고, 번들러가 단순한 "묶음 도구"가 아니라는 걸 알게 됐습니다. 이 글에서는 번들러가 왜 등장했는지, 의존성 그래프란 무엇인지, Vite는 왜 다른 접근을 택했는지 정리해보겠습니다.
번들러는 "여러 모듈을 묶는 도구"가 맞습니다. 여기서 모듈은 브라우저가 읽을 수 있는 JavaScript 파일이어야 합니다.
현대 웹개발은 대부분 TypeScript로 진행됩니다. 그런데 TypeScript는 브라우저가 읽을 수 없습니다. TypeScript를 JavaScript로 바꿔줄 무언가가 필요합니다. 그게 트랜스파일러입니다.
가끔 "TypeScript 컴파일러"라는 말이 나오기도 합니다. 그래서 트랜스파일러와 컴파일러를 정확히 구분하는 것이 번들러를 이해하기 위한 첫 걸음입니다.
컴파일러와 트랜스파일러는 둘 다 "변환"하는 도구입니다. 결정적인 차이는 "레벨"입니다. 레벨은 변환 전후 언어가 1:1 대응이 가능한지로 구분합니다.
TypeScript는 JavaScript에 타입만 입힌 언어입니다. 타입을 제거하면 그대로 JavaScript가 됩니다. 이게 "1:1 매칭"입니다. 이렇게 1:1 매칭이 가능하면 "같은 레벨"로 취급합니다. 같은 레벨끼리의 변환에 사용되는 것이 트랜스파일러입니다.
반면 C 언어에서 어셈블리 언어로의 변환은 1:1 매칭이 되지 않습니다. 이렇게 1:1 매칭이 불가능하면 "다른 레벨"로 취급합니다. 다른 레벨끼리의 변환에 사용되는 것이 컴파일러입니다.
| 구분 | 변환 방향 | 예시 |
|---|---|---|
| 컴파일러 | 다른 레벨 | C → 어셈블리 |
| 트랜스파일러 | 같은 레벨 | TS → JS, ES2024 → ES5 |
"모듈"이라는 개념이 없던 시절에는 모든 <script> 태그를 순서대로 직접 나열해야 했습니다. 순서가 하나라도 틀리면 에러가 발생합니다. 의존성을 파악하려면 매번 코드를 직접 확인해야 했습니다. 게다가 모든 변수가 전역(window)에 노출되어 이름 충돌 위험도 있었습니다.
2009년, Node.js가 등장하면서 서버에서도 JavaScript를 사용하게 됐습니다. 서버에도 모듈 시스템이 필요했고, 그래서 CommonJS가 만들어졌습니다. require()로 모듈을 가져오고 module.exports로 내보내는 방식입니다.
CommonJS는 동기적으로 작동합니다. 하나의 모듈이 로드될 때까지 다음 모듈을 기다려야 했습니다. 또한 require()는 런타임에 실행되는 함수입니다. 즉 코드를 실제로 실행해봐야 어떤 모듈을 가져오는지 알 수 있습니다.
CJS는 기본적으로 서버용으로 작성된 시스템이라 로컬에 존재하는 파일을 읽는 데 많은 시간이 걸리지 않아 동기적인 게 문제가 되지 않았습니다. 하지만 브라우저는 파일을 네트워크 요청으로 받습니다. 이게 동기적으로 작동하면 매우 느리다는 단점이 존재했습니다. 또한 CJS는 런타임 실행이라 미리 분석이 불가능했습니다.
이렇게 브라우저용 모듈 시스템이 필요해져서 나온 게 ES Module입니다. ESM은 import/export 문법을 강제합니다. 반드시 최상단에, 정적 문자열로 작성해야 합니다. 이후 번들러에서도 코드를 실행하지 않고 "어떤 모듈을 쓰는지" 알 수 있게 됐습니다. 이게 번들러에게 중요한 이유는 다음 섹션에서 설명하겠습니다.
ESM에서는 정적 분석이 가능하니 import와 export를 따라가며 어떤 모듈이 어디에 의존되어 있는지 파악할 수 있는 "의존성 그래프"를 그릴 수 있게 됐습니다.
번들러는 entry 포인트에서 시작하여 의존성 그래프를 구축합니다. 그 다음 위상 정렬로 올바른 순서를 결정합니다. 위상 정렬은 "사용되기 전에 정의되도록" 순서를 정하는 것입니다. 마지막으로 n개의 파일로 병합합니다.
단순히 파일을 이어붙이면 안 됩니다. import './utils' 같은 경로가 병합 후에는 의미가 없어지기 때문입니다. 번들러는 이 경로들을 내부 참조로 교체합니다.
지금까지 번들러가 왜 필요한지 알아봤습니다. 그런데 번들링에는 시간이 걸립니다. 프로젝트가 커질수록 빌드 시간도 길어집니다. 코드 한 줄 고치고 결과를 보려면 수십 초를 기다려야 하는 상황이 생깁니다.
Vite는 이 문제를 "개발 모드와 프로덕션 모드를 분리"하는 전략으로 해결합니다.
개발 모드에서 Vite는 번들링을 하지 않습니다. 어떻게 가능할까요? 이제 브라우저가 ESM을 네이티브로 지원하기 때문입니다. 브라우저가 import문을 만나면 해당 파일을 서버에 직접 요청합니다. Vite는 요청받은 파일만 그때그때 변환해서 응답합니다.
브라우저: import App from './App.tsx' 발견
↓
Vite 서버: App.tsx를 JavaScript로 변환해서 응답
↓
브라우저: App.tsx 안의 import도 같은 방식으로 요청
전체 프로젝트를 미리 번들링할 필요가 없으니 서버 시작이 거의 즉시입니다.
그런데 프로덕션에서도 이렇게 하면 안 됩니다. 모듈이 500개면 브라우저가 500번 네트워크 요청을 보내야 합니다. 또한 A 파일을 받아야 A 안의 import를 알 수 있으니 요청이 순차적으로 일어납니다. 이건 느립니다.
그래서 프로덕션에서는 여전히 번들링이 필요합니다. Vite는 Rollup을 사용해 최적화된 번들을 만듭니다.
Vite는 개발 모드에서 esbuild를, 프로덕션에서 Rollup을 사용합니다.
| esbuild | Rollup | |
|---|---|---|
| 속도 | 매우 빠름 (Go 언어, 병렬 처리) | 상대적으로 느림 |
| 최적화 품질 | 기본적 | 정교함 (트리 쉐이킹 우수) |
| 용도 | 개발 (속도 중요) | 프로덕션 (품질 중요) |
개발할 때는 빠른 피드백이 중요하고, 배포할 때는 최적화된 결과물이 중요합니다. Vite는 상황에 맞는 도구를 선택합니다.
[문제] 브라우저에 모듈 시스템이 없음
↓
[해결] CommonJS → ESM 표준화
↓
[새 문제] 수백 개 파일을 어떻게 전달할까?
↓
[해결] 번들러 (의존성 분석 + 병합)
↓
[새 문제] 번들링이 느림 (개발 피드백 저하)
↓
[해결] Vite: 개발은 ESM 네이티브, 프로덕션만 번들링