작고 소중한 디자인 시스템을 NPM 배포해보자

Doeunnkimm·2024년 9월 19일
20

FE log

목록 보기
6/6
post-thumbnail

@sambad/sds

아직 부족한 게 정말 너무 매우 엄청나게 많지만, 작게나마 디자인 시스템 구축을 했었어요.

최근에 개인적으로 의존성 관련 공부를 하다보니 package.json와 빌드 시 신경 써줄 수 있는 부분이 생각보다 세세하고 재밌더라구요.

그래서 디자인 시스템 패키지을 가지고 직접 처음 해보려고 합니다. 이 과정에서 NPM 배포를 위해 고려하고 설정한 내용들을 함께 남겨보려고 합니다.

제가 개발하고 있는 환경은 다음과 같아요.

  • "turbo": "^2.0.4"
  • "react": "^18"
  • "typescript": "^5"
  • "@emotion/react": "^11"

터보레포를 사용하고 있고, 내부에 존재하는 @sambad/sds 라는 패키지를 배포하려고 해요.

Bundler를 골라보자, vite ⚡️

🤔 뭘로 하지?

우선 @sambad/sds 패키지는 단순히 UI 관련 모듈들을 모아둘 목적으로 생성된 단순 패키지로 번들러는 없었어요. 그래서 번들러를 선택해야 했습니다.

제가 후보로 고민했던 번들러들은 다음과 같아요.

저는 프로덕션 빌드 시에만 번들러가 필요해서, 프로덕션 빌드타임만을 비교해보려고 합니다.

🤖 turbopack

Why Turbopack?

  • incremental computation → 변경 사항을 효율적으로 처리하여 재구축 시간을 줄이고 개발 생산성을 향상
  • 모든 함수의 결과를 캐싱 → 동일한 작업을 두 번 수행할 필요 X
  • 현재는 Next.js에서만 사용 가능
    • 개발 서버에만 내장
    • Next.js도 프로덕션 빌드 시에는 Webpack 사용

⚡️ vite

https://vitejs.dev/

  • Esbuild(개발 모드) + Rollup(프로덕션 빌드)
    • Esbuild가 현재 어떤 번들링 툴 보다 가장 빠른 성능을 자랑하지만
    • 아쉬운 생태계와 브라우저용 번들링에서 아직은 다른 툴보다 안정성이 떨어진다는 평가가 있어
    • 프로덕션 빌드 시에는 안정성과 생태계가 비교적 더 큰 Rollup을 선택
  • Typescript 는 Esbuild 를 사용
    • tsc 대비 20~30배 빠른 퍼포먼스
  • es2015 이상만을 지원
  • 라이브러리 모드 지원
    • build 의 lib 옵션과, rollupOptions 으로 빌드시에 번들링에서 제외할 라이브러리 등 여러 설정을 쉽게 가능
    • TS로 작성한다면 vite-plugin-dts 플러그인을 추가하여 모듈마다 .d.ts 파일 자동 생성 가능

🤚 vite로 결정했어요

사실 turbopack이 아직은 범용적으로 사용할 수는 없어, 자연스레 vite가 되긴 했습니다.

제가 직접 벤치마크를 측정해보진 않았지만, 해당 포스팅을 참고했을 때 모든 부분에서 vite > turbopack 이였습니다.

Webpack을 비교 대상에서 제외했던 이유는, 필수적으로 해아하는 보일러플레이트 량이 꽤 된다는 것이 가장 큰 이유였습니다. 또, 위 벤치마크 표에서도 알 수 있듯 Webpack의 성능은 vite와 비교했을 때 약 10배 정도의 차이가 있었습니다.

vite 시작하기

vite를 install하고 package.jsonbuild script만 추가한 후, 기본적인 config만 추가하면 기본적인 build는 실행됩니다.

// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'index',
      fileName: 'index',
    },
    outDir: 'dist',
  },
});

peerDependencies

최근에 어떤 자리에서 peerDependencies에 대해 아주 잠깐 언급되었어서, 알아봐야겠다~ 했었는데 번들 사이즈를 위해 중요한 역할을 하는 package.json 의 필드여서 이번 글에도 포함하게 되었어요.

간단하게 개념을 살펴보고 제가 어떻게 적용을 했는지 남겨보려고 합니다.

🤔 peerDependencies?

  • 특정 패키지가 다른 패키지의 특정 버전이 설치되어 있어야 함을 명시하는 데 사용
  • 예를 들어, React 관련 라이브러리에서 reactreact-dompeerDependencies로 지정하면, 이 라이브러리를 사용하는 프로젝트에서 반드시 해당 버전의 React가 설치되어 있어야 함을 의미

🥸 무엇을 위해 필요한걸까?

호환성 보장

특정 라이브러리와 함께 사용될 다른 라이브러리의 버전을 명시하여, 충돌이나 호환성 문제를 방지

💡 보통은 여러 개를 허용해주거나, 특정 버전 이상으로 명시해주어 폭넓게 허용

아래는 대표적인 디자인 시스템 패키지들의 package.json 내 peerDependencies 필드예요.

그래서 peerDependencies는 사용처 버전에 따라 갈 수 있도록 통합 패키지에서는 devDependencies로 사용하거나 개별 패키지를 제공하는 경우(모노레포를 사용) 번들에 포함되지 않도록 하여 사용합니다.


🤨 peerDepencies에 설정해주고, dependencies에서 제거해주었다

저는 모노레포를 사용하고 있어서, 아래와 같이 명시해주었어요.

// as-is
"dependencies": {
  "@emotion/react": ">=11",
  "react": ">=18",
  "react-dom": ">=18"
},

// to-be
"peerDependencies": {
  "@emotion/react": ">=11",
  "react": ">=18",
  "react-dom": ">=18"
},

이제 해당 패키지에서 해당 패키지들을 번들에 포함하지 않고, 사용처에서 설치된 버전에 맞게 사용되는 것을 상상했기 때문에 dependencies 필드에서는 제거해주었어요.

번들에서도 제외하기

위와 같이 package.json을 구성하고 build를 하게되면 제외될 거라고 생각했습니다. 🤔 dependencies에 명시 안 했으니까 포함 안 되지 않을까? 했는데 포함되었습니다.

이미 코드 곳곳에는 peer로 넣어둔 라이브러리를 사용하고 있긴 하므로 번들에 포함되었던 것입니다.

그렇지만, 저는 이걸 바란 게 아니였어요.

이 라이브러리에서는 이 번들을 포함하지 않고, 사용처에서 이미 사용하고 있는 버전의 패키지와는 다르게 중복되어 들어가지 않는 것과 사용처에서의 패키지가 사용되기를 원했습니다(제가 peer로 설정한 버전과 호환이 되어야겠지만요). peer로 추가한 이유도 마찬가지였습니다.


🥳 vite로 쉽게 제외하기

vite.config.ts파일로 아주 쉽게 번들에서 제외할 수 있었어요.

import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    rollupOptions: {
      external: ['react', 'react-dom', '@emotion/react'], // 번들 포함 X
    },
    ...
  },
});

번들에서 제외하고 번들 사이즈를 약 81.2%나 줄일 수 있었어요.

as-isto-be
186.52kB34.77kB

ESM, CJS

이런 경험이 처음이다보니, 많은 레퍼런스들을 보게 되었었어요. 보다보면, package.json에 이런 필드들이 있었어요.

"type": "module",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",

ESMCJS에 대해서도 지식이 얕아서 관련되어 알아보고, 어떻게 적용했는지도 기록해보려고 합니다.


🤔 둘은 뭐길래?

ESMCJS는 자바스크립트 모듈 시스템입니다.

모듈 시스템에서의 모듈은 프로그램을 구성하는 시스템을 기능 단위로 독립적인 부분으로 분리한 것을 의미합니다.

모듈 시스템은 모듈을 정의하고, 가져오고, 관리하는 규칙과 방법을 제공합니다. 자바스크립트에서는 ESM(ECMAScript Modules)CJS(CommonJS) 두 가지 주요 모듈 시스템이 있는 것이죠!


🤨 ESM(ECMAScript Modules)

  • importexport 키워드를 사용
  • 비동기 로딩: 모듈을 비동기적으로 로드 가능
  • 정적 분석: 코드가 실행되기 전에 의존성 분석 가능 → Tree-shaking 쉽게 가능
  • 정적인 구조로 모듈끼리 의존하도록 강제 → 불러온 값 수정 X

😑 CJS(CommonJS)

  • require()와 module.exports` 사용
  • 동기 로딩: 모듈을 동기적으로 로드
  • require/module.exports를 동적으로 하는 것에 제약 X = 불러온 모듈 수정 가능
    • 그래서 런타임에 동적으로 로드되고 수정될 수 있기 때문에, 코드가 실행되기 전에는 어떤 모듈이 어떤 의존성을 가질지 예측하기 어려움
    • 특정 조건에 따라 모듈을 다르게 로드하거나 수정하는 경우, 빌드 도구는 이러한 동적 관계를 분석 X

🥸 라이브러리 관점에서 둘 다 지원해야 하는건가?

  • Node.js 12부터 ESM라는 새로운 모듈 시스템이 추가가 된 것
  • 그래서, 이미 많은 프로젝트에서 CJS 사용하고 있을 것
  • 둘 다 지원하면, 사용처의 필요에 따라 하나를 선택 가능 → 유연성 증가

🙂 package.json에서 type, main, module 필드

  • type: "module" | "commonjs"
    • 모듈의 유형을 지정. 기본적으로 어떤 모듈 시스템으로 처리될지를 결정
    • 정의되어 있지 않다면 "commonjs"로 처리
  • main
    • 패키지를 불러올 때 기본적으로 사용할 진입점 파일을 지정
  • module
    • main 필드와 유사한 목적으로 사용되는 필드
    • ESM 환경에서 패키지를 사용할 때 진입되는 경로

🤔 아무 생각없이 ESM으로 개발한 것 같은데..뭐지?

최신 스캐폴딩 도구를 사용하면, package.json에서 type 필드를 module로 설정해주기 때문인데요.

type 필드 값에 따라 .js가 어떻게 처리될지가 결정됩니다.

  • "module"이면, ESM으로 처리
  • "commonjs"이면, CJS로 처리

아니면, 직접적으로 확장자명을 입력하여 처리되도록 하는 방법도 있습니다.

  • .mjs: ESM 파일로 인식
  • .cjs: CJS 파일로 인식

그래서 JS 파일이 CJS인지 ESM인지 확인하려면 파일 확장자, package.jsontype 필드, 사용된 모듈 구문을 확인하면 됩니다.


type 필드를 설정했더니 타입 에러 ?

이제 위애서 알아본대로 프로젝트가 ESM으로 동작할 수 있도록 "type": "module"을 명시해주었습니다.

"type": "module"

그랬더니 아래와 같이 갑자기 와장창 타입 에러가 발생했습니다.

이렇게 설정한 순간부터는 import/export 문이 ESM 규칙을 따라야합니다. 이 규칙 중에는 파일 확장자를 명시해야 하는 규칙이 존재합니다.

명시하라고 타입 에러가 잔뜩 난 것이죠.. 🥹

그렇지만 타입 에러이기 때문에 tsconfig를 설정하면 컴파일러가 알아서 잘 인식해줄 것 같기도 합니다. 결론적으로는 아래와 같이 설정하면 해결됩니다.

{
  "compilerOptions": {
    "moduleResolution": "Bundler"
  }
}

🤔 moduleResolution

  • TypeScript가 모듈을 찾는 방식을 지정
  • 모듈 해석 방식에 따라 import문을 해석하고 필요한 파일을 찾는 방법에 영향을 미침

다음과 설정할 수 있는 값들입니다.

  • node, node16, nodenext: ESM, CJS 모두 지원. 모듈을 찾는 방식이 조금씩 다르긴 하다
  • classic: 초기 모듈 해석 방식. node_modules를 통해 모듈 탐색 X
  • bundler: 번들러에 맞춰 해석 가능하도록. 파일 확장자 생략 가능

그래서 저는 bundler로 설정했습니다.


😟 "moduleResolution": "bundler"를 그냥은 못쓴단다

모노레포에서 base로 설정하고 있는 tsconfig에 moduleResolution만 오버라이드하여 설정해주려고 했더니 아래와 타입 에러는 만났습니다.

대충 module이라는 값을 맞추라는 에러 같아요 일단..?


🥹 tsconfig에서 module

TS 컴파일러가 어떤 모듈 시스템으로 해석할지를 결정하는 필드입니다.

사실 이 타입 에러 지옥은 package.json에서 "type": "module"로 설정한 후부터였는데요.

프로젝트 package.json에서 "type": "module"로 설정했다면, 프로젝트는 ESM 방식으로 동작하게 된다는 것이고 그렇다면 TypeScript도 ESM 방식으로 코드를 해석해야 합니다.

그래서 ESM으로 해석할 수 있는 ESNext | ES2015 이상으로 설정해야 합니다.

선택지가 아주 많군요!

보통 최신 스캐폴딩 도구를 사용하면 ESNext 혹은 NodeNext로 최신 문법을 사용할 수 있도록 설정되어지는 듯 합니다.

정말 최신의 문법을 사용한다면, 폴리필이 존재하는지 확인해야겠지만 저는 엄청나게 갓 나온 문법같은 건 사용하지 않았기 때문에..

그리고 특정 ES 버전으로 맞춰서 개발해야 하는 스펙도 없었기 때문에 ESNext로 설정해주었습니다. 또, 번들링 될 때 트랜스파일링 될 것도 생각이 들어 큰 문제는 없을 거라고 생각했습니다.

드디어 아무 문제 없이 빌드가 되는 것을 확인할 수 있었습니다 😂


🤔 근데 이때까지 왜 아무 문제 없이 동작했던거지?

이때까지 package.jsontype 필드를 설정하지 않아 CJS로 동작해서 tsconfigmodule: "NodeNext"가 짝짝꿍 잘 동작했던 것이라고 해봅시다.

그렇다면 저는 이때까지 어떻게 import/export문을 사용할 수 있었던걸까요?

Node.js 12 이상부터는 package.json에서 명시하지 않아도 ESM으로 처리가 가능하다고 합니다. TS 컴파일러도 파일에 import/export 문이 포함되어 있다면 이를 인식하고 변환이 가능하다고 합니다.

ESM, CJS 모두 지원하기

😟 아무튼 난 ESM으로 만들었는데, CJS도 지원하려면?

운좋게도 vite에서 ESMCJS를 둘 다 지원할 수 있도록 기능을 제공합니다.

// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    ...
    lib: {
      ...
      formats: ['cjs', 'es'],
    },
    ...
  },
});

그러면 아래와 같이 dist 파일 하위에 CJS 파일과 ESM 파일이 생성됩니다.

그럼 아래와 같이 package.json에 인식할 수 있도록 경로를 입력해주면 됩니다.

// package.json
"main": "./dist/index.js",
"module": "./dist/index.mjs",

.d.ts

레퍼런스들을 보면 package.jsontypes 필드에 *.d.ts 파일을 넣어주더라구요. 사실 .d.ts 파일에 대한 지식도 얕고 관련되어 어떻게 설정하는지 알아보려고 합니다.


😲 .d.ts ?

TypeScript에서 사용되는 타입 정의 파일입니다.

이 파일을 통해서 JS 코드의 타입 정보를 제공하고 TS가 해당 코드를 이해하고 타입 검사를 수행할 수 있도록 도와줍니다.


🧐 그래서 왜 필요한걸까?

TypeScript는 정적 타입 검사를 통해 코드 작성 시 오류를 사전에 발견할 수 있도록 도와주는 JavaScript의 상위 집합일 뿐이지, 브라우저와 런타임에서는 JavaScript만을 이해하고 실행할 수 있습니다.

따라서, 번들링될 때 TS파일들은 전부다 타입 정보가 제거된 JS파일로 변환됩니다.

그런데, 또 우리는 라이브러리를 가지고 개발할 때 다시 또 정적 검사가 필요하기 때문에 함수의 인자나 컴포넌트의 props나 타입 정보가 필요합니다.

이럴 때 타입 정보를 제공하기 위해 .d.ts 파일이 필요한 것이죠!


플러그인으로 쉽게 생성하기

😉 플러그인으로 가능!

vite-plugin-dts 라는 플러그인을 통해 간편하게 생성이 가능합니다.


🤔 근데 이 .d.ts 파일이 사용처에서는 어떻게 매핑되어 정적 검사를 제공해줄 수 있는걸까?

TypeScript 컴파일러는 코드 작성 시 .d.ts 파일을 참조하여 타입을 확인합니다.

생성된 부분을 자세히 보면 .d.ts.map이란 파일이 같이 생성되었어요. 이 파일은 소스 맵인데, 변환된 JS에서 원본 코드를 쉽게 찾을 수 있도록 도와줍니다.

이 소스 맵 파일이 없다고 해서 매핑될 수 없다는 것은 아닙니다. 즉, 없어도 매핑은 가능!


🤔 .d.ts.map 소스 맵 파일 없어도 된다면.. 있으면 뭐가 좋은걸까?

https://stackoverflow.com/questions/17493738/what-is-a-typescript-map-file

소스 맵 파일이 있으면, 코드 에디터나 브라우저에서 이 파일을 이용할 수 있어 JS 파일 대신 TS 파일에 직접 들어가 디버깅을 가능하게 해준다고 합니다.

어쨌든 이것도 파일이며 생성에 대한 리소스가 필요하기 때문에 빌드 시간이나 크기가 늘어날 것 같기도 합니다.

유명한 라이브러리들을 직접 빌드 돌려보고 소스 맵을 생성하는지 살펴보았는데요.

https://www.reddit.com/r/typescript/comments/uq6on1/declaration_files_needed_when_using_map_files/

위 페이지의 내용들은 조금 연식이 되긴 했지만.. 공감이 가서 가져왔는데요. 보통 디버깅이나 코드가 더 궁금하다면 공식 문서나 오픈 소스를 뜯어봄으로써 디버깅 한다고 생각합니다.

보통 라이브러리의 코드가 궁금해서 에디터를 통해서 들어가도 유의미한 정보를 얻기는 힘든 것 같아요.

그래서 내 라이브러리를 쓰는 사용자들의 디버깅을 위해 소스맵을 추가해야겠다!!! 라는 건 좀 어려울 것 같아요.

그래서 저는 소스 맵을 제외하려고 합니다.

소스맵 제외하기

이것도 tsconfig 파일을 통해 쉽게 제외할 수 있습니다.

// tsconfig.json
{
  ...
  "compilerOptions": {
    ...
    "declarationMap": false
  },
  ...
}

소스맵을 제외함으로 번들 사이즈의 축소를 기대했습니다.

아직 작고 귀여운 패키지이기에.. 소수점 2자리까지 나오는 번들 사이즈에는 차이는 없었습니다. 🤣

dist

그런데 빌드를 할 때마다 생성되는 dist 폴더는 어떻게 사용되는걸까요?

dist는 배포 파일로 볼 수 있는데요. 최종적으로 배포할 파일들이 포함됩니다.

번들러들이 소스 코드를 번들링하고 최적화하여 dist 폴더를 생성하는 것이죠!

🚀 배포!

🫢 이제 배포할 준비가 된건가..!!!!!!!!!

아래에서 버전 관리에 대해 다뤄볼 것이긴 하지만, Sementic Versioning에 따라 최초 개발 배포인 지금 0.1.0으로 업데이트 했습니다.

// package.json
"version": "0.1.0",

🙂 패키지에 대한 정보 입력해주기

// package.json
"license": "MIT",
"homepage": "https://github.com/depromeet/15th-team3-FE",
"repository": {
  "type": "git",
  "url": "https://github.com/depromeet/15th-team3-FE",
  "directory": "packages/core/sds"
},

그 전에..

난리가 났습니다.

번들에서 react를 제외하면서 고정해뒀던 버전들이 꼬인 걸로 추측됩니다.

고정되어 있던 버전들을 update 해줌으로써 사용처에서의 문제는 해결했지만

$ pnpm update

갑자기, 배포하려는 패키지에서 타입 에러 지옥에 빠지게 되었습니다 😅

원인은 이곳저곳 react 관련 패키지들이 버전이 꼬이면서 생기는 문제였습니다.

문제를 마주하게 되어 프로젝트의 package.json들을 열어보니 엉망이였습니다. 어디에는 react가 devDependencies에 들어가 있으며 (물론 소스코드에서 쓰여서 번들에 포함되긴 했나봅니다)

이곳 저곳 다른 버전을 쓰며 버전이 꼬여 에러가 발생한 걸로 보였습니다.

특정 패키지에서만 의존성을 가져야 하는 패키지와 공통으로 관리해야 할 패키지를 구분하여 package.json을 정리하니 빌드에 성공할 수 있었습니다.

진짜 npm publish

1. npm 회원가입하기

https://www.npmjs.com/signup

2. cli에서 로그인

$ npm login

아래 명령어를 실행해서 본인 username이 뜨면 로그인 성공입니다.

$ npm whoami

3. 배포

npm publish --access=public

모노레포 안에 있는 패키지를 배포하는 것이기 때문에 좀 더 설정이 필요합니다. 배포 명령어를 치면 아래와 같이 scope이 없다며 에러가 납니다.

NPM에서 Add Organization을 해줘야 합니다.

저 같은 경우에는 패키지명이@sambad/sds이므로 앞에 sambad를 입력해주었습니다.

완료 후 위 publish 명령어를 다시 입력해주면 배포 성공입니다 🎉

이제 배포된 패키지의 NPM 경로가 생기게 됩니다.

https://www.npmjs.com/package/패키지명

🚀🎉

버전 관리

업데이트 예정입니다 🎉

끝으로

번들러를 골라서 손수 빌드를 위한 것들을 챙겨준 것은 처음 경험한 작업이였습니다. 생각보다 배울 게 많았으며 문제를 겪으며 찾아보고 알게된 것들이 정말 재밌었습니다 🫢

아직 해보고 싶은 게 많아요. 버전 관리도 해보고 싶고, 버전 업데이트 시 npm 자동 배포도 구축해보고 싶고, 디자인 시스템도 성장시키고 싶고 해보고 싶은게 많습니다 🤓

Reference

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

1개의 댓글

comment-user-thumbnail
2024년 10월 3일

잘 읽었습니다!
module.exports` 이 부분 하이라이팅하고 싶으셨던 것 같아 댓글 달아요!

답글 달기