Vite를 통한 Typescript 라이브러리 만들기 - 2편

이전 포스트 요약

우리는 이전 포스트에서 Vite CLI를 통해 간단한 React 라이브러리 프로젝트를 생성하고 다음과 같은 액션을 수행했다.

  • 프로젝트 구조 설계
  • 라이브러리 빌드를 위한 설정
  • 라이브러리 배포를 위한 설정
  • 라이브러리를 설치 및 불러와서 사용하기

이번에는?

이번 포스트에서는 이전 포스트에서 생성한 라이브러리를 설치 및 불러와서 사용하는 관점에서 필요한 내용을 다루어보자.

  • Tree Shaking
  • Module Cherry Picking
  • React Server Component Support

Tree Shaking with ES Module

CommonJS 모듈은 Tree Shaking이 안되나요?

네, 불가능합니다. 트리 쉐이킹은 모듈 번들러가 수행하며 정적 분석의 영역에 해당하는 기술입니다. CJS 모듈에서 사용하는 require 문법은 어플리케이션의 런타임에 호출되어 특정 모듈을 불러오거나 동적으로 수정될 수 있는 특성을 지닙니다. 이러한 특성은 모듈 번들러의 입장에서 사전에 사용하지 않는 리소스로 판단 및 제거할 수 없게 만듭니다. 반면 ES Module에서 사용되는 import 문법은 항상 코드상의 최상단에 위치하며 어떤 모듈이 사용되거나 사용되지 않는지 100% 사전에 파악이 가능합니다.

라이브러리를 사용하는 소비자 입장에서 Tree Shaking은 최적화에 중요한 영향을 미친다.

100MB의 크기를 갖는 라이브러리를 불러와서 1MB 크기를 갖는 리소스만 사용함에도 불구하고 어플리케이션의 빌드결과가 100MB 라이브러리 리소스 전체를 포함시킨다면 상당한 손해가 아닐 수 없기 때문이다.

라이브러리를 사용하는 프로젝트에서 빌드를 진행할 때 모듈 번들러의 정적 분석을 통해 라이브러리에서 제공되는 모듈중 사용하는 모듈만 빌드 결과에 포함시킬 필요가 있다.

이 기술이 잘 동작하는지 시각적으로 테스트하기 위해 rollup-plugin-visualizer 플러그인을 적용하자.

우선은 라이브러리 자체의 빌드 및 번들링 결과를 시각적으로 확인한다.

vite.config.ts
1import { visualizer } from 'rollup-plugin-visualizer';
2
3export default defineConfig({
4  plugins: [dts({ rollupTypes: true }), visualizer({ open: true })],
5  ...restConfig,
6});

빌드 스크립트를 실행하면 라이브러리 빌드 결과가 생성된 후 번들링 결과를 시각적으로 확인할 수 있는 웹 페이지가 열린다.

1$ pnpm run build
vite-library-bundle-analyzer

여기서 집중해야 할 부분은 우리가 작성한 리소스 영역인 src 영역의 모듈 목록 부분이다.

내보내고 있는 모듈 목록은 다음과 같다.

1export { default as Button } from './components/Button';
2export { default as Dropdown } from './components/Dropdown';
3export { default as Modal } from './components/Modal';
4export { default as useAlert } from './hooks/useAlert';
5export { default as useLocalStorage } from './hooks/useLocalStorage';
6export { default as useMediaQuery } from './hooks/useMediaQuery';

이번에는 라이브러리를 불러와서 사용하는 프로젝트로 관점을 옮기자.

useAlert, Dropdown 두개의 모듈을 사용하고 있는 부분에 집중한다.

App.tsx
1import { useAlert, Dropdown } from "@hsjprime/my-ts-library";
2
3function App() {
4  const useAlertReturns = useAlert({ message: "Hello, world!" });
5
6  return (
7    <Dropdown
8      open={true}
9      onChange={() => {
10        console.log("dropdown");
11      }}
12    />
13  );
14}

이제 이 프로젝트를 빌드하여 번들링 결과를 시각적으로 확인해보자.

이런! 시각화 결과가 그냥 라이브러리 이름으로 뭉뚱그려져 있어서 명확히 증명하기 어렵다.

다른 방법을 사용해보자.

vite-library-consumer-bundle-analyzer

조금 고지식한 방법이 될 수 있겠지만 프로젝트의 빌드 설정을 임시로 수정하여 빌드 결과 파일 자체를 열어서 확인할 것이다.

다음의 설정을 추가하자. 이 옵션은 프로젝트가 빌드될 때 의존성이 있는 모듈별로 번들링 결과를 생성하도록 만드는 옵션이다.

공식 문서 가이드에서 이 옵션의 적용으로 인해 트리쉐이킹 동작은 여전히 적용된다고 언급하고 있으니 변인이 되지는 않는다.

rollup-preserve-modules-doc

빌드 설정파일에서 옵션을 추가한다.

vite.config.ts
1export default defineConfig({
2  plugins: [react(), visualizer({ open: true }) as PluginOption],
3  build: {
4    rollupOptions: {
5      preserveEntrySignatures: "strict",
6      output: {
7        preserveModules: true,
8      },
9    },
10  },
11});

빌드 명령어를 실행하고 결과 디렉토리로 이동하자.

vite-library-preserve-modules-result

이제 my-ts-library-[hash].js 파일은 어플리케이션 런타임에 불러올 리소스임은 자명하다.

이 파일의 내용을 확인해보자.

App.tsx 파일에서 사용했던 useAlert, Dropdown 모듈에 해당하는 코드 블럭만 빌드 결과에 포함된 것을 확인할 수 있다.

1import '../../../../../../../_virtual/index-DcaEFBNH.js';
2
3function m({ open: n, onChange: t }) {
4  return (
5    console.log({
6      open: n,
7      onChange: t,
8    }),
9    y.jsx('div', {
10      children: 'Dropdown',
11    })
12  );
13}
14
15function x({ message: n }) {
16  return (
17    console.log({
18      message: n,
19    }),
20    {
21      message: n,
22    }
23  );
24}
25export { m as Dropdown, x as useAlert };

OK! 우리의 라이브러리는 Tree Shaking이 잘 동작하고 있다.

Module Cherry Picking

이전 단락에서 CJS 모듈 시스템은 Tree Shaking이 불가능한 내용을 확인했다.

그렇다면 CJS 라이브러리는 항상 OverLoad를 감수해야만 하는 것일까?

lodash 라이브러리는 이런 문제를 어떻게 해결하는지 살펴볼 수 있는 좋은 사례이다.

라이브러리의 기본 모듈 형식은 CJS로 작성되어 있기 때문에 하나의 유틸함수만 불러와서 사용하더라도 전체 리소스를 불러오게 된다.

이 문제를 개선하기 위해 라이브러리는 여러가지 수단을 제공하고 있다.

그중에 하나는 Per Method Packages라는 이름으로 제공되고 있는데 트리쉐이킹이 적용될 수 없다면 라이브러리 자체를 쪼개서 제공해주는 방법이다.

1const _ = require('lodash');
2const merge = require('lodash/merge');

그러나 필요한 모듈을 불러오는 문장을 일일히 추가해서 사용하는 것은 번거로운 일이다.

가능하면 ESM 모듈 형식으로 라이브러리를 제작하는 방향이 여러모로 권장된다.

그렇다면 CJS 모듈 형식은 왜 존재했던 걸까?

다음의 내용 정도만 알고 가자.

초창기 자바스크립트에서는 모듈이라는 개념이 존재하지 않았다.

Node.js 진영에서 자바스크립트를 모듈화 및 분리하기 위해 CJS 모듈 형식을 도입하였다.

ECMAScript 2015(ES6)단계에서 ESM 형식이 클라이언트/서버 어느쪽에서나 사용될 수 있는 표준 모듈 형식으로 도입되었다.

그러므로 연식이 오래되었거나 하위 Node.js 버전 호환이 필요한 라이브러리는 CJS 모듈 형식으로 작성되어 있을 수 있다.

React Server Component(RSC) 지원

Next.js v14.2.20, React v18 에서 테스트했던 내용입니다.

이번에는 React 18에서 도입된 서버 컴포넌트 기술이 지원되는 라이브러리로 업그레이드 시켜보자.

이 작업을 달성하기 위해서는 라이브러리를 모듈별로 빌드 및 알맞은 디렉티브가 작성되어야 한다.

우선 라이브러리에 useLocalStorage 모듈 구현을 고도화 하고 Badge 모듈을 추가한다.

hooks/useLocalStorage/index.tsx
1import { useState } from "react";
2
3interface UseLocalStorageProps {
4  key: string;
5}
6
7export default function useLocalStorage({ key }: UseLocalStorageProps) {
8  const [value, setValue] = useState(localStorage.getItem(key));
9
10  const setItem = (value: string) => {
11    setValue(value);
12    localStorage.setItem(key, value);
13  };
14
15  return { value, setItem };
16}
components/Badge/index.tsx
1interface BadgeProps {
2  count: number;
3}
4
5export default function Badge({ count }: BadgeProps) {
6  return <div>{count}</div>;
7}

그리고 모듈의 구현 특성에 따라 "use client" 디렉티브를 작성한다.

여기서 모듈의 구현 특성이라 함은 모듈이 서버/클라이언트 컴포넌트 어느쪽에 해당하는지 분류하는 것을 의미한다.

Badge 컴포넌트는 입력받은 숫자를 그대로 렌더링하는 기능만 있으므로 서버상에서 렌더링 되어도 아무 문제가 없는 컴포넌트이기 때문에 디렉티브를 추가하지 않는다.

1export { default as Badge } from './components/Badge';
2export { default as Button } from './components/Button'; // "use client"
3export { default as Dropdown } from './components/Dropdown'; // "use client"
4export { default as Modal } from './components/Modal'; // "use client"
5export { default as useAlert } from './hooks/useAlert'; // "use client"
6export { default as useLocalStorage } from './hooks/useLocalStorage'; // "use client"
7export { default as useMediaQuery } from './hooks/useMediaQuery'; // "use client"

이대로 빌드를 진행하면 다음과 같은 경고 문구를 확인하게 된다.

src/hooks/useMediaQuery/index.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/hooks/useMediaQuery/index.tsx" was ignored. src/hooks/useAlert/index.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/hooks/useAlert/index.tsx" was ignored. src/components/Dropdown/index.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/Dropdown/index.tsx" was ignored. src/hooks/useLocalStorage/index.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/hooks/useLocalStorage/index.tsx" was ignored. src/components/Modal/index.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/Modal/index.tsx" was ignored. src/components/Button/index.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/Button/index.tsx" was ignored.

라이브러리를 빌드하는 과정에서 모듈별로 작성한 디렉티브 정보가 무시되고 빌드 결과에 포함되지 않는다.

디렉티브 정보를 제거해버리면 RSC를 지원하는 모듈 번들러 입장에서 모듈별로 서버/클라이언트를 구분할 수 없게 된다.

이 상황을 해결할 수 있는 첫번째 옵션은 output.preserveModules 이다.

vite.config.ts
1export default defineConfig({
2  build: {
3    rollupOptions: {
4      output: {
5        preserveModules: true,
6      },
7    },
8  },
9});

이 옵션은 우리의 라이브러리를 하나의 js 파일에 합쳐 빌드하던 동작에 변화를 준다.

다음의 구조로 빌드하게 된다. 말로 설명하는 것보다 그림이 빠르다.

1./dist
2├── _virtual
3│   ├── jsx-runtime.js
4│   ├── jsx-runtime2.js
5│   ├── react-jsx-runtime.development.js
6│   └── react-jsx-runtime.production.min.js
7├── my-ts-library.css
8├── my-ts-library.d.ts
9├── node_modules
10└── src
11    ├── components
12    │   ├── Badge
13    │   │   └── index.js
14    │   ├── Button
15    │   │   └── index.js
16    │   ├── Dropdown
17    │   │   └── index.js
18    │   └── Modal
19    │       └── index.js
20    ├── hooks
21    │   ├── useAlert
22    │   │   └── index.js
23    │   ├── useLocalStorage
24    │   │   └── index.js
25    │   └── useMediaQuery
26    │       └── index.js
27    └── index.js

그러나 디렉티브를 지정했던 하나의 모듈 빌드 결과 파일을 들여다보면 "use client" 디렉티브는 여전히 사라지고 없는 상태다.

dist/components/Button/index.js
1import { j as o } from '../../../_virtual/jsx-runtime.js';
2/* empty css          */
3function e({ onClick: t }) {
4  return (
5    console.log({ onClick: t }),
6    /* @__PURE__ */ o.jsx('div', { children: 'Button' })
7  );
8}
9export { e as default };

Github 커뮤니티 상에서는 이런 문제를 해결하기 위해 rollup-preserve-directives가 제안되고 있다.

vite.config.ts
1import preserveDirectives from 'rollup-preserve-directives';
2
3export default defineConfig({
4  plugins: [
5    ...,
6    preserveDirectives(),
7  ],
8  ...restConfig,
9});

다시 빌드를 진행하면 빌드 결과별로 작성해둔 디렉티브가 보존되어 있는 것을 확인할 수 있다.

dist/components/Button/index.js
1'use client';
2import { j as o } from '../../../_virtual/jsx-runtime.js';
3/* empty css          */
4function e({ onClick: t }) {
5  return (
6    console.log({ onClick: t }),
7    /* @__PURE__ */ o.jsx('div', { children: 'Button' })
8  );
9}
10export { e as default };

그러나 이 플러그인을 적용하더라도 일부 모듈에 작성한 "use client" 디렉티브가 의도치 않게 제거되는 문제가 발생할 수 있다.

이때는 banner 옵션을 통해 강제적으로 디렉티브를 주입시켜주는 선택지도 있다.

vite.config.ts
1export default defineConfig({
2  build: {
3    rollupOptions: {
4      output: {
5        preserveModules: true,
6        /* 빌드하려는 모듈의 파일명이 .client 로 끝난다면 "use client" 디렉티브를 주입해주세요. */
7        banner: chunkInfo => {
8          if (chunkInfo.name.endsWith('.client')) {
9            return `"use client"`;
10          }
11          return '';
12        },
13      },
14    },
15  }
16  ...restConfig,
17});

설정을 완료하고 라이브러리를 다시 빌드 및 배포한 이후 Next.js 프로젝트에서 불러와 사용하면 문제없이 동작할 것이다.

정리

여기까지 간단한 React + TypeScript 라이브러리를 End-To-End로 제작 및 배포, 사용하는 과정을 다루었다.

개발자가 수행해야할 액션은 포스트 내용만 놓고보면 많지 않다.

그러나 정보가 없는 상태에서 이슈 하나하나를 삽질해가면서 해결하는 것은 가치있는 일이지만 시간을 너무 많이 소비하게 된다.

이 포스트가 라이브러리를 만들고자 하는 많은 개발자들의 시간을 절약해주길 희망한다.