React 디자인 시스템을 처음부터 끝까지 만들어본 이야기

React 디자인 시스템을 처음부터 끝까지 만들어본 이야기

React 디자인 시스템을 처음부터 끝까지 만들어본 이야기

약 3~4주에 걸쳐 React 디자인 시스템을 처음부터 끝까지 만들어봤습니다. 토큰 설계부터 컴포넌트 구현, Storybook 문서화, 모노레포 전환까지 — 설계 과정과 기술 선택의 이유, 그리고 배운 것들을 공유합니다.

Storybook: https://leejpsd.github.io/my-design-system GitHub: https://github.com/leejpsd/my-design-system


목차

  1. 왜 디자인 시스템인가
  2. 기술 스택과 선택 이유
  3. 프로젝트 세팅과 라이브러리 빌드 구조
  4. 디자인 토큰 체계
  5. 핵심 컴포넌트 — Button, TextField
  6. 다양한 설계 패턴 — Modal, Tabs, Toast
  7. 문서화와 Example App
  8. CI/CD와 Storybook 배포
  9. 모노레포 전환 — Turborepo + Changesets
  10. 회고

왜 디자인 시스템인가

회사에서 공통 컴포넌트를 만들어 쓰다 보면 어느 순간 한계를 느끼게 됩니다. 테마를 바꾸려면 코드 전체를 뒤져야 하고, 다른 프로젝트에서 가져다 쓰려면 복붙 후 수정의 반복. **"라이브러리로 빌드되고, 테마를 커스터마이징할 수 있고, 실제로 소비할 수 있는 구조"**를 제대로 한번 만들어보고 싶었습니다.


기술 스택과 선택 이유

| 영역 | 선택 | 왜? | | ---------- | ------------------------------ | -------------------------------------------- | | 프레임워크 | 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를 선택한 이유:

  1. 런타임 오버헤드 제로 — 빌드 타임에 클래스명이 해시되므로 JS 번들에 CSS 로직이 없음
  2. 서버 컴포넌트 호환 — CSS-in-JS는 클라이언트 전용, CSS Modules는 SSR/RSC 모두 지원
  3. 디자인 시스템 라이브러리에 적합 — 가져다 쓰는 쪽에서 별도 설정 없이 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와 충돌
  • ./styles subpath export — CSS를 별도 진입점으로 제공. import '@my/react/styles' 한 줄로 사용

배운 점: peerDependenciesexternal 설정을 빠뜨리면 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 변수가 있으므로 점진적으로 도입 가능
  • 커스텀 토큰 오버라이드theme prop으로 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 접근 가능 (포커스 관리 등)
  • as prop 다형성<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를 고른 이유가 있습니다. 각각 전혀 다른 설계 패턴을 보여주기 때문입니다.

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.ListTabs.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를 자동 생성합니다.


회고

기술적으로 배운 것

  1. CSS 변수 기반 테마 시스템: data-theme 속성 하나로 전체 컬러 스킴을 전환하는 패턴. 런타임 비용 제로.

  2. 외부 스토어 패턴 (useSyncExternalStore): Toast처럼 React 컴포넌트 바깥에서 호출해야 하는 API를 구현할 때 유용. Redux나 Zustand가 내부적으로 사용하는 것과 같은 패턴.

  3. Compound Component 패턴: Tabs.List, Tabs.Trigger, Tabs.Content처럼 관련 컴포넌트를 하나의 namespace 아래에 묶으면 API가 직관적이고 확장성이 높아짐.

  4. 포커스 트랩: 모달의 접근성에서 가장 중요한 부분. Tab/Shift+Tab이 모달 내부에서만 순환하도록 구현하는 것이 생각보다 까다로움.

  5. Vite Library Mode + preserveModules: 트리셰이킹 가능한 라이브러리 빌드를 위해 필수. 모듈 구조를 유지해야 사용하는 쪽의 번들러가 불필요한 코드를 제거할 수 있음.

  6. Turborepo 빌드 그래프: "dependsOn": ["^build"] 한 줄로 패키지 간 빌드 순서가 자동 결정. 캐싱까지 합치면 CI 시간이 극적으로 줄어듦.

설계 결정에서 배운 것

  1. "처음부터 모노레포" vs "나중에 전환": 처음에는 단일 패키지로 시작했고, 컴포넌트가 충분히 쌓인 후 모노레포로 전환했습니다. 초반에 구조에 시간을 쓰다가 지치는 것보다, 먼저 동작하는 것을 만드는 게 중요했습니다.

  2. headless vs styled: Radix나 Headless UI처럼 스타일을 외부에 맡기는 방식도 좋지만, 이 프로젝트는 특정 디자인을 직접 제공하는 게 목표였습니다. 다만 내부적으로 로직과 스타일 레이어를 분리해서 전환 여지를 남겨뒀습니다.

  3. 접근성은 "나중에"가 아니라 "처음부터": 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

JP
이중표Frontend Engineer

3년차 프론트엔드 개발자. Next.js, React, TypeScript 기반 웹 애플리케이션 개발 전문. 대규모 트래픽 환경에서 SSR·ISR 렌더링 전략 설계 경험.

이력서 보기