SCENE;er 디자인 시스템 만들기 (3)

1. React를 걷어내고 Web Components(Lit)로

처음에 7개 컴포넌트를 전부 React로 만들었다. Storybook에서 보기에는 멀쩍었는데, 라이브러리로 만들어서 빌드해보니 빌드 결과물 안에 React를 그대로 불러오는 코드가 박혀 있었다. "JS로 빌드됐다"는 말이 "React 없이도 쓸 수 있다"는 뜻은 아니었다. TSX 코드를 순수 JS 문법으로 컴파일해도, 그 안에서 forwardRef나 JSX 같은 React 기능을 부르는 코드는 그대로 남는다. 번역만 됐을 뿐, 의존성은 안 없어지는 거였다.

React 없이 순수 JS만으로 쓸 수 있게 만들려면 두 가지 선택이 있었다. 하나는 지금 짠 React 컴포넌트를 Web Component로 감싸는 것, 다른 하나는 처음부터 DOM API로 새로 짜는 것. 둘 다 결국 같은 결론으로 갔다 — Lit이라는 라이브러리로 Web Components를 만드는 것.

Lit 컴포넌트는 보통 Shadow DOM을 쓴다. 스타일이 캡슐화돼서 바깥 페이지의 CSS와 충돌하지 않는다는 장점이자 단점이다. 다른 프로젝트에서 이 컴포넌트를 가져다 쓸 때, 그쪽 CSS로 색이나 모양을 덮어쓸 수가 없어졌다. 원래는 충돌할 일 자체가 없는 게 맞는 방향이지만 그냥 좀 더 유연하게 열어두고 싶었다. 그래서 createRenderRoot() { return this; }로 Shadow DOM을 끄고, 일반 DOM(Light DOM)에 그대로 렌더링하도록 만들었다. 이러면 .scener-button { background: red; }처럼 외부에서 CSS로 덮어쓸 수 있다.

Light DOM으로 가면서 따라온 문제도 있었다. <slot>은 Shadow DOM 전용 기능이라, Light DOM에서는 동작하지 않는다. <scener-button>저장하기</scener-button>처럼 태그 안에 넣은 텍스트를 버튼 안으로 옮겨 넣으려면, connectedCallback()에서 원본 자식 노드를 미리 꺼내 보관해두고 render() 이후에 다시 끼워 넣는 방식으로 풀었다.

2. 라이브러리 빌드 설정 — Vite 라이브러리 모드

Storybook에서 보던 컴포넌트를 실제로 다른 프로젝트에서 가져다 쓰려면, Vite를 라이브러리 모드로 빌드해야 한다. Storybook용 vite.config.ts와는 별도로 vite.lib.config.ts를 새로 만들었다.

import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import dts from 'vite-plugin-dts';
 
export default defineConfig({
  plugins: [
    dts({
      include: ['src/components', 'src/index.ts'],
      exclude: ['src/components/**/*.stories.ts'],
      insertTypesEntry: true,
    }),
  ],
  publicDir: false,
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'ScenerDesignSystem',
      fileName: 'scener-design-system',
      formats: ['es', 'cjs'],
    },
  },
});

publicDir: falsepublic 폴더의 정적 파일(favicon, 로고 등)이 라이브러리 빌드에 같이 섞여 들어오는 걸 막기 위해서다. dts 플러그인은 타입 선언 파일을 만들어주는데, exclude로 스토리 파일은 빼야 한다.

빌드하면서 한 가지를 더 정리했다. Storybook 전용 스타일(storybook.scss)이 global.scss에 같이 import돼 있어서, 라이브러리 빌드에도 그게 같이 들어가고 있었다. global.scss에서 그 줄을 빼고, Storybook의 preview.ts에서만 따로 import하도록 분리했다. 라이브러리에는 순수하게 컴포넌트에 필요한 스타일만 남도록.

npm run build-lib

이 명령어로 dist 폴더에 .js, .cjs, .css, .d.ts가 만들어진다.

3. GitHub + jsDelivr CDN으로 배포하기

npm에 정식으로 공개 배포하려면 npm 계정을 만들고 패키지 이름이 안 겹치는지 확인하는 절차가 필요한데, 지금 단계에서는 그렇게까지 할 필요가 없어서 더 가벼운 방법을 택했다. jsDelivr라는 무료 CDN은 공개된 GitHub 저장소의 파일을 URL로 그대로 서빙해준다.

https://cdn.jsdelivr.net/gh/{사용자명}/{저장소명}@{커밋해시}/dist/scener-design-system.js

dist 폴더는 평소엔 .gitignore로 제외하는 게 일반적이지만, 이 방식으로 쓰려면 빌드 결과물 자체가 저장소에 올라가 있어야 한다. .gitignore에서 dist를 빼고 그대로 커밋했다.

브랜치 이름(@main)으로 URL을 만들면 jsDelivr가 캐시를 들고 있다가 갱신이 늦어지는 경우가 있었다. 새로 빌드해서 push했는데도 CDN에서는 옛날 버전이 계속 나오는 상황을 몇 번 겪었다. 가장 확실한 해결책은 브랜치 대신 커밋 해시를 URL에 박는 것이었다.

git log -1 --format="%H"

이렇게 받은 해시를 @main 대신 넣으면, 그 커밋 시점의 파일을 새로 가져오기 때문에 캐시 문제를 피할 수 있다.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/dpwl35/scener-design-system@{커밋해시}/dist/scener-design-system.css" />
<script type="module" src="https://cdn.jsdelivr.net/gh/dpwl35/scener-design-system@{커밋해시}/dist/scener-design-system.js"></script>
 
<scener-button category="primary">저장하기</scener-button>

이렇게 만든 라이브러리를 실제로 블로그(Next.js)에 가져다 써봤다. 화면에는 잘 나왔다. 다른 프로젝트와 변수명이 겹치지 않도록, CSS 변수(토큰) 이름 앞에는 항상 --scener- 접두사를 붙였다.




실제로 동작하는 모습은 아래에서 확인할 수 있다.
test 페이지
scener-design-system.vercel.app