이 글은 누구를 위한 것인가
- 팀 전체가 공유하는 컴포넌트 라이브러리를 구축하려는 팀
- 디자이너-개발자 협업 도구로 Storybook을 도입하려는 개발자
- Chromatic으로 UI 회귀 테스트를 자동화하고 싶은 팀
들어가며
디자인 시스템 없이 성장한 팀은 "Button 컴포넌트가 7개"라는 고통을 안다. Storybook은 컴포넌트를 독립적으로 개발하고 문서화하는 워크숍이고, Chromatic은 PR마다 시각적 변경을 자동으로 검토하는 CI 도구다.
이 글은 bluefoxdev.kr의 디자인 시스템 구축 가이드 를 참고하여 작성했습니다.
1. 디자인 시스템 아키텍처
[디자인 시스템 구성 요소]
토큰 레이어:
색상, 타이포그래피, 간격, 그림자
CSS Custom Properties + style-dictionary
기초 컴포넌트:
Button, Input, Select, Checkbox, Badge, Avatar
외부 의존성 없음, 모든 상태 명시
합성 컴포넌트:
Modal, Dropdown, Tabs, Toast, DatePicker
기초 컴포넌트 조합
패턴/템플릿:
폼, 카드, 리스트, 네비게이션
[Storybook 8 주요 기능]
CSF3: 스토리 간결한 형식
Controls: props 실시간 변경
Actions: 이벤트 핸들러 로깅
a11y 애드온: 접근성 자동 검사
Docs: MDX 기반 자동 문서화
Test runner: Playwright 기반 테스트
Vitest 통합 (8.x 신기능)
[Chromatic 워크플로우]
PR 생성 → Chromatic 빌드
스크린샷 비교 (기준 대비)
시각적 변경 발견 시 리뷰 요청
승인 후 새 기준으로 업데이트
2. Storybook과 Chromatic 구현
// src/components/Button/Button.tsx
import { forwardRef, ButtonHTMLAttributes } from 'react';
import { clsx } from 'clsx';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
variant = 'primary',
size = 'md',
loading = false,
leftIcon,
rightIcon,
children,
disabled,
className,
...props
}, ref) => {
return (
<button
ref={ref}
disabled={disabled || loading}
aria-busy={loading}
className={clsx(
'inline-flex items-center justify-center gap-2 font-medium rounded-lg',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
{
'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500':
variant === 'primary',
'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50':
variant === 'secondary',
'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500':
variant === 'danger',
'text-gray-700 hover:bg-gray-100':
variant === 'ghost',
'text-sm px-3 py-1.5': size === 'sm',
'text-sm px-4 py-2': size === 'md',
'text-base px-6 py-3': size === 'lg',
'opacity-50 cursor-not-allowed': disabled || loading,
},
className
)}
{...props}
>
{loading ? (
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
) : leftIcon}
{children}
{!loading && rightIcon}
</button>
);
});
Button.displayName = 'Button';
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Button } from './Button';
// CSF3 형식
const meta = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: '다양한 변형을 지원하는 기본 버튼 컴포넌트입니다.',
},
},
},
tags: ['autodocs'], // 자동 문서화
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger', 'ghost'],
description: '버튼 스타일 변형',
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
},
args: {
onClick: fn(), // Actions 패널에 기록
children: '버튼',
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
// 기본 스토리
export const Primary: Story = {
args: { variant: 'primary' },
};
export const Secondary: Story = {
args: { variant: 'secondary' },
};
export const Danger: Story = {
args: { variant: 'danger' },
};
// 전체 변형 매트릭스
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
{(['primary', 'secondary', 'danger', 'ghost'] as const).map(variant => (
<div key={variant} className="flex gap-3 items-center">
{(['sm', 'md', 'lg'] as const).map(size => (
<Button key={size} variant={variant} size={size}>
{variant} {size}
</Button>
))}
</div>
))}
</div>
),
};
// 로딩 상태
export const Loading: Story = {
args: { loading: true },
};
// 접근성 테스트 - 비활성 상태
export const Disabled: Story = {
args: { disabled: true },
play: async ({ canvasElement }) => {
const { getByRole } = await import('@storybook/test');
const button = getByRole(canvasElement, 'button');
// 비활성 버튼 검증
if (!button.hasAttribute('disabled')) {
throw new Error('버튼이 비활성 상태가 아닙니다');
}
},
};
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-a11y', // 접근성 검사
'@storybook/addon-interactions', // play 함수 실행
'@chromatic-com/storybook', // Chromatic 통합
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag', // 'autodocs' 태그 있는 스토리 자동 문서화
},
};
export default config;
# .github/workflows/chromatic.yml
name: Chromatic 비주얼 테스트
on: [push]
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: 'npm'
- run: npm ci
- name: Chromatic 배포
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
# 변경된 스토리만 테스트 (속도 최적화)
onlyChanged: true
# Storybook 빌드
buildScriptName: build-storybook
# PR에서만 승인 요구
autoAcceptChanges: main
// 접근성 테스트 설정
// .storybook/preview.ts
import { Preview } from '@storybook/react';
import { withA11y } from '@storybook/addon-a11y';
const preview: Preview = {
decorators: [withA11y],
parameters: {
a11y: {
// axe-core 규칙 설정
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'label', enabled: true },
{ id: 'button-name', enabled: true },
],
},
},
},
};
export default preview;
마무리
Storybook의 play 함수와 @storybook/test를 결합하면 스토리가 단순 문서를 넘어 실행 가능한 인터랙션 테스트가 된다. Chromatic은 PR마다 시각적 차이를 자동으로 잡아주어 "의도치 않은 디자인 변경"을 막는다. 디자인 시스템의 ROI는 초기 구축 비용보다 팀 전체의 일관성 유지 비용 절감에 있다. 컴포넌트 30개가 넘어가면 Storybook 없이는 관리가 불가능해진다.