디자인 시스템 구축: Storybook과 Chromatic으로 컴포넌트 관리

프론트엔드

StorybookChromatic디자인 시스템컴포넌트CI/CD

이 글은 누구를 위한 것인가

  • 팀 전체가 공유하는 컴포넌트 라이브러리를 구축하려는 팀
  • 디자이너-개발자 협업 도구로 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 없이는 관리가 불가능해진다.