Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 Storybook 개발하기: UI 카탈로그, 상호작용 테스트, CI

Claude Code로 Storybook 설정, TypeScript stories, interaction test, CI, Chromatic 검증까지 구현합니다.

Claude Code로 Storybook 개발하기: UI 카탈로그, 상호작용 테스트, CI

Storybook은 예쁜 목록이 아니라 UI 계약서다

Storybook은 앱 본체에서 UI 컴포넌트를 떼어 내 독립적으로 개발하고 확인하는 도구입니다. 초보자에게는 버튼, 폼, 카드 같은 부품을 따로 열어 보는 카탈로그로 설명할 수 있습니다. 하지만 실무에서의 가치는 더 큽니다. props, 상태, 사용자 조작, 접근성, 디자인 리뷰, CI 확인을 한곳에 남기는 UI 계약서 역할을 합니다.

Claude Code에 단순히 “Storybook을 추가해줘”라고 말하면 Button.stories.tsx 몇 개를 만들고 끝날 수 있습니다. 운영 가능한 Storybook을 만들려면 어떤 컴포넌트를 넣을지, 어떤 상태를 반드시 보여 줄지, play 함수로 어떤 조작을 검증할지, PR에서 Chromatic 또는 그에 준하는 시각 회귀 검사를 어떻게 돌릴지를 먼저 정해야 합니다.

이 글은 React + TypeScript + Vite를 기준으로 Storybook 8/9 이후에도 자연스러운 구성을 사용합니다. .storybook/main.ts, CSF의 Meta/StoryObj, satisfies, @storybook/test interaction test, @storybook/addon-vitest, Chromatic-ready CI까지 연결합니다. 공식 문서는 main configuration, TypeScript stories, interaction testing, Vitest addon, Chromatic for Storybook을 확인했습니다.

디자인 시스템을 함께 다룬다면 Claude Code 디자인 시스템 구축Claude Code 접근성 대응도 같이 읽어 보세요. Storybook은 화면 모음이 아니라 품질을 반복해서 확인하는 작업대가 됩니다.

먼저 잡을 3가지 실전 범위

처음부터 모든 UI를 넣지 않습니다. 변경이 잦고 전환, 문의, 구매 흐름에 가까운 컴포넌트부터 정리합니다.

예시Storybook에 넣을 상태Claude Code에 맡길 작업
디자인 시스템 Buttonprimary, secondary, loading, disabled, 모든 크기props 타입, stories, controls, autodocs
무료 등록 폼비어 있음, 입력 오류, 제출 중, 성공play 함수, mock, validation 확인
글 하단/상품 페이지 CTA기본, 강조, 모바일 폭, 긴 문구반응형 확인, Chromatic diff, 내부 링크 점검

이 세 가지를 먼저 만들면 디자이너는 상태를 리뷰하고, 개발자는 props 계약을 확인하며, 운영자는 수익과 가까운 CTA가 깨지지 않았는지 볼 수 있습니다.

Vite 기반 설정으로 고정하기

Storybook의 Vitest addon을 쓰려면 Vite 기반 framework가 가장 단순합니다. React + Vite라면 @storybook/react-vite, Next.js라면 @storybook/nextjs-vite를 우선 검토합니다.

npm create vite@latest storybook-claude-demo -- --template react-ts
cd storybook-claude-demo
npm install
npm create storybook@latest
npx storybook add @storybook/addon-a11y
npx storybook add @storybook/addon-vitest
npm install -D chromatic @vitest/browser-playwright playwright
npx playwright install chromium
{
  "scripts": {
    "dev": "vite",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test-storybook": "vitest --project=storybook --run",
    "chromatic": "chromatic"
  }
}
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  framework: '@storybook/react-vite',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-vitest'],
  docs: {
    defaultName: 'Docs',
  },
  staticDirs: ['../public'],
};

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

const preview: Preview = {
  tags: ['autodocs'],
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    a11y: {
      test: 'todo',
    },
  },
};

export default preview;

예시 1: Button story를 타입 안전하게 만들기

Storybook 전용 props를 만들면 실제 앱과 문서가 어긋납니다. stories는 실제 컴포넌트 계약을 보여 줘야 합니다.

// src/components/Button.tsx
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';

type ButtonVariant = 'primary' | 'secondary' | 'outline';
type ButtonSize = 'sm' | 'md' | 'lg';

export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  children: ReactNode;
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
};

const variantStyle: Record<ButtonVariant, CSSProperties> = {
  primary: { background: '#2563eb', color: '#ffffff', borderColor: '#2563eb' },
  secondary: { background: '#0f172a', color: '#ffffff', borderColor: '#0f172a' },
  outline: { background: '#ffffff', color: '#0f172a', borderColor: '#94a3b8' },
};

const sizeStyle: Record<ButtonSize, CSSProperties> = {
  sm: { minHeight: 32, padding: '0 12px', fontSize: 14 },
  md: { minHeight: 40, padding: '0 16px', fontSize: 15 },
  lg: { minHeight: 48, padding: '0 20px', fontSize: 16 },
};

export function Button({
  children,
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled,
  style,
  ...props
}: ButtonProps) {
  return (
    <button
      {...props}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      style={{
        ...variantStyle[variant],
        ...sizeStyle[size],
        borderWidth: 1,
        borderStyle: 'solid',
        borderRadius: 6,
        fontWeight: 700,
        cursor: disabled || loading ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.55 : 1,
        ...style,
      }}
    >
      {loading ? 'Sending...' : children}
    </button>
  );
}
// src/components/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';

const meta = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  args: {
    children: 'Start free trial',
    variant: 'primary',
    size: 'md',
  },
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline'],
    },
    size: {
      control: 'inline-radio',
      options: ['sm', 'md', 'lg'],
    },
    loading: { control: 'boolean' },
    disabled: { control: 'boolean' },
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary = {} satisfies Story;

export const Secondary = {
  args: {
    variant: 'secondary',
    children: 'View pricing',
  },
} satisfies Story;

export const AllSizes = {
  render: () => (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  ),
} satisfies Story;

export const Loading = {
  args: {
    loading: true,
  },
} satisfies Story;

예시 2: 등록 폼을 interaction test로 확인하기

play 함수는 story가 렌더링된 뒤 사용자 입력을 재현합니다. 그래서 화면이 보이는지뿐 아니라 실제로 입력하고 제출되는지도 확인할 수 있습니다.

// src/components/SignupForm.tsx
import type { FormEvent } from 'react';
import { useState } from 'react';
import { Button } from './Button';

export type SignupFormData = {
  name: string;
  email: string;
  plan: 'free' | 'team';
};

type SignupFormProps = {
  plan?: SignupFormData['plan'];
  onSubmit?: (data: SignupFormData) => Promise<void> | void;
};

export function SignupForm({ plan = 'free', onSubmit }: SignupFormProps) {
  const [message, setMessage] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    const data: SignupFormData = {
      name: String(form.get('name') ?? ''),
      email: String(form.get('email') ?? ''),
      plan,
    };

    if (!data.email.includes('@')) {
      setMessage('Please enter a valid email address.');
      return;
    }

    await onSubmit?.(data);
    setMessage('Thanks, we will send the setup guide.');
  }

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 12, maxWidth: 360 }}>
      <label>
        Name
        <input name="name" required style={{ display: 'block', width: '100%', minHeight: 36 }} />
      </label>
      <label>
        Email
        <input name="email" type="email" required style={{ display: 'block', width: '100%', minHeight: 36 }} />
      </label>
      <Button type="submit">Get the guide</Button>
      {message ? <p role="status">{message}</p> : null}
    </form>
  );
}
// src/components/SignupForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from '@storybook/test';
import { SignupForm } from './SignupForm';

const meta = {
  title: 'Components/SignupForm',
  component: SignupForm,
  args: {
    plan: 'team',
    onSubmit: fn(),
  },
} satisfies Meta<typeof SignupForm>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Empty = {} satisfies Story;

export const InvalidEmail = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByLabelText('Name'), 'Masa');
    await userEvent.type(canvas.getByLabelText('Email'), 'masa.example.com');
    await userEvent.click(canvas.getByRole('button', { name: 'Get the guide' }));

    await expect(canvas.getByRole('status')).toHaveTextContent('valid email');
  },
} satisfies Story;

export const SuccessfulSubmit = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByLabelText('Name'), 'Masa');
    await userEvent.type(canvas.getByLabelText('Email'), 'masa@example.com');
    await userEvent.click(canvas.getByRole('button', { name: 'Get the guide' }));

    await expect(args.onSubmit).toHaveBeenCalledWith({
      name: 'Masa',
      email: 'masa@example.com',
      plan: 'team',
    });
    await expect(canvas.getByRole('status')).toHaveTextContent('setup guide');
  },
} satisfies Story;

예시 3: Vitest와 Chromatic에 가까운 CI

Vitest addon은 stories를 브라우저 기반 컴포넌트 테스트로 변환합니다. play 함수가 있으면 상호작용과 assertion도 같이 실행됩니다.

// .storybook/vitest.setup.ts
import { setProjectAnnotations } from '@storybook/react-vite';
import * as previewAnnotations from './preview';

setProjectAnnotations([previewAnnotations]);
// vitest.config.ts
import { defineConfig, defineProject, mergeConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import viteConfig from './vite.config';

const dirname = path.dirname(fileURLToPath(import.meta.url));

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      projects: [
        defineProject({
          extends: true,
          plugins: [
            storybookTest({
              configDir: path.join(dirname, '.storybook'),
              storybookScript: 'npm run storybook -- --ci',
              storybookUrl: 'http://localhost:6006',
              tags: {
                include: ['test'],
                exclude: ['experimental'],
              },
            }),
          ],
          test: {
            name: 'storybook',
            browser: {
              enabled: true,
              provider: playwright({}),
              headless: true,
              instances: [{ browser: 'chromium' }],
            },
            setupFiles: ['./.storybook/vitest.setup.ts'],
          },
        }),
      ],
    },
  }),
);
# .github/workflows/storybook.yml
name: storybook

on:
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test-storybook
      - run: npm run build-storybook
      - name: Publish to Chromatic
        if: ${{ secrets.CHROMATIC_PROJECT_TOKEN != '' }}
        run: npx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}

Claude Code 요청 템플릿

React + TypeScript + Vite 프로젝트의 Storybook을 정리해 주세요.

범위:
- src/components/Button.tsx
- src/components/SignupForm.tsx

요구사항:
- .storybook/main.ts는 @storybook/react-vite를 사용
- CSF는 Meta/StoryObj와 satisfies로 타입 안전하게 작성
- Button은 primary, secondary, outline, loading, disabled, all sizes를 stories로 작성
- SignupForm은 invalid email과 successful submit의 play 함수를 작성
- @storybook/test의 within, userEvent, expect, fn을 사용
- Vitest addon 설정을 추가해 test-storybook이 CI에서 실행되게 하기
- GitHub Actions에 build-storybook과 Chromatic 수준의 visual check 추가

금지:
- 의사코드로 끝내지 않기
- 실제 컴포넌트에 없는 props를 추가하지 않기
- secret을 코드에 직접 쓰지 않기
- 범위 밖 파일을 포맷하지 않기

완료 보고:
- 변경 파일
- 실행한 명령
- 남은 위험

자주 생기는 실수

첫째, Storybook 전용 props를 추가하는 것입니다. 둘째, 성공 경로만 테스트하는 것입니다. 오류 입력, loading, disabled, 중복 제출도 확인해야 합니다. 셋째, 날짜, 난수, 애니메이션, 외부 이미지 때문에 Chromatic diff가 매번 발생할 수 있습니다. 넷째, @storybook/react, @storybook/react-vite, @storybook/nextjs-vite를 섞으면 설정 문제가 생깁니다. 다섯째, CI에서 Chromium 설치를 빼먹으면 브라우저 모드 테스트가 실패합니다. 여섯째, 수익과 가까운 CTA, 상품 카드, 상담 폼을 나중으로 미루면 Storybook의 효과가 작아집니다.

수익 흐름과 실제로 해 본 결과

기술 미디어나 제품 사이트에서는 Storybook이 수익 흐름을 지키는 도구가 됩니다. 무료 자료 신청 폼, 상품 페이지 CTA, 교육 상담 폼, 글 하단 내부 링크 카드를 stories로 만들고 PR마다 interaction test와 visual check로 확인할 수 있습니다.

Claude Code 요청 패턴을 먼저 만들고 싶다면 무료 치트시트부터 시작하세요. 반복 가능한 템플릿이 필요하면 제품과 템플릿을 확인하고, 실제 저장소에서 Storybook, CI, Chromatic, 접근성, CTA 리뷰까지 묶고 싶다면 Claude Code 교육 및 도입 상담이 적합합니다.

작은 React 예제에서 실제로 해 보니, 가장 큰 효과는 Button 색상 변형이 아니라 SignupForm의 실패 story에서 나왔습니다. InvalidEmailSuccessfulSubmit을 나누고 CI에서 test-storybookbuild-storybook을 돌리자, Claude Code에 다시 수정 요청하는 횟수가 줄었습니다. 시각 검증도 처음부터 전체 페이지를 잡기보다 CTA와 폼에 집중하는 편이 리뷰하기 쉬웠습니다.

#Claude Code #Storybook #UI 개발 #components #frontend
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.