
React 디자인 시스템을 처음부터 끝까지 만들어본 이야기
React 디자인 시스템을 처음부터 끝까지 만들어본 이야기
약 3~4주에 걸쳐 React 디자인 시스템을 처음부터 끝까지 만들어봤습니다. 토큰 설계부터 컴포넌트 구현, Storybook 문서화, 모노레포 전환까지 — 설계 과정과 기술 선택의 이유, 그리고 배운 것들을 공유합니다.
Storybook: https://leejpsd.github.io/my-design-system GitHub: https://github.com/leejpsd/my-design-system
목차
- 왜 디자인 시스템인가
- 기술 스택과 선택 이유
- 프로젝트 세팅과 라이브러리 빌드 구조
- 디자인 토큰 체계
- 핵심 컴포넌트 — Button, TextField
- 다양한 설계 패턴 — Modal, Tabs, Toast
- 문서화와 Example App
- CI/CD와 Storybook 배포
- 모노레포 전환 — Turborepo + Changesets
- 회고
왜 디자인 시스템인가
회사에서 공통 컴포넌트를 만들어 쓰다 보면 어느 순간 한계를 느끼게 됩니다. 테마를 바꾸려면 코드 전체를 뒤져야 하고, 다른 프로젝트에서 가져다 쓰려면 복붙 후 수정의 반복. **"라이브러리로 빌드되고, 테마를 커스터마이징할 수 있고, 실제로 소비할 수 있는 구조"**를 제대로 한번 만들어보고 싶었습니다.
기술 스택과 선택 이유
| 영역 | 선택 | 왜? | | ---------- | ------------------------------ | -------------------------------------------- | | 프레임워크 | React 19 + TypeScript 5.9 | 가장 수요 많음 | | 스타일링 | CSS Modules | runtime overhead 없음, 서버 컴포넌트 호환 | | 번들러 | Vite 7 (Library Mode) | 빠른 DX + 라이브러리 빌드 지원 | | 모노레포 | pnpm Workspace + Turborepo | 빌드 캐싱, 의존성 그래프 자동 관리 | | 문서화 | Storybook 10 | 업계 표준, 컴포넌트 문서화 + 인터랙션 테스트 | | 버저닝 | Changesets | CHANGELOG 자동화, 시맨틱 버저닝 | | CI/CD | GitHub Actions | 무료, GitHub Pages로 Storybook 배포 |
왜 CSS Modules인가?
Tailwind, styled-components, Emotion 등 선택지가 많았지만 CSS Modules를 선택한 이유:
- 런타임 오버헤드 제로 — 빌드 타임에 클래스명이 해시되므로 JS 번들에 CSS 로직이 없음
- 서버 컴포넌트 호환 — CSS-in-JS는 클라이언트 전용, CSS Modules는 SSR/RSC 모두 지원
- 디자인 시스템 라이브러리에 적합 — 가져다 쓰는 쪽에서 별도 설정 없이 CSS만 import하면 됨
왜 Vite Library Mode인가?
라이브러리를 빌드해서 npm 패키지처럼 쓸 수 있으려면 ESM/CJS 듀얼 패키지 빌드가 필요합니다. Vite의 Library Mode가 이걸 한 줄 설정으로 해결합니다:
// vite.config.ts export default defineConfig({ build: { lib: { entry: path.resolve(dirname, 'src/index.ts'), formats: ['es', 'cjs'], fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`, }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], output: { preserveModules: true, preserveModulesRoot: 'src', }, }, }, });
preserveModules: true는 트리셰이킹을 위해 중요합니다. 번들을 하나로 뭉치지 않고 모듈 구조를 유지해서, import { Button } from '@my/react'만 하면 Button 관련 코드만 포함되게 합니다.
프로젝트 세팅과 라이브러리 빌드 구조
제일 먼저 한 일은 "라이브러리로 가져다 쓸 수 있는 구조"를 잡는 것이었습니다.
// package.json — 핵심 필드 { "name": "@my/react", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, "./styles": "./dist/style.css" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }
여기서 중요한 설계 결정 몇 가지:
exports필드 — ESM/CJS 진입점을 명확하게 분리. Node.js 12+에서 지원하는 현대적 방식peerDependencies로 React — 라이브러리가 React를 번들에 포함하면 안 됨. 사용하는 앱의 React와 충돌./stylessubpath export — CSS를 별도 진입점으로 제공.import '@my/react/styles'한 줄로 사용
배운 점:
peerDependencies와external설정을 빠뜨리면 React가 두 번 번들되어 "Invalid Hook Call" 에러가 납니다. 처음부터 잡아두는 게 중요합니다.
디자인 토큰 체계
컴포넌트를 만들기 전에 토큰 체계를 먼저 설계했습니다.
3계층 토큰 구조
Primitive (gray-500) → Semantic (text-primary) → Component (btn-color)
TypeScript 객체로 토큰을 정의하고, CSS 변수로 실제 사용합니다:
// packages/tokens/src/colors.ts export const gray = { 50: '#f9fafb', 100: '#f3f4f6', // ... 900: '#111827', } as const; export const semantic = { primary: blue, danger: red, success: green, warning: amber, neutral: gray, } as const;
/* themes/light.css */ :root, [data-theme='light'] { --color-primary-500: #3b82f6; --color-bg-primary: #ffffff; --color-text-primary: #111827; --color-border: #e5e7eb; /* spacing, typography, radius, shadow... */ }
/* themes/dark.css */ [data-theme='dark'] { --color-primary-500: #60a5fa; --color-bg-primary: #111827; --color-text-primary: #f9fafb; --color-border: #374151; }
ThemeProvider — 테마 커스터마이징 구조
// packages/core/src/ThemeProvider.tsx export function ThemeProvider({ children, defaultMode = 'light', theme, }: ThemeProviderProps) { const [mode, setMode] = useState<ThemeMode>(defaultMode); // data-theme 속성으로 CSS 변수 전환 useEffect(() => { document.documentElement.setAttribute('data-theme', mode); }, [mode]); // 커스텀 테마는 inline CSS 변수로 오버라이드 const style = useMemo(() => { if (!theme) return undefined; const vars: Record<string, string> = {}; for (const [key, value] of Object.entries(theme)) { const varName = key.startsWith('--') ? key : `--${key}`; vars[varName] = value; } return vars; }, [theme]); return ( <ThemeContext.Provider value={{ mode, setMode, toggleMode }}> <div style={style}>{children}</div> </ThemeContext.Provider> ); }
핵심 설계:
- CSS 변수 기반 —
data-theme속성만 바꾸면 모든 컴포넌트의 색상이 전환. 런타임 오버헤드 없음 - ThemeProvider 없이도 동작 —
:root에 기본 CSS 변수가 있으므로 점진적으로 도입 가능 - 커스텀 토큰 오버라이드 —
themeprop으로 CSS 변수를 덮어써서 브랜드 색상 적용 가능
핵심 컴포넌트 — Button, TextField
컴포넌트 수보다 완성도를 중요하게 생각했습니다. Button과 TextField 2개에 집중했습니다.
Button — 다형성, 접근성, forwardRef
// packages/react/src/Button/Button.tsx export const Button = forwardRef<HTMLButtonElement, ButtonProps>( ( { variant = 'solid', size = 'md', colorScheme = 'primary', isLoading = false, leftIcon, rightIcon, fullWidth = false, disabled, children, className, as: Component = 'button', ...rest }, ref, ) => { const isDisabled = disabled || isLoading; return ( <Component ref={ref} className={clsx( styles.button, styles[variant], styles[size], styles[colorScheme], fullWidth && styles.fullWidth, isLoading && styles.loading, className, )} disabled={isDisabled} aria-busy={isLoading || undefined} aria-disabled={isDisabled || undefined} {...rest} > {isLoading && <Spinner size="sm" className={styles.spinner} />} {!isLoading && leftIcon && ( <span className={styles.icon}>{leftIcon}</span> )} <span className={styles.label}>{children}</span> {!isLoading && rightIcon && ( <span className={styles.icon}>{rightIcon}</span> )} </Component> ); }, );
주목할 만한 포인트들:
forwardRef— 외부에서 ref로 DOM 접근 가능 (포커스 관리 등)asprop 다형성 —<Button as="a" href="/link">처럼<a>태그로도 렌더링 가능aria-busy— 로딩 중임을 스크린리더에 알려줌- 모든 prop에 기본값 —
variant="solid",size="md"등 zero-config 사용 가능
TextField — 접근성과 label 연결
TextField에서 가장 신경 쓴 부분은 접근성입니다:
useId()로 고유 ID 생성 →<label htmlFor>↔<input id>자동 연결aria-describedby로 에러/헬퍼 텍스트와 input 연결aria-invalid로 에러 상태 표시
다양한 설계 패턴 — Modal, Tabs, Toast
Modal, Tabs, Toast를 고른 이유가 있습니다. 각각 전혀 다른 설계 패턴을 보여주기 때문입니다.
Modal — Portal 패턴
createPortal → DOM 트리 외부 렌더링
포커스 트랩 → Tab 키가 모달 안에서만 순환
ESC 닫기 → 키보드 접근성
스크롤 잠금 → body overflow: hidden
진입/퇴장 애니메이션 → CSS transition + usePrevious 훅
useFocusTrap 훅을 직접 구현했습니다. 모달이 열리면 첫 번째 포커스 가능한 요소에 포커스를 주고, Tab/Shift+Tab이 모달 내부에서만 순환하도록 합니다.
Tabs — Compound Component 패턴
<Tabs defaultValue="tab1"> <Tabs.List> <Tabs.Trigger value="tab1">프로필</Tabs.Trigger> <Tabs.Trigger value="tab2">설정</Tabs.Trigger> </Tabs.List> <Tabs.Content value="tab1">프로필 내용</Tabs.Content> <Tabs.Content value="tab2">설정 내용</Tabs.Content> </Tabs>
이 패턴의 핵심은 Context API로 내부 상태를 공유하되, 사용하는 개발자에게는 선언적 API를 제공하는 것입니다.
// Object.assign으로 compound component 구현 export const Tabs = Object.assign(TabsRoot, { List: TabsList, Trigger: TabsTrigger, Content: TabsContent, });
WAI-ARIA Tabs 패턴도 준수합니다:
role="tablist",role="tab",role="tabpanel"aria-selected,aria-controls,aria-labelledby- ← → 화살표, Home, End 키로 탭 전환
compound pattern을 선택한 이유는 유연성 때문입니다. props 방식은 미리 정해진 구조만 허용하지만, compound는 Tabs.List와 Tabs.Content 사이에 다른 요소를 끼워 넣는 것도 자연스럽습니다. API가 더 선언적이고, 확장할 때 기존 코드를 건드리지 않아도 됩니다.
Toast — 명령형 API 패턴
// 컴포넌트 외부에서 바로 호출 가능 toast.success('저장 완료!'); toast.error('오류가 발생했습니다');
이걸 구현하기 위해 React 외부 스토어 패턴을 사용했습니다:
// toastStore.ts — React 바깥의 순수 JS 스토어 let toasts: ToastItem[] = []; const listeners = new Set<Listener>(); function addToast(type: ToastType, message: string, duration = 3000) { const id = String(++idCounter); toasts = [...toasts, { id, type, message, duration }]; listeners.forEach((l) => l()); // 구독자에게 변경 알림 return id; } export const toastStore = { getToasts: () => toasts, subscribe: (listener: Listener) => { listeners.add(listener); return () => listeners.delete(listener); }, remove: removeToast, }; export const toast = { success: (msg: string, duration?: number) => addToast('success', msg, duration), error: (msg: string, duration?: number) => addToast('error', msg, duration), // ... };
React 컴포넌트에서는 useSyncExternalStore로 이 외부 스토어를 구독합니다:
export function ToastContainer() { const toasts = useSyncExternalStore( toastStore.subscribe, toastStore.getToasts, ); // ... }
useSyncExternalStore는 외부 스토어와 React 렌더링 사이클을 동기화하는 훅입니다. 덕분에 toast.success()를 이벤트 핸들러든 API 콜백이든 어디서나 호출할 수 있습니다.
문서화와 Example App
Storybook 문서화
각 컴포넌트마다 Story를 5개 이상 작성했습니다:
- Default — 기본 사용법
- AllVariants — 모든 variant/size 조합을 한눈에
- Playground — Controls 패널로 모든 prop을 직접 조작
- Do / Don't — 올바른 사용법과 피해야 할 패턴
추가로 Recipe Stories를 만들어 컴포넌트 조합 패턴을 보여줬습니다:
- 로그인 폼 (TextField + Button + Toast)
- 설정 페이지 (Tabs + TextField + Modal + Toast)
Example App — 실제 사용 데모
Storybook은 개별 컴포넌트를 보여주지만, **"이 라이브러리를 실제로 쓰면 어떻게 되나?"**는 보여주지 못합니다. 별도 Vite 앱을 만들어 @my/react를 실제로 import해서 사용하는 데모를 구현했습니다:
// packages/example/src/main.tsx import { ThemeProvider } from '@my/react'; import '@my/react/styles'; createRoot(document.getElementById('root')!).render( <ThemeProvider defaultMode="light"> <App /> </ThemeProvider>, );
pnpm workspace의 workspace:* 프로토콜 덕분에 로컬 패키지를 npm에 올리지 않고도 실제 패키지처럼 import할 수 있습니다. 개발하면서 "이게 진짜 라이브러리처럼 동작하는 건지" 확인할 수 있는 가장 빠른 방법이었습니다.
CI/CD와 Storybook 배포
GitHub Actions CI
jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Lint run: pnpm lint - name: Type Check run: pnpm type-check - name: Build Packages run: pnpm build - name: Build Storybook run: pnpm build:storybook
Storybook 배포
GitHub Pages에 Storybook을 자동 배포합니다. main 브랜치에 push하면 빌드 → 업로드 → 배포가 자동으로 이루어집니다.
배포된 Storybook: https://leejpsd.github.io/my-design-system
모노레포 전환
컴포넌트가 어느 정도 쌓인 후, Turborepo 모노레포로 전환했습니다.
왜 모노레포?
단일 패키지 @my/design-system을 3개로 분리:
@my/tokens ←── @my/core ←── @my/react
(순수 JS/CSS) (+ React) (+ tokens, core, clsx)
- @my/tokens — 디자인 토큰만. React 의존성 없음. Vue나 일반 HTML 프로젝트에서도 쓸 수 있음
- @my/core — ThemeProvider, useTheme. React를 peerDependency로
- @my/react — UI 컴포넌트. tokens + core를 re-export해서 이것 하나만 설치해도 됨
이렇게 나눈 이유는 선택적으로 가져다 쓸 수 있게 하기 위해서입니다. 토큰만 필요한 팀은 @my/tokens만 설치하면 되고, UI까지 필요하면 @my/react 하나만 설치하면 tokens와 core까지 re-export되므로 전부 사용할 수 있습니다.
Turborepo — 빌드 파이프라인
// turbo.json { "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "type-check": { "dependsOn": ["^build"] } } }
"dependsOn": ["^build"]는 "내 dependency의 build가 끝난 후에 내 build를 실행"이라는 뜻입니다. 이 한 줄로 tokens → core → react 빌드 순서가 자동 결정됩니다. 캐싱도 자동 — 변경 없는 패키지는 빌드를 건너뜁니다.
실제 빌드 결과:
@my/tokens:build ✓ built in 570ms
@my/core:build ✓ built in 779ms (tokens 빌드 완료 후)
@my/react:build ✓ built in 1.18s (core 빌드 완료 후)
@my/example:build ✓ built in 590ms (react 빌드 완료 후)
Tasks: 4 successful, 4 total
두 번째 빌드:
Tasks: 4 successful, 4 total
Cached: 4 cached, 4 total
Time: 87ms >>> FULL TURBO
7초 → 87ms. 캐시 히트 시 80배 빨라집니다.
Changesets — 패키지별 독립 버저닝
// .changeset/config.json { "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["@my/example-app"] }
pnpm changeset으로 변경사항을 기록하고, pnpm changeset version으로 시맨틱 버저닝 + CHANGELOG를 자동 생성합니다.
회고
기술적으로 배운 것
-
CSS 변수 기반 테마 시스템:
data-theme속성 하나로 전체 컬러 스킴을 전환하는 패턴. 런타임 비용 제로. -
외부 스토어 패턴 (
useSyncExternalStore): Toast처럼 React 컴포넌트 바깥에서 호출해야 하는 API를 구현할 때 유용. Redux나 Zustand가 내부적으로 사용하는 것과 같은 패턴. -
Compound Component 패턴:
Tabs.List,Tabs.Trigger,Tabs.Content처럼 관련 컴포넌트를 하나의 namespace 아래에 묶으면 API가 직관적이고 확장성이 높아짐. -
포커스 트랩: 모달의 접근성에서 가장 중요한 부분.
Tab/Shift+Tab이 모달 내부에서만 순환하도록 구현하는 것이 생각보다 까다로움. -
Vite Library Mode + preserveModules: 트리셰이킹 가능한 라이브러리 빌드를 위해 필수. 모듈 구조를 유지해야 사용하는 쪽의 번들러가 불필요한 코드를 제거할 수 있음.
-
Turborepo 빌드 그래프:
"dependsOn": ["^build"]한 줄로 패키지 간 빌드 순서가 자동 결정. 캐싱까지 합치면 CI 시간이 극적으로 줄어듦.
설계 결정에서 배운 것
-
"처음부터 모노레포" vs "나중에 전환": 처음에는 단일 패키지로 시작했고, 컴포넌트가 충분히 쌓인 후 모노레포로 전환했습니다. 초반에 구조에 시간을 쓰다가 지치는 것보다, 먼저 동작하는 것을 만드는 게 중요했습니다.
-
headless vs styled: Radix나 Headless UI처럼 스타일을 외부에 맡기는 방식도 좋지만, 이 프로젝트는 특정 디자인을 직접 제공하는 게 목표였습니다. 다만 내부적으로 로직과 스타일 레이어를 분리해서 전환 여지를 남겨뒀습니다.
-
접근성은 "나중에"가 아니라 "처음부터":
aria-*속성, 키보드 내비게이션, 포커스 관리를 처음부터 넣지 않으면 나중에 추가하기가 매우 어렵습니다.
아쉬운 점
- Chromatic Visual Regression 미적용 — CI는 구성했지만 PR마다 스크린샷을 비교하는 부분은 넣지 못했습니다.
- npm 실제 배포 미진행 — 빌드 구조는 잡혀있어
npm publish만 하면 되지만, 공개 배포까지는 하지 않았습니다.
마무리
만들어보니 생각보다 배운 게 많았습니다. 컴포넌트를 잘 만드는 것과 라이브러리를 잘 만드는 건 꽤 다른 문제고, 빌드 시스템이나 모노레포는 직접 운영해보기 전까지는 감이 잘 안 잡히는 영역이기도 했습니다.
돌이켜보면 가장 도움이 됐던 건 모든 선택마다 "왜?"를 기록해두는 것이었습니다. CSS Modules를 선택한 이유, compound pattern을 선택한 이유, 초반에 모노레포를 안 한 이유 — 이걸 글로 정리하면서 스스로 설계를 다시 검토하게 됐고, 결국 코드보다 그 결정들이 더 오래 남았습니다.
GitHub: https://github.com/leejpsd/my-design-system Storybook: https://leejpsd.github.io/my-design-system