Rollup으로 라이브러리 번들링: 여러 출력 포맷, 플러그인, 외부 의존성, Tree Shaking

이 글의 핵심

Rollup 설정 파일 작성부터 ESM·CJS 동시 출력, 필수 플러그인, peerDependencies를 번들에서 빼는 법, Tree Shaking이 동작하는 조건을 React 라이브러리 예제로 다룹니다.

이 글의 핵심

Rollup으로 npm 라이브러리를 번들링하는 방법을 정리한 글입니다. 여러 출력 포맷, 필수 플러그인, 외부 의존성 처리, Tree Shaking이 실제로 동작하는 조건, TypeScript 선언 파일 생성까지 React 컴포넌트 라이브러리 예제로 다룹니다.

실무에서 마주치는 문제들

Webpack으로 만든 라이브러리 번들이 이상해요

Webpack은 애플리케이션을 위해 설계되어, 기본 출력에 자체 모듈 로더 런타임(__webpack_require__)이 포함됩니다. 라이브러리를 소비하는 쪽도 번들러를 쓰므로 이 런타임은 중복이고, 무엇보다 결과물이 ES 모듈이 아니라서 소비자 쪽 번들러가 Tree Shaking을 하기 어렵습니다. Rollup은 모듈들을 하나의 스코프로 이어 붙이는(scope hoisting) 방식이라 런타임 코드가 거의 없고 ESM을 그대로 출력합니다.

Tree Shaking이 안 돼요

라이브러리에서 함수 하나만 import했는데 전체가 번들에 들어가는 경우입니다. Tree Shaking은 번들러가 “쓰지 않는 export는 지워도 안전하다”고 판단할 수 있을 때만 동작합니다. 뒤에서 그 조건을 다룹니다.

여러 포맷이 필요해요

최신 번들러용 ESM, Node.js의 require()용 CJS, <script> 태그용 UMD를 한 번의 빌드로 만들어야 합니다. Rollup은 output을 배열로 주면 같은 입력에서 여러 포맷을 동시에 출력합니다.


Rollup이 라이브러리 번들러로 쓰이는 이유

Rollup은 ES 모듈 기반 번들러로, 특히 라이브러리 배포에 많이 쓰입니다. 주요 장점:

  • Tree Shaking: ESM의 정적 import/export 분석으로 쓰지 않는 코드 제거
  • ESM 출력: 결과물을 ES 모듈 그대로 내보냄
  • Multiple Formats: ESM, CJS, UMD, IIFE
  • 작은 런타임: scope hoisting으로 모듈 래퍼 코드가 거의 없음
  • Plugins: Vite와 공유하는 플러그인 생태계

반대로 Rollup이 약한 영역도 있습니다. 개발 서버, HMR, CSS·이미지 같은 에셋 처리는 기본 제공이 아니라 플러그인을 조합해야 합니다. 그래서 애플리케이션은 Vite(내부적으로 Rollup 계열 번들러 사용)나 Webpack으로, 라이브러리는 Rollup이나 Rollup을 감싼 도구(tsup은 esbuild 기반, Vite의 library mode는 Rollup 기반)로 만드는 것이 일반적인 분업입니다.


설치와 최소 rollup.config.js

설치

npm install -D rollup

rollup.config.js

export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
  },
};

package.json

{
  "scripts": {
    "build": "rollup -c"
  }
}

rollup -c는 현재 폴더의 rollup.config.js(또는 .mjs, .ts)를 읽습니다. Rollup 3 이후 처음 설정할 때 자주 만나는 에러가 Rollup failed to load config because it has ESM syntax입니다. 설정 파일에 export default를 썼는데 package.json에 "type": "module"이 없어서 Node가 CommonJS로 해석하려 하기 때문입니다. 설정 파일 이름을 rollup.config.mjs로 바꾸거나, "type": "module"을 추가하거나, rollup -c --bundleConfigAsCjs 플래그를 쓰면 해결됩니다.


ESM·CJS·UMD를 한 번에 출력하기

export default {
  input: 'src/index.js',
  output: [
    {
      file: 'dist/bundle.esm.js',
      format: 'esm',
    },
    {
      file: 'dist/bundle.cjs.js',
      format: 'cjs',
    },
    {
      file: 'dist/bundle.umd.js',
      format: 'umd',
      name: 'MyLib',
    },
  ],
};

UMD 포맷에는 name이 필수입니다. 번들러 없이 <script>로 불러왔을 때 window.MyLib에 라이브러리가 붙기 때문입니다. 빠뜨리면 You must supply "output.name" for UMD bundles 에러가 납니다. 요즘은 CDN 사용자도 esm.sh나 <script type="module">로 ESM을 직접 불러오는 경우가 많아, UMD를 꼭 제공해야 하는지부터 따져 보는 것이 좋습니다.

package.json의 main·module·exports 연결

{
  "main": "dist/bundle.cjs.js",
  "module": "dist/bundle.esm.js",
  "browser": "dist/bundle.umd.js",
  "types": "dist/index.d.ts"
}

main은 Node의 require()가, module은 Webpack·Rollup 같은 번들러가 우선 읽는 필드입니다. 여기서 주의할 것은 browser 필드입니다. 이 필드의 원래 의미는 “브라우저 환경용 대체 파일”이라서, Webpack은 브라우저 타깃일 때 module보다 browser를 먼저 고릅니다. UMD 파일을 browser에 넣으면 소비자의 번들러가 ESM 대신 UMD를 가져가 Tree Shaking이 사라집니다. UMD는 unpkg나 jsdelivr 필드로 CDN에만 알리는 편이 안전합니다.

현재 Node.js와 번들러가 가장 먼저 보는 것은 exports 필드입니다. "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.esm.js", "require": "./dist/index.cjs.js" } }처럼 조건별 진입점을 명시하면 환경마다 정확한 파일이 선택되고, exports에 없는 내부 경로는 import할 수 없게 막힙니다. types 조건은 항상 첫 번째에 두어야 TypeScript가 올바르게 찾습니다.


자주 쓰는 공식 플러그인

@rollup/plugin-node-resolve

npm install -D @rollup/plugin-node-resolve
import resolve from '@rollup/plugin-node-resolve';
export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
  },
  plugins: [resolve()],
};

Rollup은 기본적으로 import 'lodash-es' 같은 bare import를 node_modules에서 찾지 않습니다. 이 플러그인 없이 빌드하면 Unresolved dependencies ... (imported by src/index.js) 경고가 나고 해당 import는 외부 모듈로 남습니다. 라이브러리라면 대부분의 의존성을 일부러 외부로 남기는 것이 맞으므로(아래 external 절 참고), resolve 플러그인은 “번들에 포함하기로 한 의존성”을 찾기 위해 씁니다.

@rollup/plugin-commonjs

npm install -D @rollup/plugin-commonjs
import commonjs from '@rollup/plugin-commonjs';
export default {
  plugins: [commonjs()],
};

Rollup은 ES 모듈만 이해하므로, module.exports로 작성된 CJS 패키지를 번들에 포함하려면 이 플러그인이 ESM으로 변환해야 합니다. 플러그인 순서는 resolve() 다음에 commonjs()가 일반적입니다. CJS 코드는 정적 분석이 어려워서 변환된 부분은 Tree Shaking이 잘 되지 않는다는 점도 알아 두십시오.

@rollup/plugin-typescript

npm install -D @rollup/plugin-typescript typescript tslib
import typescript from '@rollup/plugin-typescript';
export default {
  input: 'src/index.ts',
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
  },
  plugins: [typescript()],
};

tslib은 importHelpers가 켜졌을 때 TypeScript가 생성하는 헬퍼 함수(__awaiter 등)를 파일마다 복사하지 않고 한 곳에서 가져오기 위한 패키지로, 이 플러그인이 peer 의존성으로 요구합니다. 빌드가 느리다면 타입 체크를 tsc --noEmit으로 분리하고 트랜스파일은 esbuild·SWC 기반 플러그인에 맡기는 구성도 많이 씁니다.


peer 의존성을 external로 빼기

export default {
  input: 'src/index.js',
  external: ['react', 'react-dom'],
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
    globals: {
      react: 'React',
      'react-dom': 'ReactDOM',
    },
  },
};

external에 넣은 모듈은 번들에 포함되지 않고 import React from 'react' 문장이 그대로 출력됩니다. React를 라이브러리 번들에 포함하면 소비하는 앱에 React가 두 벌 존재하게 되고, 훅을 쓰는 순간 Invalid hook call. Hooks can only be called inside of the body of a function component 에러가 납니다. React는 내부 상태를 모듈 단위로 들고 있어서 사본이 두 개면 서로를 인식하지 못하기 때문입니다.

globals는 UMD·IIFE 포맷에서만 의미가 있으며, “외부 모듈 react는 전역 변수 React로 존재한다”고 알려 줍니다. ESM 출력에는 영향이 없습니다.

제가 라이브러리를 만들며 가장 자주 겪은 실수는 external: ['react']만 적고 react/jsx-runtime을 빠뜨리는 것입니다. 새 JSX 변환은 react/jsx-runtime을 import하는데, 문자열 배열은 정확히 일치하는 이름만 외부로 처리하므로 JSX 런타임이 번들에 들어가 버립니다. external: (id) => /^react(-dom)?($|\/)/.test(id)처럼 함수로 하위 경로까지 잡거나, package.json의 peerDependencies와 dependencies 목록을 읽어 자동으로 external을 만드는 방식이 안전합니다.


소비자 쪽 Tree Shaking이 동작하는 조건

// src/utils.js
export function add(a, b) {
  return a + b;
}
export function subtract(a, b) {
  return a - b;
}
// src/index.js
import { add } from './utils';
console.log(add(1, 2));
// dist/bundle.js에는 add만 포함됨 (subtract는 제거)

Rollup이 subtract를 지울 수 있는 이유는 ESM의 import/export가 정적이기 때문입니다. require()는 런타임에 조건부로 호출될 수 있지만 import { add }는 파일을 실행하지 않고도 무엇을 쓰는지 알 수 있습니다.

라이브러리 작성자에게 더 중요한 것은 소비자 쪽의 Tree Shaking입니다. 라이브러리가 ESM으로 배포되어도, 모듈 최상위에서 부작용(전역 객체 수정, CSS import, 폴리필 설치)이 있으면 소비자의 번들러는 그 파일을 지워도 안전한지 판단할 수 없어 전부 남깁니다. package.json에 "sideEffects": false(CSS를 import한다면 ["*.css"])를 선언하면 번들러가 사용되지 않는 모듈을 과감히 제거합니다. 반대로 실제 부작용이 있는 파일에 false를 선언하면 그 코드가 사라져, “개발 모드에서는 되는데 프로덕션 빌드에서만 스타일이 빠지는” 버그가 생깁니다.

클래스의 정적 필드 초기화나 export const theme = createTheme()처럼 최상위에서 함수를 호출하는 코드도 번들러가 부작용 여부를 확신하지 못하는 대표적인 경우입니다. 이런 호출이 순수하다면 /* @__PURE__ */ createTheme() 주석을 붙여 알려 줄 수 있습니다.


여러 엔트리로 청크 나누기

export default {
  input: ['src/index.js', 'src/another.js'],
  output: {
    dir: 'dist',
    format: 'esm',
  },
};

입력이 여러 개이거나 코드에 import()가 있으면 Rollup은 여러 청크를 만들어야 하므로 output.file 대신 output.dir이 필요합니다. file을 쓰면 Invalid value for option "output.file" - when building multiple chunks, the "output.dir" option must be used 에러가 납니다. 두 진입점이 공유하는 코드는 별도 청크로 자동 분리됩니다.

라이브러리에서는 이 기능을 컴포넌트별 진입점에 씁니다. input: { index: 'src/index.ts', button: 'src/Button.tsx' }처럼 주고 exports에 "./button"을 등록하면, 소비자가 import { Button } from 'my-lib/button'으로 필요한 것만 불러올 수 있어 Tree Shaking이 약한 환경에서도 번들이 작게 유지됩니다. 모든 파일 구조를 그대로 유지해 출력하고 싶다면 output.preserveModules: true도 선택지입니다.


예제: React 컴포넌트 라이브러리 번들링

프로젝트 구조

my-lib/
├── src/
│   ├── index.ts
│   ├── Button.tsx
│   └── Input.tsx
├── rollup.config.js
├── tsconfig.json
└── package.json

rollup.config.js

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
export default {
  input: 'src/index.ts',
  external: ['react', 'react-dom'],
  output: [
    {
      file: 'dist/index.esm.js',
      format: 'esm',
      sourcemap: true,
    },
    {
      file: 'dist/index.cjs.js',
      format: 'cjs',
      sourcemap: true,
    },
    {
      file: 'dist/index.umd.js',
      format: 'umd',
      name: 'MyLib',
      globals: {
        react: 'React',
        'react-dom': 'ReactDOM',
      },
      sourcemap: true,
    },
  ],
  plugins: [
    resolve(),
    commonjs(),
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist',
    }),
    terser(),
  ],
};

예전 예제에서 흔히 보이는 rollup-plugin-terser는 더 이상 관리되지 않아 Rollup 3 이상과 peer 의존성이 맞지 않으므로, 공식 패키지 @rollup/plugin-terser(default export)를 씁니다.

이 설정에서 판단이 필요한 부분이 두 가지 있습니다. 첫째, terser()를 모든 출력에 적용하면 ESM·CJS 결과물도 압축됩니다. 소비자의 번들러가 어차피 최종 압축을 하므로 라이브러리의 ESM 출력을 압축하는 이점은 작고, 오히려 사용자가 node_modules 안에서 디버깅하기 어려워집니다. 압축은 CDN용 UMD 출력의 plugins에만 넣는 것을 권합니다. 둘째, external에 react-dom을 넣었지만 위에서 말한 react/jsx-runtime 문제가 그대로 남아 있습니다. tsconfig.json의 "jsx": "react-jsx"를 쓰고 있다면 external을 함수 형태로 바꾸십시오.

라이브러리용 package.json

{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "browser": "dist/index.umd.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

files는 npm publish 시 패키지에 포함할 경로 목록입니다. 소스와 테스트를 빼고 dist만 배포해 설치 크기를 줄입니다. 배포 전에 npm pack --dry-run으로 실제로 어떤 파일이 들어가는지 확인하면 .d.ts가 빠졌거나 소스맵만 잔뜩 들어간 실수를 미리 잡을 수 있습니다. peerDependencies의 범위는 너무 좁게 잡으면 React 19를 쓰는 앱에서 설치 경고나 ERESOLVE 에러가 나므로, 실제로 테스트한 버전 범위(예: ^18.0.0 || ^19.0.0)로 넓혀 두는 것이 좋습니다. 개발 중에는 같은 패키지를 devDependencies에도 넣어야 테스트와 빌드가 동작합니다.


watch 모드로 개발 중 재빌드하기

export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
  },
  watch: {
    include: 'src/**',
  },
};
rollup -c -w

-w는 파일이 바뀔 때마다 다시 빌드합니다. 라이브러리를 개발하면서 예제 앱에서 바로 확인하려면 npm link나 workspace로 연결하는데, 이때 예제 앱과 라이브러리가 각자의 node_modules/react를 가지면 앞서 말한 Invalid hook call이 다시 나타납니다. 라이브러리 쪽 React는 devDependency로만 두고, 예제 앱의 번들러 설정에서 react를 한 곳으로 alias(Vite의 resolve.dedupe: ['react'])하면 해결됩니다.


라이브러리 배포 전 요점

  • Rollup은 ESM을 그대로 출력하고 런타임 코드가 적어 라이브러리 배포에 잘 맞습니다.
  • React 같은 peer 의존성은 반드시 external로 두고, react/jsx-runtime 같은 하위 경로까지 잡습니다.
  • 소비자 쪽 Tree Shaking을 위해 ESM 출력, exports 필드, 정확한 sideEffects 선언을 갖춥니다.
  • browser 필드에 UMD를 넣지 않고, 압축은 CDN용 출력에만 적용합니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Webpack과 비교하면 어떤가요?

A. Rollup은 ESM 출력과 적은 런타임 코드 덕분에 라이브러리에 잘 맞고, Webpack은 개발 서버·에셋 처리·Module Federation 등 애플리케이션 기능이 풍부합니다. Webpack 5도 output.library.type: 'module'로 ESM을 출력할 수 있지만 아직 실험적 기능에 가깝습니다.

Q. Vite가 Rollup을 사용하나요?

A. 네, Vite는 오랫동안 프로덕션 빌드에 Rollup을 사용해 왔고, 현재는 Rollup과 호환되는 Rust 기반 번들러 Rolldown으로 전환하고 있습니다. Rollup 플러그인 API가 유지되므로 기존 플러그인 지식은 그대로 쓸 수 있습니다.

Q. 애플리케이션도 번들링할 수 있나요?

A. 가능하지만 개발 서버, HMR, 에셋 처리를 직접 조합해야 합니다. 애플리케이션이라면 Rollup을 내부에서 쓰는 Vite를 쓰는 편이 훨씬 편합니다.