02 — 번들링과 tree-shaking: tsup으로 ESM·CJS·DTS dual output
이 문서가 답하는 질문: React 컴포넌트 라이브러리를 어떻게 묶어야 ESM·CJS·DTS를 동시에 제공하면서, RSC의
"use client"를 보존하고, 사용자 번들에서 tree-shaking이 안전하게 동작하는가. 한 줄 답 (Pyramid Top): tsup으로 **format: ['esm', 'cjs']+dts: true+splitting: true**를 켜고,package.json의exportsmap과sideEffects필드로 진입점과 부작용을 선언하며,"use client"directive는 banner로 보존한다 — 이 셋이 갖춰지면 사용자는 Next.js·Vite·Jest·Webpack 어디서든 같은 패키지를 require/import 할 수 있다.
Why — 왜 두 포맷을 동시에
JavaScript 생태계는 ESM과 CJS의 이중 우주에 살고 있다.
| 환경 | 기본 모듈 시스템 | 비고 |
|---|---|---|
| Next.js App Router | ESM | RSC는 "use client" directive 필수 |
| Next.js Pages Router | 혼합 | next.config.mjs로 transpilePackages 가능 |
| Vite | ESM | node_modules도 ESM 우선 |
| Jest (기본) | CJS | ESM 지원은 실험적 |
| Storybook 8 (Vite) | ESM | webpack 빌더 선택 시 혼합 |
| Bun | 둘 다 | 무관 |
라이브러리가 한쪽만 지원하면 다른 쪽에서 못 쓴다. 특히 Jest로 컴포넌트를 테스트하는 사용자는 CJS가 필요하다.
| 풀려는 문제 | 이전 해법 | 한계 |
|---|---|---|
| ESM·CJS 둘 다 | Rollup 직접 설정 | 학습 곡선·DTS 별도 |
| DTS 함께 | tsc 별도 실행 → 합치기 | 빌드 단계 분리, 캐시 안 됨 |
| RSC 지원 | 수동 banner | 컴포넌트마다 수동 |
| ”use client” 보존 | 빌드 후 grep으로 추가 | 깨지기 쉬움 |
tsup(2021, esbuild 기반)이 등장하면서 위 4개를 한 설정에서 해결할 수 있게 됐다.
How — tsup의 동작 원리
tsup 내부는 (1) esbuild가 ESM·CJS 코드 생성, (2) tsc 또는 @microsoft/api-extractor 류가 DTS 생성, (3) banner 옵션으로 directive 삽입의 3단 처리다.
What — 실제 설정
packages/react/tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
'Button/index': 'src/Button/index.ts',
'Dialog/index': 'src/Dialog/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
splitting: true, // ESM에서 동적 chunk 분리
treeshake: true,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
// "use client" directive 보존
banner: ({ format }) => {
// ESM·CJS 모두에 적용
return { js: '"use client";' };
},
// 또는 더 안전하게: directive를 가진 파일만
esbuildOptions(options) {
options.banner = {
js: '',
};
},
});"use client" 보존의 정확한 방법
위 banner는 모든 출력 파일에 "use client"를 박는다 — 안전하지만 서버 전용 유틸리티까지 클라이언트로 강제된다. 더 정확한 방법은 directive를 가진 파일만 보존하는 esbuild 플러그인이다.
// tsup.config.ts
import { defineConfig } from 'tsup';
import { preserveDirectivesPlugin } from 'esbuild-plugin-preserve-directives';
export default defineConfig({
entry: ['src/**/*.{ts,tsx}'],
format: ['esm', 'cjs'],
dts: true,
splitting: true,
esbuildPlugins: [
preserveDirectivesPlugin({
directives: ['use client', 'use server'],
include: /\.(js|ts|jsx|tsx)$/,
exclude: /node_modules/,
}),
],
});소스에서:
// src/Button/Button.tsx
"use client";
import { forwardRef } from 'react';
export const Button = forwardRef<HTMLButtonElement>(...);빌드 후 dist/Button/index.mjs의 첫 줄이 "use client";로 시작한다. Next.js RSC가 이 directive를 보고 클라이언트 컴포넌트 경계를 인식한다.
package.json의 exports map
{
"name": "@org/react",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./Button": {
"types": "./dist/Button/index.d.ts",
"import": "./dist/Button/index.mjs",
"require": "./dist/Button/index.cjs"
},
"./styles.css": "./dist/styles.css"
},
"files": ["dist", "README.md"],
"sideEffects": ["**/*.css"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
}핵심 필드:
| 필드 | 의미 |
|---|---|
type: "module" | .js 확장자를 ESM으로 해석 |
main | 레거시 (Node.js의 기본 CJS 진입점) |
module | 레거시 ESM 진입점 (번들러 힌트) |
exports | 진짜 진입점 매핑 — Node 12+/번들러가 우선 사용 |
sideEffects | tree-shaking 허가 — false면 import 안 한 것은 모두 제거 |
files | npm publish 시 포함할 파일 (dist만) |
sideEffects 의 정확한 의미
"sideEffects": false // 모든 파일이 부작용 없음 → 적극 tree-shake
"sideEffects": ["**/*.css"] // CSS만 부작용 있음 (실행 시 스타일 주입) → CSS는 보존
"sideEffects": ["./src/polyfills.ts", "**/*.css"] // 특정 파일 + CSS함정: sideEffects: false로 설정한 라이브러리에 CSS import가 있으면, Webpack/Vite가 CSS를 제거해 스타일이 사라진다. CSS-in-JS가 아닌 진짜 CSS 파일(예: Panda CSS의 생성된 styles.css)을 import 한다면 반드시 ["**/*.css"]로 예외 처리.
tree-shaking이 깨지는 패턴 — code-level
안티패턴 ①: barrel re-export의 부작용
// src/index.ts (BAD)
export * from './Button';
export * from './Dialog';
import './styles.css'; // 부작용 — 모든 import를 강제→ 사용자가 import { Button } from '@org/react'만 해도 Dialog도 따라온다 (CSS import의 부작용으로 인해 전체 모듈이 평가됨).
// src/index.ts (GOOD)
export { Button } from './Button';
export { Dialog } from './Dialog';
// styles.css는 별도 import — 사용자가 명시적으로사용자 측:
import { Button } from '@org/react';
import '@org/react/styles.css'; // 사용자가 명시안티패턴 ②: default export
// BAD
export default { Button, Dialog, Tooltip };→ default export는 객체 전체가 한 단위. tree-shaking 불가.
// GOOD
export { Button } from './Button';
export { Dialog } from './Dialog';
export { Tooltip } from './Tooltip';안티패턴 ③: 클래스 초기화 시 부작용
// BAD
export const tokens = computeTokens(); // 모듈 로드 시 즉시 실행→ esbuild는 computeTokens()의 side-effect를 알 수 없어 보존한다.
// GOOD
export function getTokens() {
return computeTokens();
}
// 또는 PURE annotation
export const tokens = /*#__PURE__*/ computeTokens();/*#__PURE__*/ 주석은 esbuild/terser에게 이 호출은 부작용 없음을 알린다.
What-if — 잘못 쓰면 어떻게 깨지는가
-
함정 1 — Dual package hazard: 사용자 앱에서 ESM과 CJS 두 버전이 동시에 로드되어 같은 React context가 두 인스턴스가 됨 → “Invalid hook call” 에러. 대응:
peerDependencies로 React 강제 +exportsmap에서import·require한쪽만 사용하도록 사용자 가이드. -
함정 2 —
"use client"사라짐: Storybook 8의 Vite 빌더가 directive를 주석으로 처리. RSC 환경에서 hydration mismatch. 대응:esbuild-plugin-preserve-directives또는tsup0.7+의banner옵션. 빌드 후head -1 dist/Button/index.mjs로 검증. -
함정 3 —
sideEffects: false로 CSS 제거됨: Panda CSS의styles.css를 import 했는데 빌드 후 사라짐. 대응:"sideEffects": ["**/*.css"]. -
함정 4 — DTS resolution 깨짐: TypeScript의
moduleResolution: "node"(레거시)에서exportsmap을 무시. 대응: 사용자에게moduleResolution: "bundler"또는"node16"사용 권장. 우리 패키지의tsconfig도 마찬가지. -
함정 5 — bundlesize 폭증:
external: ['react']를 빠뜨림 → React가 사용자 번들에 두 번 포함. 대응: 모든 peer dep를external에 명시. CI에서size-limit또는bundlewatch로 검증.
# .github/workflows/ci.yml (일부)
- name: Bundle size check
uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}.size-limit.json:
[
{
"name": "@org/react/Button",
"path": "packages/react/dist/Button/index.mjs",
"limit": "5 KB"
}
]Insight — esbuild가 SWC를 이긴 이유
2021년 시점에 컴파일러 신성은 두 명이었다:
| 도구 | 작성 언어 | 등장 | 강점 |
|---|---|---|---|
| esbuild (Evan Wallace, Figma) | Go | 2020 | 단일 바이너리, 10~100x babel 속도 |
| SWC (강동윤, Vercel) | Rust | 2019 | TypeScript 완전 지원, Next.js의 내장 |
디자인 시스템 도메인에서 esbuild가 번들러 라이브러리(tsup, unbuild) 표준이 된 이유:
- 단순한 플러그인 API — JavaScript로 작성 가능
- CommonJS·ESM dual output 1순위 — 라이브러리 빌드 표준
- 트랜스파일 + 번들이 한 단계 — Rollup처럼 분리되지 않음
SWC는 애플리케이션 영역(Next.js)에서 표준이 됐다. 라이브러리 vs 앱의 경계가 두 컴파일러를 갈랐다.
반전: 2024년 unbuild(Nuxt 팀)와 tsup은 둘 다 Rolldown(Rust 기반 Rollup 대체) 마이그레이션을 검토 중이다. Rolldown은 Vite 7의 기본 빌더가 될 예정 — Rust 진영이 결국 라이브러리 빌드까지 가져갈 가능성이 있다.
요약
- tsup으로
format: ['esm', 'cjs']+dts: true+splitting: true를 켜라. exportsmap으로 진입점을 명시하라 —main·module은 레거시 호환용.- **
sideEffects: ["**/*.css"]**로 CSS만 보존, 나머지는 tree-shake 가능하게. "use client"directive는esbuild-plugin-preserve-directives로 보존.- 안티패턴: barrel의 부작용, default export, 모듈 로드 시 함수 호출.