20. 디자인 시스템 가이드

0. 먼저 알고 가기 (30초 요약)

  • 디자인 시스템은 컴포넌트 구현보다도 사용 규칙이 먼저입니다.
  • 토큰/변형/가이드가 팀 전체에서 공유되면 UI 일관성 비용이 줄어듭니다.
  • 새 기능은 기존 규칙으로 시작하고, 예외는 문서화된 이유와 함께 정리하세요.

초심자용 한눈에 보기

디자인 시스템은 “누가 만들든 같은 느낌”을 내는 팀 계약입니다.

일상 비유: 디자인 시스템은 프랜차이즈 매장 운영 매뉴얼과 같습니다. 어느 매장에 가도 같은 맛, 같은 메뉴 배치, 같은 응대를 받습니다. 매뉴얼이 없으면 매장마다 결과가 달라지고, 매뉴얼이 너무 두꺼우면 새 직원이 적응할 수 없습니다.

핵심 용어 빠르게 정리

용어 쉬운 뜻
디자인 토큰 색/간격/폰트 같은 공통 수치의 중앙 정의
컴포넌트 API 컴포넌트에서 외부에 공개하는 사용 규칙
variant 같은 컴포넌트의 변형(버튼 크기/색상/상태)
primitive 기본 조각 컴포넌트(토대가 되는 최소 단위)
마이그레이션 기존 UI를 새 규격으로 점진 이전

디자인 시스템 전체 구조 한눈에 보기

flowchart TD
  subgraph Foundation["기반층"]
    T1["Primitive Token<br/>(원시 값)"]
    T2["Semantic Token<br/>(의미 부여)"]
    T3["Component Token<br/>(컴포넌트 바인딩)"]
    T1 --> T2 --> T3
  end
  subgraph Components["컴포넌트층"]
    P1["Primitive<br/>(Radix/React Aria)"]
    P2["UI Component<br/>(Button, Dialog, Card)"]
    P3["Pattern<br/>(Form, Table, Wizard)"]
    P1 --> P2 --> P3
  end
  subgraph Apps["소비층"]
    A1["Feature 컴포넌트"]
    A2["페이지"]
  end
  T3 --> P2
  P3 --> A1 --> A2
분류 심화 주제 상태 Stable
연관 가이드 02. React 19, 19. 웹 접근성, 25. 애니메이션 도구 원칙 벤더 중립
핵심 테마 Tailwind CSS v4 라인, DTCG Tokens, headless primitives, source-owned registry, Dark Mode Update 최신 기준

"디자인 시스템은 컴포넌트 모음이 아니라, 팀의 일관된 언어이자 효율적인 협업 도구다." 본 가이드는 특정 디자인 도구나 컴포넌트 라이브러리가 아니라 토큰 표준, 접근성 계약, 컴포넌트 API, 배포/버전 정책을 기준으로 디자인 시스템을 운영하는 방법을 다룹니다. Tailwind CSS v4, headless primitive, zero-runtime CSS, registry 기반 컴포넌트는 선택 가능한 구현 예시로만 취급합니다.

현재 디자인 시스템 생태계 변화 요약

  • Design Tokens Format Module 2025.10: W3C Community Group Final Specification으로 공개되어 $value, $type, group/alias/composite token 교환 형식이 안정화되었습니다.
  • CSS-first theming: Cascade Layers, @property, color-mix(), container queries, logical properties 기반 토큰 적용이 production baseline에 가까워졌습니다.
  • Headless primitive + registry: 접근성 동작과 시각 스타일을 분리하고, 컴포넌트 소스 코드를 제품 저장소에서 소유하는 방식이 확산되었습니다.
  • Zero-runtime styling: RSC/SSR, 번들 크기, hydration cost를 줄이기 위해 빌드타임 CSS 추출과 CSS 변수 토큰부터 평가합니다.
  • Design QA 자동화: 토큰 diff, visual regression, a11y regression, RTL/pseudo-localization smoke test를 release gate로 둡니다.

추천 항목 (실무 우선순위)

  • 시작 추천: 컴포넌트 토큰과 variant API를 먼저 정해 팀간 해석 오차를 줄입니다.
  • 안정 추천: 배포/버전 규칙을 정해서 breaking change 영향을 미리 통지하세요.
  • 운영 추천: 디자인 토큰 변경은 accessibility 검증과 릴리즈 노트와 함께 승인받습니다.

추천 항목 고도화 체크

  • 첫 적용 — token, component API, migration plan 중 하나를 실제 PR이나 운영 이슈에 붙이고, 변경 전 기준을 먼저 적는다.
  • 증거 정리 — token diff, Storybook/visual test, migration codemod 결과를 같은 작업 기록에 남긴다.
  • 재점검 — 중복 컴포넌트 수, token drift, visual regression 수가 나아졌는지 30일 안에 확인하고 기준을 유지, 수정, 폐기 중 하나로 판정한다.

추천 항목 실행 기록 템플릿

  • 작업 : token, component API, migration plan 적용 범위를 어느 화면, 패키지, 문서에 둘지 적는다.
  • 증거 : token diff, Storybook/visual test, migration codemod 결과 중 실제로 남긴 항목만 링크한다.
  • 판정 : 유지/수정/폐기 중 하나와 이유를 한 문장으로 남긴다.
  • 다음 점검 : 중복 컴포넌트 수, token drift, visual regression 수를 다시 볼 날짜와 담당자를 지정한다.

문서 책임 범위

이 문서가 결정하는 것 단일 출처로 따르는 문서
token contract, component API, theme, design QA gate 19. 웹 접근성, 25. 애니메이션
컴포넌트 배포, 버전, breaking change 관리 11. CI/CD, 14. 배포
React/RSC 경계와 client component 정책 02. React 19, 04. 아키텍처
AI가 제안한 컴포넌트/토큰 변경의 검증 책임 18. AI 개발 워크플로우

0. 모든 프론트엔드 그룹 공통 Baseline

기준 최소 적용
Token contract primitive, semantic, component token을 분리하고 DTCG 호환 JSON 또는 동등한 구조로 export합니다.
Accessibility contract 컴포넌트별 keyboard, focus, ARIA, reduced motion, contrast 기준을 문서와 테스트로 둡니다.
API stability variant, size, slot, state, data attribute를 public API로 관리하고 breaking change를 release note에 남깁니다.
Cross-platform web/mobile/native를 모두 지원하지 않더라도 naming, color, spacing, typography 단위가 플랫폼 변환 가능해야 합니다.
Governance 새 컴포넌트 추가, 토큰 변경, deprecated variant 제거는 RFC/ADR 또는 동등한 변경 기록을 남깁니다.

0.0 디자인 시스템 계약 흐름

flowchart TD
  A[도메인/제품 요구] --> B[Token Contract 설계]
  B --> C[컴포넌트 API/Slot/Variant 정의]
  C --> D[접근성·테마·RTL 정책 바인딩]
  D --> E[문서화 + 스냅샷 기준]
  E --> F[테스트 자동화 적용]
  F --> G{Visual/A11y/성능 gate}
  G -->|Fail| H[컴포넌트/API 수정]
  G -->|Pass| I[릴리즈 버전/변경 기록]
  H --> C
  I --> J[소비 앱 마이그레이션 가이드 배포]

0.1 교차 검증 매트릭스

권고 1차 출처 실행 증거 운영 증거 철회 조건
디자인 토큰은 DTCG 2025.10 호환 구조를 우선한다 W3C Design Tokens CG Final Specification token schema validation, build transform 토큰 drift, 플랫폼 변환 실패율 기존 toolchain이 DTCG를 안정 지원하지 않아 adapter 비용이 더 큰 경우
headless primitive는 접근성 계약을 통과한 경우만 채택한다 WAI-ARIA APG, WCAG 2.2 keyboard/focus/a11y tests 접근성 회귀, support issue 제품 상호작용이 표준 pattern과 맞지 않아 custom primitive가 더 안전할 때
CSS 변수 기반 theming을 기본값으로 둔다 CSS Custom Properties/Cascade Layers 표준 theme switch E2E, visual diff theme defect, dark mode contrast issue 레거시 브라우저 지원 비용이 더 클 때
visual regression은 release gate에 포함한다 테스트 도구 공식 문서, 디자인 QA 정책 snapshot diff threshold 디자인 회귀 발견 시간 동적/개인화 화면이 많아 deterministic snapshot이 불가능할 때

0.2 운영 게이트

Gate Evidence Owner Rollback
Token 변경 token diff, schema validation, platform transform 결과 Design system owner 이전 token set 복구 또는 alias 유지
Component API breaking change 여부, migration note, usage scan Component owner deprecated API 유지 기간 연장
접근성/시각 회귀 keyboard/focus/a11y report, visual diff QA owner release 차단 또는 affected component rollback
배포/버전 release note, changelog, 소비 앱 영향 범위 Release owner patch release 또는 registry 이전 버전 고정

1. 스타일링 기초: Tailwind CSS v4

Tailwind CSS v4 라인은 2025.01 GA 이후 CSS-first 설정, 자동 content detection, design token의 CSS 변수 노출, container query 내장을 핵심 변화로 제공합니다. 현재 공식 문서 기준 공식 문서는 v4.3 라인을 표시하므로, 표준 문서에서는 특정 patch 버전에 고정하지 않고 lockfile과 upgrade guide로 검증합니다. 성능 개선 수치는 제품별 CSS 규모, 빌드 도구, cache 상태에 따라 달라지므로 공식 벤치마크를 그대로 SLA로 쓰지 않습니다.

1.0 Tailwind v4의 핵심 변경점

영역 v3 v4
빌드 엔진 Node.js 기반 새 high-performance engine, 제품별 build benchmark로 검증
설정 방식 tailwind.config.js (JS) @theme { ... } (CSS-first)
콘텐츠 탐색 content: [...] 명시 자동 감지 (heuristic 기반)
CSS 변수 노출 opt-in 모든 토큰을 CSS 변수로 자동 노출
3D Transforms 미지원 rotate-x-*, rotate-y-*, translate-z-*, perspective-*
Dynamic Utilities 미리 정의된 값만 color-mix(), @property 기반 즉석 생성
Color Scheme 별도 플러그인 scheme-light, scheme-dark 유틸리티 내장
컨테이너 쿼리 플러그인 필요 @container 빌트인
현대 CSS 기능 부분 지원 Cascade Layers, @property, color-mix() 기본 활용

1.1 테마 정의 (Theming)

/* app.css - Tailwind v4 CSS-first 설정 */
@import 'tailwindcss';

@theme {
  /* ── 색상 시스템 ── */
  --color-primary: #007bff;
  --color-primary-hover: #0056b3;
  --color-primary-active: #004494;
  --color-secondary: #6c757d;
  --color-accent: #17a2b8;

  /* 시맨틱 색상 (의미 기반) */
  --color-success: #28a745;
  --color-warning: #ffc107;
  --color-error: #dc3545;
  --color-info: #17a2b8;

  /* 표면(Surface) 색상 - 라이트 모드 기본값 */
  --color-surface: #ffffff;
  --color-surface-elevated: #f8f9fa;
  --color-surface-sunken: #e9ecef;
  --color-on-surface: #212529;
  --color-on-surface-muted: #6c757d;

  /* ── 타이포그래피 ── */
  --font-sans: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: 'JetBrains Mono', 'Fira Code', monospace;

  /* 폰트 크기 - fluid typography 스케일 */
  --text-xs: 0.75rem; /* 12px */
  --text-sm: 0.875rem; /* 14px */
  --text-base: 1rem; /* 16px */
  --text-lg: 1.125rem; /* 18px */
  --text-xl: 1.25rem; /* 20px */
  --text-2xl: 1.5rem; /* 24px */
  --text-3xl: 1.875rem; /* 30px */
  --text-4xl: 2.25rem; /* 36px */

  /* ── 간격 시스템 (4px 그리드) ── */
  --spacing-unit: 0.25rem;
  --spacing-xs: 0.25rem; /* 4px */
  --spacing-sm: 0.5rem; /* 8px */
  --spacing-md: 1rem; /* 16px */
  --spacing-lg: 1.5rem; /* 24px */
  --spacing-xl: 2rem; /* 32px */
  --spacing-2xl: 3rem; /* 48px */
  --spacing-3xl: 4rem; /* 64px */

  /* ── 반응형 브레이크포인트 ── */
  --breakpoint-sm: 640px;
  --breakpoint-md: 768px;
  --breakpoint-lg: 1024px;
  --breakpoint-xl: 1280px;
  --breakpoint-2xl: 1536px;

  /* ── 그림자 시스템 ── */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);

  /* ── 둥글기(Radius) ── */
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
  --radius-xl: 0.75rem;
  --radius-full: 9999px;

  /* ── 트랜지션 ── */
  --duration-fast: 150ms;
  --duration-normal: 250ms;
  --duration-slow: 350ms;
  --ease-default: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
  --ease-out: cubic-bezier(0, 0, 0.2, 1);

  /* ── z-index 스케일 ── */
  --z-dropdown: 1000;
  --z-sticky: 1020;
  --z-modal-backdrop: 1040;
  --z-modal: 1050;
  --z-popover: 1060;
  --z-tooltip: 1070;
  --z-toast: 1080;
}

1.2 다크 모드 테마 확장

/* app.css - 다크 모드 토큰 오버라이드 */
@theme dark {
  --color-surface: #1a1a2e;
  --color-surface-elevated: #16213e;
  --color-surface-sunken: #0f0f23;
  --color-on-surface: #e2e8f0;
  --color-on-surface-muted: #94a3b8;

  --color-primary: #60a5fa;
  --color-primary-hover: #93c5fd;
  --color-primary-active: #3b82f6;

  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4);
}

1.3 Tailwind v4 커스텀 플러그인

/* 커스텀 유틸리티 추가 */
@utility text-balance {
  text-wrap: balance;
}

@utility scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}

/* 커스텀 variant 추가 */
@variant hocus (&:hover, &:focus-visible);
@variant group-hocus (:merge(.group):hover &, :merge(.group):focus-visible &);

1.4 v3 → v4 마이그레이션 핵심 정리

v3 (tailwind.config.js) v4 (CSS @theme) 비고
theme.extend.colors @theme { --color-* } CSS 변수로 전환
theme.extend.spacing @theme { --spacing-* } 동일 패턴
plugins: [...] @plugin "..." 또는 @utility CSS 내 선언
darkMode: 'class' @variant dark (&:where(.dark, .dark *)) 기본은 prefers-color-scheme
content: [...] 자동 감지 (설정 불필요) 콘텐츠 경로 자동 탐색
tailwind.config.js 삭제 가능 호환 모드에서는 유지 가능

마이그레이션 단계 다이어그램

flowchart LR
  S0["v3 프로젝트"] --> S1["1. upgrade CLI 실행"]
  S1 --> S2["2. config theme<br/>→ @theme 이동"]
  S2 --> S3["3. plugin<br/>→ @utility/@variant"]
  S3 --> S4["4. content 배열 제거"]
  S4 --> S5["5. darkMode 전환"]
  S5 --> S6["6. 빌드 회귀 검증"]
  S6 -->|통과| Done["v4 적용"]
  S6 -->|회귀| Roll["호환 모드 유지<br/>+ 점진 마이그레이션"]
  Roll --> S2

마이그레이션 단계:

  1. @tailwindcss/upgrade CLI 도구로 자동 변환 실행
  2. tailwind.config.js의 커스텀 테마를 @theme 블록으로 이동
  3. 커스텀 플러그인을 @utility / @variant로 전환
  4. content 배열 제거 (v4는 자동 감지)
  5. darkMode 설정을 CSS @variant 방식으로 전환
# 자동 마이그레이션 도구 실행
npx @tailwindcss/upgrade

일상 비유: v3 → v4 마이그레이션은 종이 가계부에서 가계부 앱으로 옮기는 것과 같습니다. 모든 항목(테마, 플러그인, 다크 모드)을 한 번에 옮기려 하지 말고, 자동 변환기로 1차 옮긴 다음 손으로 다듬는 것이 안전합니다.

이 그림은 Tailwind v3 → v4 전환을 시간 순으로 어떤 단계로 나누는지 보여줍니다.

gantt
  title Tailwind v3 → v4 마이그레이션 단계
  dateFormat YYYY-MM-DD
  axisFormat %m/%d

  section 준비
  의존성/노드 버전 점검            :done, prep1, 2025-01-01, 1d
  현재 config 스냅샷 백업           :done, prep2, after prep1, 1d

  section 자동 변환
  npx @tailwindcss/upgrade 실행     :active, auto1, after prep2, 1d
  변환 diff 리뷰                    :auto2, after auto1, 1d

  section 수동 정리
  @theme 블록 정리                  :man1, after auto2, 2d
  커스텀 플러그인 → @utility 변환   :man2, after man1, 2d
  darkMode → @variant 전환          :man3, after man2, 1d
  content 배열 제거                 :man4, after man3, 1d

  section 검증
  Storybook 시각 회귀               :ver1, after man4, 2d
  E2E 핵심 페이지 비교              :ver2, after ver1, 1d
  점진 배포 (feature flag)          :ver3, after ver2, 2d

단계별 핵심 산출물: 각 단계가 끝날 때마다 ① 변환 diff PR, ② 시각 회귀 리포트, ③ 점진 배포 metric을 남겨두면 rollback 결정이 빨라집니다.


2. 디자인 토큰 체계

왜 중요한가: 토큰 없이 색상/간격을 직접 쓰면 다크 모드, 브랜드 리프레시, 접근성 대응이 전부 검색-치환 작업으로 바뀝니다. 토큰은 "한 곳을 바꾸면 모든 곳이 따라온다"를 보장하는 단일 출처입니다.

일상 비유: 토큰 계층은 화폐 단위와 같습니다. 1원이라는 원시 단위(primitive)가 있고, "점심 한 끼"라는 의미 단위(semantic)가 있고, "이 식당의 메뉴 가격"이라는 구체 단위(component)가 있습니다. 셋을 섞어 쓰면 가격표 변경이 악몽이 됩니다.

토큰은 코드뿐만 아니라 디자인 도구와 동기화되어야 합니다. 3계층 토큰 구조를 통해 일관성과 유연성을 동시에 확보합니다.

2.1 토큰 계층 구조 (Primitive → Semantic → Component)

flowchart TD
  subgraph L3["Component Tokens (컴포넌트 토큰)"]
    C1["--button-bg"]
    C2["--card-border-radius"]
    C3["--input-padding"]
  end
  subgraph L2["Semantic Tokens (시맨틱 토큰)"]
    S1["--color-primary"]
    S2["--color-error"]
    S3["--spacing-md"]
    S4["--radius-lg"]
  end
  subgraph L1["Primitive Tokens (기본 토큰)"]
    P1["--blue-500"]
    P2["--blue-600"]
    P3["--red-500"]
    P4["--gray-100"]
    P5["--space-4"]
  end
  P2 --> S1
  P3 --> S2
  P5 --> S3
  S1 --> C1
  S4 --> C2
  S3 --> C3
  note["다크 모드는 L2 토큰만 재정의<br/>L3과 컴포넌트는 그대로 유지"]

토큰 사용 결정 트리

flowchart TD
  Q["새 컴포넌트에서 색상 필요"] --> Q1{"공통 의미가 있나?<br/>(brand/error/success 등)"}
  Q1 -->|예| SEM["Semantic Token 사용<br/>--color-primary 등"]
  Q1 -->|아니오| Q2{"이 컴포넌트만 쓰나?"}
  Q2 -->|예| COMP["Component Token 신설<br/>--toast-warning-bg"]
  Q2 -->|아니오| SEM
  Q1 -->|새 의미가 필요| ADD["Semantic Token 추가 제안<br/>(디자인 시스템 팀 협의)"]
  ADD --> SEM
/* 1단계: Primitive 토큰 - 원시 값 정의 */
:root {
  --blue-50: #eff6ff;
  --blue-100: #dbeafe;
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --blue-700: #1d4ed8;
  --gray-50: #f9fafb;
  --gray-100: #f3f4f6;
  --gray-800: #1f2937;
  --gray-900: #111827;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
}

/* 2단계: Semantic 토큰 - 의미 부여 (Tailwind @theme과 연결) */
@theme {
  --color-primary: var(--blue-600);
  --color-primary-hover: var(--blue-700);
  --color-error: var(--red-500);
  --color-bg-default: var(--gray-50);
  --color-bg-elevated: white;
  --color-text-default: var(--gray-900);
  --color-text-muted: var(--gray-500);
  --color-border-default: var(--gray-200);
}

/* 3단계: Component 토큰 - 컴포넌트별 바인딩 */
:root {
  --button-bg: var(--color-primary);
  --button-bg-hover: var(--color-primary-hover);
  --button-text: white;
  --button-radius: var(--radius-md);
  --button-padding-x: var(--space-4);
  --button-padding-y: var(--space-2);

  --card-bg: var(--color-bg-elevated);
  --card-border: var(--color-border-default);
  --card-radius: var(--radius-lg);
  --card-shadow: var(--shadow-md);
  --card-padding: var(--space-6);

  --input-bg: var(--color-bg-default);
  --input-border: var(--color-border-default);
  --input-border-focus: var(--color-primary);
  --input-radius: var(--radius-md);
  --input-padding: var(--space-2) var(--space-4);
}

2.2 디자인 도구 동기화 워크플로우

왜 중요한가: 디자이너 파일과 코드 토큰이 따로 살면 두 곳에서 같은 결정을 두 번 내리게 됩니다. 한 곳에서 결정을 내리고 다른 곳은 자동 생성되어야 합니다.

일상 비유: 우체국 우편번호 데이터베이스가 한 곳에만 있어야 하는 것과 같습니다. 사무실마다 별도 우편번호 책을 쓰면 누군가는 항상 옛 주소로 보냅니다.

디자이너와 개발자 간 토큰 불일치를 방지하기 위해 단일 소스(Single Source of Truth)를 유지합니다. W3C DTCG(Design Tokens Community Group) 포맷($value, $type, $description)은 토큰 교환을 위한 상호운용 기준으로 우선 검토하고, 토큰 빌드 도구와 디자인 도구 adapter가 이 형식을 지원하는지 확인합니다.

flowchart LR
  D["Design Tool Variables<br/>(디자이너 작업)"] -->|export| T["tokens.json<br/>(DTCG W3C 표준 포맷)"]
  T -->|Style Dictionary 4| C1["CSS Custom Properties"]
  T -->|Style Dictionary 4| C2["TypeScript 상수"]
  T -->|Style Dictionary 4| C3["iOS Swift"]
  T -->|Style Dictionary 4| C4["Android XML"]
  T -.역방향 sync.-> D
  C1 --> App1["Web App"]
  C2 --> App1
  C3 --> App2["Mobile App"]
  C4 --> App2

권장 도구 (현재 기준):

  • Style Dictionary 4: DTCG $value/$type 포맷 first-class 지원, 다중 플랫폼 빌드 (CSS, JS/TS, iOS Swift, Android XML)
  • token transform adapter: 디자인 도구 export → Style Dictionary 변환
  • Open Props v1: 프레임워크 독립 CSS 변수 기반 토큰 시스템(대안)
// tokens/color.json - Style Dictionary 4 / DTCG (W3C) 포맷
// 현재 표준: $value, $type, $description prefix 사용
{
  "color": {
    "primitive": {
      "blue": {
        "500": { "$value": "#3b82f6", "$type": "color" },
        "600": { "$value": "#2563eb", "$type": "color" }
      }
    },
    "semantic": {
      "primary": {
        "$value": "{color.primitive.blue.600}",
        "$type": "color",
        "$description": "주요 브랜드 색상. CTA 버튼, 링크 등에 사용"
      },
      "error": {
        "$value": "{color.primitive.red.500}",
        "$type": "color",
        "$description": "오류 상태 표시. 유효성 검증 실패, 에러 메시지 등"
      }
    }
  }
}

DTCG 호환성 메모: Style Dictionary 4는 기존 value/type 포맷과 $value/$type 포맷을 모두 허용합니다. 신규 프로젝트는 W3C 표준인 $ prefix를 권장합니다. 디자인 도구에서 export할 때 DTCG 호환 모드를 활성화하세요.

// style-dictionary.config.js
export default {
  source: ['tokens/**/*.json'],
  platforms: {
    css: {
      // CSS Custom Properties로 변환
      transformGroup: 'css',
      buildPath: 'src/styles/',
      files: [
        {
          destination: 'tokens.css',
          format: 'css/variables',
        },
      ],
    },
    ts: {
      // TypeScript 상수로 변환 (타입 안전성 확보)
      transformGroup: 'js',
      buildPath: 'src/tokens/',
      files: [
        {
          destination: 'tokens.ts',
          format: 'javascript/es6',
        },
      ],
    },
  },
}

2.3 토큰 문서화 템플릿

모든 토큰은 아래 형식으로 문서화하여 팀 내 공유합니다.

토큰 이름 용도 다크 모드 값 사용 예시
--color-primary #2563eb CTA, 링크, 강조 #60a5fa bg-primary
--color-error #dc3545 오류, 경고 #f87171 text-error
--color-surface #ffffff 기본 배경 #1a1a2e bg-surface
--spacing-md 1rem 기본 여백 동일 p-md
--radius-lg 0.5rem 카드, 모달 둥글기 동일 rounded-lg

3. 컴포넌트 설계: Headless UI 전략

왜 중요한가: 접근성 동작(키보드, 포커스, ARIA)을 모든 컴포넌트마다 처음부터 만들면 비용이 크고 오류도 많습니다. Radix/React Aria는 수십 명의 전문가가 검증한 결과물입니다. 우리는 스타일만 입히면 됩니다.

일상 비유: 헤드리스 라이브러리는 "엔진과 변속기까지 검증된 차대(chassis)"입니다. 우리가 입히는 외관과 색상은 자유롭게 결정해도 안전 표준은 깨지지 않습니다.

3.0 컴포넌트 의존성 그래프

flowchart TD
  subgraph HL["헤드리스 레이어 (외부 의존)"]
    R1["Radix Primitives"]
    R2["React Aria"]
  end
  subgraph Style["스타일 도구"]
    S1["CVA<br/>(variant 관리)"]
    S2["tailwind-merge<br/>(클래스 병합)"]
    S3["cn() 유틸"]
  end
  subgraph DS["디자인 시스템 컴포넌트"]
    B["Button"]
    D["Dialog"]
    P["Popover"]
    F["Form Field"]
  end
  R1 --> D
  R1 --> P
  R2 --> F
  S1 --> B
  S1 --> D
  S2 --> S3
  S3 --> B
  S3 --> D
  S3 --> P
  S3 --> F
  subgraph App["애플리케이션"]
    Feat["Feature 컴포넌트"]
  end
  B --> Feat
  D --> Feat
  P --> Feat
  F --> Feat

직접 모든 로직(접근성, 키보드 인터랙션 등)을 구현하기보다, 검증된 Headless UI 라이브러리를 기반으로 스타일을 입히는 것을 권장합니다.

  • Radix Primitives / React Aria: 가장 성숙한 headless 접근성 라이브러리입니다. production 기준은 WCAG 2.2와 WAI-ARIA 1.2 안정 패턴이며, ARIA 1.3 draft 속성은 브라우저/스크린 리더 호환성 검증 후 제한적으로 도입합니다.
  • shadcn/ui registry: Tailwind v4 + React 19 기반 컴포넌트 레시피. 모든 primitive에 data-slot 속성이 추가되어 데이터 속성 기반 스타일링이 표준화됨.
  • CVA (Class Variance Authority): 컴포넌트의 다양한 변형(Variant)을 타입 안전하게 관리합니다.
  • tailwind-merge: 충돌하는 Tailwind 클래스를 지능적으로 병합합니다.
  • Pigment CSS / Panda CSS: zero-runtime CSS-in-JS가 필요할 때(특히 React Server Components 환경) 검토. 런타임 비용 없이 빌드 타임에 CSS 추출.

3.1 유틸리티 함수: cn()

// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

/** Tailwind 클래스 병합 유틸리티 - 중복/충돌 클래스를 자동 해결 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

3.2 Button 컴포넌트 (CVA 완전한 예시)

// components/ui/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

/** 버튼 스타일 변형 정의 */
const buttonVariants = cva(
  // 기본 스타일 (모든 변형에 적용)
  [
    'inline-flex items-center justify-center gap-2',
    'rounded-md text-sm font-medium whitespace-nowrap',
    'transition-colors duration-fast ease-default',
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
    'disabled:pointer-events-none disabled:opacity-50',
    'aria-disabled:pointer-events-none aria-disabled:opacity-50',
  ],
  {
    variants: {
      /** 시각적 스타일 */
      variant: {
        primary: 'bg-primary text-white hover:bg-primary-hover active:bg-primary-active shadow-sm',
        secondary: 'bg-secondary text-white hover:bg-secondary/80',
        outline:
          'border border-border-default bg-transparent hover:bg-surface-elevated text-on-surface',
        ghost: 'hover:bg-surface-elevated text-on-surface',
        destructive: 'bg-error text-white hover:bg-error/90',
        link: 'text-primary underline-offset-4 hover:underline p-0 h-auto',
      },
      /** 크기 */
      size: {
        xs: 'h-7 px-2 text-xs rounded',
        sm: 'h-9 px-3 text-sm',
        md: 'h-10 px-4 py-2 text-sm',
        lg: 'h-11 px-6 text-base',
        xl: 'h-12 px-8 text-lg',
        icon: 'h-10 w-10', // 아이콘 전용 정사각형
        'icon-sm': 'h-8 w-8',
      },
      /** 너비 */
      fullWidth: {
        true: 'w-full',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  },
)

export interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
  /** true이면 자식 요소를 버튼으로 렌더링 (Slot 패턴) */
  asChild?: boolean
  /** 로딩 상태 */
  isLoading?: boolean
}

/** 디자인 시스템 기본 버튼 컴포넌트 */
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant,
      size,
      fullWidth,
      asChild = false,
      isLoading,
      children,
      disabled,
      ...props
    },
    ref,
  ) => {
    const Comp = asChild ? Slot : 'button'
    return (
      <Comp
        ref={ref}
        className={cn(buttonVariants({ variant, size, fullWidth, className }))}
        disabled={disabled || isLoading}
        aria-disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <>
            {/* 로딩 스피너 */}
            <svg
              className="h-4 w-4 animate-spin"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              aria-hidden="true"
            >
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
              />
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
              />
            </svg>
            <span>로딩 중...</span>
          </>
        ) : (
          children
        )}
      </Comp>
    )
  },
)
Button.displayName = 'Button'

export { Button, buttonVariants }

3.3 Modal/Dialog 컴포넌트 (Radix UI 기반)

// components/ui/Dialog.tsx
import { forwardRef, type ComponentPropsWithoutRef } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'

/** 루트 - 상태 관리 */
const Dialog = DialogPrimitive.Root

/** 트리거 - 열기 버튼 */
const DialogTrigger = DialogPrimitive.Trigger

/** 포탈 - body에 렌더링 */
const DialogPortal = DialogPrimitive.Portal

/** 닫기 버튼 */
const DialogClose = DialogPrimitive.Close

/** 오버레이 - 배경 딤 처리 */
const DialogOverlay = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      'fixed inset-0 z-modal-backdrop bg-black/50',
      // 진입/퇴장 애니메이션
      'data-[state=open]:animate-in data-[state=open]:fade-in-0',
      'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
      className,
    )}
    {...props}
  />
))
DialogOverlay.displayName = 'DialogOverlay'

/** 콘텐츠 - 모달 본문 */
const DialogContent = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        // 위치 및 크기
        'fixed left-1/2 top-1/2 z-modal -translate-x-1/2 -translate-y-1/2',
        'w-full max-w-lg max-h-[85vh] overflow-y-auto',
        // 스타일
        'rounded-xl bg-surface p-6 shadow-xl border border-border-default',
        // 애니메이션
        'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
        'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
        // 포커스 트랩은 Radix가 자동 처리
        className,
      )}
      {...props}
    >
      {children}
      {/* 닫기 버튼 - 키보드 Esc와 동일 동작 */}
      <DialogPrimitive.Close
        className={cn(
          'absolute right-4 top-4 rounded-sm opacity-70',
          'hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary',
          'transition-opacity',
        )}
        aria-label="닫기"
      >
        <X className="h-4 w-4" />
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = 'DialogContent'

/** 헤더 영역 */
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('flex flex-col gap-1.5 text-center sm:text-left', className)} {...props} />
)

/** 제목 */
const DialogTitle = forwardRef<
  HTMLHeadingElement,
  ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn('text-lg font-semibold leading-none tracking-tight', className)}
    {...props}
  />
))
DialogTitle.displayName = 'DialogTitle'

/** 설명 */
const DialogDescription = forwardRef<
  HTMLParagraphElement,
  ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Description
    ref={ref}
    className={cn('text-sm text-on-surface-muted', className)}
    {...props}
  />
))
DialogDescription.displayName = 'DialogDescription'

/** 푸터 영역 */
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2 mt-6', className)}
    {...props}
  />
)

export {
  Dialog,
  DialogPortal,
  DialogOverlay,
  DialogClose,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
}

사용 예시:

// 실제 사용 코드
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'

function DeleteConfirmDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive">삭제</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>정말 삭제하시겠습니까?</DialogTitle>
          <DialogDescription>
            이 작업은 되돌릴 수 없습니다. 데이터가 영구적으로 삭제됩니다.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">취소</Button>
          </DialogClose>
          <Button variant="destructive" onClick={handleDelete}>
            삭제 확인
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

3.4 Compound Component 패턴

일상 비유: Compound 컴포넌트는 레고 블록 세트입니다. 루트 블록(Card)이 공통 색·결합 규칙을 정해두면, 위에 끼우는 헤더·본문·푸터 블록은 자유롭게 조합할 수 있습니다. 호출부는 "어떤 블록을 어떤 순서로 끼울지"만 결정하면 됩니다.

이 그림은 Compound 컴포넌트의 primitive 합성 흐름을 보여줍니다. 루트가 Context로 상태를 내려주고, 자식은 정해진 슬롯에 자유롭게 꽂힙니다.

flowchart TD
  subgraph Author["디자인 시스템 저작 영역"]
    Root["Card.Root<br/>(Context Provider)"]
    Slot1["Card.Header"]
    Slot2["Card.Title"]
    Slot3["Card.Content"]
    Slot4["Card.Footer"]
    Root -. "Context: variant" .-> Slot1
    Root -. "Context: variant" .-> Slot2
    Root -. "Context: variant" .-> Slot3
    Root -. "Context: variant" .-> Slot4
  end
  subgraph Caller["애플리케이션 호출부"]
    Use1["<Card variant=elevated>"]
    Use2["<Card.Header><Card.Title/></Card.Header>"]
    Use3["<Card.Content>...</Card.Content>"]
    Use4["<Card.Footer><Button/></Card.Footer>"]
  end
  Slot1 --> Use2
  Slot2 --> Use2
  Slot3 --> Use3
  Slot4 --> Use4
  Use1 --> Use2
  Use1 --> Use3
  Use1 --> Use4
  Use4 --> Render["DOM 렌더 결과"]
  Use3 --> Render
  Use2 --> Render

핵심 규칙: ① 자식은 루트 없이 단독으로 렌더되지 않습니다. ② variant 같은 공통 상태는 Context로 전달합니다. ③ 호출부는 빈 슬롯을 선택적으로 끼울 수 있으므로 헤더 없는 카드, 푸터 두 개짜리 카드도 만들 수 있습니다.

// components/ui/Card.tsx
// Compound Component: 하위 요소를 자유롭게 조합하는 패턴
import { createContext, useContext, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

interface CardContextValue {
  variant: 'default' | 'outlined' | 'elevated'
}

const CardContext = createContext<CardContextValue>({ variant: 'default' })

/** Card 루트 */
function Card({
  variant = 'default',
  className,
  children,
}: {
  variant?: CardContextValue['variant']
  className?: string
  children: ReactNode
}) {
  return (
    <CardContext.Provider value={{ variant }}>
      <div
        className={cn(
          'rounded-xl p-card-padding',
          {
            default: 'bg-card-bg border border-card-border shadow-card-shadow',
            outlined: 'bg-transparent border-2 border-card-border',
            elevated: 'bg-card-bg shadow-lg',
          }[variant],
          className,
        )}
      >
        {children}
      </div>
    </CardContext.Provider>
  )
}

/** Card 헤더 */
function CardHeader({ className, children }: { className?: string; children: ReactNode }) {
  return <div className={cn('flex flex-col gap-1.5 pb-4', className)}>{children}</div>
}

/** Card 제목 */
function CardTitle({ className, children }: { className?: string; children: ReactNode }) {
  return (
    <h3 className={cn('text-xl font-semibold leading-tight tracking-tight', className)}>
      {children}
    </h3>
  )
}

/** Card 본문 */
function CardContent({ className, children }: { className?: string; children: ReactNode }) {
  return <div className={cn('text-sm text-on-surface-muted', className)}>{children}</div>
}

/** Card 푸터 */
function CardFooter({ className, children }: { className?: string; children: ReactNode }) {
  return (
    <div className={cn('flex items-center gap-2 pt-4 border-t border-border-default', className)}>
      {children}
    </div>
  )
}

// Compound Component로 내보내기
Card.Header = CardHeader
Card.Title = CardTitle
Card.Content = CardContent
Card.Footer = CardFooter

export { Card }

// ── 사용 예시 ──
// <Card variant="elevated">
//   <Card.Header>
//     <Card.Title>프로젝트 현황</Card.Title>
//   </Card.Header>
//   <Card.Content>
//     <p>진행 중인 작업: 12건</p>
//   </Card.Content>
//   <Card.Footer>
//     <Button size="sm">자세히 보기</Button>
//   </Card.Footer>
// </Card>

4. 문서화 및 검증: Storybook 9

컴포넌트의 독립적인 개발과 테스트를 위해 Storybook 같은 컴포넌트 워크벤치를 사용합니다. Storybook 9 라인을 선택하는 경우 공식 release note와 addon 호환성을 확인하고, 접근성·인터랙션·시각 회귀 테스트가 release gate와 연결되는지 검증합니다.

일상 비유: Storybook은 공장의 품질검사 라인입니다. 같은 부품(컴포넌트)을 다양한 각도(variant, props, viewport)로 점검대에 올려두고, 사람 눈 검수(시각 회귀)·기계 측정(인터랙션 테스트)·안전 점검(a11y)을 차례로 통과시킵니다.

이 그림은 Storybook이 컴포넌트를 어떻게 단계별로 검증하는지 보여줍니다.

flowchart LR
  Dev["개발자<br/>컴포넌트 변경"] --> Story["*.stories.tsx<br/>(variant 매트릭스 정의)"]
  Story --> SB["Storybook 빌드"]
  SB --> A11y["a11y addon<br/>WCAG 위반 점검"]
  SB --> Int["interaction test<br/>(@storybook/test)"]
  SB --> VR["Chromatic<br/>(시각 회귀)"]
  A11y -->|위반| Fix["디자인/마크업 수정"]
  Int -->|실패| Fix
  VR -->|diff| Review["디자이너 리뷰"]
  Fix --> Story
  Review -->|승인| Merge["PR merge"]
  Review -->|거절| Fix
  A11y -->|통과| Merge
  Int -->|통과| Merge

컴포넌트 워크벤치 검증 기준

  • 접근성 애드온 또는 동등한 검사로 WCAG 위반을 개발 중에 확인합니다.
  • 인터랙션 테스트는 실제 사용자 이벤트와 focus 이동을 검증합니다.
  • RSC/SSR preview는 사용하는 React/프레임워크 조합에서 별도 smoke test를 둡니다.
  • test runner는 CI에서 동일한 브라우저/환경으로 재현되어야 합니다.

4.1 스토리 파일 작성

// components/ui/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { Button } from './Button'

/** Button 컴포넌트 메타 정보 */
const meta = {
  title: 'UI/Button',
  component: Button,
  tags: ['autodocs'], // 자동 문서 생성
  parameters: {
    layout: 'centered',
    docs: {
      description: {
        component: '디자인 시스템의 기본 버튼 컴포넌트. CVA 기반 변형 관리.',
      },
    },
  },
  // 글로벌 args 기본값
  args: {
    onClick: fn(), // Storybook Actions에 자동 연결
    children: '버튼',
  },
  // Controls 패널 설정
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline', 'ghost', 'destructive', 'link'],
      description: '버튼의 시각적 스타일',
    },
    size: {
      control: 'select',
      options: ['xs', 'sm', 'md', 'lg', 'xl', 'icon'],
      description: '버튼 크기',
    },
    isLoading: {
      control: 'boolean',
      description: '로딩 상태 표시',
    },
    disabled: {
      control: 'boolean',
      description: '비활성화 상태',
    },
  },
} satisfies Meta<typeof Button>

export default meta
type Story = StoryObj<typeof meta>

/** 기본 버튼 */
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: '기본 버튼',
  },
}

/** 모든 변형 한눈에 보기 */
export const AllVariants: Story = {
  render: () => (
    <div className="flex flex-wrap gap-4">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="link">Link</Button>
    </div>
  ),
}

/** 모든 크기 */
export const AllSizes: Story = {
  render: () => (
    <div className="flex items-center gap-4">
      <Button size="xs">XS</Button>
      <Button size="sm">SM</Button>
      <Button size="md">MD</Button>
      <Button size="lg">LG</Button>
      <Button size="xl">XL</Button>
    </div>
  ),
}

/** 로딩 상태 */
export const Loading: Story = {
  args: {
    isLoading: true,
    children: '저장 중',
  },
}

/** 비활성화 */
export const Disabled: Story = {
  args: {
    disabled: true,
    children: '비활성화',
  },
}

4.2 Interaction Test (인터랙션 테스트)

// components/ui/Button.stories.tsx (테스트 추가)
import { expect, userEvent, within } from '@storybook/test'

/** 클릭 인터랙션 테스트 */
export const ClickTest: Story = {
  args: {
    children: '클릭 테스트',
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement)
    const button = canvas.getByRole('button', { name: '클릭 테스트' })

    // 버튼이 렌더링 되었는지 확인
    await expect(button).toBeInTheDocument()
    await expect(button).toBeEnabled()

    // 클릭 이벤트 발생
    await userEvent.click(button)
    await expect(args.onClick).toHaveBeenCalledTimes(1)

    // 키보드 접근성: Enter와 Space로 활성화
    await userEvent.keyboard('{Enter}')
    await expect(args.onClick).toHaveBeenCalledTimes(2)
  },
}

/** 비활성화 상태에서 클릭 차단 테스트 */
export const DisabledClickTest: Story = {
  args: {
    disabled: true,
    children: '비활성화 버튼',
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement)
    const button = canvas.getByRole('button')

    // disabled 상태 확인
    await expect(button).toBeDisabled()

    // 클릭해도 핸들러가 호출되지 않아야 함
    await userEvent.click(button)
    await expect(args.onClick).not.toHaveBeenCalled()
  },
}

4.3 Chromatic CI 연동

# .ci/workflows/chromatic.yml
name: Chromatic - Visual Regression

on:
  pull_request:
    branches: [main, develop]

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # 전체 히스토리 (비교 기준)

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Chromatic 퍼블리시
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          buildScriptName: 'build-storybook'
          exitZeroOnChanges: true # 변경 감지 시에도 CI 실패하지 않음
          autoAcceptChanges: 'main' # main 브랜치는 자동 승인

4.4 접근성 애드온 (a11y)

// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite'

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y', // 접근성 자동 검사
    '@storybook/addon-interactions', // 인터랙션 테스트
    '@storybook/addon-themes', // 테마 전환 (다크 모드)
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
}

export default config
// .storybook/preview.ts
import type { Preview } from '@storybook/react'

const preview: Preview = {
  parameters: {
    // 접근성 검사 기본 설정
    a11y: {
      config: {
        rules: [
          { id: 'color-contrast', enabled: true }, // 색상 대비 검사
          { id: 'label', enabled: true }, // 폼 라벨 검사
        ],
      },
    },
    // 다크 모드 전환 지원
    backgrounds: {
      default: 'light',
      values: [
        { name: 'light', value: '#ffffff' },
        { name: 'dark', value: '#1a1a2e' },
      ],
    },
  },
}

export default preview

5. 다크 모드 구현 전략

왜 중요한가: 다크 모드를 "색 반전"으로만 보면 그림자, 대비, 일러스트가 모두 깨집니다. 라이트와 다크는 별개 테마이고, 토큰 시스템이 받쳐줘야 흔들리지 않습니다.

일상 비유: 다크 모드는 같은 식당의 점심·저녁 분위기 전환입니다. 조명을 끄는 것만으로는 부족하고, 메뉴판 글씨 크기, 식탁 광택, 음악 볼륨까지 같이 바뀝니다.

다크 모드 상태 머신

stateDiagram-v2
  [*] --> System: 초기 로드
  System --> Dark: prefers-color-scheme: dark
  System --> Light: prefers-color-scheme: light
  Dark --> Light: 사용자 토글
  Light --> Dark: 사용자 토글
  Light --> System: 토글 (시스템 모드)
  Dark --> System: 토글 (시스템 모드)
  System --> Dark: OS 설정 변경 감지
  System --> Light: OS 설정 변경 감지
  note right of System
    localStorage에 저장
    .dark / .light 클래스로 DOM 표시
    FOUC 방지 인라인 스크립트로 첫 렌더 처리
  end note

다크 모드는 단순한 색상 반전이 아니라, 가독성과 시각적 계층을 유지하면서 사용자 눈의 피로를 줄이는 독립적 테마입니다.

5.1 CSS Custom Properties 기반 구현

/* themes.css - 시스템 테마 연동 + 수동 전환 지원 */

/* 라이트 모드 (기본) */
:root {
  color-scheme: light;
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --bg-tertiary: #e9ecef;
  --text-primary: #212529;
  --text-secondary: #495057;
  --text-tertiary: #6c757d;
  --border-default: #dee2e6;
  --border-strong: #adb5bd;
}

/* 다크 모드 - 시스템 설정 자동 감지 */
@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    color-scheme: dark;
    --bg-primary: #1a1a2e;
    --bg-secondary: #16213e;
    --bg-tertiary: #0f3460;
    --text-primary: #e2e8f0;
    --text-secondary: #94a3b8;
    --text-tertiary: #64748b;
    --border-default: #334155;
    --border-strong: #475569;
  }
}

/* 다크 모드 - 수동 강제 적용 (클래스 기반) */
:root.dark {
  color-scheme: dark;
  --bg-primary: #1a1a2e;
  --bg-secondary: #16213e;
  --bg-tertiary: #0f3460;
  --text-primary: #e2e8f0;
  --text-secondary: #94a3b8;
  --text-tertiary: #64748b;
  --border-default: #334155;
  --border-strong: #475569;
}

5.2 테마 토글 구현 (React)

일상 비유: 테마 적용 결정은 호텔 룸 조명 같은 것입니다. 손님이 직접 누른 스위치가 있으면 그걸 따르고, 없으면 호텔 시스템(OS 설정)이 시간대에 맞춰 자동으로 켜고 끕니다. 우리 코드는 이 두 입력을 순서대로 읽어 최종 모드를 정합니다.

이 그림은 한 사용자가 페이지를 열었을 때 어떤 모드를 적용할지 결정하는 흐름입니다.

flowchart TD
  Start["페이지 로드"] --> Q1{"localStorage에<br/>theme-preference 있나?"}
  Q1 -->|예| Q2{"값이 system인가?"}
  Q1 -->|아니오| OS{"prefers-color-scheme<br/>= dark?"}
  Q2 -->|아니오| Apply1["저장된 light/dark 강제 적용"]
  Q2 -->|예| OS
  OS -->|예| ApplyDark["다크 모드 적용<br/>.dark 클래스 부착"]
  OS -->|아니오| ApplyLight["라이트 모드 적용<br/>.light 클래스 부착"]
  Apply1 --> Render["최종 렌더"]
  ApplyDark --> Render
  ApplyLight --> Render
  Render --> Listen{"OS 설정 변경<br/>이벤트 발생?"}
  Listen -->|"저장값이 system"| OS
  Listen -->|"저장값이 명시"| Apply1

FOUC 방지 포인트: 이 결정은 React 렌더 이전, <head> 인라인 스크립트에서 동기적으로 수행해야 합니다. 그렇지 않으면 첫 paint가 라이트로 잡혔다가 다크로 깜빡입니다 (5.3 절 참고).

// hooks/useTheme.ts
import { useCallback, useEffect, useState } from 'react'

type Theme = 'light' | 'dark' | 'system'

const STORAGE_KEY = 'theme-preference'

/** 테마 관리 훅 - localStorage 영속화 + 시스템 설정 연동 */
export function useTheme() {
  const [theme, setThemeState] = useState<Theme>(() => {
    // SSR 안전: 서버에서는 기본값 반환
    if (typeof window === 'undefined') return 'system'
    return (localStorage.getItem(STORAGE_KEY) as Theme) || 'system'
  })

  // 실제 적용되는 테마 계산
  const resolvedTheme = useResolvedTheme(theme)

  const setTheme = useCallback((newTheme: Theme) => {
    setThemeState(newTheme)
    localStorage.setItem(STORAGE_KEY, newTheme)
  }, [])

  // DOM에 클래스 적용
  useEffect(() => {
    const root = document.documentElement
    root.classList.remove('light', 'dark')

    if (theme !== 'system') {
      root.classList.add(theme)
    }
  }, [theme])

  return { theme, resolvedTheme, setTheme }
}

/** 시스템 설정을 반영한 실제 테마 계산 */
function useResolvedTheme(theme: Theme): 'light' | 'dark' {
  const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('light')

  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)')
    setSystemTheme(mq.matches ? 'dark' : 'light')

    const handler = (e: MediaQueryListEvent) => {
      setSystemTheme(e.matches ? 'dark' : 'light')
    }
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [])

  return theme === 'system' ? systemTheme : theme
}
// components/ThemeToggle.tsx
import { Moon, Sun, Monitor } from 'lucide-react'
import { useTheme } from '@/hooks/useTheme'
import { Button } from '@/components/ui/Button'

/** 3단 테마 전환 버튼 (라이트 / 다크 / 시스템) */
export function ThemeToggle() {
  const { theme, setTheme } = useTheme()

  const cycle = () => {
    const next = { light: 'dark', dark: 'system', system: 'light' } as const
    setTheme(next[theme])
  }

  const Icon = { light: Sun, dark: Moon, system: Monitor }[theme]
  const label = { light: '라이트 모드', dark: '다크 모드', system: '시스템 설정' }[theme]

  return (
    <Button variant="ghost" size="icon" onClick={cycle} aria-label={`현재 ${label}, 클릭하여 전환`}>
      <Icon className="h-5 w-5" />
    </Button>
  )
}

5.3 FOUC(Flash of Unstyled Content) 방지

<!-- index.html <head> 최상단에 인라인 스크립트 삽입 -->
<script>
  // 렌더링 차단 스크립트: 깜빡임 방지를 위해 동기적으로 실행
  ;(function () {
    var theme = localStorage.getItem('theme-preference')
    if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark')
    }
  })()
</script>

6. 아이콘 시스템 구축

6.1 lucide-react 통합

// components/ui/Icon.tsx
import { type LucideIcon, type LucideProps } from 'lucide-react'
import { cn } from '@/lib/utils'

interface IconProps extends LucideProps {
  /** lucide-react에서 가져온 아이콘 컴포넌트 */
  icon: LucideIcon
  /** 미리 정의된 크기 */
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
}

const sizeMap = {
  xs: 'h-3 w-3',
  sm: 'h-4 w-4',
  md: 'h-5 w-5',
  lg: 'h-6 w-6',
  xl: 'h-8 w-8',
} as const

/** 디자인 시스템 표준 아이콘 래퍼 */
export function Icon({ icon: LucideIcon, size = 'md', className, ...props }: IconProps) {
  return (
    <LucideIcon
      className={cn(sizeMap[size], className)}
      aria-hidden="true" // 장식용 아이콘은 스크린 리더에서 숨김
      {...props}
    />
  )
}
// 사용 예시
import { Icon } from '@/components/ui/Icon'
import { Search, Bell, Settings, ChevronRight } from 'lucide-react'

function NavigationIcons() {
  return (
    <nav className="flex items-center gap-4">
      <Icon icon={Search} size="md" />
      <Icon icon={Bell} size="md" />
      <Icon icon={Settings} size="md" />
      {/* 의미가 있는 아이콘은 aria-label 명시 */}
      <button aria-label="알림 3건">
        <Icon icon={Bell} size="md" aria-hidden={undefined} />
        <span className="sr-only">알림 3건</span>
      </button>
    </nav>
  )
}

6.2 SVG 스프라이트 (대규모 커스텀 아이콘용)

// 커스텀 아이콘이 많을 때는 SVG 스프라이트로 번들 최적화
// public/icons/sprite.svg
// <svg xmlns="http://www.w3.org/2000/svg">
//   <symbol id="icon-dashboard" viewBox="0 0 24 24">...</symbol>
//   <symbol id="icon-analytics" viewBox="0 0 24 24">...</symbol>
// </svg>

interface SpriteIconProps {
  name: string
  className?: string
  title?: string
}

/** SVG 스프라이트 기반 아이콘 컴포넌트 */
export function SpriteIcon({ name, className, title }: SpriteIconProps) {
  return (
    <svg className={cn('h-5 w-5', className)} aria-hidden={!title} role={title ? 'img' : undefined}>
      {title && <title>{title}</title>}
      <use href={`/icons/sprite.svg#icon-${name}`} />
    </svg>
  )
}

7. 반응형 디자인 시스템

7.1 브레이크포인트 토큰 활용

// hooks/useBreakpoint.ts
import { useEffect, useState } from 'react'

const breakpoints = {
  sm: 640,
  md: 768,
  lg: 1024,
  xl: 1280,
  '2xl': 1536,
} as const

type Breakpoint = keyof typeof breakpoints

/** 현재 브레이크포인트를 반환하는 훅 */
export function useBreakpoint(): Breakpoint {
  const [current, setCurrent] = useState<Breakpoint>('sm')

  useEffect(() => {
    const update = () => {
      const width = window.innerWidth
      if (width >= breakpoints['2xl']) setCurrent('2xl')
      else if (width >= breakpoints.xl) setCurrent('xl')
      else if (width >= breakpoints.lg) setCurrent('lg')
      else if (width >= breakpoints.md) setCurrent('md')
      else setCurrent('sm')
    }

    update()
    window.addEventListener('resize', update)
    return () => window.removeEventListener('resize', update)
  }, [])

  return current
}

/** 특정 브레이크포인트 이상인지 확인 */
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false)

  useEffect(() => {
    const mq = window.matchMedia(query)
    setMatches(mq.matches)
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [query])

  return matches
}

7.2 Container Queries

부모 컨테이너 크기에 반응하는 컴포넌트를 구현합니다. 뷰포트가 아닌 컨테이너 기준이므로 재사용성이 뛰어납니다.

// Tailwind v4 Container Query 활용
function ResponsiveCard() {
  return (
    // @container로 컨테이너 쿼리 컨텍스트 선언
    <div className="@container">
      <div
        className={cn(
          'flex flex-col gap-4 p-4',
          // 컨테이너가 400px 이상이면 가로 배치
          '@md:flex-row @md:items-center @md:gap-6',
          // 컨테이너가 600px 이상이면 더 넓은 패딩
          '@lg:p-8',
        )}
      >
        <img src="/thumbnail.jpg" alt="" className="w-full rounded-lg @md:w-48 @md:shrink-0" />
        <div className="flex-1">
          <h3 className="text-base font-semibold @lg:text-xl">제목</h3>
          <p className="text-sm text-on-surface-muted mt-1">설명 텍스트</p>
        </div>
      </div>
    </div>
  )
}

7.3 Fluid Typography (유동 타이포그래피)

뷰포트 크기에 따라 글꼴 크기가 부드럽게 변하도록 설정합니다.

/* Fluid typography - clamp()를 활용한 부드러운 스케일링 */
@theme {
  /* 최소 16px ~ 최대 20px, 뷰포트 기준 선형 보간 */
  --text-fluid-base: clamp(1rem, 0.5rem + 1vw, 1.25rem);

  /* 최소 24px ~ 최대 40px */
  --text-fluid-heading: clamp(1.5rem, 1rem + 2vw, 2.5rem);

  /* 최소 36px ~ 최대 64px */
  --text-fluid-display: clamp(2.25rem, 1.5rem + 3vw, 4rem);
}
// 사용 예시
function HeroSection() {
  return (
    <section className="px-4 py-20 text-center">
      {/* fluid 크기가 뷰포트에 따라 자동 조절 */}
      <h1 className="text-fluid-display font-bold tracking-tight text-balance">
        미래를 설계하는 디자인 시스템
      </h1>
      <p className="text-fluid-base text-on-surface-muted mt-4 max-w-2xl mx-auto">
        일관성, 접근성, 확장성을 갖춘 컴포넌트 라이브러리
      </p>
    </section>
  )
}

8. 컴포넌트 API 설계 원칙

8.1 다형성 컴포넌트 (as prop)

// components/ui/Box.tsx
import { type ElementType, type ComponentPropsWithoutRef, forwardRef } from 'react'
import { cn } from '@/lib/utils'

/**
 * 다형성(Polymorphic) 컴포넌트 타입
 * `as` prop에 따라 렌더링되는 HTML 요소와 허용 속성이 동적으로 변경
 */
type BoxProps<C extends ElementType = 'div'> = {
  as?: C
  className?: string
} & Omit<ComponentPropsWithoutRef<C>, 'as' | 'className'>

/** 다형성 Box 컴포넌트 - 어떤 HTML 요소로든 렌더링 가능 */
function Box<C extends ElementType = 'div'>({ as, className, ...props }: BoxProps<C>) {
  const Component = as || 'div'
  return <Component className={cn(className)} {...props} />
}

// 사용 예시:
// <Box as="section" className="p-4">섹션</Box>
// <Box as="article" className="prose">본문</Box>
// <Box as="a" href="/about" className="text-primary">링크처럼 동작</Box>

8.2 Render Props vs Hooks 패턴

// ── Render Props: UI 렌더링 제어권을 사용자에게 위임 ──
interface DropdownProps<T> {
  items: T[]
  /** 각 항목의 렌더링을 사용자가 결정 */
  renderItem: (item: T, index: number) => React.ReactNode
  /** 트리거 버튼 렌더링 */
  renderTrigger: (props: { isOpen: boolean; toggle: () => void }) => React.ReactNode
}

function Dropdown<T>({ items, renderItem, renderTrigger }: DropdownProps<T>) {
  const [isOpen, setIsOpen] = useState(false)
  const toggle = () => setIsOpen((prev) => !prev)

  return (
    <div className="relative">
      {renderTrigger({ isOpen, toggle })}
      {isOpen && (
        <ul
          role="listbox"
          className="absolute mt-1 rounded-md bg-surface shadow-lg border border-border-default"
        >
          {items.map((item, i) => (
            <li key={i} role="option">
              {renderItem(item, i)}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

// ── Custom Hook: 로직만 제공, UI는 완전히 자유 ──
function useToggle(initial = false) {
  const [isOpen, setIsOpen] = useState(initial)
  const toggle = useCallback(() => setIsOpen((p) => !p), [])
  const open = useCallback(() => setIsOpen(true), [])
  const close = useCallback(() => setIsOpen(false), [])
  return { isOpen, toggle, open, close } as const
}

선택 기준: | 상황 | 권장 패턴 | | :--- | :--- | | UI 커스터마이징이 핵심 | Render Props | | 로직 재사용이 핵심, UI는 호출부에서 결정 | Custom Hook | | 정해진 UI 구조 내에서 콘텐츠만 교체 | Compound Component | | 단순 on/off, 시각적 변형만 필요 | Props (CVA variants) |

8.3 좋은 컴포넌트 API 설계 규칙

// ✅ 좋은 예: 의미 기반 props, 일관된 네이밍
<Button variant="destructive" size="sm" isLoading disabled>삭제</Button>
<Input type="email" isRequired isInvalid errorMessage="올바른 이메일을 입력하세요" />

// ❌ 나쁜 예: 불명확한 props, 불일치 네이밍
<Button color="red" small loading={true} isDisabled>삭제</Button>
<Input inputType="email" required invalid error="올바른 이메일을 입력하세요" />

규칙 요약:

  1. boolean prop은 is/has 접두어 사용 (isLoading, hasError)
  2. 이벤트 핸들러는 on 접두어 (onClick, onValueChange)
  3. 열거형은 variant, size 등 의미 기반 이름
  4. className은 항상 마지막 스타일 오버라이드용으로 지원
  5. children은 가능한 한 지원하여 유연성 확보

9. 디자인 시스템 배포 및 버저닝

왜 중요한가: 디자인 시스템은 "수십 개 제품이 동시에 의존하는 코드"입니다. breaking change 한 번에 여러 팀의 빌드가 깨질 수 있어, 버전 관리와 마이그레이션 가이드가 핵심 자산입니다.

일상 비유: 디자인 시스템 배포는 운영체제 업데이트와 비슷합니다. patch는 보안 패치(자동 설치 가능), minor는 새 기능(선택 설치), major는 새 OS 버전(앱 호환성 점검 필수)입니다.

9.0 디자인 시스템 릴리즈 흐름

sequenceDiagram
  participant Dev as 컴포넌트 개발자
  participant CS as Changeset
  participant CI as CI 파이프라인
  participant Reg as npm 레지스트리
  participant Apps as 소비 앱(여러 제품)
  Dev->>CS: 변경 의도 기록 (patch/minor/major)
  Dev->>CI: PR 생성
  CI->>CI: lint + visual regression + a11y test
  CI-->>Dev: 결과 보고
  Dev->>CS: PR 머지
  CS->>Reg: 자동 버전 bump + 퍼블리시
  CS->>Apps: changelog + migration note 공지
  Apps->>Apps: 업그레이드 + 마이그레이션
  Apps-->>Dev: 회귀 보고

9.1 시맨틱 버저닝 (SemVer)

일상 비유: SemVer는 책의 판본 표기와 같습니다. 오탈자 수정은 인쇄 차수(patch), 새 챕터 추가는 개정판(minor), 책 구조를 갈아엎으면 새 판(major)입니다. 표지에 적힌 숫자만 보고도 독자가 마이그레이션 노력을 가늠할 수 있어야 합니다.

이 그림은 변경이 들어왔을 때 어떤 bump 종류를 선택할지 보여줍니다.

flowchart TD
  Change["변경 사항 정리"] --> Q1{"기존 API가<br/>제거/이름 변경/시그니처 변경?"}
  Q1 -->|예| MAJ["MAJOR bump<br/>X.0.0"]
  Q1 -->|아니오| Q2{"새 컴포넌트/<br/>새 prop/<br/>새 variant 추가?"}
  Q2 -->|예| MIN["MINOR bump<br/>x.X.0"]
  Q2 -->|아니오| Q3{"버그 수정/<br/>내부 리팩토링/<br/>의존성 패치?"}
  Q3 -->|예| PAT["PATCH bump<br/>x.x.X"]
  Q3 -->|아니오| Doc["문서 전용 변경<br/>버전 bump 없음"]
  MAJ --> Mig["마이그레이션 가이드 작성 필수<br/>(codemod 권장)"]
  MIN --> News["release note에 추가 기능 명시"]
  PAT --> Quick["자동 배포 가능<br/>(시각 회귀 통과 시)"]
  Doc --> Skip["changeset 생략"]

주의: 토큰 값을 바꾸는 것도 시각적으로는 breaking일 수 있습니다. 예: --color-primary가 파랑→보라로 바뀌면 minor가 아니라 major로 취급해야 하는 조직도 있습니다. 조직별 정책을 changeset 가이드에 명시하세요.

MAJOR.MINOR.PATCH

MAJOR (1.0.0 → 2.0.0): Breaking change - 기존 API 변경/삭제
  예: Button의 `type` prop이 `variant`로 이름 변경

MINOR (1.0.0 → 1.1.0): 하위 호환 기능 추가
  예: Button에 `ghost` variant 추가

PATCH (1.0.0 → 1.0.1): 버그 수정
  예: Button의 포커스 링 색상 수정

9.2 Changelog 자동 생성

// package.json
{
  "name": "@company/design-system",
  "version": "1.5.0",
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "release": "changeset publish",
    "version": "changeset version"
  }
}
## <!-- .changeset/add-ghost-button.md -->

## "@company/design-system": minor

Button 컴포넌트에 `ghost` variant를 추가했습니다.
배경 없이 텍스트만 표시되며, hover 시 배경이 나타납니다.

9.3 패키지 구조 및 퍼블리싱

// src/index.ts - Barrel export
export { Button, type ButtonProps, buttonVariants } from './components/Button'
export { Dialog, DialogContent, DialogTrigger } from './components/Dialog'
export { Card } from './components/Card'
export { useTheme } from './hooks/useTheme'

// 토큰 CSS도 함께 제공
import './tokens/tokens.css'
// tsup.config.ts
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'], // ESM + CommonJS 동시 지원
  dts: true, // 타입 선언 파일 자동 생성
  splitting: true, // 코드 스플리팅으로 트리 셰이킹 지원
  sourcemap: true,
  clean: true,
  external: ['react', 'react-dom'], // peer dependency 제외
})

10. 주의사항 및 흔한 실수

10.1 과도한 추상화 (Over-Abstraction)

// ❌ 과도한 추상화: 의미 파악이 어려움
<FlexContainer direction="row" justify="between" align="center" wrap gap={4}>
  <TextElement variant="heading" level={3} weight="bold" />
</FlexContainer>

// ✅ Tailwind 유틸리티를 직접 사용하는 것이 더 명확
<div className="flex items-center justify-between flex-wrap gap-4">
  <h3 className="text-xl font-bold">제목</h3>
</div>

원칙: 레이아웃 유틸리티(Flex, Grid, Stack)는 Tailwind 클래스로 충분합니다. 비즈니스 의미가 있는 컴포넌트만 추상화하세요.

10.2 토큰 불일치

/* ❌ 하드코딩: 토큰과 무관한 임의 값 */
.card {
  border-radius: 7px;
  padding: 18px;
}

/* ✅ 토큰 사용: 시스템과 일관성 유지 */
.card {
  border-radius: var(--radius-lg);
  padding: var(--spacing-lg);
}

ESLint 규칙으로 강제:

// eslint 설정에서 임의 값 사용 경고
{
  "rules": {
    "no-restricted-syntax": [
      "warn",
      {
        "selector": "Literal[value=/^\\d+px$/]",
        "message": "하드코딩된 px 값 대신 디자인 토큰을 사용하세요."
      }
    ]
  }
}

10.3 접근성 누락

// ❌ 접근성 누락: 스크린 리더에서 의미 전달 불가
<div onClick={handleClick} className="cursor-pointer">삭제</div>
<img src="chart.png" />
<input placeholder="이메일" />

// ✅ 접근성 준수
<button onClick={handleClick} type="button">삭제</button>
<img src="chart.png" alt="월간 매출 추이 차트 - 3월 최고치 기록" />
<label>
  이메일
  <input type="email" aria-required="true" />
</label>

접근성 상세 가이드는 19. 웹 접근성 가이드를 참고하세요.

10.4 다크 모드에서 흔한 실수

/* ❌ 그림자가 다크 모드에서 보이지 않음 */
.card {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* ✅ 다크 모드에서는 border나 밝은 그림자로 대체 */
.card {
  box-shadow: var(--shadow-md);
  border: 1px solid var(--border-default);
}

/* ❌ 배경과 텍스트 대비 부족 */
.dark .muted-text {
  color: #666;
} /* 대비 부족 */

/* ✅ WCAG AA 기준 4.5:1 이상 대비 확보 */
.dark .muted-text {
  color: #94a3b8;
} /* 슬레이트 400 */

AI 보조 컴포넌트 개발 검증

AI는 컴포넌트 코드, token migration, story, 접근성 테스트 후보를 빠르게 만들 수 있지만, public API와 접근성 계약은 사람이 검증합니다. 입력 구조와 민감정보 처리 기준은 18. AI 개발 워크플로우를 따릅니다.

시나리오 입력 AI 산출물 필수 검증
컴포넌트 초안 token contract, state, interaction, 접근성 패턴 component API, state별 markup, test 후보 keyboard/focus/name-role-value test
토큰 마이그레이션 기존 token, 사용처, 플랫폼 요구 primitive/semantic/component 분리안 token schema validation, visual diff
Story/문서 생성 component props, states, edge cases stories, usage note, controls interaction test, a11y addon, snapshot diff
다크 모드 감사 rendered screenshot, token map, contrast report hard-coded color 후보, 수정안 WCAG contrast, theme switch E2E

승인 조건은 명확합니다. AI 산출물은 token diff, 접근성 테스트, visual regression 중 최소 하나의 증거와 연결되어야 하고, public API 변경은 migration note를 남겨야 합니다.


체크리스트

스타일링 기초

  • [ ] 스타일이 CSS-first 방식의 Tailwind v4 표준을 따르고 있나요?
  • [ ] tailwind.config.js 대신 @theme, @utility, @variant를 사용하나요?
  • [ ] v3에서 마이그레이션 시 @tailwindcss/upgrade 도구를 실행했나요?

디자인 토큰

  • [ ] 토큰이 Primitive → Semantic → Component 3계층 구조를 따르나요?
  • [ ] 하드코딩된 색상/간격/크기 값 없이 토큰만 사용하고 있나요?
  • [ ] 토큰이 디자인 도구와 단일 소스(SSOT)로 동기화되어 있나요?
  • [ ] 토큰 문서화(이름, 값, 용도, 다크 모드 값)가 완료되었나요?

컴포넌트 설계

  • [ ] 컴포넌트의 접근성이 Radix UIReact Aria를 통해 보장되었나요?
  • [ ] 변형(Variant) 관리에 CVA를 도입하여 타입 안전성을 확보했나요?
  • [ ] cn() 유틸리티로 클래스 병합 시 충돌을 방지하고 있나요?
  • [ ] 적절한 패턴(Compound, Render Props, Polymorphic)을 선택했나요?

다크 모드

  • [ ] 다크 모드가 토큰 레벨에서 구현되어 있나요?
  • [ ] prefers-color-scheme 시스템 감지와 수동 토글을 모두 지원하나요?
  • [ ] FOUC 방지 인라인 스크립트가 <head>에 포함되어 있나요?
  • [ ] 다크 모드에서 색상 대비(WCAG AA 4.5:1)가 확보되었나요?

문서화 및 테스트

  • [ ] 모든 공통 컴포넌트가 Storybook에 등록되어 시각적으로 확인 가능한가요?
  • [ ] Storybook 인터랙션 테스트가 핵심 동작을 검증하고 있나요?
  • [ ] Chromatic 또는 동등 도구로 시각적 회귀 테스트가 CI에 연동되어 있나요?
  • [ ] a11y 애드온이 Storybook에 설치되어 접근성 자동 검사가 활성화되어 있나요?

반응형 및 아이콘

  • [ ] Container Queries를 활용하여 컴포넌트 단위 반응형을 구현했나요?
  • [ ] Fluid Typography로 뷰포트 대응 타이포그래피를 적용했나요?
  • [ ] 아이콘이 aria-hidden 또는 적절한 aria-label로 접근성을 갖추고 있나요?

배포 및 운영

  • [ ] 시맨틱 버저닝(SemVer)을 준수하고 있나요?
  • [ ] Changeset으로 변경 이력을 관리하고 있나요?
  • [ ] 패키지가 ESM/CJS 양쪽으로 빌드되고 타입 선언이 포함되어 있나요?

12. 폼의 응집도와 Component API

디자인 시스템은 재사용성을 높이지만, 잘못 올린 feature 컴포넌트는 결합도를 키웁니다.

12.1 필드 단위와 폼 전체 단위를 구분한다

  • 이메일, 전화번호, 추천 코드처럼 독립 검증이 있고 여러 폼에서 재사용되는 입력은 field component와 field validation을 함께 둡니다.
  • 결제 정보, 배송 정보, wizard form처럼 필드 간 의존성이 큰 경우에는 form schema와 submit flow를 폼 전체 단위로 관리합니다.
  • field component를 디자인 시스템으로 올릴 때는 도메인 copy, API 호출, feature flag, analytics를 제거합니다.

12.2 Props Drilling을 줄이는 API를 설계한다

  • compound component, slot, render prop, children composition으로 중간 컴포넌트가 사용하지 않는 prop 전달을 줄입니다.
  • Context API는 깊은 컴포넌트 트리에서 같은 상태를 직접 소비할 때만 사용하고, 단순한 값 전달을 숨기는 용도로 남용하지 않습니다.
  • public prop 이름은 변경 비용이 크므로 firstName처럼 도메인 구체명이 필요한지, label/value 같은 primitive 이름이 맞는지 먼저 결정합니다.

12.3 중복 허용 기준

  • 두 feature의 UI가 현재 비슷해도 validation, copy, permission, logging이 달라질 가능성이 있으면 공통 컴포넌트로 올리지 않습니다.
  • 세 번째 사용처가 생기고 변경 이유가 같다는 증거가 있을 때 shared 후보로 승격합니다.
  • 승격 시 Storybook, interaction test, a11y test, migration note를 함께 추가합니다.

실무 적용 가이드

언제 이 문서를 펼칠까

  • 화면마다 버튼, 입력, 간격, 색상 규칙이 다르게 구현될 때
  • 공통 컴포넌트 변경이 여러 제품을 동시에 깨뜨릴 때
  • token과 component API migration 기준이 필요할 때

적용 순서

  1. 토큰, primitive, composite, feature component를 구분한다.
  2. primitive는 접근성 contract와 keyboard behavior를 먼저 고정한다.
  3. token은 source of truth와 build output을 분리한다.
  4. breaking change에는 codemod 또는 migration guide를 붙인다.
  5. visual/a11y regression을 release gate로 둔다.

함께 두는 파일

  • 컴포넌트, story, test, token 사용 예시, migration note를 같은 package에 둔다.
  • feature 전용 컴포넌트는 디자인 시스템으로 올리지 않는다.
  • 공통 primitive는 도메인 의존 없이 독립 배포 가능해야 한다.

흔한 실수

  • 디자인 시스템에 비즈니스 로직을 넣는다.
  • token 이름이 색상값이나 특정 화면을 그대로 드러낸다.
  • 접근성 테스트 없는 headless primitive를 배포한다.
  • migration guide 없이 prop을 제거한다.

PR 완료 기준

  • [ ] token build가 통과한다.
  • [ ] story와 visual regression이 있다.
  • [ ] 접근성 contract가 테스트된다.
  • [ ] breaking change에 migration note가 있다.

추천 항목 실행 우선순위 매핑

  • P1(7일 내) — token, component API, migration plan 중 하나를 작은 변경 1건에 적용하고 증거(token diff)를 남긴다.
  • P2(30일 내) — 디자인 시스템 기준을 팀 템플릿, 체크리스트, CI 중 한 곳에 고정한다.
  • P3(90일 내) — 중복 컴포넌트 수, token drift, visual regression 수 추이를 보고 기준을 유지할지 조정할지 결정한다.
  • 완료 기준 — 디자인 시스템 오너가 증거와 철회 조건을 확인했다는 기록을 남긴다.

추천 항목 실행 체크리스트

  • [ ] 1단계(7일) : token, component API, migration plan 적용 대상을 1개로 좁힌다.
  • [ ] 2단계(30일) : 증거(token diff, Storybook/visual test, migration codemod 결과)를 PR, ADR, 회고 중 한 곳에 연결한다.
  • [ ] 3단계(60일) : 중복 컴포넌트 수, token drift, visual regression 수가 기준 안에 들어왔는지 확인한다.
  • [ ] 문제 대응 : 미달성 사유와 다음 조치, 중단 여부를 같은 기록에 남긴다.

추천 항목 실행 운영 규칙

  • 실행 게이트 : 컴포넌트 API가 특정 도메인 요구를 과하게 품지 않는지 확인한다.
  • 승인 체계 : 디자인 시스템 오너가 영향 범위와 rollback 담당자를 적용 전에 확인한다.
  • 재개 조건 : usage audit와 visual test가 통과하면 shared component로 승격한다.
  • 정지 조건 : 토큰 없이 색/간격이 직접 추가되면 배포를 멈추고 정리한다.
  • 리스크 점수 : 사용처 수, breaking prop 수, visual diff 범위로 산정한다.
  • 리더 승인자 : 디자인 시스템 리드가 최종 승인 책임을 맡는다.
  • 승인 역할 : 디자인 시스템 작성자, 검토자, 운영 확인자를 분리해 기록한다.
  • 재평가 주기 : 월 1회 token drift와 deprecated API를 정리한다.