나의 첫 라이브러리 Flame
Headless UI 라이브러리 Flame에 대한 나의 첫 npm publish 회고
평소와 다름없이 일을하던 와중에 왠지 모르겠지만 “돈이 드는것도 아닌데 아무거나 npm에 올려보는겻 또한 값진 경험이되지 않을까?” 라는 생각이 들었다.
마침 사내 디자인시스템도 갈아 엎었어야 했고 적용해 보고싶었던 모노레포 구조도 있었기때문에 겸사겸사 Headless UI 라이브러리를 개발해보기로 했다.
모노레포 구조
packages
- ui
apps
- storybook
- docs
configs
거창한 구조는 아니지만 configs를 따로 빼서 각 모노레포가 공유하는 형태에 대한 trade-off가 꽤나 궁금했었다.
현재구조론 configs를 따로 둘 이유는 없어보이지만 ui 패키지 말고도 별도 table도 계획을 했었기때문에 이렇게 진행했다. 구조도 minimal해서 turbo-repo같은 tool도 사용하지않고 그냥 scripts로써 다 처리했다.
biome-config, ts-config, tsup-config의 base를 만들고 각 모노레포에서 base기반으로 확장하는 형태를 생각하고 구성했고 나름 만족스럽긴 했다만 어쩌다 한번 확인하는 config파일이 local-config -> base-config 단계를 거쳐서 확인하는점은 살~짝 불편한 포인트긴 했다.
CI/CD
Github-Action을 사용했다. Jenkins만 직접구성해보고 Github-Action은 처음해봤는데 말도안되게 편하긴했다.. public레포는 무료라고하니 안쓸 이유가없었다.
Docs페이지는 Cloudflare Pages를 사용했다. cli툴로 action과 연동해서 deploy도 간편했고 거의 무료니까 선택했다.
Docs
초기엔 SEO가 필요하니 별생각없이 Next.js로 작성했다. 하지만 회고 블로그를 작성하기로 마음먹음 -> 블로그는 Astro인데? -> Docs도 Astro가 아닐이유가 있나? 라는 생각의 흐름으로 Astro로 재작성을 하게됐고 Next.js보다 몇배는 만족스러운 결과가 나왔다.
i18n 및 mdx 적용이 next.js보단 훨씬 간편했고 Docs는 100% SSG일거니까 별다른 기능도 필요없어서 더 가벼운 Astro가 맞는 선택이었다. 다크모드 부분에서 문제가 좀 있어 확인을좀 해봤다.
<script is:inline data-astro-rerun>
function applyTheme() {
const theme = document.cookie
.split('; ')
.find((row) => row.startsWith('flame-theme='))
?.split('=')[1];
document.documentElement.classList.toggle('dark', theme === 'dark');
}
applyTheme();
</script>
기본적으로 SSG기 때문에 빌드타임때 cookie나 web-storage에 dark-mode값을 넣을 순 없다. 하여
Astro는 기본적으로 처리를 빌드타임때 type="module"로 가공해서 전달한다. module script의 기본은 defer기 때문에 다운로드는 dom과 병렬로 이루어지지만 실행시점은 결국 dom 파싱 이후다.
그래서 default-theme가 보였다가 세팅된 theme로 보여지는 플리커 현상이 존재했다. 하여 is:inline을 넣어 작성한 그대로 전달되게 하도록 했다.
플리커는 수정됐다. 다만 라우팅시 다크모드가 깨지는 현상이 있었다. 이는 View Transition API와 연관이 있었다. Astro에서는 해당 api에 대한 <ViewTransition /> 컴포넌트를 제공하지만, 이는 곧 SPA모드를 켠다는것과 다름없었다.
하여 새로운 페이지를 매번 받아오는게 아니니 스크립트내 함수가 실행될리 없었고 다크모드적용이 안됐던거다.. 간단하게 data-astro-rerun를 추가해서 transition이후 해당 스크립트가 실행되도록 하니 해결됐다.
(Astro v6부터는 ViewTransition 대신 ClientRouter로 아예 대체된 것 같다)
번들러
당연히 vite를 쓰면 되겠지 생각했는데, 어차피 storybook으로 테스트를 진행하기도 했어서 개발서버도 필요없고 해서 서칭을 좀 해보니 tsup이란 번들러가 있었다. 라이브러리 개발시 많이 사용하기도하고 esbuild 기반이라 빠르면서 가볍기까지해 선택을 안할 이유가 없었다. --watch모드를 통해 마치 개발서버가 켜져있는듯한 개발경험을 제공했다.
import { packageBundleConfig } from "@flame/configs";
import { defineConfig } from "tsup";
export default defineConfig({
...packageBundleConfig(),
entry: ["core/index.ts"],
banner: {
js: "'use client'",
},
});
comppound-pattern이라 root-children간 상태 공유를 위해 useContext 및 use hook을 사용하기때문에 그냥 client component를 강제했다
AI Agent의 역할
많은 도움을 줬다.. testcode, storybook, docs작성을 도와줬고 ui쪽은 설계가 끝난 이후 손으로 코드를 작성해야 할때 적극 사용해줬다. 에이전트사용을 하면할수록 느끼지만 이해하지 못했으면 사용을 지양하고,설계 및 방향은 본인이 책임져야 한다는거다.
a11y 적용
정~말 지루한과정이었다.. b2b만 개발했어서 크게 신경을 안쓰기도 했었고 스크린리더를 내가 직접 쓸 일이 없기때문에 사용자에게 어떻게 적용되는지 보이지가 않았기 때문이다.
하지만 Toss 접근성 체험하기란 좋은 컨텐츠를 발견하고 직접 체험해보니 필요성을 절실하게 느꼈다.
flame에서 제공하는 Drawer컴포넌트는 div태그를 써서 구현이 되었지만 a11y를 적용하기 위해 이리저리 작업하고 있는것을 보면 갑을관계가 바뀐 느낌을 계속 받아서 다 reset하고 <dialog />로 재개발을 하니 코드도 그렇고 광명을 찾았다.
항상 시멘틱을 우선시 해야한다는 교훈을 얻었다. 이런것들때문에 사이드잡을 계속 해야하는구나 다시한번 느꼈다.
package.json내에서 좀더 많은 정보를 취득할 수 있게 됐다
직접 publish하지 않고 개발만할때는 사실 scripts 나 dependencies항목 정도만 알아도 전혀 문제는 없었지만 직접 만들어보니 알게되는 필드가 꽤많았고 뇌빼고 세팅했던 것들이 좀더 명징해졌다.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
]
}
exports는 진입점을 명시하는 곳인데 types가 먼저 명시되어야지 ts엔진이 type찾은 이후 import로 넘겨서 문제가없는걸 깨달았다. main,types필드는 legacy지만 exports필드를 이해 못하는 도구를위해서 있어야한다고해서 넣어줬다.
files가 npm에 publish될 파일이다. 추가로 version 필드를 수기로 수정하는줄 알았는데,, version patch, minor, major 등 패키지매니저 명령어로써 자동 업데이트가 되더라,, 별거아니지만 놀라운 포인트였다
마치며
flame은 디자인 시스템의 뼈대가되는 라이브러리라면 이런구조면 좋지않을까의 생각 즉 내가일하면서 불편했던게 모여져서 만들어진 결과물이다.
물론 radix나 base-ui, 이들을 베이스로한 shadcn-ui등 선택지가 많지만 그래도 한번은 불만을 해소하고싶었다. 아직 라이브러리의 관점에서 개발하기엔 경험이 많이 부족하고 테스트 커버리지또한 충분하지않아서 문제가 많이 있을순 있겠지만, 시작이 반이라고 npm에 발자국 하나 남긴게 큰 터닝포인트가 될 것 같은 느낌이든다. 즐거웠다🔥