sxungchxn.dev

Vite로 만나는 라이브러리 세계

2024년 08월 22일  8일 전
  • Vite
  • Rollup
  • React
Vite로 만나는 라이브러리 세계

목차

Intro

이번 글에서는 vite 를 번들러로 활용하여 프론트엔드 라이브러리를 만드는 방법에 대해서 알아본다. 또한 라이브러리를 더 높은 수준으로 만들어내려면 필수적으로 알아야 할 tree-shake에 대해 이해 해보고 이를 vite 에도 적용해 볼 것이다.

Vite는 무엇인가

vite 는 최근에 리액트를 개발에서 CRA(create-react-app) 를 대신하는 필수적인 도구로 자리잡았다. 이는 vite 가 리액트의 개발도구로서 의존성 사전 번들링을 통해서 CRA 비하여 압도적으로 빠른 개발서버 구성 및 HMR 기능을 제공하고 있기 때문이다. 이러한 훌륭한 개발경험 덕분에 최근 vite 는 2023년 프론트엔드 라이브러리 설문조사에서 98%의 retention을 보여주는 기염을 토했다.

이러한 개발 환경의 이점은 단순히 개발 서버 구성 뿐만 아니라 라이브러리 번들링에서도 엿볼 수 있다. vite 는 스스로 번들링을 진행하는게 아니라 이 역할을 rollup 에게 맡기고 있다.

그럼 rollup은 무엇인가

rollup 은 자바스크립트 기반의 번들러 로서 라이브러리 번들링에 특화되어있다. 특히나, 다른 번들링 도구에 비하여 esm 모듈의 코드를 번들링을 지원해주고 그리고 사용되는 코드만 필수적으로 포함시키는 tree-shaking 을 지원해주는데에 특화되어 있어 라이브러리의 번들 사이즈를 줄이는데 굉장히 탁월하다.

💡 rollup 이 막 등장했던 시기에 webpack 은 기본적으로 ESM 모듈 구조에 대한 번들링을 지원해주지 않았으나, webpack 5 에서 부터는 공식적으로 ESM 번들링이 기본 지원이 되고 있다.

이 뿐만 아니라 rollup 출시된 이후로 다양한 플러그인들이 오픈소스로 출시되면서 굉장히 깊이있는 번들링 생태계를 갖추고 있다. 이 때문에 번들링이 이뤄지는 빌드 타임에서는 안정적인 빌드 환경 및 생태계를 제공해주고자 성능이 굉장히 좋은 esbuild 대신해서 rollup 을 사용하고 있다. 이에 대한 더 자세한 내용은 vite의 공식문서를 참고하면 좋다.

Vite로 라이브러리 만들기

이제 본격적으로 앞서 살펴본 vite 를 기반으로 하여 라이브러리를 만들어 볼 것이다. 리액트 관련 라이브러리를 만드는 상황을 가정해보자.

vite config

// vite.config.ts
import { resolve } from 'node:path'
import { defineConfig, type PluginOption } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import dts from 'vite-plugin-dts'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      fileName: 'index',
      formats: ['es', 'cjs'],
    },
		rollupOptions: {
      external: ['react', 'react-dom'],
    },
  },
  plugins: [
    // parse tsconfig paths
    tsconfigPaths(),

    // declaration type concerned plugin
    dts({
      copyDtsFiles: true,
      entryRoot: 'src',
      include: ['src'],
      exclude: ['node_modules', 'dist', '**/*.test.ts*', './src/vite-env.d.ts'],
    })
  ],
})

위의 config 파일에서 설정들의 의미를 이야기 해보자면 다음과 같다.

build config

vite에 설정하는 config 값으로 라이브러리 형태로 설정하도록 설정하는 부분이다. 각 config 별로 설명해보자면

  • build.lib : 라이브러리 형태로 vite 를 사용하고자 할때 사용해야하는 부분이다.
  • build.lib.entry : 라이브러리의 엔트리포인트가 되는 부분이다.
  • build.lib.fileName : 라이브러리 모드로 번들링 된 자바스크립트 파일의 이름이 되는 부분이다. fileName: 'index' 라고 설정하였으니 index.js 라는 파일이 아웃풋으로 생성될 것이다
  • build.lib.formats : 빌드 결과물이 도출할 자바스크립트 포맷을 결정하는 부분이다. ESM 모듈과 CommonJS 모듈 모두에 지원되는 라이브러리를 만들고자 [’es’, ‘cjs’] 값을 기입했다.

다음은 vite가 아닌 내부 rollup 에 적용되는 옵션이다.

  • build.rollupOptions.external : 라이브러리에 포함되지 않아야할 의존성들을 명시하는 부분이다. 가령, 리액트를 기반으로 만들어지는 라이브러리의 경우 이 값을 설정해주어야 한다. 이는 라이브러리 사용자는 어차피 react 의존성을 설치하기 마련이기에 react 패키지 내용이 번들링에 포함되면 불필요한 중복이 될 뿐더러 라이브러리의 사이즈만 키우는 꼴이 되기 때문에 꼭 설정해주어야 한다.

plugins config

다음은 vite 에서 사용되는 플러그인을 기입하는 부분이다. 필수적인 부분과 그렇지 않은 부분이 있으니 참고해서 보길 바란다.

  • vite-plugin-dts : 라이브러리에서 타입을 지원해야할 때 필수적으로 사용해야 하는 플러그인이다. 이는 타입스크립트로 작성된 소스코드를 통해 라이브러리 사용자가 필요한 타입을 만들어주는 플러그인이다.

    • copyDtsFile 은 소스코드에 선언 타입 파일(d.ts)이 있는 경우 이를 번들링에 포함시키는 역할을 한다. include, exclude 는 이름에서 쉽게 유추할 수 있듯이 타입을 생성할 대상에 포함시킬지 아닐지를 결정하는 부분이다. 나의 경우는 소스코드 부분 (src 하위 모든 폴더)은 포함시키되, 테스트 파일이나 불필요한 파일들은 제외 시켰다.
    • entryRootsrc 경로를 생략한 구조로 나오도록 하는 역할을 한다. 가령 예를 들어 소스코드에서 빌드에 포함되는 경로가 src/components/index.ts 라면, 빌드 후 번들된 파일의 경로는 components/index.js 가 된다
  • vite-tsconfig-paths : 타입스크립트로 소스코드를 작성할때 import 경로에 alias가 포함되어 있는 경우에 사용하면 편리한 플러그인이다. 만약 이 플러그인 없이 aliasvite 환경에서 사용한다면, 이중으로 관련 설정을 해주어야 한다

    // tsconfig.json
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
          "@/*": ["./src/*"]
        },
     },
     
     
    // vite.config.ts
    resolve: {
        alias: [
          { find: "@", replacement: path.resolve(__dirname, "src") },
        ],
    },
    

    이렇게 처리하는 대신에 이 플러그인을 사용하게 된다면 vite.config.tsalias 설정은 생략하고, tsconfig 에 설정한 aliasimport 경로를 파싱해준다. 기본적으로는 추가적인 설정이 없다면 tsconfig.json 을 기준으로 경로를 파싱한다.

package.json 설정

다음은 빌드된 결과물을 패키지로 내보내고자 할때 사용자가 참고해야되는 부분을 기입하는 작업이다.

peerDependency

이 라이브러리를 설정할때 같이 설치해줘야할 의존성을 명시하는 부분이다. 앞서 vite 설정 부분 중 external 에 기입된 의존성과 같은 의존성을 기입해주면 된다.

"peerDependencies": {
  "react": "^18.2.0",
  "react-dom": "^18.2.0",
}

해당하는 패키지의 이름과 함께 설치되어야할 버전을 명시해주면 된다.

exports와 기타 field들

다음은 사용자가 라이브버리를 import 할때 알맞은 빌드의 결과물을 찾아갈 수 있도록 하는 부분이다. 앞서 vite 설정 값으로 도출된 번들 결과물에 대한 일종의 가이드를 작성하는 것이다.

pacakge/
└── dist/
	 ├── index.css // 스타일시트 파일
   ├── index.js
   ├── index.cjs
   └── index.d.ts

가령 위와 같은 결과물이 번들링 결과로 도출 되었다면 아래와 같은 설정이 필요하다.

"files": [
  "dist"
],
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
  ".": {
    "require": "./dist/index.cjs",
    "import": "./dist/index.js",
    "types": "./dist/index.d.ts"
  },
  "./styles": {
    "import": "./dist/index.css",
    "require": "./dist/index.css"
  }
}

이 설정에 대해 이야기 해보자면 다음과 같다.

  • files : 배포시 포함되어야할 파일들을 명시하는 부분이다.
  • main: 이 라이브러리를 commonjs 형태로 사용할 때 진입점이 되는 경로이다.
  • module : 이 라이브러리를 esmodule 형태로 사용할 때 진입점이 되는 경로이다.
  • types : 이 라이브러리의 타입 파일을 사용하고자 할때 진입점이 되는 경로이다.
  • exports : 패키지를 import 할때 세부적인 경로를 명시한다. 여기서 . 은 패키지 자체를 의미 한다. 만약 패키지 이름이 mypackage 이고 import * form 'mypackage' 를 통해 import 한다면, dist/index.*js 파일을 참조한다는 것을 의미한다. 반면, import 'mypackage/styles' 라는 경로로 import 하면 dist/index.css 파일을 참조하게 된다.
    • require : cjs 형태로 라이브러리를 사용할때 참조하는 경로이다.
    • import : esm 형태로 라이브러리를 사용할때 참조하는 경로이다.
    • types : 타입을 참조하는 경로이다.

이렇게 간단한 설정으로 라이브러리 하나가 완성되었다.

라이브러리 코드 점검하기

위에서 설명한 방식대로 빌드한 라이브러리는 어떠한 형태로 사용자 앱에 전달될까? 실제 빌드 결과물을 갖고 그 차이를 확인해보고자 한다.

예시로 들고자 하는 나의 라이브러리는 UI 컴포넌트 키트이다. 이 패키지의 엔트리 파일은 다음과 같다.

/* atoms */
export * from './atoms/box'

export * from './atoms/icon'

export * from './atoms/text'

export * from './atoms/flex'

export * from './atoms/grid'

/* molecules */

export * from './molecules/icon-button'

export * from './molecules/theme-switch'

export * from './molecules/chip'

export * from './molecules/progressive-hover-circle'

/* organisms */
export * from './organism/carousel'

각 폴더 하위에 box,icon, text 와 같은 파일들로부터 모든 컴포넌트들을 export 하는 방식이다.

// atoms/box.tsx
import { createElement, type ElementType, forwardRef, type ReactElement } from 'react'
import { type BoxProps } from './box.types'
import { useBoxProps } from './hooks/use-box-props'
import { type PolymorphicRef } from '@/types/polymorphic'
import { Slot } from '@radix-ui/react-slot'

export type BoxComponent = <C extends ElementType = 'div'>(
  props: BoxProps<C>,
) => ReactElement<BoxProps<C>>

export const Box = forwardRef(
  <C extends ElementType = 'div'>(props: BoxProps<C>, ref: PolymorphicRef<C>) => {
    const { as, asChild, ...otherBoxProps } = useBoxProps(props)
    
    console.log('box component') // make console log
    
    return createElement(asChild ? Slot : as ?? 'div', { ref, ...otherBoxProps })
  },
) as BoxComponent

그리고 컴포넌트 Box 의 코드는 다음과 같이 생겼으며 확인을 위해 간단히 로그를 남기는 코드를 작성해봤다.

// organism/carousel.tsx
...
export const CarouselPagination = ({
  className,
  size = 'sm',
  ...rest
}: CarouselPaginationProps) => {
  ... 
  
  console.log('carousel component') // make console log

  return (
    <Box className={clsx(styles.knobList, className)} {...rest}>
      ...
    </Box>
  )
}
...

그리고 Carousel 컴포넌트는 다음과 같이 생겼으며 확인을 위해 로그가 찍히는 코드가 들어가 있다. 이러한 라이브러리를 앞서 소개한 방식대로 번들링화면 아래와 같은 코드가 도출된다

...
const mr = jr(
  (r, i) => {
    const { as: s, asChild: t, ...n } = Is(r);
    return console.log("box component"), si(t ? rs : s ?? "div", { ref: i, ...n });
  }
);
...
), l = (u) => (c) => {
    c.preventDefault(), c.stopPropagation(), d(u);
};
return console.log("carousel component"), /* @__PURE__ */ T.jsx(mr, { className: pr(Xt, r), ...s, children: a.map((u, c) => /* @__PURE__ */ T.jsx(

정확한 형태는 알아볼 수 없으나, 번들링 된 코드에 두 컴포넌트가 담겨있는 것이 제대로 확인된다.

6143cbfc-59b0-416f-a222-d2797ac7ff54_imageblock_1.png

방금 번들링한 라이브러리의 대략적인 구조는 다음과 같다. 이때 이상적으로 라이브러리 사용자가 하나의 컴포넌트만 사용한다면 그 컴포넌트와 관련된 코드들만 사용하는 것이 당연한 이치일 것 이다. 예를 들어, Box 컴포넌트만 사용한다면 아래와 같이 색칠된 코드들만 사용되는 것이 이상적일 것이다.

6143cbfc-59b0-416f-a222-d2797ac7ff54_imageblock_2.png

이를 직접 확인해보고자 간단한 Vite 프로젝트를 이용해 Box 컴포넌트 만을 import 해오는 다음의 코드를 작성하였다.

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import { Box } from '@sxungchxn/dev-ui'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

function App() {
  return (
    <div>
      <Box />
    </div>
  )
}

이러한 코드를 번들링 해보면 다음과 같다.

t)=>{const{as:r,asChild:n,...i}=am(e);return console.log("box component"),w.createElement(n?l1:r??"div",{ref:t,...i})});var

...

.stopPropagation(),s(u)};return console.log("carousel component"),V.jsx(Be,{className:et(x_,e),...r,children:o.map((u,f)=>V.jsx

...

작성한 Vite 코드 내에서는 Box 컴포넌트만을 import 해 사용하고 있음에도 불구하고, 번들링 결과물에는 이와 관련 없는 Carousel 컴포넌트 코드가 담겨져 있다. 나중에 이야기할 treeshake 기법을 적용한 번들 결과물과 비교해보면 차이가 많이 느껴진다.

// 기존 방식 대로 번들된 라이브러리를 사용하는 코드 번들
vite v5.0.11 building for production...
✓ 328 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/index-BPvgi06w.css    0.92 kB │ gzip:  0.50 kB
dist/assets/index-C6XOIYZp.js   257.03 kB │ gzip: 75.43 kB

// treeshake 기법을 적용해 번들된 라이브러리를 사용하는 코드 번들
vite v5.0.11 building for production...
✓ 388 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/index-BPvgi06w.css    0.92 kB │ gzip:  0.50 kB
dist/assets/index-DQVu27KS.js   204.42 kB │ gzip: 55.69 kB

사용할 코드만 가져오는 Treeshake

그렇다면 treeshake 기법은 무엇일까? webpack 공식 문서에는 다음과 같이 정의되어 있다.

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export

ES2015(ES6) 에서부터 도입된 import, export 문을 이용해 모듈의 정적인 구조를 파악하여 죽은 코드(사용되지 않는)코드를 제거하는 기법으로 정의를 내리고 있다.

6143cbfc-59b0-416f-a222-d2797ac7ff54_imageblock_3.png

아까 예시로 들었던 이 사진과 같이 사용되지 않는 코드(하얀색)들은 번들 대상에서 제외하는 것이다.

treeshake의 성립조건

treeshake 의 의미와 영향력을 확인했으니 이를 사용하기 위한 조건들을 확인해보자.

1️⃣ ESM 모듈

정의에서도 보았듯이 esm 모듈에서 사용하는 export, import 문을 기반으로 하여 treeshake 를 적용할 수 있다고 하고 있다. 이는 기존의 다른 모듈과는 다르게 모듈 구조를 정적으로 분석이 가능한 문법이기 때문이다. 이는 ESM 고유의 특징들이 때문인데 이는 이 글에서 정말 잘 다뤄주고 있으니 한 번 읽어볼 것을 추천한다. 주요 특징들만 짧게 이야기 하자면 다음과 같다.

  • import 하는 변수는 값을 가져오지 않고 참조값을 가져온다.
  • import 문은 항상 최상단에 위치하기에 조건부 import 가 불가하고 경로 변수로 할당이 불가능하다.
  • import, export 문을 통해 연결된 모듈 구조가 모두 파악되어야 그 모듈들의 실행이 가능하다.

이러한 특성들 덕분에, ES 모듈은 정적인 모듈 구조 분석을 가능하게 한다.

2️⃣ sideEffect 없는 코드

여기서 sideEffect 란 모듈 관점에서의 sideEffect 를 의미하는데, 이는 실행이 되지 않았음에도 모듈을 로드하면서 실행되는 코드 부분을 의미한다.

// module.ts
export const code = 'code'

console.log(code) // code with side-effect

export const removeTrailingSlash = (input: string) => {
	...
}

// index.ts
import { removeTrailingSlash } from './module'

removeTrailingSlash() // 'code' 가 출력된다.

다음과 같이 module.ts 파일에 있는 console.log 부분은 해당 파일이 import 된다는 이유만으로 실행이 이루어진다. 이것이 sideEffect 이다. 또다른 대표적인 예가 css 파일을 임포트 하는 것이다. 이는 모듈 로드만으로 실행없이도 관련 내용들이 모두 불러와져야 하는 부분이다.

import 'style.css'

한편, 번들러 입장에서는 이러한 sideEffect 가 있는 코드 없이 모듈관계가 명확한 코드를 제공 받게 될때 불필요한 코드를 제거하는 작업을 더 탁월하게 해낼 수 있다. 빌드 도구가 이를 자동으로 감지할 수 있다면 더할나위 없이 좋겠지만 아직까지는 개발자가 이를 판별해주는게 보다 더 효율적이다. 이때 개발자가 효과적으로 사용할 수 있는 것이 package.jsonsideEffects 필드이다.

{
	"name": "my-package",
	"sideEffects": false
}

sideEffects 가 없다고 개발자 단에서 선언해줌으로써, 번들러 입장에서는 더욱 과감한 최적화가 가능해지는 것이다.

{
	"name": "my-package",
	"sideEffects": [
		"*.css",
	]
}

반면, 디자인 시스템 내에 포함된 css 파일과 같이 sideEffect 가 동반될 수 밖에 없는 코드에 대해서는 매칭되는 파일 이름을 명시함으로써 sideEffect 가 있는 코드에 대한 힌트를 번들러에게 제공해 줄 수 있다.

3️⃣ 모듈 구조의 유지

사실 앞 문단에서 언급한 sideEffect 의 이점을 제대로 누리기 위해서는 모듈 구조를 번들 결과물에서도 유지할 수 있어야 한다. 왜냐하면, 단일 파일로 번들링 할 경우, 사용되지 않은 코드를 없애는 최적화 과정이 제대로 이뤄지지 않은 경우가 발생할 수 있기 때문이다. 대표적인 예로 CommonJS 만을 지원하는 라이브러리를 import 하는 경우가 대표적이다.

// dist/index.js
import { isNil } from "lodash"; // lodash module import

const checkExistance = (variable) => !isNil(variable);

const userAccount = {
  name: "user account",
};

const getUserAccount = () => {
  return userAccount;
};

const getUserPhoneNumber = () => "***********";

const getUserName = () => "John Doe";

export { checkExistance, getUserName, getUserPhoneNumber, getUserAccount };

commonjs 만을 지원하는 lodash 라이브러리로부터 isNil 임포트 하는 결과물들을 비롯해 여러 코드들이 하나의 파일로 번들링 되었다고 하자. 이러한 라이브러리를 사용하는 코드(첫번째)를 webpack으로 번들링 하게 되면 다음의 결과물(두번째)이 도출된다.

import { getUserName } from "user-library";

console.log(getUserName());
/***/ "./node_modules/user-library/dist/index.js":
/*!*************************************************!*\
  !*** ./node_modules/user-library/dist/index.js ***!
  \*************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "getUserName": () => (/* binding */ getUserName)
/* harmony export */ });
/* unused harmony exports checkExistance, userAccount, getUserPhoneNumber, getUserAccount */
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash */ "./node_modules/user-library/node_modules/lodash/lodash.js");
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash__WEBPACK_IMPORTED_MODULE_0__);

const checkExistance = (variable) => !isNil(variable);

const userAccount = {
  name: "user account",
};

const getUserPhoneNumber = {
	number: '***********'
};

const getUserAccount = () => {
	return userAccount
};

const getUserName = () => 'John Doe';

/***/ }),

/***/ "./node_modules/user-library/node_modules/lodash/lodash.js":
/*!*****************************************************************!*\
  !*** ./node_modules/user-library/node_modules/lodash/lodash.js ***!
  \*****************************************************************/
/***/ (function(module, exports, __webpack_require__) {

/* module decorator */ module = __webpack_require__.nmd(module);
var __WEBPACK_AMD_DEFINE_RESULT__;/**
 * @license
 * Lodash <https://lodash.com/>
 * Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
 * Released under MIT license <https://lodash.com/license>
 * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
 * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
 */
// ...

lodash 코드를 사용하지 않는 getUserName 만 사용하는 코드만을 import 하였음에도 lodash 관련 코드가 번들되어 있는 것을 볼 수 있다. 단일 파일로 번들내에서는 commonjs 모듈인 lodash 불러와 무조건 번들링에 포함되기 때문이다.

💡 CommonJS는 왜 무조건 번들링에 포함될까?
CommonJS 는 조건부 import , dynamic pathES Module 보다 융통성있는 기능들이 제공되지만 이는 정적인 방식으로 모듈 구조를 파악하지 못하고 동적인 방식으로(런타임에 실행을 통해)만 모듈구조를 파악할 수 있게 만들기 때문이다. 관련한 자세한 내용은 ESM, CJS를 잘 비교한 이 자료를 참고해보면 좋다.

6143cbfc-59b0-416f-a222-d2797ac7ff54_imageblock_4.png

이 때문에 모듈구조를 유지하는 것이 sideEffects 의 최적화를 보다 원활하게 해주기 때문에 보다 나은 tree-shaking 을 위해선 꼭 필요한 부분이다.

Vite로 Treeshake 적용하기

모듈 구조 잘 유지되게 만들기

라이브러리 빌드 결과물이 모듈 구조를 잘 유지할 수 있도록 rolluppreserveModules 옵션을 활용해준다. 이렇게 하면 우리가 소스코드에서 import 하는 구조와 거의 동일하게 빌드 결과물이 도출되게 해준다.

export default defineConfig({
  plugins: [
	  // 이전 config와 동일
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'index',
      fileName: 'index',
    },
    rollupOptions: {
      external: ['react', 'react-dom'],
      // 아까와 달라지는 부분들
      input: ['src/index.ts'],
      output: [
        {
          format: 'esm',
          dir: 'dist',
          preserveModules: true,
          preserveModulesRoot: 'src',
          entryFileNames: ({ name: fileName }) => {
            return `${fileName}.js`
          },
        },
        {
          format: 'cjs',
          dir: 'dist',
          entryFileNames: ({ name: fileName }) => {
            return `${fileName}.cjs`
          },
        },
      ],
    },
  },
})

이제는 아까와는 다르게 빌드의 주된 업무를 rollup 에 맡겨주도록 해야 한다. 이를 위해 rollupOptions 에 빌드와 관련된 설정을 해준다. 처음에 명시하는 input 필드는 엔트리포인트가 될 파일을 의미한다. output 부터는 빌드 결과물에 대한 설정을 적는 필드이다. 각 필드들을 설명해보자면 다음과 같다.

  • output.format : 빌드 결과물의 포맷. esm , cjs 등을 명시하여 원하는 포맷의 결과물을 만들어낼 수 있다.
  • output.dir : 결과물이 위치할 폴더명
  • output.preserverModues : 모듈구조 유지 여부와 관련된 옵션
  • output.preserveModulesRoot : 빌드 결과물이 어느 폴더 기준점부터 생략하여 구조를 유지할지 결정한다. 이를 명시하지 않으면 src/**/*.ts 와 같은 경로의 파일들은 dist/src/**/* 와 같이 src 폴더 경로가 최상단에 위치하게 된다. 이를 방지하기 위해서 src 폴더 계층은 걷어내게 해주는 옵션이다.
  • output.entryFileNames : chunk 파일들의 파일명을 결정하는 함수이다. 이는 esm 으로 빌드되는 결과물은 .js 확장자로, cjs 로 빌드되는 결과물은 .cjs 확장자로 파일을 만들어내도록 구분짓는 역할을 한다.

💡 CJS 포맷에는 모듈 구조를 유지해주더라도 treeshake 가 제대로 동작하지는 않기 때문에 단일 파일로 번들되게 설정해주었다.

사이드 이펙트 명시하기

다음은 번들러가 보다 잘 트리셰이킹 할 수 있게 sideEffects 를 명시해야 한다. 앞서 언급한 대로 만약 sideEffects 를 유발하는 코드가 없다면, 다음과 같이 false 를 명시해 주어야 한다.

// package.json
{
	"sideEffects": false,
}

반면 다음과 같이 사이드 이펙트를 유발하는 코드가 어쩔 수 없이 포함되는 경우가 있다면 다음과 같이 이를 유발하는 파일의 경로명을 명시해줄 필요가 있다. 가령, vanilla-extract 라이브러리를 사용하는 ui 라이브러리에서 아래 코드와 같이

 import "./reset.css" // reset.css.ts를 import

사이드 이펙트를 유발하는 코드가 있는 경우라면 아래와 같이 해당하는 파일의 경로를 명시해주어야 한다. 경로는 glob pattern 과 같은 형식도 허용한다.

// package.json
{
	"sideEffects": [
		"*.css.ts"
	],
}

완성된 코드 결과물은 이 레포지토리에서 확인해 볼 수 있다.

결과물 비교하기

# non-tresshaking build
dist/index.js   305.72 kB │ gzip: 78.04 kB

# treeshaking build
dist/components/atoms/text/text.js   1.15 kB │ gzip:  0.50 kB
dist/components/molecules/chip/chip.js 0.88 kB │ gzip:  0.43 kB
...
dist/index.js   2.27 kB │ gzip:  0.73 kB

트리셰이킹이 적용되기 전에는 빌드 결과물이 하나의 파일로만 나오고 그 용량은 305.72kB 이나 된다. 이 때문에 특정 컴포넌트만 사용하게 되더라도 다른 컴포넌트의 코드까지 같이 사용자앱에 번들되버릴 수 있다.

반면, 트리셰이킹이 적용된 라이브러리에서는 모듈 구조에 따라 코드들이 잘게 쪼개어져있으며, 특정 컴포넌트만 사용한다면 그 컴포넌트 코드와 연관된 코드들만 불러올 수 있게 된다.

정리

이번 글을 통해 vite 가 번들러로서 어떠한 의미를 가지는지, 이를 바탕으로 라이브러리는 어떻게 만들어볼 수 있는지 그리고 보다 고도화된 라이브러리를 만들어내기 위해 필요한 treeshaking 에 대해서까지 알아봤다. 이번 글을 통해 vite 그리고 rollup 을 이용한 라이브러리 세계로 진입할 수 있는 시야가 열렸으면 좋겠다.

참고문헌

다음 게시글
Notion으로 나만의 블로그 CMS 만들기