최근 회사에서 여러 프로젝트에 걸쳐 사용할 수 있는 디자인 시스템을 구축하게 되었습니다. Tailwind CSS를 기반으로 한 디자인 시스템 구축 과정과 노하우를 공유하겠습니다.
🎨 디자인 시스템이 필요한 이유
기존 문제점들
- 일관성 부족: 프로젝트마다 다른 색상, 간격, 타이포그래피
- 유지보수 어려움: 변경사항이 생기면 모든 곳을 수정해야 함
- 개발 속도 저하: 매번 스타일을 새로 작성해야 함
- 협업 비효율: 디자이너와 개발자 간 소통 비용 증가
디자인 시스템의 장점
- 일관된 사용자 경험 제공
- 개발 속도 향상 (재사용 가능한 컴포넌트)
- 유지보수성 개선
- 확장성 확보
🏗️ 구축 과정
1. Tailwind Config 기본 설정
먼저 프로젝트의 브랜드 컬러와 기본 설정을 정의했습니다.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6", // Main brand color
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
gray: {
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
success: "#10b981",
warning: "#f59e0b",
error: "#ef4444",
},
fontFamily: {
sans: ["Pretendard", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
spacing: {
18: "4.5rem",
88: "22rem",
},
borderRadius: {
xl: "1rem",
"2xl": "1.5rem",
},
},
},
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
};
2. 컴포넌트 라이브러리 구축
재사용 가능한 컴포넌트들을 만들었습니다.
Button 컴포넌트
// components/Button.tsx
import { ButtonHTMLAttributes, ReactNode } from "react";
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
// 기본 스타일
"inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
primary: "bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500",
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500",
ghost: "text-gray-700 hover:bg-gray-100 focus:ring-gray-500",
danger: "bg-red-500 text-white hover:bg-red-600 focus:ring-red-500",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-12 px-6 text-lg",
xl: "h-14 px-8 text-xl",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
children: ReactNode;
loading?: boolean;
}
export function Button({ children, variant, size, loading, className, ...props }: ButtonProps) {
return (
<button className={buttonVariants({ variant, size, className })} disabled={loading} {...props}>
{loading && (
<svg className="w-4 h-4 mr-2 animate-spin" viewBox="0 0 24 24">
<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-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
Input 컴포넌트
// components/Input.tsx
import { InputHTMLAttributes, forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
const inputVariants = cva("block w-full rounded-lg border transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", {
variants: {
variant: {
default: "border-gray-300 focus:border-primary-500 focus:ring-primary-500",
error: "border-red-500 focus:border-red-500 focus:ring-red-500",
success: "border-green-500 focus:border-green-500 focus:ring-green-500",
},
size: {
sm: "px-3 py-2 text-sm",
md: "px-4 py-3 text-base",
lg: "px-5 py-4 text-lg",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
});
interface InputProps extends InputHTMLAttributes<HTMLInputElement>, VariantProps<typeof inputVariants> {
label?: string;
helperText?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(({ label, helperText, error, variant, size, className, ...props }, ref) => {
const inputVariant = error ? "error" : variant;
return (
<div className="space-y-1">
{label && <label className="block text-sm font-medium text-gray-700">{label}</label>}
<input ref={ref} className={inputVariants({ variant: inputVariant, size, className })} {...props} />
{error && <p className="text-sm text-red-600">{error}</p>}
{helperText && !error && <p className="text-sm text-gray-500">{helperText}</p>}
</div>
);
});
Input.displayName = "Input";
3. 레이아웃 시스템
일관된 레이아웃을 위한 컴포넌트들을 만들었습니다.
// components/Layout.tsx
interface ContainerProps {
children: React.ReactNode;
size?: "sm" | "md" | "lg" | "xl" | "full";
}
export function Container({ children, size = "lg" }: ContainerProps) {
const sizeClasses = {
sm: "max-w-screen-sm",
md: "max-w-screen-md",
lg: "max-w-screen-lg",
xl: "max-w-screen-xl",
full: "max-w-full",
};
return <div className={`mx-auto px-4 sm:px-6 lg:px-8 ${sizeClasses[size]}`}>{children}</div>;
}
// 그리드 시스템
export function Grid({ children, cols = 1, gap = "md" }: { children: React.ReactNode; cols?: 1 | 2 | 3 | 4 | 6 | 12; gap?: "sm" | "md" | "lg" }) {
const colsClasses = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
6: "grid-cols-2 md:grid-cols-3 lg:grid-cols-6",
12: "grid-cols-4 md:grid-cols-6 lg:grid-cols-12",
};
const gapClasses = {
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
};
return <div className={`grid ${colsClasses[cols]} ${gapClasses[gap]}`}>{children}</div>;
}
4. 유틸리티 클래스 확장
자주 사용하는 패턴들을 유틸리티 클래스로 만들었습니다.
/* styles/utilities.css */
@layer utilities {
/* Card 스타일 */
.card {
@apply bg-white rounded-xl border border-gray-200 shadow-sm;
}
.card-hover {
@apply card transition-shadow hover:shadow-md;
}
/* Text 스타일 */
.text-gradient {
@apply bg-gradient-to-r from-primary-600 to-blue-600 bg-clip-text text-transparent;
}
/* Animation */
.fade-in {
@apply opacity-0 animate-pulse;
animation: fadeIn 0.5s ease-in-out forwards;
}
/* Focus styles */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
📱 반응형 디자인 전략
1. 모바일 퍼스트 접근
// 모바일 우선으로 설계
<div
className="
p-4 text-sm // 모바일 기본
sm:p-6 sm:text-base // 작은 화면 (640px+)
md:p-8 md:text-lg // 중간 화면 (768px+)
lg:p-12 lg:text-xl // 큰 화면 (1024px+)
xl:p-16 xl:text-2xl // 매우 큰 화면 (1280px+)
"
>
Responsive content
</div>
2. 컨테이너 쿼리 활용
@layer utilities {
.card-responsive {
@apply p-4;
@container (min-width: 300px) {
@apply p-6;
}
@container (min-width: 500px) {
@apply p-8;
}
}
}
🔧 개발 도구 및 워크플로우
1. Storybook 도입
컴포넌트 문서화와 테스트를 위해 Storybook을 사용했습니다.
// Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta: Meta<typeof Button> = {
title: "Components/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
variant: "primary",
children: "Primary Button",
},
};
export const AllVariants: Story = {
render: () => (
<div className="space-x-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</div>
),
};
2. 디자인 토큰 자동화
// scripts/generate-tokens.js
const fs = require("fs");
const config = require("../tailwind.config.js");
function generateTokens() {
const tokens = {
colors: config.theme.extend.colors,
spacing: config.theme.extend.spacing,
fontFamily: config.theme.extend.fontFamily,
};
// CSS 변수로 출력
const cssVars = Object.entries(tokens.colors.primary)
.map(([key, value]) => ` --color-primary-${key}: ${value};`)
.join("\n");
const css = `:root {\n${cssVars}\n}`;
fs.writeFileSync("./src/styles/tokens.css", css);
console.log("✅ Design tokens generated!");
}
generateTokens();
📊 성과 측정
Before vs After
| 지표 | 도입 전 | 도입 후 | 개선율 |
|---|---|---|---|
| CSS 번들 크기 | 284KB | 95KB | 66% ⬇️ |
| 컴포넌트 개발 시간 | 2-3시간 | 30분 | 75% ⬇️ |
| 디자인 일관성 | 60% | 95% | 58% ⬆️ |
| 반응형 버그 | 주 5-6개 | 주 1-2개 | 70% ⬇️ |
개발자 경험 개선
- 코드 리뷰 시간 단축: 스타일 관련 리뷰 시간 50% 감소
- 신규 개발자 온보딩: 학습 곡선 완만화
- 유지보수성: 버그 수정 시간 60% 단축
🎯 모범 사례와 팁
1. 클래스명 순서 규칙
// 권장 순서: 레이아웃 → 박스모델 → 타이포그래피 → 비주얼 → 기타
<div className="
flex items-center justify-between // 레이아웃
w-full h-16 p-4 m-2 // 박스모델
text-lg font-medium // 타이포그래피
bg-white border border-gray-200 // 비주얼
transition-colors hover:bg-gray-50 // 기타
">
2. 조건부 스타일링
import { clsx } from "clsx";
function Badge({ variant, size }: BadgeProps) {
return (
<span
className={clsx("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", {
"bg-blue-100 text-blue-800": variant === "info",
"bg-green-100 text-green-800": variant === "success",
"bg-red-100 text-red-800": variant === "error",
"px-2 py-1 text-xs": size === "sm",
"px-3 py-1.5 text-sm": size === "md",
})}
>
{children}
</span>
);
}
3. 다크모드 지원
// tailwind.config.js
module.exports = {
darkMode: "class",
theme: {
extend: {
colors: {
background: {
light: "#ffffff",
dark: "#1f2937",
},
},
},
},
};
// 다크모드 스타일링
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">Content</div>
🚀 앞으로의 계획
1. 고도화 계획
- 애니메이션 라이브러리 통합 (Framer Motion)
- 접근성 개선 (ARIA 속성, 키보드 내비게이션)
- 테마 시스템 확장 (다중 브랜드 지원)
2. 도구 개선
- Figma 토큰 연동 (Design Tokens Studio)
- 자동 테스팅 (Visual Regression Testing)
- 성능 모니터링 대시보드
3. 팀 확산
- 디자인 시스템 워크샵 진행
- 가이드라인 문서 작성
- 컴포넌트 리뷰 프로세스 구축
💭 마무리
Tailwind CSS 기반 디자인 시스템 구축을 통해 개발 효율성과 일관성을 크게 향상시킬 수 있었습니다. 특히 utility-first 접근법은 빠른 프로토타이핑과 유지보수에 큰 도움이 되었습니다.
중요한 것은 처음부터 완벽한 시스템을 만들려 하지 말고, 팀의 니즈에 맞게 점진적으로 발전시켜 나가는 것입니다.
다음 포스트에서는 Headless UI와 Radix UI를 활용한 고급 컴포넌트 패턴에 대해 다뤄보겠습니다! 🎨✨