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
마이그레이션 단계:
@tailwindcss/upgradeCLI 도구로 자동 변환 실행-
tailwind.config.js의 커스텀 테마를@theme블록으로 이동 - 커스텀 플러그인을
@utility/@variant로 전환 content배열 제거 (v4는 자동 감지)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="올바른 이메일을 입력하세요" />
규칙 요약:
-
boolean prop은
is/has접두어 사용 (isLoading,hasError) -
이벤트 핸들러는
on접두어 (onClick,onValueChange) - 열거형은
variant,size등 의미 기반 이름 className은 항상 마지막 스타일 오버라이드용으로 지원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 UI나 React 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,
childrencomposition으로 중간 컴포넌트가 사용하지 않는 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 기준이 필요할 때
적용 순서
- 토큰, primitive, composite, feature component를 구분한다.
- primitive는 접근성 contract와 keyboard behavior를 먼저 고정한다.
- token은 source of truth와 build output을 분리한다.
- breaking change에는 codemod 또는 migration guide를 붙인다.
- 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를 정리한다.