모듈은 어떻게 번들이 되나 #1 — 브라우저는 원래 import를 몰랐다
브라우저는 원래 import를 몰랐다. 수백 개 파일에 쓰는 import가 하나로 합쳐져 브라우저에서 돌기까지 무슨 일이 일어나는지 — 모듈 시스템(격리·정적)부터 ESM 3단계 로딩, 그리고 번들러 런타임까지 밑바닥부터 따라갔다.
브라우저는 원래
import를 몰랐다. 그런데 지금 우리는 수백 개의 파일에import를 쓰고, 그게 하나로 합쳐져 브라우저에서 돈다. 그 사이에 무슨 일이 일어나는 걸까.
왜 밑바닥부터 다시 봤나
번들러를 쓰다 보면 이상한 일들을 만난다. “개발 서버에선 잘 되는데 빌드하면 깨진다”, “안 쓰는 코드가 어떤 건 사라지고 어떤 건 안 사라진다”, “does not provide an export named ... 같은 유령 에러”. 이걸 그때그때 검색해서 넘길 수도 있지만, 매번 원리를 모른 채 주문처럼 rm -rf node_modules/.vite 를 외우고 있었다.
그래서 정리하기로 했다. 그런데 번들러의 거의 모든 영리한 동작 — 합치기, 안 쓰는 코드 제거, “이 파일만 다시 로드” — 이 전부 모듈 시스템이 코드를 정적으로 분석할 수 있다는 성질에 기대고 있었다. 번들러를 이해하려면 모듈부터 이해해야 했다. 그래서 밑바닥, 즉 “모듈이 대체 왜 필요했나”부터 시작한다.
1. 모듈이 없던 시절 — 전역 스코프 문제
모듈이 없을 때 브라우저는 <script> 태그를 여러 개 나열해 파일을 불러왔다. 문제는 이 스크립트들이 하나의 전역 스코프를 공유한다는 것이었다.
<script src="a.js"></script> <!-- var total = ... -->
<script src="b.js"></script> <!-- var total = ... ← a.js의 total과 충돌 -->
한 파일의 total 과 다른 파일의 total 이 조용히 덮어써지고, 로드 순서가 곧 의존성이 됐다. b.js 가 a.js 의 무언가를 쓰려면 반드시 a.js 를 먼저 로드해야 했고, 그 순서를 사람이 손으로 관리했다. 파일이 늘어날수록 이건 지옥이 된다.
2. 모듈 시스템 — 격리와 계약
이 문제를 풀려고 나온 게 모듈 시스템이다. 모듈 시스템은 “코드를 파일 단위로 나누고, 각 파일이 무엇을 내보내고(export) 무엇을 가져올지(import)를 명시하는 방식”이다.
핵심은 두 가지다.
- 격리된 스코프 — 각 파일이 자기만의 스코프를 갖는다. 파일 안에서 선언한 건 밖으로 새지 않는다. (물론 공유하는 전역 스코프는 여전히 존재하고 접근도 가능하다. 다만 이제 기본이 격리고, 공유는 선택이다.)
- 명시적 계약 — 파일 간의 관계가
import/export로 코드에 드러난다.
그리고 이 “드러남”이 중요하다. 뒤에서 볼 번들러의 모든 자동화가 바로 이 명시적 관계를 재료로 삼기 때문이다. (참고로 번들러는 이렇게 나뉜 수백 개의 모듈을, 브라우저가 받기 좋은 소수의 파일로 합치고 다듬는 도구다. 6절에서 다룬다.)
자바스크립트에는 이 문제를 푼 모듈 시스템이 둘 있다. Node가 먼저 만든 CommonJS(CJS) 와, 나중에 언어 표준이 된 ES Modules(ESM) 다. 둘의 차이가 이 글의 나머지를 거의 다 결정한다.
3. ESM vs CommonJS — 정적이냐 동적이냐
표면적으로는 문법 차이처럼 보인다.
// CommonJS
const { total } = require('./a.js'); // 가져오기 — 함수 안, if 안 어디든 가능
// ES Modules
import { total } from './a.js'; // 가져오기 — 반드시 파일 최상위
하지만 진짜 차이는 require 가 런타임에 실행되는 평범한 함수 호출이라는 데 있다. require(조건 ? './a' : './b') 처럼, 무엇을 불러올지 코드를 실행해봐야 아는 경우가 가능하다. 반면 import 는 최상위에서만, 정적으로만 쓸 수 있다. 코드를 실행하지 않고도 파싱만으로 “이 파일이 뭘 가져오고 내보내는지”를 전부 알 수 있다.
이 한 줄이 모든 걸 가른다.
ESM은 정적(static) — 실행 전에 의존 관계가 확정된다. CommonJS는 동적(dynamic) — 실행해봐야 안다.
tree-shaking, 코드 분할, 순환 의존 처리가 전부 이 “실행 전에 알 수 있음”에서 나온다.
여기에 하나가 더 따라온다 — 가져온 값이 어떻게 평가되는가다.
| ESM | CommonJS | |
|---|---|---|
| 분석 시점 | 정적 — 파싱만으로 그래프 확정 | 동적 — 런타임 실행 필요 |
| 가져온 값 | live binding — 원본과 같은 메모리를 가리킴 | 값 복사 — 가져오는 순간의 스냅샷 |
| 로딩 | 비동기 | 동기 |
live binding 을 눈으로 보면 이렇다.
// counter.js (ESM)
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 ← 값이 살아있다 (CommonJS라면 여전히 0)
ESM은 값을 복사한 게 아니라 원본과 같은 메모리 상자를 가리킨다. 그래서 원본이 바꾸면 가져간 쪽에도 보인다. CommonJS는 가져오는 순간의 스냅샷을 복사하므로, 이후 원본이 바뀌어도 그 스냅샷을 계속 본다. (정확히는 복사되는 게 바인딩이라, 객체를 내보내면 그 객체의 프로퍼티 변경은 양쪽에 보인다. “복사되는 건 바인딩이지 객체 자체가 아니다”가 정확한 문장이다.)
참고로 2026년 현재, 오랫동안 골칫거리였던 CJS↔ESM 상호운용은 많이 풀렸다. Node.js 20.19 / 22.12부터
require(esm)이 플래그 없이 안정화되어, CommonJS 코드에서도 ESM을 동기적으로 불러올 수 있다. 새 코드는 사실상 ESM이 표준이지만, CommonJS는 여전히 곳곳에 남아 있다.
4. ESM은 어떻게 로드되나 — 3단계
“정적이라 실행 전에 안다”가 실제로 어떻게 동작하는지는, ESM의 로딩이 세 단계로 분리되어 있다는 데서 나온다. CommonJS의 require 가 “찾고·실행하고·값 돌려주기”를 한 호출에 뭉쳐서 하는 것과 대조된다.
1단계 — Construction (비동기).
진입 파일을 파싱해 import 를 찾고, 그 파일들을 가져와 또 파싱한다. 진입 파일에서 의존성 방향으로 내려가며 모듈 그래프를 만든다. 각 파일은 모듈 레코드가 되고, 모듈 맵이 URL별로 하나씩 캐시한다. 아직 코드는 한 줄도 실행되지 않는다.
여기서 “비동기”는 파일을 가져오는 I/O가 메인 스레드를 막지 않는다는 뜻이다. 브라우저에서 모듈을 네트워크로 받는 건 시간이 걸리는데, 이걸 동기로 하면 다운로드 동안 페이지가 멈춘다. 그래서 여러 모듈을 병렬로, 논블로킹으로 받는다. (CommonJS의 require 가 동기인 것과 대비된다. Node는 로컬 디스크라 빨라서 괜찮았지만, 브라우저 ESM은 네트워크라 블로킹하면 안 됐다.)
2단계 — Instantiation (동기).
모든 export 를 위한 메모리 상자를 할당한다. 아직 값은 안 채운다. 그리고 import 와 export 를 같은 상자로 연결한다 — 이게 live binding이다.
3단계 — Evaluation (동기). 이제 최상위 코드를 실행해 상자를 실제 값으로 채운다. 모듈 맵 덕분에 각 모듈은 정확히 한 번만 실행된다.
이걸 파면서 헷갈렸던 것 두 개를 짚고 간다.
“leaf부터 위로 처리한다”는 건 진입 파일의 반대다. 발견(1단계)은 진입 파일 → 의존성으로 내려가고, 처리(2·3단계)는 가장 깊은 말단(leaf)부터 진입 파일 쪽으로 올라온다. 왜냐하면 main 을 실행하는 순간 그가 쓰는 utils 의 값이 이미 준비돼 있어야 하기 때문이다. “쓰이는 걸 먼저 만들어 놓고, 쓰는 쪽으로 올라온다.”
왜 2·3단계를 나눴나. 값을 채우기(3단계) 전에 연결(2단계)을 전부 끝내기 때문에, 두 모듈이 서로를 import하는 순환 의존도 동작한다. 상자는 이미 이어져 있고, 값만 나중에 채워지면 되니까. (CommonJS는 이 분리가 없어서, 순환 시 아직 안 채워진 undefined 가 그대로 새어 나간다.)
5. import 는 “값 가져오기”가 아니라 “모듈 실행”이다
가장 자주 오해하는 지점. import { one } from './m' 은 m 에서 one 만 떼오는 게 아니다. 모듈 m 전체가 1회 실행되고(3단계 Evaluation), 그중 one 이라는 상자를 참조할 뿐이다.
그래서 내보낼 값이 하나도 없어도 import에는 의미가 있다 — 부작용(side effect)을 위한 import다.
import './analytics.js'; // 가져오는 값 0개. 하지만 모듈이 1회 실행된다 (초기화·등록 등)
import './styles.css'; // 번들러 환경: CSS 주입이라는 부작용
import 'core-js/stable'; // 폴리필: 전역을 건드리는 부작용
여기서 “부작용”은 모듈이 실행될 때 값을 내보내는 것 말고 바깥 세상에 일으키는 변화다 — 전역 수정, DOM 조작, 리스너 등록, 네트워크 요청 같은 것. 모듈의 top-level 코드에 이런 게 있으면 그 모듈이 평가될 때 부작용이 일어난다.
그리고 4절에서 말한 “각 모듈은 정확히 한 번만 실행된다”가 여기서 중요해진다. 만약 여러 번 평가되면 폴리필이 두 번 적용되거나 리스너가 중복 등록되는 문제가 난다. 모듈 맵이 “이미 평가함”을 기억해서 재실행을 막기 때문에 — 여러 곳에서 같은 모듈을 import해도 — top-level은 딱 한 번 돌고, 부작용도 한 번만 일어난다.
6. 번들러 — 그래프를 하나로
이제 모듈을 이해했으니 번들러의 일이 선명해진다. 번들러는 진입 파일에서 시작해 import 를 따라가며 모듈 그래프 전체를 구성하고(1단계 Construction과 같은 발상), 그걸 브라우저가 받기 좋은 소수의 파일 — 청크(chunk) — 로 합친다.
그런데 여기서 한 가지 오해를 풀어야 한다. 나는 처음에 이 3단계 로딩이 “번들링 전에 수행되는 단계”인 줄 알았다. 아니다. 모듈을 다루는 경로는 둘로 갈린다.
- 경로 A — 런타임 로딩(번들 없음). 브라우저가
<script type="module">로 ESM을 직접 로드하거나 Node가.mjs를 실행할 때. 이때 JS 엔진이 3단계(construction→instantiation→evaluation)를 실제로 수행한다. - 경로 B — 빌드 타임 번들링. Vite·webpack이 배포 전에 모듈을 합칠 때. 번들러는 코드를 실행하지 않고
import/export를 정적으로 파싱해 그래프를 그리고, 하나의 파일로 합친다.
즉 3단계 로딩과 번들링은 순서(전/후) 관계가 아니라 모듈을 처리하는 서로 다른 두 방식이다. 하나는 런타임 엔진이, 하나는 빌드 도구가 한다. 그리고 둘은 중복이 아니라 둘 중 하나만 일어난다 — 번들하면 런타임엔 3단계가 없다.
그럼 왜 굳이 빌드 타임에 그래프를 그려 합치나? 두 가지 이득 때문이다.
- 네트워크 왕복 제거. 경로 A로 수백 개 파일을 로드하면 요청이 폭포수처럼 이어진다 —
a를 받아야a의 import를 알고, 그걸 또 받고… 빌드 타임에 미리 합쳐두면 요청이 몇 개로 준다. - 빌드 타임에만 가능한 최적화. tree-shaking, 압축, 코드 분할은 전체 그래프를 한 번에 놓고 봐야 할 수 있다. 런타임 엔진은 이런 걸 안 한다.
사실 원래 번들러는 선택이 아니라 필수였다. 2010년대 초 브라우저는 모듈 문법 자체를 이해 못 했고(native ESM은 2017~2018에야 등장), 여러 모듈을 브라우저가 아는 하나의 평범한 스크립트로 변환해야 실행이 됐다. 그게 번들러의 출발점이다.
7. 번들의 정체 — 번들러 런타임
그럼 여러 모듈을 합쳐 파일 하나가 되면, 원래 모듈 시스템이 해주던 일 — 스코프 격리, 서로 부르기, 한 번만 실행 — 은 누가 하나? 브라우저는 그 파일을 그냥 평범한 스크립트로 볼 뿐인데.
답은 번들러 런타임이다. 번들러가 번들 안에 같이 끼워 넣는 작은 로더 코드로, 브라우저의 네이티브 모듈 로딩 대신 번들 스스로 require 를 흉내낸다. 아주 단순화하면 이렇게 생겼다.
(function (modules) {
// ── 번들러 런타임 ──
var cache = {};
function __webpack_require__(id) {
if (cache[id]) return cache[id].exports; // 이미 실행됨 → 캐시 반환 (한 번만 실행)
var module = cache[id] = { exports: {} }; // ★ 실행 '전에' 캐시에 먼저 등록
modules[id](module, module.exports, __webpack_require__); // 모듈 함수 실행
return module.exports;
}
return __webpack_require__(0); // 엔트리 부팅
})({
// ── 모듈 레지스트리: 각 모듈이 '함수'로 감싸짐 ──
0: function (module, exports, require) { // main.js
const { add } = require(1);
console.log(add(1, 2));
},
1: function (module, exports, require) { // math.js
exports.add = (a, b) => a + b;
},
});
이 20줄 안에 앞에서 배운 게 전부 재현돼 있다.
| 개념 | 번들 런타임에서의 실체 |
|---|---|
| 스코프 격리 (1절) | 모듈을 함수로 감싸서 — 함수 스코프 = 모듈 스코프 |
| 한 번만 실행 (4절 모듈 맵) | cache[id] 확인 후 재사용 |
| 순환 → partial exports | cache[id] = { exports:{} } 를 실행 전에 등록 |
세 번째가 재밌다. 순환일 때 b 가 a 를 require하는데 a 가 아직 실행 중이면, 아직 안 채워진 빈 exports 객체를 돌려받는다. 4절에서 “CommonJS는 순환 시 undefined가 샌다”고 한 게, 바로 이 cache[id] = { exports: {} } 를 실행 전에 등록하는 한 줄 때문이다. 개념이 아니라 코드 한 줄로 보인다.
번들러마다 스타일은 조금 다르다. webpack은 위처럼 모듈마다 함수로 래핑하고 __webpack_require__ 로 잇는다. Rollup·Vite(prod)는 가능하면 scope hoisting — 함수로 안 감싸고 모듈들을 하나의 스코프로 병합하되 이름 충돌은 변수 리네임으로 푼다. 그래서 번들이 더 작고 깔끔하다.
정리
한 문장으로 줄이면 이렇다.
모듈은 전역 스코프 문제를 풀려고 나왔고, ESM의 “정적” 성질이 뒤에 올 모든 자동화의 재료이며, 번들러는 그 그래프를 빌드 타임에 하나로 합친 결과물(번들러 런타임)로 브라우저에 도착시킨다.
- 모듈은 격리 + 명시적 계약. 전역 스코프 충돌을 푼 것.
- ESM은 정적(실행 전에 그래프 확정), CommonJS는 동적. live binding vs 값 복사.
- ESM 로딩은 3단계 — construction(비동기 그래프)/instantiation(연결)/evaluation(1회 실행).
import는 값이 아니라 모듈 실행. 그래서 부작용 import가 성립한다.- 번들러는 그래프를 빌드 타임에 합치고, 그 결과물 안엔
require를 흉내내는 번들러 런타임이 들어간다.