🧩 Design System8. Pipeline & Distribution02 — 번들링과 tree-shaking: tsup으로 ESM·CJS·DTS dual output

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.jsonexports map과 sideEffects 필드로 진입점과 부작용을 선언하며, "use client" directive는 banner로 보존한다 — 이 셋이 갖춰지면 사용자는 Next.js·Vite·Jest·Webpack 어디서든 같은 패키지를 require/import 할 수 있다.


Why — 왜 두 포맷을 동시에

JavaScript 생태계는 ESM과 CJS의 이중 우주에 살고 있다.

환경기본 모듈 시스템비고
Next.js App RouterESMRSC는 "use client" directive 필수
Next.js Pages Router혼합next.config.mjs로 transpilePackages 가능
ViteESMnode_modules도 ESM 우선
Jest (기본)CJSESM 지원은 실험적
Storybook 8 (Vite)ESMwebpack 빌더 선택 시 혼합
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+/번들러가 우선 사용
sideEffectstree-shaking 허가 — false면 import 안 한 것은 모두 제거
filesnpm 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 강제 + exports map에서 import·require 한쪽만 사용하도록 사용자 가이드.

  • 함정 2 — "use client" 사라짐: Storybook 8의 Vite 빌더가 directive를 주석으로 처리. RSC 환경에서 hydration mismatch. 대응: esbuild-plugin-preserve-directives 또는 tsup 0.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"(레거시)에서 exports map을 무시. 대응: 사용자에게 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)Go2020단일 바이너리, 10~100x babel 속도
SWC (강동윤, Vercel)Rust2019TypeScript 완전 지원, Next.js의 내장

디자인 시스템 도메인에서 esbuild가 번들러 라이브러리(tsup, unbuild) 표준이 된 이유:

  1. 단순한 플러그인 API — JavaScript로 작성 가능
  2. CommonJS·ESM dual output 1순위 — 라이브러리 빌드 표준
  3. 트랜스파일 + 번들이 한 단계 — Rollup처럼 분리되지 않음

SWC는 애플리케이션 영역(Next.js)에서 표준이 됐다. 라이브러리 vs 앱의 경계가 두 컴파일러를 갈랐다.

반전: 2024년 unbuild(Nuxt 팀)와 tsup은 둘 다 Rolldown(Rust 기반 Rollup 대체) 마이그레이션을 검토 중이다. Rolldown은 Vite 7의 기본 빌더가 될 예정 — Rust 진영이 결국 라이브러리 빌드까지 가져갈 가능성이 있다.


요약

  • tsup으로 format: ['esm', 'cjs'] + dts: true + splitting: true를 켜라.
  • exports map으로 진입점을 명시하라 — main·module은 레거시 호환용.
  • **sideEffects: ["**/*.css"]**로 CSS만 보존, 나머지는 tree-shake 가능하게.
  • "use client" directiveesbuild-plugin-preserve-directives로 보존.
  • 안티패턴: barrel의 부작용, default export, 모듈 로드 시 함수 호출.