아직 부족한 게 정말 너무 매우 엄청나게 많지만, 작게나마 디자인 시스템 구축을 했었어요.
최근에 개인적으로 의존성 관련 공부를 하다보니 package.json
와 빌드 시 신경 써줄 수 있는 부분이 생각보다 세세하고 재밌더라구요.
그래서 디자인 시스템 패키지을 가지고 직접 처음 해보려고 합니다. 이 과정에서 NPM 배포를 위해 고려하고 설정한 내용들을 함께 남겨보려고 합니다.
제가 개발하고 있는 환경은 다음과 같아요.
"turbo": "^2.0.4"
"react": "^18"
"typescript": "^5"
"@emotion/react": "^11"
터보레포를 사용하고 있고, 내부에 존재하는 @sambad/sds
라는 패키지를 배포하려고 해요.
🤔 뭘로 하지?
우선 @sambad/sds
패키지는 단순히 UI 관련 모듈들을 모아둘 목적으로 생성된 단순 패키지로 번들러는 없었어요. 그래서 번들러를 선택해야 했습니다.
제가 후보로 고민했던 번들러들은 다음과 같아요.
저는 프로덕션 빌드 시에만 번들러가 필요해서, 프로덕션 빌드타임만을 비교해보려고 합니다.
🤖 turbopack
Next.js
에서만 사용 가능Webpack
사용⚡️ vite
Rollup
을 선택Esbuild
를 사용tsc
대비 20~30배 빠른 퍼포먼스@vitejs/plugin-legacy
사용해서 가능vite-plugin-dts
플러그인을 추가하여 모듈마다 .d.ts
파일 자동 생성 가능🤚 vite로 결정했어요
사실 turbopack
이 아직은 범용적으로 사용할 수는 없어, 자연스레 vite
가 되긴 했습니다.
제가 직접 벤치마크를 측정해보진 않았지만, 해당 포스팅을 참고했을 때 모든 부분에서 vite
> turbopack
이였습니다.
Webpack
을 비교 대상에서 제외했던 이유는, 필수적으로 해아하는 보일러플레이트 량이 꽤 된다는 것이 가장 큰 이유였습니다. 또, 위 벤치마크 표에서도 알 수 있듯 Webpack
의 성능은 vite
와 비교했을 때 약 10배 정도의 차이가 있었습니다.
vite
를 install하고 package.json
에 build
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에 대해 아주 잠깐 언급되었어서, 알아봐야겠다~ 했었는데 번들 사이즈를 위해 중요한 역할을 하는 package.json
의 필드여서 이번 글에도 포함하게 되었어요.
간단하게 개념을 살펴보고 제가 어떻게 적용을 했는지 남겨보려고 합니다.
🤔 peerDependencies?
react
와 react-dom
을 peerDependencies
로 지정하면, 이 라이브러리를 사용하는 프로젝트에서 반드시 해당 버전의 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-is | to-be |
---|---|
186.52kB | 34.77kB |
이런 경험이 처음이다보니, 많은 레퍼런스들을 보게 되었었어요. 보다보면, package.json
에 이런 필드들이 있었어요.
"type": "module",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
ESM
과 CJS
에 대해서도 지식이 얕아서 관련되어 알아보고, 어떻게 적용했는지도 기록해보려고 합니다.
🤔 둘은 뭐길래?
ESM
과 CJS
는 자바스크립트 모듈 시스템입니다.
모듈 시스템에서의 모듈
은 프로그램을 구성하는 시스템을 기능 단위로 독립적인 부분으로 분리한 것을 의미합니다.
모듈 시스템은 모듈을 정의하고, 가져오고, 관리하는 규칙과 방법을 제공합니다. 자바스크립트에서는 ESM(ECMAScript Modules)
와 CJS(CommonJS)
두 가지 주요 모듈 시스템이 있는 것이죠!
🤨 ESM(ECMAScript Modules)
import
와 export
키워드를 사용😑 CJS(CommonJS)
require()
와 module.exports` 사용🥸 라이브러리 관점에서 둘 다 지원해야 하는건가?
ESM
라는 새로운 모듈 시스템이 추가가 된 것CJS
사용하고 있을 것🙂 package.json에서 type, main, module 필드
type
: "module" | "commonjs"main
module
main
필드와 유사한 목적으로 사용되는 필드ESM
환경에서 패키지를 사용할 때 진입되는 경로🤔 아무 생각없이 ESM으로 개발한 것 같은데..뭐지?
최신 스캐폴딩 도구를 사용하면, package.json
에서 type
필드를 module
로 설정해주기 때문인데요.
type
필드 값에 따라 .js
가 어떻게 처리될지가 결정됩니다.
아니면, 직접적으로 확장자명을 입력하여 처리되도록 하는 방법도 있습니다.
.mjs
: ESM 파일로 인식.cjs
: CJS 파일로 인식그래서 JS 파일이 CJS
인지 ESM
인지 확인하려면 파일 확장자, package.json
의 type
필드, 사용된 모듈 구문을 확인하면 됩니다.
이제 위애서 알아본대로 프로젝트가 ESM으로 동작할 수 있도록 "type": "module"
을 명시해주었습니다.
"type": "module"
그랬더니 아래와 같이 갑자기 와장창 타입 에러가 발생했습니다.
이렇게 설정한 순간부터는 import/export
문이 ESM 규칙을 따라야합니다. 이 규칙 중에는 파일 확장자를 명시해야 하는 규칙이 존재합니다.
명시하라고 타입 에러가 잔뜩 난 것이죠.. 🥹
그렇지만 타입 에러이기 때문에 tsconfig를 설정하면 컴파일러가 알아서 잘 인식해줄 것 같기도 합니다. 결론적으로는 아래와 같이 설정하면 해결됩니다.
{
"compilerOptions": {
"moduleResolution": "Bundler"
}
}
🤔 moduleResolution
다음과 설정할 수 있는 값들입니다.
node
, node16
, nodenext
: ESM, CJS 모두 지원. 모듈을 찾는 방식이 조금씩 다르긴 하다classic
: 초기 모듈 해석 방식. node_modules를 통해 모듈 탐색 Xbundler
: 번들러에 맞춰 해석 가능하도록. 파일 확장자 생략 가능그래서 저는 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.json
에 type
필드를 설정하지 않아 CJS로 동작해서 tsconfig
의 module: "NodeNext"
가 짝짝꿍 잘 동작했던 것이라고 해봅시다.
그렇다면 저는 이때까지 어떻게 import/export
문을 사용할 수 있었던걸까요?
Node.js 12 이상부터는 package.json에서 명시하지 않아도 ESM으로 처리가 가능하다고 합니다. TS 컴파일러도 파일에 import/export 문이 포함되어 있다면 이를 인식하고 변환이 가능하다고 합니다.
😟 아무튼 난 ESM으로 만들었는데, CJS도 지원하려면?
운좋게도 vite
에서 ESM
와 CJS
를 둘 다 지원할 수 있도록 기능을 제공합니다.
// 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",
레퍼런스들을 보면 package.json
에 types
필드에 *.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 폴더를 생성하는 것이죠!
🫢 이제 배포할 준비가 된건가..!!!!!!!!!
아래에서 버전 관리에 대해 다뤄볼 것이긴 하지만, 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
을 정리하니 빌드에 성공할 수 있었습니다.
1. npm 회원가입하기
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 자동 배포도 구축해보고 싶고, 디자인 시스템도 성장시키고 싶고 해보고 싶은게 많습니다 🤓
잘 읽었습니다!
module.exports` 이 부분 하이라이팅하고 싶으셨던 것 같아 댓글 달아요!