번들러는 무엇을 하는가
파일 여러 개를 하나로 합치는 일이 왜 라이브러리에서는 유독 어려운지, CommonJS와 ESM이 어디서 갈라지는지를 직접 빌드하고 확인한다.
최근 필자는 사내에서 생산성을 높이는 도구를 여럿 만들었다. 처음에는 한 프로젝트의 폴더에 불과했던 코드가 두 번째, 세 번째 프로젝트에서도 필요해지자 공통 코드를 라이브러리로 분리하는 일이 잦아졌다. 그러자 한 가지 의문이 생겼다. 코드를 프로젝트 밖으로 떼어내기만 하면 라이브러리가 되는 것일까? 다른 프로젝트에서도 문제없이 사용하려면 파일을 어떻게 묶고 어떤 형식으로 내보내야 할까? 이 질문에 답하려면 먼저 번들링을 이해해야 했다.
라이브러리의 번들링은 산출물을 만드는 일로 끝나지 않는다. 만들어진 파일은 곧바로 브라우저에 전달되는 대신 라이브러리를 사용하는 프로젝트의 도구를 거친다. Next.js의 webpack, Vite의 Rollup, Node의 모듈 로더가 저마다의 규칙으로 라이브러리의 dist/를 해석한다.
시리즈의 첫 편에서는 번들러가 하는 가장 기본적인 일부터 살펴본다. 파일을 하나로 합치면 무엇이 달라지는지, CommonJS와 ESM은 어디서 갈라지는지, 그 차이가 라이브러리를 사용하는 프로젝트에 어떤 영향을 주는지 직접 빌드하며 확인한다. 이 글의 모든 수치는 예시 저장소 library-bundling-examples의 episodes/01-bundler-and-formats/에서 그대로 재현할 수 있다.
번들러가 하는 일
번들러는 모듈 그래프를 파일 하나 또는 몇 개로 합치는 도구다. main.ts에서 시작해 import 문이 참조하는 파일을 모두 찾은 다음, 파일의 코드를 하나의 스코프에 배치한다. 이 과정에서 TypeScript를 JavaScript로 변환하고, 사용하지 않는 코드를 제거하며(tree-shaking), 필요하면 코드도 압축한다.
이 시리즈에서는 tsdown을 사용한다. tsdown은 Rust로 만든 번들러 Rolldown을 기반으로 라이브러리 빌드에 필요한 기본 설정을 제공한다. 설정 파일 하나로 ESM과 CJS 형식의 JavaScript 파일 및 타입 선언 파일을 함께 생성할 수 있다. 다른 번들러를 사용해도 이 글에서 설명하는 개념은 동일하게 적용된다.
파일을 그대로 내보내면
파일을 합치지 않으면 어떤 일이 생기는지부터 확인해보자. formatDate, slugify, clamp, debounce, deepEqual 함수로 구성된 유틸리티 라이브러리를 만들었다. 각 함수는 별도 파일에 있고, index.ts는 다섯 함수를 모두 다시 내보내는 배럴 파일이다.
// lib/src/index.ts
export { formatDate } from "./format-date";
export { slugify } from "./slugify";
export { clamp } from "./clamp";
export { debounce } from "./debounce";
export { deepEqual, DeepEqualError } from "./deep-equal";이 라이브러리를 번들링하지 않고 파일 단위로 내보낸 뒤, 브라우저에서 <script type="module">로 직접 불러온다.
// app-unbundled/main.js
import { slugify } from "../lib/dist-unbundled/index.js";
document.getElementById("out").textContent = slugify(
"Hello, Library Bundling!"
);브라우저가 이 코드를 실행하기까지 보내는 요청의 수를 측정했다. 정적 서버를 실행하고, 브라우저처럼 import 문을 분석하는 크롤러로 main.js부터 의존 파일을 추적했다.
$ node app-unbundled/serve.mjs --crawl
┌───────┬──────────────────────────────────────┬───────┐
│ depth │ path │ bytes │
├───────┼──────────────────────────────────────┼───────┤
│ 0 │ '/app-unbundled/main.js' │ 345 │
│ 1 │ '/lib/dist-unbundled/index.js' │ 302 │
│ 2 │ '/lib/dist-unbundled/format-date.js' │ 427 │
│ 2 │ '/lib/dist-unbundled/slugify.js' │ 293 │
│ 2 │ '/lib/dist-unbundled/clamp.js' │ 262 │
│ 2 │ '/lib/dist-unbundled/debounce.js' │ 335 │
│ 2 │ '/lib/dist-unbundled/deep-equal.js' │ 804 │
└───────┴──────────────────────────────────────┴───────┘
unbundled: 7 requests, 2768 bytes, 3 round-trips
bundled: 2 requests, 2290 bytes, 2 round-tripsdepth는 브라우저가 해당 파일을 요청하기까지 거치는 의존 단계의 수다. 브라우저는 main.js를 파싱한 뒤 index.js를 요청하고, index.js를 파싱한 뒤 나머지 다섯 파일을 요청한다. 같은 단계의 요청은 병렬로 처리되지만 의존 파일을 찾는 과정은 순차적이다. 단계가 하나 늘 때마다 추가 네트워크 왕복이 발생하며, 출력의 round-trips가 그 횟수를 나타낸다.
요청이 진행되는 순서를 데모로 확인해보자.
함수 하나를 쓰기 위해 브라우저가 보내는 요청 (세로선 = 단계 경계)
왼쪽은 파일을 그대로 내보낸 경우다. 세로 점선은 각 요청 단계를 구분하며, 다섯 개의 유틸리티 파일은 세 번째 단계에서 요청된다. 오른쪽은 라이브러리를 파일 하나로 합친 경우다. 앱 파일과 라이브러리 파일을 요청하는 두 단계로 끝난다.
| 요청 수 | 전송 바이트 | 요청 단계 | |
|---|---|---|---|
| 번들 없음 | 7 | 2,768 | 3 |
| 번들 1개 | 2 | 2,290 | 2 |
파일이 다섯 개뿐인 라이브러리에서도 이 차이가 발생한다. 실제 라이브러리는 수십에서 수백 개의 파일로 구성되며 의존 관계도 여러 단계로 이어진다. 네트워크 왕복에 수십 ms가 걸리는 모바일 환경에서는 요청 단계가 많을수록 로딩 시간도 길어진다. 번들러는 라이브러리 내부의 여러 요청 단계를 하나로 줄인다.
앱 번들과 라이브러리 번들
그렇다면 라이브러리도 앱처럼 파일을 최대한 합치고 압축하면 되는 것 아닌가? 앱 번들과 라이브러리 번들은 목적이 다르다.
앱 번들은 브라우저에서 직접 실행할 최종 산출물이다. 필요한 의존성을 포함하고 코드를 최대한 압축하며, 브라우저가 실행할 수 있다면 내부 모듈 구조를 유지할 필요가 없다.
라이브러리 번들은 소비자 프로젝트의 빌드 입력이다. 소비자 프로젝트의 번들러가 라이브러리 산출물을 읽고 tree-shaking과 압축을 수행한다. 따라서 라이브러리 번들에는 앱 번들과 다른 기준이 필요하다.
- 의존성은 번들에 포함하지 않는다. 소비자 프로젝트가 같은 패키지를 이미 사용한다면 중복 코드가 포함될 수 있다.
- 압축하지 않는다. 소비자 프로젝트의 번들러가 최종 앱을 빌드할 때 압축하며, 압축하지 않은 코드는 분석과 디버깅에도 유리하다.
- 산출물의 구조는 소비자 프로젝트와의 호환성을 결정한다. 모듈 형식, 파일 경로, package.json 필드에 따라 소비자 프로젝트가 라이브러리를 불러오는 방식이 달라진다.
마지막 항목이 이 시리즈의 주제다. 산출물의 구조는 배포 후 공개 API의 일부가 된다. 서브패스를 제거하거나 CJS 지원을 중단하거나 타입 선언 파일의 위치를 옮기면 기존 소비자 프로젝트와의 호환성이 깨진다. 앱 내부에서는 자유롭게 바꿀 수 있는 구조도 라이브러리에서는 메이저 버전 변경이 될 수 있다.
그중 가장 기본적인 결정이 모듈 형식이다.
CommonJS
CommonJS는 Node.js가 처음부터 사용한 모듈 시스템이다. require로 모듈을 가져오고 module.exports로 값을 내보낸다.
// CommonJS
const { slugify } = require("./slugify");
module.exports = { slugify, clamp };핵심은 require가 함수라는 점이다. 코드를 실행해야 반환값을 알 수 있고, 실행 시점에 인자를 바꿀 수도 있다.
const name = process.env.MODE === "dev" ? "./dev" : "./prod";
const mod = require(name); // 실행하기 전엔 어느 파일인지 모른다이 방식은 정적 분석을 어렵게 한다. 코드를 실행하지 않고 어떤 모듈이 어떤 이름을 내보내는지 확정할 수 없다. module.exports는 객체이므로 실행 중에 속성을 추가하거나 객체 전체를 다른 값으로 바꿀 수 있다. 따라서 번들러는 CJS 모듈의 내보내기를 파싱만으로 정확하게 분석하기 어렵다.
tsdown이 생성한 CJS 파일의 끝부분에서 이 방식을 확인할 수 있다.
// lib/dist/index.cjs (끝부분)
exports.DeepEqualError = DeepEqualError;
exports.clamp = clamp;
exports.debounce = debounce;
exports.deepEqual = deepEqual;
exports.formatDate = formatDate;
exports.slugify = slugify;exports 객체에 속성을 하나씩 대입한다. 이 대입은 파일이 실행될 때 이루어진다.
ESM
ESM은 ECMAScript 표준 모듈 시스템이다. import로 모듈을 가져오고 export로 값을 내보낸다. 브라우저가 네이티브로 지원하는 유일한 모듈 시스템이며 Node.js도 지원한다.
// ESM
import { slugify } from "./slugify.js";
export { slugify, clamp };ESM의 import는 함수가 아니라 문법이다. 정적 import 문은 파일 최상위에만 올 수 있고, 경로는 문자열 리터럴이어야 하며, 조건문 안에 넣을 수 없다. 이 제약 덕분에 파일을 실행하지 않고 파싱만으로 모듈 그래프와 각 모듈이 내보내는 이름을 파악할 수 있다. 이 특성을 정적 구조라고 부른다.
ESM으로 내보낸 값은 복사본이 아니라 라이브 바인딩이다. 값을 내보낸 모듈에서 변수를 변경하면 가져온 모듈에서도 변경된 값이 보인다. 반면 CJS에서 구조 분해로 가져온 원시값은 값을 가져온 시점의 복사본이다.
같은 소스를 ESM으로 빌드하면 파일 끝에 다음 export 문이 생성된다.
// lib/dist/index.js (끝부분)
export { DeepEqualError, clamp, debounce, deepEqual, formatDate, slugify };번들러는 파일을 실행하지 않고도 이 export 문을 파싱해 여섯 개의 내보내기를 파악할 수 있다.
두 포맷의 차이를 한 표로 정리하면 이렇다.
| CommonJS | ESM | |
|---|---|---|
| 문법 | require() / module.exports | import / export |
| 해석 시점 | 실행 중 | 파싱 시점 (정적) |
| 로딩 | 동기 | 비동기 (브라우저), 정적 분석 후 로드 |
| 내보낸 값 | module.exports 객체 | 라이브 바인딩 |
| 브라우저 | 지원 안 함 | 네이티브 지원 |
| 번들러의 내보내기 분석 | 실행해야 알 수 있음 | 파싱만으로 가능 |
함수 하나만 가져왔을 때
라이브러리 사용자는 대개 내보낸 기능 중 일부만 사용한다. 다섯 함수 중 slugify만 가져오는 앱을 만들고, 라이브러리의 ESM 파일을 사용한 경우와 CJS 파일을 사용한 경우를 각각 빌드했다.
// app-esm/src/main.ts, app-cjs/src/main.ts
import { slugify } from "@ep1/lib";
console.log(slugify("Hello, Library Bundling!"));$ node ../../scripts/size.mjs app-esm/dist/main.js app-cjs/dist/main.js
┌──────────────────────┬──────┬──────┐
│ file │ raw │ gzip │
├──────────────────────┼──────┼──────┤
│ app-esm/dist/main.js │ 364 │ 312 │
│ app-cjs/dist/main.js │ 2450 │ 1314 │
└──────────────────────┴──────┴──────┘같은 코드와 번들러, 설정을 사용했지만 파일 크기는 6.7배 차이가 난다. 각 결과 파일에 포함된 코드를 확인해보자.
함수 5개짜리 라이브러리에서 slugify 하나만 가져온 소비자 번들
위는 ESM 파일을 사용한 앱이다. 번들러는 import { slugify }를 파싱해 나머지 네 함수가 사용되지 않는다고 판단하고 결과 파일에서 제거한다. 아래는 CJS 파일을 사용한 앱이다. require의 반환값은 실행해야 확정할 수 있으므로 번들러는 CJS 모듈 전체를 결과 파일에 포함한다.
실제 CJS 쪽 산출물은 이렇게 생겼다.
// app-cjs/dist/main.js (일부)
var __commonJSMin = (cb, mod) => () => (
mod || (cb((mod = { exports: {} }).exports, mod), (cb = null)), mod.exports
);
var import_dist = /* @__PURE__ */ __commonJSMin((exports) => {
function formatDate(date, withTime = false) {
/* … */
}
function slugify(input) {
/* … */
}
function clamp(value, min, max) {
/* … */
}
function debounce(fn, wait) {
/* … */
}
var DeepEqualError = class extends Error {
/* … */
};
function deepEqual(a, b, seen) {
/* … */
}
exports.DeepEqualError = DeepEqualError;
exports.clamp = clamp;
// …
})();
console.log((0, import_dist.slugify)("Hello, Library Bundling!"));__commonJSMin이라는 런타임 헬퍼가 추가되고, 그 안에 여섯 개의 내보내기가 모두 포함된다. 번들러는 CJS 모듈을 실행 시점에 exports 객체를 구성하는 함수로 변환하지만, 사용하지 않는 내보내기를 안전하게 제거하지는 못한다.
ESM은 tree-shaking에 필요한 정적 구조를 제공하지만 제거 결과까지 보장하지는 않는다. 배럴 파일에 부작용이 있거나 sideEffects 설정이 잘못되면 ESM이어도 사용하지 않는 코드가 제거되지 않는다. 이 내용은 2편에서 다룬다. 여기서 확인할 점은 하나다. 라이브러리가 CJS 파일만 배포하면 소비자 번들러는 CJS 파일을 입력으로 받는다. 그래서 소비자가 사용하지 않는 내보내기만 골라 tree-shaking하기 어렵다.
ESM과 CJS 함께 제공하기
그렇다면 ESM만 제공하면 되지 않을까? 신규 라이브러리라면 대체로 그렇다.
이전에는 ESM만 제공하면 CommonJS 소비자가 해당 패키지를 사용할 수 없었다. Node 20.19와 22.12부터는 CommonJS 코드에서도 require로 ESM 패키지를 불러올 수 있다. Node 20은 2026년 4월에 지원이 종료되었고, 현재 지원되는 LTS 버전인 22와 24는 모두 별도 설정 없이 이 기능을 지원한다.
그러나 다음 조건에서는 여전히 CJS 파일을 함께 제공해야 할 수 있다.
최상위 await. ESM 그래프에 최상위 await가 있으면 require는 ERR_REQUIRE_ASYNC_MODULE 오류를 발생시킨다. 라이브러리 자체뿐 아니라 의존성에 최상위 await가 하나라도 포함되면 같은 오류가 발생한다. 의존성이 마이너 업데이트에서 최상위 await를 추가하면 require를 사용하던 소비자 코드가 실행되지 않는다. 라이브러리 작성자가 직접 통제하기 어려운 조건이다.
타입 검사. 런타임 지원 여부와 tsc의 타입 검사 결과는 다를 수 있다. 소비자가 module: node16을 사용하면서 CommonJS 파일에서 ESM 전용 패키지를 require하면 TS1471 오류가 발생한다. nodenext나 node20으로 변경하면 통과하지만, 소비자 프로젝트의 설정을 변경해야 한다.
테스트 러너. Jest의 ESM 지원은 2026년에도 실험 단계다. ESM을 사용하려면 --experimental-vm-modules 플래그와 transformIgnorePatterns 설정을 관리해야 한다. Vitest를 사용하는 프로젝트에는 해당하지 않는다.
셋 중 하나라도 해당한다면 두 모듈 형식을 함께 제공한다. tsdown에서는 설정 한 줄로 구성할 수 있다.
// lib/tsdown.config.ts
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
platform: "neutral",
dts: true,
});빌드하면 dist/index.js(ESM), dist/index.cjs(CJS)와 각 형식에 대응하는 타입 선언 파일 index.d.ts, index.d.cts가 생성된다. 소비자 환경이 불러올 파일은 package.json 설정에 따라 결정된다.
{
"name": "@ep1/lib",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}소비자가 import 문을 사용하면 import 조건이, require를 사용하면 require 조건이 선택된다. 각 조건은 타입 선언 파일과 JavaScript 파일의 경로를 지정한다. exports 필드의 동작 방식과 조건 순서가 중요한 이유는 2편에서 다룬다.
두 모듈 형식의 중복 인스턴스
두 모듈 형식을 함께 제공하면 새로운 문제가 생긴다. 한 프로세스 안에서 같은 패키지를 import와 require로 각각 불러오면 어떻게 될까?
// hazard/run.mjs
import { createRequire } from "node:module";
import { DeepEqualError as EsmError } from "@ep1/lib";
const require = createRequire(import.meta.url);
const { DeepEqualError: CjsError } = require("@ep1/lib");
const err = new CjsError("from cjs");
console.log("same class? ", EsmError === CjsError);
console.log("instanceof esm? ", err instanceof EsmError);
console.log("instanceof cjs? ", err instanceof CjsError);$ node hazard/run.mjs
same class? false
instanceof esm? false
instanceof cjs? trueimport는 dist/index.js를 읽고 require는 dist/index.cjs를 읽는다. Node는 경로가 다른 두 파일을 별도 모듈로 처리하고 각각 평가한다. 그 결과 DeepEqualError 클래스가 두 번 생성되며, 한 모듈에서 만든 에러는 다른 모듈의 instanceof 검사에서 false를 반환한다. 이것을 dual package hazard라고 부른다.
애플리케이션 코드에서 두 방식을 직접 함께 사용하지 않아도 발생할 수 있다. 의존성 트리의 한 패키지가 CJS에서 이 라이브러리를 require하면, 애플리케이션이 ESM으로 불러온 모듈과 의존성이 CJS로 불러온 모듈이 동시에 존재한다. 모듈 스코프의 싱글턴인 캐시, 레지스트리, 전역 설정도 각각 생성되며 instanceof와 Symbol 비교가 예상과 다른 결과를 반환할 수 있다.
대응은 네 가지다.
-
파일 바깥에 함께 쓰는 값을 만들지 않는다. slugify처럼 입력을 받아 결과만 돌려주는 함수만 내보내면, 파일이 두 번 실행되어도 결과가 같다. 다만 클래스도 파일이 실행될 때 만들어지는 값이다. 소비자가 instanceof로 검사할 클래스를 내보낸다면 이 방법만으로는 부족하다.
-
함께 쓰는 값은 CJS 파일 한 곳에서만 만든다. ESM 파일은 설정 객체나 클래스를 새로 만들지 않고, CJS 파일을 가져와 그대로 다시 내보낸다. import로 불러오든 require로 불러오든 결국 같은 CJS 파일이 만든 값을 사용하게 된다.
// dist/index.js (ESM) import lib from "./index.cjs"; export const { DeepEqualError, clamp } = lib; -
싱글턴을 globalThis에 저장해 두 모듈이 같은 인스턴스를 사용하도록 한다. 다른 방법을 적용할 수 없을 때 고려한다.
-
ESM만 제공한다. 중복 인스턴스가 생길 가능성을 제거한다.
정리
- 번들러는 모듈 그래프를 합쳐 라이브러리 내부의 요청 단계를 하나로 줄인다. 파일 다섯 개로 구성된 라이브러리를 그대로 내보냈을 때는 요청 7번과 3단계가 필요했다.
- 라이브러리 산출물은 소비자 프로젝트의 빌드 도구와 모듈 로더가 읽는다. 따라서 의존성을 포함하지 않고 압축하지 않으며, 소비 방식에 맞는 구조로 배포해야 한다.
- CommonJS의 내보내기는 실행해야 확정할 수 있지만 ESM의 내보내기는 파싱만으로 파악할 수 있다. 이 차이는 함수 하나만 가져온 앱의 결과 파일에서 364 B와 2,450 B의 크기 차이로 나타났다.
- Node 20.19와 22.12부터 CommonJS 코드도 ESM 패키지를 require할 수 있어 ESM 전용 배포를 기본으로 선택할 수 있다. 최상위 await,
module: node16소비자, Jest 지원이 필요하다면 두 모듈 형식을 제공하고 중복 인스턴스를 방지해야 한다.
이 글의 결과를 재현하려면 예시 저장소 루트에서 다음을 실행하면 된다. 편별 실행 방법과 확인 포인트는 episodes/01-bundler-and-formats의 README에 정리했다.
pnpm install && pnpm --filter "@ep1/*" build
node episodes/01-bundler-and-formats/app-unbundled/serve.mjs --crawl
node scripts/size.mjs episodes/01-bundler-and-formats/app-{esm,cjs}/dist/main.js
node episodes/01-bundler-and-formats/hazard/run.mjs다음 편에서는 이 산출물의 위치와 특성을 소비자 프로젝트에 전달하는 방법을 다룬다. package.json의 exports, sideEffects, dependencies 세 필드가 소비자 번들에 미치는 영향을 같은 방식으로 측정한다.