본문 바로가기
Web/React

리액트 라이브러리 npm에 배포하기 with rollup

by 김첨지 2024. 1. 31.

 
여러 repository에서 동일하게 사용하는 component와 custom hook이 늘어나면서 관리에 많은 공수가 들었다.
 
코드 하나를 수정하면 다른 프로젝트에도 똑같이 반영하고, 새 프로젝트를 시작할 때마다 똑같은 코드를 옮겨야 했다.

이러한 비효율적인 작업을 개선하기 위해 여러 프로젝트에 걸쳐 사용하는 컴포넌트와 커스텀 훅을 라이브러리로 만들어 npm에 배포하여 사용하기로 했다.

프로젝트 세팅

패키지 매니저로는 개발자 경험에 이점이 있는 pnpm을 사용했다.

pnpm init

 

 
먼저 package json에서 npm 배포에 중요한 부분만 설정했다.
 
package.json의 각 항목은 다음과 같은 역할을 한다.
 

  • name
    • npm에 배포될 라이브러리의 이름
  • version
    • 라이브러리의 버전
    • Semantic Versioning 규칙에 따라 변경
  • main
    • Node.js에 의해 기본적으로 사용되는 entry point
    • 패키지를 require() 함수로 불러올 때 실행할 파일 결정
    • ES 모듈을 사용하는 경우 type 필드에 "module"을 설정하고, exports 필드를 사용하여 엔트리 포인트를 지정
    • 처음에는 배포와 상관없는 repository의 진입점으로 생각하고 src/index.ts로 했다. 하지만 src/index.ts 파일이 같이 배포되어 컴파일된 파일만 배포되도록 dist/index.js로 바꿔주었다.
    • 배포 전 소스 코드의 진입점과 배포 후 코드 진입점을 분리하고자 한다면 "publishConfig" 속성을 이용하면 된다.
  • files
    • 패키지에 포함될 파일 또는 디렉터리
    • 컴파일된 파일만 배포하기 위해 두 가지 선택지가 있다.
      • .npmignore에 명시
      • package.json의 files 필드에 추가
    • 나는 package.json 한 곳에서 패키지를 제어하고자 files 필드에 배포할 파일을 명시하기로 했다.
  • license
    • 사람들이 라이브러리를 어떻게 사용할 수 있는지, 어떤 제한이 있는지 알 수 있도록 패키지에 대한 라이센스를 지정할 수 있다.
    • 나는 누구나 자유롭게 사용할 수 있도록 MIT로 지정했다.

 

TypeScript 설치

정적 타입 체크를 통한 런타임 에러 감소, 자동완성, 가독성 등 다양한 이점을 가진 타입스크립트는 필수다.
 

pnpm i react typescript @types/node -D

 

React 설치

pnpm i react @types/react -D

 
리액트로 만들어진 컴포넌트와 훅을 사용할 것이기 때문에 리액트가 

타입스크립트뿐만 아니라 리액트도 devdependencies 처리가 되어야 한다.
 
라이브러리를 개발하는 환경에서만 리액트를 사용하고, 라이브러리를 실제 사용하는 곳에서는 해당 환경의 리액트를 사용하기 때문이다.
 
이에 따라 라이브러리를 직접 사용하는 환경에서는 리액트가 필수이므로 peerdependencies가 추가되어야 한다.
 

라이브러리에서 리액트의 Hook을 사용해야 하기 때문에, peerDependencies를 리액트 16.8 이후의 버전으로 설정했다.
 

모듈 구현

먼저 useToggle이라는 hook을 배포해 보기로 했다.
 

// src/hooks/useToggle.ts

import { useState } from 'react';

type ReturnTypes = [boolean, () => void];

export function useToggle(initialValue = false): ReturnTypes {
  const [value, setValue] = useState(initialValue);

  const onToggle = () => setValue(!value);
  return [value, onToggle];
}

 
useToggle은 value값을 토글하는 함수와 그 값을 리턴하는 훅이다.
 
타입스크립트로 작성했지만, 라이브러리로 사용하기 위해선 자바스크립트 코드가 필요하다.

즉 자바스크립트 코드로 컴파일해야 한다는 소리다.

터미널에 tsc 명령어를 입력하면 tsconfig.json 설정에 따라 컴파일된 자바스크립트 코드를 얻을 수 있다.

pnpm tsc

 

 
tsconfig.json 기본 값으로 target이 ES2016, module이 CommonJS로 되어 있어 위와 같은 코드가 나타났다. 
 
outDir 속성만 dist로 설정하여 컴파일된 결과물이 dist 폴더에 저장될 수 있도록 했다.
 

npm 배포


먼저 pnpm login을 입력하여 npm에 연결한다. (npm login도 가능하다.)

npm 계정이 없다면 npm 저장소에서 계정을 생성한다.
 

 
계속 진행하면 새 브라우저가 열리고 이곳에 메일에 전달된 OTP 번호를 입력하면 연결이 완료된다.
 

pnpm whoami  // 정상적으로 로그인 되었는지 확인할 수 있다.
pnpm publish  // npm에 배포

 
pnpm publish는 npm publish와 동일하게 npm에 배포하는 역할을 수행한다.
 
하지만 pnpm publish에는 의도하지 않은 배포를 막기 위한 안전장치가 되어있다.
 

git 저장소가 깨끗한 상태인지 확인하여 변경 사항이 커밋되지 않았거나 stash 되지 않았다면 에러를 발생시킨다.

 

--no-git-checks 옵션을 사용하면 git 작업 트리의 상태를 확인하지 않고 배포할 수 있다.

 

모듈 지원

새로 프로젝트를 만들어서 배포된 라이브러리를 사용하면 다음과 같은 에러가 나타난다.

 
현재 배포된 라이브러리는 CommonJS 형식으로 컴파일되었는데, package.json에는 ESModule로 명시되어 있기 때문이다.
 

 
따라서 라이브러리의 "type"를 CommonJS로 설정하든, 자바스크립트 형식을 ESModule로 컴파일하든 하면 된다.
 
하지만 Server-side Rendering을 지원하기 위해서는 CJS가 필요하고, Tree-shaking을 쉽게 하기 위해서는 ESM이 필요하다.
 
두 기능 모두 포기할 수 없기에 CJS와 ESM 둘 다 지원할 수 있는 라이브러리를 만들어 보기로 했다.
 
처음에는 번들러의 필요성을 느끼지 못하여 타입스크립트의 컴파일 기능만으로 해당 기능을 구현하려고 했다.
 
하지만 CJS와 ESM을 동시 지원하기 위해선 컴파일된 결과물에 .cjs 또는 .mjs를 붙여줘야 하는데 타입스크립트는 확장자를 제어하는 기능이 없다.

이를 해결하기 위해 Bundler를 도입하기로 했다.
 

Bundler 도입

컴파일 결과물의 확장자를 바꾸기 위해서 선택할 수 있는 방법으로 두 가지가 있다.
 
첫 번째는 자바스크립트 파일로 컴파일한 후 파일 확장자를 변경하는 스크립트를 실행하는 것이다. 하지만 이 방법은 컴파일 한 파일을 한 번 더 조작해야 하는 절차가 늘어나는 단점이 있다.
 
두 번째는 webpack이나 rollup과 같은 모듈 번들러를 사용하여 원하는 확장자로 출력하는 것이다. 별도로 확장자를 변경하는 작업 없이도 원하는 확장자로 만들 수 있기에 나는 번들러를 사용하기로 했다.
 
그렇다면 여러 번들러 중 어떤 번들러를 사용하는 게 best practice일까
 

  • Webpack
    • 장점
      • 다양한 loader와 plugin을 지원하여 복잡한 작업을 처리 가능
      • code splitting과 lazy loading 등 다양한 최적화 기능 지원
    • 단점
      • 상대적으로 설정이 복잡하여 초기 설정에 많은 시간 소요
      • 상대적으로 느린 빌드 속도
  • Vite
    • 장점
      • 개발 서버 구동 시간이 매우 빠름
      • CSS 빌드 최적화 (참고)
    • 단점
      • 아직 웹팩만큼의 다양한 플러그인과 커뮤니티를 가지지 못함
      • 웹팩에 비해 디테일한 설정이 제한적
  • Rollup
    • 장점
      • 상대적으로 가벼운 번들 사이즈
      • 번들링 방식의 차이로 더 빠른 속도 
      • 모듈을 함수로 감싸서 평가하는 방식을 사용하는 웹팩과 달리 모든 모듈을 호이스팅하여 성능 향상
    • 단점
      • 비트, 웹팩에 비해 애플리케이션 빌드에 부적합
      • 웹팩에 비해 부족한 최적화 기능

 
일반적으로 웹팩과 비트는 애플리케이션 빌드에 사용되고 라이브러리 빌드의 경우 롤업을 종종 사용한다.
 
애초에 상대적으로 무거운 웹팩은 고려 대상이 아니었고 비트와 롤업 중에서 고민이 되었다.
 
하지만 라이브러리 생태계에서 롤업이 커뮤니티와 자료가 가장 활성화되어 있고, 현재 구현에 필요한 기능을 고려했을 때 롤업이 적절하다 판단하여 롤업을 사용하기로 결정했다.
  

TypeScript Plugin

 롤업이 타입스크립트 파일을 번들링 하기 위해선 플러그인이 필요하다.
 
타입스크립트 플러그인으로 @rollup/plugin-typescript과 rollup-plugin-typescript2 중 어떤 것을 사용할지 고민이 되었는데 사람들마다 각 플러그인을 체감하는 성능이 달라서 결정하기 쉽지 않았다.
 

https://npmtrends.com/@rollup/plugin-typescript-vs-rollup-plugin-typescript2
  • rollup-plugin-typescript2
    • npm 다운로드 수가 더 많음 (더 보편적으로 사용된다.)
    • 빌드 시간을 줄이기 위한 캐싱 기능 제공
    • 특정 상황에 빌드 속도가 현저하게 떨어짐 (참고)
  • @rollup/plugin-typescript
    • 롤업이 유지보수하는 공식 플러그인
      • 안정성과 호환성 기대
    • 벤치마크 비교시 더 빠른 속도 (참고)
      • npm 다운로드 수는 품질을 나타내는 지표가 아니라 레거시를 나타내는 지표일 수도 있음
      • rollup-plugin-typescript2는 기존 rollup-plugin-typescript(옛날 롤업에서 지원했던 공식 타입스크립트 플러그인)의 문제로부터 만들어졌다. 하지만 롤업에서 다시 @rollup/plugin-typescript라는 플러그인을 만들었고, 지금까지 잘 관리되고 있다. (참고)

 
npm 다운로드 수에서 차이가 나는 것은 초창기 공식 플러그인의 부진으로 인한 것 같다.
 
점차 탄탄한 기반으로 앞서나갈 것으로 생각되는  @rollup/plugin-typescript을 도입하기로 했다.
 

@rollup/plugin-terser

번들을 극한으로 최적화해 주는 플러그인도 추가했다.
 

terser 적용 전

 

terser 적용 후

 
 

pnpm i rollup @rollup/plugin-typescript @rollup/plugin-terser  -D

 

번들링

플러그인까지 설치한 후 rollup.comfig.js를 다음과 같이 설정했다.

// rollup.config.js

import teser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';

export default [
  {
    input: 'src/index.ts',
    output: [
      { file: 'dist/index.cjs', format: 'cjs' },
      { file: 'dist/index.mjs', format: 'esm' },
    ],

    plugins: [typescript(), teser()],
  },
];

 
 output을 통해 CommonJS와 ESModule을 사용하는 두 개의 파일로 번들링 될 수 있도록 했다.
 

pnpm rollup -c

 
rollup -c 명령어는 rollup.config.js의 설정을 적용해 번들링한다.
 
번들링으로 만들어진 두 개의 파일 index.cjs와 index.mjs를 npm에 배포했다.
 

타입 지원

라이브러리에 타입을 추가하면 다음과 같은 장점이 있다.
- 문서화 효과
- 코드 가독성 향상
- 자동완성 지원
 
타입 지원은 d.ts 파일을 함께 배포함으로써 가능하다.
 
이에 따라 컴파일시 자동으로 d.ts 파일을 만들어 주기 위해  tsconfig.json의 "declaration":true를 추가했다. 
 


pnpm rollup -c 명령어를 이용하여 다시 빌드하면 위와 같이 d.ts 파일이 생긴다.
 
이것을 배포하면 라이브러리에 다음과 같이 타입을 확인할 수 있다.
 

 

이슈

새로 만든 프로젝트에서는 잘 작동하지만 기존 프로젝트에서 라이브러리를 인식하지 못하는 이슈가 발생했다.
 

 
다른 라이브러리를 추가해 봤을 때는 이상이 없는 것으로 보아 나의 라이브러리에 문제가 있는 건 확실했다.
 
먼저 타입스크립트 에러로 판단하여 해당 환경의 tsconfig를 확인해 보았다. 
 
"moduleResolution" 즉 타입스크립트의 모듈을 찾는 방식을 확인해봐야겠다는 생각이 들었다.
 
기존 "moduleResolution"의 값은 "Node"였다. 이것을 "NodeNext"로 변경해 보았다.
 

 
라이브러리가 정상적으로 인식되었다.
 
"Node"에서는 안되고 "NodeNext"에서는 된다라
 
이건 분명 Node 버전과 관련이 있다.
 
지금까지 알아낸 것을 정리하면 
 
1. 나의 라이브러리에 문제가 있다.
2. Node10의 모듈 해석 방식과 호환이 되지 않는다.
 

 
"moduleResolution"이 "NodeNext"일 경우 "exports" 필드를 해석하여 모듈을 찾는다.
 
하지만 "Node"의 경우에는 "exports" 필드의 해석이 불가능해 "main" 필드를 해석하여 패키지의 진입점을 찾는다.
 
이에 따라 "main" 필드에 문제가 있다고 판단할 수 있었고, 경로를 잘못 입력하는 실수를 했음을 알게 됐다.
 

Module System에 따른 올바른 확장자 사용

토스 기술 블로그의 CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기 라는 블로그를 보면 Module System에 따른 올바른 확장자 사용
 

 

후기

사실 라이브러리를 만들고 npm에 배포하는 것이 어려운 작업은 아니다.
 
인터넷에 나와있는 자료들을 보면서 그대로 따라 하면 금방 끝나는 작업이다.
 
하지만 나는 완벽한 라이브러리를 완벽한 이해도를 통해 만들어 보고 싶었다.
 
여러 유명 오픈 소스와 레퍼런스를 참고하면서 ‘얘네는 왜 이렇게 했을까’ 하나하나 고민하면서 한 땀 한 땀 코드를 작성했다.

시간은 오래 걸렸지만 덕분에 타입스크립트, 노드, 번들러, 모듈 시스템에 대한 깨달음을 얻었다.

처음에는 단순히 내가 사용할 목적으로 라이브러리를 만들었지만 지금은 많은 사람들이 사용했으면 하는 욕심이 생겼다.

지속적으로 유지보수하며 갈고 닦아 유명 라이브러리까지는 힘들겠지만 오픈 소스 생태계에 손톱만큼이라도 좋은 영향이갔으면 좋겠다.

Reference 

https://www.evolvingdev.com/post/webpack-vs-rollup
https://toss.tech/article/commonjs-esm-exports-field
https://github.com/rollup/plugins/tree/master/packages/typescript
https://github.com/ezolenko/rollup-plugin-typescript2 
https://github.com/rollup/plugins/issues/541 
https://twitter.com/atcb/status/1634653474041503744
https://twitter.com/atcb/status/1634653474041503744
https://www.youtube.com/watch?v=mee1QbvaO10

'Web > React' 카테고리의 다른 글

Recoil을 알아보자  (0) 2022.04.28